feat: Implement vulnerability token signing and verification utilities
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:
master
2025-11-03 10:02:29 +02:00
parent bf2bf4b395
commit b1e78fe412
215 changed files with 19441 additions and 12185 deletions

View File

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

View File

@@ -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,

View File

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

View File

@@ -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;

View File

@@ -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