Files
git.stella-ops.org/src/Findings/StellaOps.Findings.Ledger/Services/FindingWorkflowService.cs
master 536f6249a6
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add SBOM, symbols, traces, and VEX files for CVE-2022-21661 SQLi case
- 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.
2025-11-08 20:53:45 +02:00

569 lines
20 KiB
C#

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