feat: Implement vulnerability token signing and verification utilities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added VulnTokenSigner for signing JWT tokens with specified algorithms and keys. - Introduced VulnTokenUtilities for resolving tenant and subject claims, and sanitizing context dictionaries. - Created VulnTokenVerificationUtilities for parsing tokens, verifying signatures, and deserializing payloads. - Developed VulnWorkflowAntiForgeryTokenIssuer for issuing anti-forgery tokens with configurable options. - Implemented VulnWorkflowAntiForgeryTokenVerifier for verifying anti-forgery tokens and validating payloads. - Added AuthorityVulnerabilityExplorerOptions to manage configuration for vulnerability explorer features. - Included tests for FilesystemPackRunDispatcher to ensure proper job handling under egress policy restrictions.
This commit is contained in:
@@ -1,168 +1,168 @@
|
||||
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",
|
||||
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",
|
||||
"conselierExportId",
|
||||
"excitorExportId",
|
||||
"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",
|
||||
},
|
||||
"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",
|
||||
@@ -378,32 +378,32 @@ public static class CanonicalJsonSerializer
|
||||
"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());
|
||||
|
||||
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());
|
||||
@@ -418,53 +418,53 @@ public static class CanonicalJsonSerializer
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Execution mode for a schedule.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(ScheduleModeConverter))]
|
||||
public enum ScheduleMode
|
||||
{
|
||||
AnalysisOnly,
|
||||
ContentRefresh,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selector scope determining which filters are applied.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SelectorScopeConverter))]
|
||||
public enum SelectorScope
|
||||
{
|
||||
AllImages,
|
||||
ByNamespace,
|
||||
ByRepository,
|
||||
ByDigest,
|
||||
ByLabels,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source that triggered a run.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(RunTriggerConverter))]
|
||||
public enum RunTrigger
|
||||
{
|
||||
Cron,
|
||||
Feedser,
|
||||
Vexer,
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle state of a scheduler run.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(RunStateConverter))]
|
||||
public enum RunState
|
||||
{
|
||||
Planning,
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Error,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity rankings used in scheduler payloads.
|
||||
/// </summary>
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Execution mode for a schedule.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(ScheduleModeConverter))]
|
||||
public enum ScheduleMode
|
||||
{
|
||||
AnalysisOnly,
|
||||
ContentRefresh,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selector scope determining which filters are applied.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SelectorScopeConverter))]
|
||||
public enum SelectorScope
|
||||
{
|
||||
AllImages,
|
||||
ByNamespace,
|
||||
ByRepository,
|
||||
ByDigest,
|
||||
ByLabels,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source that triggered a run.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(RunTriggerConverter))]
|
||||
public enum RunTrigger
|
||||
{
|
||||
Cron,
|
||||
Conselier,
|
||||
Excitor,
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle state of a scheduler run.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(RunStateConverter))]
|
||||
public enum RunState
|
||||
{
|
||||
Planning,
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Error,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity rankings used in scheduler payloads.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SeverityRankConverter))]
|
||||
public enum SeverityRank
|
||||
{
|
||||
None = 0,
|
||||
Info = 1,
|
||||
Low = 2,
|
||||
Medium = 3,
|
||||
Low = 2,
|
||||
Medium = 3,
|
||||
High = 4,
|
||||
Critical = 5,
|
||||
Unknown = 6,
|
||||
|
||||
@@ -1,378 +1,378 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Execution record for a scheduler run.
|
||||
/// </summary>
|
||||
public sealed record Run
|
||||
{
|
||||
public Run(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunTrigger trigger,
|
||||
RunState state,
|
||||
RunStats stats,
|
||||
DateTimeOffset createdAt,
|
||||
RunReason? reason = null,
|
||||
string? scheduleId = null,
|
||||
DateTimeOffset? startedAt = null,
|
||||
DateTimeOffset? finishedAt = null,
|
||||
string? error = null,
|
||||
IEnumerable<DeltaSummary>? deltas = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
trigger,
|
||||
state,
|
||||
stats,
|
||||
reason ?? RunReason.Empty,
|
||||
scheduleId,
|
||||
Validation.NormalizeTimestamp(createdAt),
|
||||
Validation.NormalizeTimestamp(startedAt),
|
||||
Validation.NormalizeTimestamp(finishedAt),
|
||||
Validation.TrimToNull(error),
|
||||
NormalizeDeltas(deltas),
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public Run(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunTrigger trigger,
|
||||
RunState state,
|
||||
RunStats stats,
|
||||
RunReason reason,
|
||||
string? scheduleId,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? startedAt,
|
||||
DateTimeOffset? finishedAt,
|
||||
string? error,
|
||||
ImmutableArray<DeltaSummary> deltas,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
Trigger = trigger;
|
||||
State = state;
|
||||
Stats = stats ?? throw new ArgumentNullException(nameof(stats));
|
||||
Reason = reason ?? RunReason.Empty;
|
||||
ScheduleId = Validation.TrimToNull(scheduleId);
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
StartedAt = Validation.NormalizeTimestamp(startedAt);
|
||||
FinishedAt = Validation.NormalizeTimestamp(finishedAt);
|
||||
Error = Validation.TrimToNull(error);
|
||||
Deltas = deltas.IsDefault
|
||||
? ImmutableArray<DeltaSummary>.Empty
|
||||
: deltas.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal).ToImmutableArray();
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureRun(schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
public RunTrigger Trigger { get; }
|
||||
|
||||
public RunState State { get; init; }
|
||||
|
||||
public RunStats Stats { get; init; }
|
||||
|
||||
public RunReason Reason { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? FinishedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<DeltaSummary> Deltas { get; } = ImmutableArray<DeltaSummary>.Empty;
|
||||
|
||||
private static ImmutableArray<DeltaSummary> NormalizeDeltas(IEnumerable<DeltaSummary>? deltas)
|
||||
{
|
||||
if (deltas is null)
|
||||
{
|
||||
return ImmutableArray<DeltaSummary>.Empty;
|
||||
}
|
||||
|
||||
return deltas
|
||||
.Where(static delta => delta is not null)
|
||||
.Select(static delta => delta!)
|
||||
.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context describing why a run executed.
|
||||
/// </summary>
|
||||
public sealed record RunReason
|
||||
{
|
||||
public static RunReason Empty { get; } = new();
|
||||
|
||||
public RunReason(
|
||||
string? manualReason = null,
|
||||
string? feedserExportId = null,
|
||||
string? vexerExportId = null,
|
||||
string? cursor = null)
|
||||
{
|
||||
ManualReason = Validation.TrimToNull(manualReason);
|
||||
FeedserExportId = Validation.TrimToNull(feedserExportId);
|
||||
VexerExportId = Validation.TrimToNull(vexerExportId);
|
||||
Cursor = Validation.TrimToNull(cursor);
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ManualReason { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? FeedserExportId { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? VexerExportId { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Cursor { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ImpactWindowFrom { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ImpactWindowTo { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated counters for a scheduler run.
|
||||
/// </summary>
|
||||
public sealed record RunStats
|
||||
{
|
||||
public static RunStats Empty { get; } = new();
|
||||
|
||||
public RunStats(
|
||||
int candidates = 0,
|
||||
int deduped = 0,
|
||||
int queued = 0,
|
||||
int completed = 0,
|
||||
int deltas = 0,
|
||||
int newCriticals = 0,
|
||||
int newHigh = 0,
|
||||
int newMedium = 0,
|
||||
int newLow = 0)
|
||||
{
|
||||
Candidates = Validation.EnsureNonNegative(candidates, nameof(candidates));
|
||||
Deduped = Validation.EnsureNonNegative(deduped, nameof(deduped));
|
||||
Queued = Validation.EnsureNonNegative(queued, nameof(queued));
|
||||
Completed = Validation.EnsureNonNegative(completed, nameof(completed));
|
||||
Deltas = Validation.EnsureNonNegative(deltas, nameof(deltas));
|
||||
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
|
||||
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
|
||||
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
|
||||
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
|
||||
}
|
||||
|
||||
public int Candidates { get; } = 0;
|
||||
|
||||
public int Deduped { get; } = 0;
|
||||
|
||||
public int Queued { get; } = 0;
|
||||
|
||||
public int Completed { get; } = 0;
|
||||
|
||||
public int Deltas { get; } = 0;
|
||||
|
||||
public int NewCriticals { get; } = 0;
|
||||
|
||||
public int NewHigh { get; } = 0;
|
||||
|
||||
public int NewMedium { get; } = 0;
|
||||
|
||||
public int NewLow { get; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of delta impact for an image processed in a run.
|
||||
/// </summary>
|
||||
public sealed record DeltaSummary
|
||||
{
|
||||
public DeltaSummary(
|
||||
string imageDigest,
|
||||
int newFindings,
|
||||
int newCriticals,
|
||||
int newHigh,
|
||||
int newMedium,
|
||||
int newLow,
|
||||
IEnumerable<string>? kevHits = null,
|
||||
IEnumerable<DeltaFinding>? topFindings = null,
|
||||
string? reportUrl = null,
|
||||
DeltaAttestation? attestation = null,
|
||||
DateTimeOffset? detectedAt = null)
|
||||
: this(
|
||||
imageDigest,
|
||||
Validation.EnsureNonNegative(newFindings, nameof(newFindings)),
|
||||
Validation.EnsureNonNegative(newCriticals, nameof(newCriticals)),
|
||||
Validation.EnsureNonNegative(newHigh, nameof(newHigh)),
|
||||
Validation.EnsureNonNegative(newMedium, nameof(newMedium)),
|
||||
Validation.EnsureNonNegative(newLow, nameof(newLow)),
|
||||
NormalizeKevHits(kevHits),
|
||||
NormalizeFindings(topFindings),
|
||||
Validation.TrimToNull(reportUrl),
|
||||
attestation,
|
||||
Validation.NormalizeTimestamp(detectedAt))
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public DeltaSummary(
|
||||
string imageDigest,
|
||||
int newFindings,
|
||||
int newCriticals,
|
||||
int newHigh,
|
||||
int newMedium,
|
||||
int newLow,
|
||||
ImmutableArray<string> kevHits,
|
||||
ImmutableArray<DeltaFinding> topFindings,
|
||||
string? reportUrl,
|
||||
DeltaAttestation? attestation,
|
||||
DateTimeOffset? detectedAt)
|
||||
{
|
||||
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
|
||||
NewFindings = Validation.EnsureNonNegative(newFindings, nameof(newFindings));
|
||||
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
|
||||
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
|
||||
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
|
||||
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
|
||||
KevHits = kevHits.IsDefault ? ImmutableArray<string>.Empty : kevHits;
|
||||
TopFindings = topFindings.IsDefault
|
||||
? ImmutableArray<DeltaFinding>.Empty
|
||||
: topFindings
|
||||
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
|
||||
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
ReportUrl = Validation.TrimToNull(reportUrl);
|
||||
Attestation = attestation;
|
||||
DetectedAt = Validation.NormalizeTimestamp(detectedAt);
|
||||
}
|
||||
|
||||
public string ImageDigest { get; }
|
||||
|
||||
public int NewFindings { get; }
|
||||
|
||||
public int NewCriticals { get; }
|
||||
|
||||
public int NewHigh { get; }
|
||||
|
||||
public int NewMedium { get; }
|
||||
|
||||
public int NewLow { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> KevHits { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<DeltaFinding> TopFindings { get; } = ImmutableArray<DeltaFinding>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReportUrl { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DeltaAttestation? Attestation { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? DetectedAt { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeKevHits(IEnumerable<string>? kevHits)
|
||||
=> Validation.NormalizeStringSet(kevHits, nameof(kevHits));
|
||||
|
||||
private static ImmutableArray<DeltaFinding> NormalizeFindings(IEnumerable<DeltaFinding>? findings)
|
||||
{
|
||||
if (findings is null)
|
||||
{
|
||||
return ImmutableArray<DeltaFinding>.Empty;
|
||||
}
|
||||
|
||||
return findings
|
||||
.Where(static finding => finding is not null)
|
||||
.Select(static finding => finding!)
|
||||
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
|
||||
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top finding entry included in delta summaries.
|
||||
/// </summary>
|
||||
public sealed record DeltaFinding
|
||||
{
|
||||
public DeltaFinding(string purl, string vulnerabilityId, SeverityRank severity, string? link = null)
|
||||
{
|
||||
Purl = Validation.EnsureSimpleIdentifier(purl, nameof(purl));
|
||||
VulnerabilityId = Validation.EnsureSimpleIdentifier(vulnerabilityId, nameof(vulnerabilityId));
|
||||
Severity = severity;
|
||||
Link = Validation.TrimToNull(link);
|
||||
}
|
||||
|
||||
public string Purl { get; }
|
||||
|
||||
public string VulnerabilityId { get; }
|
||||
|
||||
public SeverityRank Severity { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Link { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor/attestation information surfaced with a delta summary.
|
||||
/// </summary>
|
||||
public sealed record DeltaAttestation
|
||||
{
|
||||
public DeltaAttestation(string? uuid, bool? verified = null)
|
||||
{
|
||||
Uuid = Validation.TrimToNull(uuid);
|
||||
Verified = verified;
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Uuid { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? Verified { get; }
|
||||
}
|
||||
|
||||
internal sealed class SeverityRankComparer : IComparer<SeverityRank>
|
||||
{
|
||||
public static SeverityRankComparer Instance { get; } = new();
|
||||
|
||||
private static readonly Dictionary<SeverityRank, int> Order = new()
|
||||
{
|
||||
[SeverityRank.Critical] = 0,
|
||||
[SeverityRank.High] = 1,
|
||||
[SeverityRank.Unknown] = 2,
|
||||
[SeverityRank.Medium] = 3,
|
||||
[SeverityRank.Low] = 4,
|
||||
[SeverityRank.Info] = 5,
|
||||
[SeverityRank.None] = 6,
|
||||
};
|
||||
|
||||
public int Compare(SeverityRank x, SeverityRank y)
|
||||
=> GetOrder(x).CompareTo(GetOrder(y));
|
||||
|
||||
private static int GetOrder(SeverityRank severity)
|
||||
=> Order.TryGetValue(severity, out var value) ? value : int.MaxValue;
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Execution record for a scheduler run.
|
||||
/// </summary>
|
||||
public sealed record Run
|
||||
{
|
||||
public Run(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunTrigger trigger,
|
||||
RunState state,
|
||||
RunStats stats,
|
||||
DateTimeOffset createdAt,
|
||||
RunReason? reason = null,
|
||||
string? scheduleId = null,
|
||||
DateTimeOffset? startedAt = null,
|
||||
DateTimeOffset? finishedAt = null,
|
||||
string? error = null,
|
||||
IEnumerable<DeltaSummary>? deltas = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
trigger,
|
||||
state,
|
||||
stats,
|
||||
reason ?? RunReason.Empty,
|
||||
scheduleId,
|
||||
Validation.NormalizeTimestamp(createdAt),
|
||||
Validation.NormalizeTimestamp(startedAt),
|
||||
Validation.NormalizeTimestamp(finishedAt),
|
||||
Validation.TrimToNull(error),
|
||||
NormalizeDeltas(deltas),
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public Run(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunTrigger trigger,
|
||||
RunState state,
|
||||
RunStats stats,
|
||||
RunReason reason,
|
||||
string? scheduleId,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? startedAt,
|
||||
DateTimeOffset? finishedAt,
|
||||
string? error,
|
||||
ImmutableArray<DeltaSummary> deltas,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
Trigger = trigger;
|
||||
State = state;
|
||||
Stats = stats ?? throw new ArgumentNullException(nameof(stats));
|
||||
Reason = reason ?? RunReason.Empty;
|
||||
ScheduleId = Validation.TrimToNull(scheduleId);
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
StartedAt = Validation.NormalizeTimestamp(startedAt);
|
||||
FinishedAt = Validation.NormalizeTimestamp(finishedAt);
|
||||
Error = Validation.TrimToNull(error);
|
||||
Deltas = deltas.IsDefault
|
||||
? ImmutableArray<DeltaSummary>.Empty
|
||||
: deltas.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal).ToImmutableArray();
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureRun(schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
public RunTrigger Trigger { get; }
|
||||
|
||||
public RunState State { get; init; }
|
||||
|
||||
public RunStats Stats { get; init; }
|
||||
|
||||
public RunReason Reason { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? FinishedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<DeltaSummary> Deltas { get; } = ImmutableArray<DeltaSummary>.Empty;
|
||||
|
||||
private static ImmutableArray<DeltaSummary> NormalizeDeltas(IEnumerable<DeltaSummary>? deltas)
|
||||
{
|
||||
if (deltas is null)
|
||||
{
|
||||
return ImmutableArray<DeltaSummary>.Empty;
|
||||
}
|
||||
|
||||
return deltas
|
||||
.Where(static delta => delta is not null)
|
||||
.Select(static delta => delta!)
|
||||
.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context describing why a run executed.
|
||||
/// </summary>
|
||||
public sealed record RunReason
|
||||
{
|
||||
public static RunReason Empty { get; } = new();
|
||||
|
||||
public RunReason(
|
||||
string? manualReason = null,
|
||||
string? conselierExportId = null,
|
||||
string? excitorExportId = null,
|
||||
string? cursor = null)
|
||||
{
|
||||
ManualReason = Validation.TrimToNull(manualReason);
|
||||
ConselierExportId = Validation.TrimToNull(conselierExportId);
|
||||
ExcitorExportId = Validation.TrimToNull(excitorExportId);
|
||||
Cursor = Validation.TrimToNull(cursor);
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ManualReason { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ConselierExportId { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ExcitorExportId { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Cursor { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ImpactWindowFrom { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ImpactWindowTo { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated counters for a scheduler run.
|
||||
/// </summary>
|
||||
public sealed record RunStats
|
||||
{
|
||||
public static RunStats Empty { get; } = new();
|
||||
|
||||
public RunStats(
|
||||
int candidates = 0,
|
||||
int deduped = 0,
|
||||
int queued = 0,
|
||||
int completed = 0,
|
||||
int deltas = 0,
|
||||
int newCriticals = 0,
|
||||
int newHigh = 0,
|
||||
int newMedium = 0,
|
||||
int newLow = 0)
|
||||
{
|
||||
Candidates = Validation.EnsureNonNegative(candidates, nameof(candidates));
|
||||
Deduped = Validation.EnsureNonNegative(deduped, nameof(deduped));
|
||||
Queued = Validation.EnsureNonNegative(queued, nameof(queued));
|
||||
Completed = Validation.EnsureNonNegative(completed, nameof(completed));
|
||||
Deltas = Validation.EnsureNonNegative(deltas, nameof(deltas));
|
||||
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
|
||||
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
|
||||
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
|
||||
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
|
||||
}
|
||||
|
||||
public int Candidates { get; } = 0;
|
||||
|
||||
public int Deduped { get; } = 0;
|
||||
|
||||
public int Queued { get; } = 0;
|
||||
|
||||
public int Completed { get; } = 0;
|
||||
|
||||
public int Deltas { get; } = 0;
|
||||
|
||||
public int NewCriticals { get; } = 0;
|
||||
|
||||
public int NewHigh { get; } = 0;
|
||||
|
||||
public int NewMedium { get; } = 0;
|
||||
|
||||
public int NewLow { get; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of delta impact for an image processed in a run.
|
||||
/// </summary>
|
||||
public sealed record DeltaSummary
|
||||
{
|
||||
public DeltaSummary(
|
||||
string imageDigest,
|
||||
int newFindings,
|
||||
int newCriticals,
|
||||
int newHigh,
|
||||
int newMedium,
|
||||
int newLow,
|
||||
IEnumerable<string>? kevHits = null,
|
||||
IEnumerable<DeltaFinding>? topFindings = null,
|
||||
string? reportUrl = null,
|
||||
DeltaAttestation? attestation = null,
|
||||
DateTimeOffset? detectedAt = null)
|
||||
: this(
|
||||
imageDigest,
|
||||
Validation.EnsureNonNegative(newFindings, nameof(newFindings)),
|
||||
Validation.EnsureNonNegative(newCriticals, nameof(newCriticals)),
|
||||
Validation.EnsureNonNegative(newHigh, nameof(newHigh)),
|
||||
Validation.EnsureNonNegative(newMedium, nameof(newMedium)),
|
||||
Validation.EnsureNonNegative(newLow, nameof(newLow)),
|
||||
NormalizeKevHits(kevHits),
|
||||
NormalizeFindings(topFindings),
|
||||
Validation.TrimToNull(reportUrl),
|
||||
attestation,
|
||||
Validation.NormalizeTimestamp(detectedAt))
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public DeltaSummary(
|
||||
string imageDigest,
|
||||
int newFindings,
|
||||
int newCriticals,
|
||||
int newHigh,
|
||||
int newMedium,
|
||||
int newLow,
|
||||
ImmutableArray<string> kevHits,
|
||||
ImmutableArray<DeltaFinding> topFindings,
|
||||
string? reportUrl,
|
||||
DeltaAttestation? attestation,
|
||||
DateTimeOffset? detectedAt)
|
||||
{
|
||||
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
|
||||
NewFindings = Validation.EnsureNonNegative(newFindings, nameof(newFindings));
|
||||
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
|
||||
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
|
||||
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
|
||||
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
|
||||
KevHits = kevHits.IsDefault ? ImmutableArray<string>.Empty : kevHits;
|
||||
TopFindings = topFindings.IsDefault
|
||||
? ImmutableArray<DeltaFinding>.Empty
|
||||
: topFindings
|
||||
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
|
||||
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
ReportUrl = Validation.TrimToNull(reportUrl);
|
||||
Attestation = attestation;
|
||||
DetectedAt = Validation.NormalizeTimestamp(detectedAt);
|
||||
}
|
||||
|
||||
public string ImageDigest { get; }
|
||||
|
||||
public int NewFindings { get; }
|
||||
|
||||
public int NewCriticals { get; }
|
||||
|
||||
public int NewHigh { get; }
|
||||
|
||||
public int NewMedium { get; }
|
||||
|
||||
public int NewLow { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> KevHits { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<DeltaFinding> TopFindings { get; } = ImmutableArray<DeltaFinding>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReportUrl { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DeltaAttestation? Attestation { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? DetectedAt { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeKevHits(IEnumerable<string>? kevHits)
|
||||
=> Validation.NormalizeStringSet(kevHits, nameof(kevHits));
|
||||
|
||||
private static ImmutableArray<DeltaFinding> NormalizeFindings(IEnumerable<DeltaFinding>? findings)
|
||||
{
|
||||
if (findings is null)
|
||||
{
|
||||
return ImmutableArray<DeltaFinding>.Empty;
|
||||
}
|
||||
|
||||
return findings
|
||||
.Where(static finding => finding is not null)
|
||||
.Select(static finding => finding!)
|
||||
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
|
||||
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top finding entry included in delta summaries.
|
||||
/// </summary>
|
||||
public sealed record DeltaFinding
|
||||
{
|
||||
public DeltaFinding(string purl, string vulnerabilityId, SeverityRank severity, string? link = null)
|
||||
{
|
||||
Purl = Validation.EnsureSimpleIdentifier(purl, nameof(purl));
|
||||
VulnerabilityId = Validation.EnsureSimpleIdentifier(vulnerabilityId, nameof(vulnerabilityId));
|
||||
Severity = severity;
|
||||
Link = Validation.TrimToNull(link);
|
||||
}
|
||||
|
||||
public string Purl { get; }
|
||||
|
||||
public string VulnerabilityId { get; }
|
||||
|
||||
public SeverityRank Severity { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Link { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor/attestation information surfaced with a delta summary.
|
||||
/// </summary>
|
||||
public sealed record DeltaAttestation
|
||||
{
|
||||
public DeltaAttestation(string? uuid, bool? verified = null)
|
||||
{
|
||||
Uuid = Validation.TrimToNull(uuid);
|
||||
Verified = verified;
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Uuid { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? Verified { get; }
|
||||
}
|
||||
|
||||
internal sealed class SeverityRankComparer : IComparer<SeverityRank>
|
||||
{
|
||||
public static SeverityRankComparer Instance { get; } = new();
|
||||
|
||||
private static readonly Dictionary<SeverityRank, int> Order = new()
|
||||
{
|
||||
[SeverityRank.Critical] = 0,
|
||||
[SeverityRank.High] = 1,
|
||||
[SeverityRank.Unknown] = 2,
|
||||
[SeverityRank.Medium] = 3,
|
||||
[SeverityRank.Low] = 4,
|
||||
[SeverityRank.Info] = 5,
|
||||
[SeverityRank.None] = 6,
|
||||
};
|
||||
|
||||
public int Compare(SeverityRank x, SeverityRank y)
|
||||
=> GetOrder(x).CompareTo(GetOrder(y));
|
||||
|
||||
private static int GetOrder(SeverityRank severity)
|
||||
=> Order.TryGetValue(severity, out var value) ? value : int.MaxValue;
|
||||
}
|
||||
|
||||
@@ -432,14 +432,14 @@ internal sealed class SchedulerEventPublisher : ISchedulerEventPublisher
|
||||
return $"manual:{reason.ManualReason}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reason.FeedserExportId))
|
||||
if (!string.IsNullOrWhiteSpace(reason.ConselierExportId))
|
||||
{
|
||||
return $"feedser:{reason.FeedserExportId}";
|
||||
return $"conselier:{reason.ConselierExportId}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reason.VexerExportId))
|
||||
if (!string.IsNullOrWhiteSpace(reason.ExcitorExportId))
|
||||
{
|
||||
return $"vexer:{reason.VexerExportId}";
|
||||
return $"excitor:{reason.ExcitorExportId}";
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -10,7 +10,7 @@ rescan activity in near real time.
|
||||
- `scheduler.rescan.delta@1` — published once per runner segment when that
|
||||
segment produced at least one meaningful delta (new critical/high findings or
|
||||
KEV hits). Payload batches all impacted digests for the segment and includes
|
||||
severity totals. Reason strings (manual trigger, Feedser/Vexer exports) flow
|
||||
severity totals. Reason strings (manual trigger, Conselier/Excitor exports) flow
|
||||
from the run reason when present.
|
||||
- `scanner.report.ready@1` — published for every image the runner processes.
|
||||
The payload mirrors the Scanner contract (verdict, summary buckets, DSSE
|
||||
|
||||
Reference in New Issue
Block a user