471 lines
13 KiB
C#
471 lines
13 KiB
C#
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.Json.Serialization.Metadata;
|
|
|
|
namespace StellaOps.Scheduler.Models;
|
|
|
|
/// <summary>
|
|
/// Deterministic serializer for scheduler DTOs.
|
|
/// </summary>
|
|
public static class CanonicalJsonSerializer
|
|
{
|
|
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
|
|
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
|
|
|
|
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
|
|
{
|
|
[typeof(Schedule)] = new[]
|
|
{
|
|
"schemaVersion",
|
|
"id",
|
|
"tenantId",
|
|
"name",
|
|
"enabled",
|
|
"cronExpression",
|
|
"timezone",
|
|
"mode",
|
|
"selection",
|
|
"onlyIf",
|
|
"notify",
|
|
"limits",
|
|
"subscribers",
|
|
"createdAt",
|
|
"createdBy",
|
|
"updatedAt",
|
|
"updatedBy",
|
|
},
|
|
[typeof(Selector)] = new[]
|
|
{
|
|
"scope",
|
|
"tenantId",
|
|
"namespaces",
|
|
"repositories",
|
|
"digests",
|
|
"includeTags",
|
|
"labels",
|
|
"resolvesTags",
|
|
},
|
|
[typeof(LabelSelector)] = new[]
|
|
{
|
|
"key",
|
|
"values",
|
|
},
|
|
[typeof(ScheduleOnlyIf)] = new[]
|
|
{
|
|
"lastReportOlderThanDays",
|
|
"policyRevision",
|
|
},
|
|
[typeof(ScheduleNotify)] = new[]
|
|
{
|
|
"onNewFindings",
|
|
"minSeverity",
|
|
"includeKev",
|
|
"includeQuietFindings",
|
|
},
|
|
[typeof(ScheduleLimits)] = new[]
|
|
{
|
|
"maxJobs",
|
|
"ratePerSecond",
|
|
"parallelism",
|
|
"burst",
|
|
},
|
|
[typeof(Run)] = new[]
|
|
{
|
|
"schemaVersion",
|
|
"id",
|
|
"tenantId",
|
|
"scheduleId",
|
|
"trigger",
|
|
"state",
|
|
"stats",
|
|
"reason",
|
|
"createdAt",
|
|
"startedAt",
|
|
"finishedAt",
|
|
"error",
|
|
"deltas",
|
|
},
|
|
[typeof(RunStats)] = new[]
|
|
{
|
|
"candidates",
|
|
"deduped",
|
|
"queued",
|
|
"completed",
|
|
"deltas",
|
|
"newCriticals",
|
|
"newHigh",
|
|
"newMedium",
|
|
"newLow",
|
|
},
|
|
[typeof(RunReason)] = new[]
|
|
{
|
|
"manualReason",
|
|
"feedserExportId",
|
|
"vexerExportId",
|
|
"cursor",
|
|
"impactWindowFrom",
|
|
"impactWindowTo",
|
|
},
|
|
[typeof(DeltaSummary)] = new[]
|
|
{
|
|
"imageDigest",
|
|
"newFindings",
|
|
"newCriticals",
|
|
"newHigh",
|
|
"newMedium",
|
|
"newLow",
|
|
"kevHits",
|
|
"topFindings",
|
|
"reportUrl",
|
|
"attestation",
|
|
"detectedAt",
|
|
},
|
|
[typeof(DeltaFinding)] = new[]
|
|
{
|
|
"purl",
|
|
"vulnerabilityId",
|
|
"severity",
|
|
"link",
|
|
},
|
|
[typeof(ImpactSet)] = new[]
|
|
{
|
|
"schemaVersion",
|
|
"selector",
|
|
"images",
|
|
"usageOnly",
|
|
"generatedAt",
|
|
"total",
|
|
"snapshotId",
|
|
},
|
|
[typeof(ImpactImage)] = new[]
|
|
{
|
|
"imageDigest",
|
|
"registry",
|
|
"repository",
|
|
"namespaces",
|
|
"tags",
|
|
"usedByEntrypoint",
|
|
"labels",
|
|
},
|
|
[typeof(AuditRecord)] = new[]
|
|
{
|
|
"id",
|
|
"tenantId",
|
|
"category",
|
|
"action",
|
|
"occurredAt",
|
|
"actor",
|
|
"entityId",
|
|
"scheduleId",
|
|
"runId",
|
|
"correlationId",
|
|
"metadata",
|
|
"message",
|
|
},
|
|
[typeof(AuditActor)] = new[]
|
|
{
|
|
"actorId",
|
|
"displayName",
|
|
"kind",
|
|
},
|
|
[typeof(GraphBuildJob)] = new[]
|
|
{
|
|
"schemaVersion",
|
|
"id",
|
|
"tenantId",
|
|
"sbomId",
|
|
"sbomVersionId",
|
|
"sbomDigest",
|
|
"graphSnapshotId",
|
|
"status",
|
|
"trigger",
|
|
"attempts",
|
|
"cartographerJobId",
|
|
"correlationId",
|
|
"createdAt",
|
|
"startedAt",
|
|
"completedAt",
|
|
"error",
|
|
"metadata",
|
|
},
|
|
[typeof(GraphOverlayJob)] = new[]
|
|
{
|
|
"schemaVersion",
|
|
"id",
|
|
"tenantId",
|
|
"graphSnapshotId",
|
|
"buildJobId",
|
|
"overlayKind",
|
|
"overlayKey",
|
|
"subjects",
|
|
"status",
|
|
"trigger",
|
|
"attempts",
|
|
"correlationId",
|
|
"createdAt",
|
|
"startedAt",
|
|
"completedAt",
|
|
"error",
|
|
"metadata",
|
|
},
|
|
[typeof(PolicyRunRequest)] = new[]
|
|
{
|
|
"schemaVersion",
|
|
"tenantId",
|
|
"policyId",
|
|
"policyVersion",
|
|
"mode",
|
|
"priority",
|
|
"runId",
|
|
"queuedAt",
|
|
"requestedBy",
|
|
"correlationId",
|
|
"metadata",
|
|
"inputs",
|
|
},
|
|
[typeof(PolicyRunInputs)] = new[]
|
|
{
|
|
"sbomSet",
|
|
"advisoryCursor",
|
|
"vexCursor",
|
|
"environment",
|
|
"captureExplain",
|
|
},
|
|
[typeof(PolicyRunStatus)] = new[]
|
|
{
|
|
"schemaVersion",
|
|
"runId",
|
|
"tenantId",
|
|
"policyId",
|
|
"policyVersion",
|
|
"mode",
|
|
"status",
|
|
"priority",
|
|
"queuedAt",
|
|
"startedAt",
|
|
"finishedAt",
|
|
"determinismHash",
|
|
"errorCode",
|
|
"error",
|
|
"attempts",
|
|
"traceId",
|
|
"explainUri",
|
|
"metadata",
|
|
"stats",
|
|
"inputs",
|
|
},
|
|
[typeof(PolicyRunJob)] = new[]
|
|
{
|
|
"schemaVersion",
|
|
"id",
|
|
"tenantId",
|
|
"policyId",
|
|
"policyVersion",
|
|
"mode",
|
|
"priority",
|
|
"priorityRank",
|
|
"runId",
|
|
"queuedAt",
|
|
"requestedBy",
|
|
"correlationId",
|
|
"metadata",
|
|
"inputs",
|
|
"status",
|
|
"attemptCount",
|
|
"lastAttemptAt",
|
|
"lastError",
|
|
"createdAt",
|
|
"updatedAt",
|
|
"availableAt",
|
|
"submittedAt",
|
|
"completedAt",
|
|
"leaseOwner",
|
|
"leaseExpiresAt",
|
|
"cancellationRequested",
|
|
"cancellationRequestedAt",
|
|
"cancellationReason",
|
|
"cancelledAt",
|
|
},
|
|
[typeof(PolicyRunStats)] = new[]
|
|
{
|
|
"components",
|
|
"rulesFired",
|
|
"findingsWritten",
|
|
"vexOverrides",
|
|
"quieted",
|
|
"suppressed",
|
|
"durationSeconds",
|
|
},
|
|
[typeof(PolicyDiffSummary)] = new[]
|
|
{
|
|
"schemaVersion",
|
|
"added",
|
|
"removed",
|
|
"unchanged",
|
|
"bySeverity",
|
|
"ruleHits",
|
|
},
|
|
[typeof(PolicyDiffSeverityDelta)] = new[]
|
|
{
|
|
"up",
|
|
"down",
|
|
},
|
|
[typeof(PolicyDiffRuleDelta)] = new[]
|
|
{
|
|
"ruleId",
|
|
"ruleName",
|
|
"up",
|
|
"down",
|
|
},
|
|
[typeof(PolicyExplainTrace)] = new[]
|
|
{
|
|
"schemaVersion",
|
|
"findingId",
|
|
"policyId",
|
|
"policyVersion",
|
|
"tenantId",
|
|
"runId",
|
|
"evaluatedAt",
|
|
"verdict",
|
|
"ruleChain",
|
|
"evidence",
|
|
"vexImpacts",
|
|
"history",
|
|
"metadata",
|
|
},
|
|
[typeof(PolicyExplainVerdict)] = new[]
|
|
{
|
|
"status",
|
|
"severity",
|
|
"quiet",
|
|
"score",
|
|
"rationale",
|
|
},
|
|
[typeof(PolicyExplainRule)] = new[]
|
|
{
|
|
"ruleId",
|
|
"ruleName",
|
|
"action",
|
|
"decision",
|
|
"score",
|
|
"condition",
|
|
},
|
|
[typeof(PolicyExplainEvidence)] = new[]
|
|
{
|
|
"type",
|
|
"reference",
|
|
"source",
|
|
"status",
|
|
"weight",
|
|
"justification",
|
|
"metadata",
|
|
},
|
|
[typeof(PolicyExplainVexImpact)] = new[]
|
|
{
|
|
"statementId",
|
|
"provider",
|
|
"status",
|
|
"accepted",
|
|
"justification",
|
|
"confidence",
|
|
},
|
|
[typeof(PolicyExplainHistoryEvent)] = new[]
|
|
{
|
|
"status",
|
|
"occurredAt",
|
|
"actor",
|
|
"note",
|
|
},
|
|
};
|
|
|
|
public static string Serialize<T>(T value)
|
|
=> JsonSerializer.Serialize(value, CompactOptions);
|
|
|
|
public static string SerializeIndented<T>(T value)
|
|
=> JsonSerializer.Serialize(value, PrettyOptions);
|
|
|
|
public static T Deserialize<T>(string json)
|
|
=> JsonSerializer.Deserialize<T>(json, PrettyOptions)
|
|
?? throw new InvalidOperationException($"Unable to deserialize {typeof(T).Name}.");
|
|
|
|
private static JsonSerializerOptions CreateOptions(bool writeIndented)
|
|
{
|
|
var options = new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = writeIndented,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
|
};
|
|
|
|
var resolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
|
|
options.TypeInfoResolver = new DeterministicResolver(resolver);
|
|
options.Converters.Add(new ScheduleModeConverter());
|
|
options.Converters.Add(new SelectorScopeConverter());
|
|
options.Converters.Add(new RunTriggerConverter());
|
|
options.Converters.Add(new RunStateConverter());
|
|
options.Converters.Add(new SeverityRankConverter());
|
|
options.Converters.Add(new GraphJobStatusConverter());
|
|
options.Converters.Add(new GraphBuildJobTriggerConverter());
|
|
options.Converters.Add(new GraphOverlayJobTriggerConverter());
|
|
options.Converters.Add(new GraphOverlayKindConverter());
|
|
options.Converters.Add(new PolicyRunModeConverter());
|
|
options.Converters.Add(new PolicyRunPriorityConverter());
|
|
options.Converters.Add(new PolicyRunExecutionStatusConverter());
|
|
options.Converters.Add(new PolicyVerdictStatusConverter());
|
|
options.Converters.Add(new PolicyRunJobStatusConverter());
|
|
return options;
|
|
}
|
|
|
|
private sealed class DeterministicResolver : IJsonTypeInfoResolver
|
|
{
|
|
private readonly IJsonTypeInfoResolver _inner;
|
|
|
|
public DeterministicResolver(IJsonTypeInfoResolver inner)
|
|
{
|
|
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
|
}
|
|
|
|
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
|
|
{
|
|
var info = _inner.GetTypeInfo(type, options);
|
|
if (info is null)
|
|
{
|
|
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
|
|
}
|
|
|
|
if (info.Kind is JsonTypeInfoKind.Object && info.Properties.Count > 1)
|
|
{
|
|
var ordered = info.Properties
|
|
.OrderBy(property => ResolveOrder(type, property.Name))
|
|
.ThenBy(property => property.Name, StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
info.Properties.Clear();
|
|
foreach (var property in ordered)
|
|
{
|
|
info.Properties.Add(property);
|
|
}
|
|
}
|
|
|
|
return info;
|
|
}
|
|
|
|
private static int ResolveOrder(Type type, string propertyName)
|
|
{
|
|
if (PropertyOrder.TryGetValue(type, out var order))
|
|
{
|
|
var index = Array.IndexOf(order, propertyName);
|
|
if (index >= 0)
|
|
{
|
|
return index;
|
|
}
|
|
}
|
|
|
|
return int.MaxValue;
|
|
}
|
|
}
|
|
}
|