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.
569 lines
20 KiB
C#
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);
|
|
}
|