Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implemented the PhpAnalyzerPlugin to analyze PHP projects. - Created ComposerLockData class to represent data from composer.lock files. - Developed ComposerLockReader to load and parse composer.lock files asynchronously. - Introduced ComposerPackage class to encapsulate package details. - Added PhpPackage class to represent PHP packages with metadata and evidence. - Implemented PhpPackageCollector to gather packages from ComposerLockData. - Created PhpLanguageAnalyzer to perform analysis and emit results. - Added capability signals for known PHP frameworks and CMS. - Developed unit tests for the PHP language analyzer and its components. - Included sample composer.lock and expected output for testing. - Updated project files for the new PHP analyzer library and tests.
258 lines
8.6 KiB
C#
258 lines
8.6 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using StellaOps.Findings.Ledger.Domain;
|
|
using StellaOps.Findings.Ledger.Hashing;
|
|
using StellaOps.Findings.Ledger.Infrastructure.Policy;
|
|
|
|
namespace StellaOps.Findings.Ledger.Services;
|
|
|
|
public static class LedgerProjectionReducer
|
|
{
|
|
public static ProjectionReduceResult Reduce(
|
|
LedgerEventRecord record,
|
|
FindingProjection? current,
|
|
PolicyEvaluationResult evaluation)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
ArgumentNullException.ThrowIfNull(evaluation);
|
|
|
|
var eventObject = record.EventBody["event"]?.AsObject()
|
|
?? throw new InvalidOperationException("Ledger event payload is missing 'event' object.");
|
|
var payload = eventObject["payload"] as JsonObject;
|
|
|
|
var status = evaluation.Status ?? DetermineStatus(record.EventType, payload, current?.Status);
|
|
var severity = evaluation.Severity ?? DetermineSeverity(payload, current?.Severity);
|
|
var riskScore = evaluation.RiskScore ?? current?.RiskScore;
|
|
var riskSeverity = evaluation.RiskSeverity ?? current?.RiskSeverity;
|
|
var riskProfileVersion = evaluation.RiskProfileVersion ?? current?.RiskProfileVersion;
|
|
var riskExplanationId = evaluation.RiskExplanationId ?? current?.RiskExplanationId;
|
|
var riskEventSequence = evaluation.RiskEventSequence ?? current?.RiskEventSequence ?? record.SequenceNumber;
|
|
|
|
var labels = CloneLabels(evaluation.Labels);
|
|
MergeLabels(labels, payload);
|
|
|
|
var explainRef = evaluation.ExplainRef ?? DetermineExplainRef(payload, current?.ExplainRef);
|
|
var rationale = CloneArray(evaluation.Rationale);
|
|
if (rationale.Count == 0 && !string.IsNullOrWhiteSpace(explainRef))
|
|
{
|
|
rationale.Add(explainRef);
|
|
}
|
|
|
|
var updatedAt = record.RecordedAt;
|
|
|
|
var provisional = new FindingProjection(
|
|
record.TenantId,
|
|
record.FindingId,
|
|
record.PolicyVersion,
|
|
status,
|
|
severity,
|
|
riskScore,
|
|
riskSeverity,
|
|
riskProfileVersion,
|
|
riskExplanationId,
|
|
riskEventSequence,
|
|
labels,
|
|
record.EventId,
|
|
explainRef,
|
|
rationale,
|
|
updatedAt,
|
|
string.Empty);
|
|
|
|
var cycleHash = ProjectionHashing.ComputeCycleHash(provisional);
|
|
var projection = provisional with { CycleHash = cycleHash };
|
|
|
|
var historyEntry = new FindingHistoryEntry(
|
|
record.TenantId,
|
|
record.FindingId,
|
|
record.PolicyVersion,
|
|
record.EventId,
|
|
projection.Status,
|
|
projection.Severity,
|
|
record.ActorId,
|
|
DetermineComment(payload),
|
|
record.OccurredAt);
|
|
|
|
var actionEntry = CreateActionEntry(record, payload);
|
|
return new ProjectionReduceResult(projection, historyEntry, actionEntry);
|
|
}
|
|
|
|
private static string DetermineStatus(string eventType, JsonObject? payload, string? currentStatus)
|
|
{
|
|
var candidate = ExtractString(payload, "status") ?? currentStatus;
|
|
|
|
return eventType switch
|
|
{
|
|
LedgerEventConstants.EventFindingCreated => candidate ?? "affected",
|
|
LedgerEventConstants.EventFindingStatusChanged => candidate ?? currentStatus ?? "affected",
|
|
LedgerEventConstants.EventFindingClosed => candidate ?? "closed",
|
|
LedgerEventConstants.EventFindingAcceptedRisk => candidate ?? "accepted_risk",
|
|
_ => candidate ?? currentStatus ?? "affected"
|
|
};
|
|
}
|
|
|
|
private static decimal? DetermineSeverity(JsonObject? payload, decimal? current)
|
|
{
|
|
if (payload is null)
|
|
{
|
|
return current;
|
|
}
|
|
|
|
if (payload.TryGetPropertyValue("severity", out var severityNode))
|
|
{
|
|
if (TryConvertDecimal(severityNode, out var severity))
|
|
{
|
|
return severity;
|
|
}
|
|
|
|
if (severityNode is JsonValue value && value.TryGetValue(out string? severityString)
|
|
&& decimal.TryParse(severityString, out var severityFromString))
|
|
{
|
|
return severityFromString;
|
|
}
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
private static void MergeLabels(JsonObject target, JsonObject? payload)
|
|
{
|
|
if (payload is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (payload.TryGetPropertyValue("labels", out var labelsNode) && labelsNode is JsonObject labelUpdates)
|
|
{
|
|
foreach (var property in labelUpdates)
|
|
{
|
|
if (property.Value is null || property.Value.GetValueKind() == JsonValueKind.Null)
|
|
{
|
|
target.Remove(property.Key);
|
|
}
|
|
else
|
|
{
|
|
target[property.Key] = property.Value.DeepClone();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (payload.TryGetPropertyValue("labelsRemove", out var removeNode) && removeNode is JsonArray removeArray)
|
|
{
|
|
foreach (var item in removeArray)
|
|
{
|
|
if (item is JsonValue value && value.TryGetValue(out string? key) && !string.IsNullOrWhiteSpace(key))
|
|
{
|
|
target.Remove(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string? DetermineExplainRef(JsonObject? payload, string? current)
|
|
{
|
|
var explainRef = ExtractString(payload, "explainRef") ?? ExtractString(payload, "explain_ref");
|
|
return explainRef ?? current;
|
|
}
|
|
|
|
private static string? DetermineComment(JsonObject? payload)
|
|
{
|
|
return ExtractString(payload, "comment")
|
|
?? ExtractString(payload, "justification")
|
|
?? ExtractString(payload, "note");
|
|
}
|
|
|
|
private static TriageActionEntry? CreateActionEntry(LedgerEventRecord record, JsonObject? payload)
|
|
{
|
|
var actionType = record.EventType switch
|
|
{
|
|
LedgerEventConstants.EventFindingStatusChanged => "status_change",
|
|
LedgerEventConstants.EventFindingCommentAdded => "comment",
|
|
LedgerEventConstants.EventFindingAssignmentChanged => "assign",
|
|
LedgerEventConstants.EventFindingRemediationPlanAdded => "remediation_plan",
|
|
LedgerEventConstants.EventFindingAcceptedRisk => "accept_risk",
|
|
LedgerEventConstants.EventFindingAttachmentAdded => "attach_evidence",
|
|
LedgerEventConstants.EventFindingClosed => "close",
|
|
_ => null
|
|
};
|
|
|
|
if (actionType is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var payloadClone = payload?.DeepClone()?.AsObject() ?? new JsonObject();
|
|
return new TriageActionEntry(
|
|
record.TenantId,
|
|
record.EventId,
|
|
record.EventId,
|
|
record.FindingId,
|
|
actionType,
|
|
payloadClone,
|
|
record.RecordedAt,
|
|
record.ActorId);
|
|
}
|
|
|
|
private static JsonObject CloneLabels(JsonObject? source)
|
|
{
|
|
return source is null ? new JsonObject() : (JsonObject)source.DeepClone();
|
|
}
|
|
|
|
private static JsonArray CloneArray(JsonArray source)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
|
|
var clone = new JsonArray();
|
|
foreach (var item in source)
|
|
{
|
|
clone.Add(item?.DeepClone());
|
|
}
|
|
|
|
return clone;
|
|
}
|
|
|
|
private static string? ExtractString(JsonObject? obj, string propertyName)
|
|
{
|
|
if (obj is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!obj.TryGetPropertyValue(propertyName, out var node) || node is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (node is JsonValue value && value.TryGetValue(out string? result))
|
|
{
|
|
return string.IsNullOrWhiteSpace(result) ? null : result;
|
|
}
|
|
|
|
return node.ToString();
|
|
}
|
|
|
|
private static bool TryConvertDecimal(JsonNode? node, out decimal value)
|
|
{
|
|
switch (node)
|
|
{
|
|
case null:
|
|
value = default;
|
|
return false;
|
|
case JsonValue jsonValue when jsonValue.TryGetValue(out decimal decimalValue):
|
|
value = decimalValue;
|
|
return true;
|
|
case JsonValue jsonValue when jsonValue.TryGetValue(out double doubleValue):
|
|
value = Convert.ToDecimal(doubleValue);
|
|
return true;
|
|
default:
|
|
if (decimal.TryParse(node.ToString(), out var parsed))
|
|
{
|
|
value = parsed;
|
|
return true;
|
|
}
|
|
|
|
value = default;
|
|
return false;
|
|
}
|
|
}
|
|
}
|