Rename Feedser to Concelier
This commit is contained in:
		
							
								
								
									
										30
									
								
								src/StellaOps.Concelier.Models/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/StellaOps.Concelier.Models/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| # AGENTS | ||||
| ## Role | ||||
| Canonical data model for normalized advisories and all downstream serialization. Source of truth for merge/export. | ||||
| ## Scope | ||||
| - Canonical types: Advisory, AdvisoryReference, CvssMetric, AffectedPackage, AffectedVersionRange, AdvisoryProvenance. | ||||
| - Invariants: stable ordering, culture-invariant serialization, UTC timestamps, deterministic equality semantics. | ||||
| - Field semantics: preserve all aliases/references; ranges per ecosystem (NEVRA/EVR/SemVer); provenance on every mapped field. | ||||
| - Backward/forward compatibility: additive evolution; versioned DTOs where needed; no breaking field renames. | ||||
| - Detailed field coverage documented in `CANONICAL_RECORDS.md`; update alongside model changes. | ||||
| ## Participants | ||||
| - Source connectors map external DTOs into these types. | ||||
| - Merge engine composes/overrides AffectedPackage sets and consolidates references/aliases. | ||||
| - Exporters serialize canonical documents deterministically. | ||||
| ## Interfaces & contracts | ||||
| - Null-object statics: Advisory.Empty, AdvisoryReference.Empty, CvssMetric.Empty. | ||||
| - AffectedPackage.Type describes semantics (e.g., rpm, deb, cpe, semver). Identifier is stable (e.g., NEVRA, PURL, CPE). | ||||
| - Version ranges list is ordered by introduction then fix; provenance identifies source/kind/value/recordedAt. | ||||
| - Alias schemes must include CVE, GHSA, OSV, JVN/JVNDB, BDU, VU(CERT/CC), MSRC, CISCO-SA, ORACLE-CPU, APSB/APA, APPLE-HT, CHROMIUM-POST, VMSA, RHSA, USN, DSA, SUSE-SU, ICSA, CWE, CPE, PURL. | ||||
| ## In/Out of scope | ||||
| In: data shapes, invariants, helpers for canonical serialization and comparison. | ||||
| Out: fetching/parsing external schemas, storage, HTTP. | ||||
| ## Observability & security expectations | ||||
| - No secrets; purely in-memory types. | ||||
| - Provide debug renders for test snapshots (canonical JSON). | ||||
| - Emit model version identifiers in logs when canonical structures change; keep adapters for older readers until deprecated. | ||||
| ## Tests | ||||
| - Author and review coverage in `../StellaOps.Concelier.Models.Tests`. | ||||
| - Shared fixtures (e.g., `MongoIntegrationFixture`, `ConnectorTestHarness`) live in `../StellaOps.Concelier.Testing`. | ||||
| - Keep fixtures deterministic; match new cases to real-world advisories or regression scenarios. | ||||
|  | ||||
							
								
								
									
										223
									
								
								src/StellaOps.Concelier.Models/Advisory.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								src/StellaOps.Concelier.Models/Advisory.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,223 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Canonical advisory document produced after merge. Collections are pre-sorted for deterministic serialization. | ||||
| /// </summary> | ||||
| public sealed record Advisory | ||||
| { | ||||
|     public static Advisory Empty { get; } = new( | ||||
|         advisoryKey: "unknown", | ||||
|         title: "", | ||||
|         summary: null, | ||||
|         language: null, | ||||
|         published: null, | ||||
|         modified: null, | ||||
|         severity: null, | ||||
|         exploitKnown: false, | ||||
|         aliases: Array.Empty<string>(), | ||||
|         credits: Array.Empty<AdvisoryCredit>(), | ||||
|         references: Array.Empty<AdvisoryReference>(), | ||||
|         affectedPackages: Array.Empty<AffectedPackage>(), | ||||
|         cvssMetrics: Array.Empty<CvssMetric>(), | ||||
|         provenance: Array.Empty<AdvisoryProvenance>(), | ||||
|         description: null, | ||||
|         cwes: Array.Empty<AdvisoryWeakness>(), | ||||
|         canonicalMetricId: null); | ||||
|  | ||||
|     public Advisory( | ||||
|         string advisoryKey, | ||||
|         string title, | ||||
|         string? summary, | ||||
|         string? language, | ||||
|         DateTimeOffset? published, | ||||
|         DateTimeOffset? modified, | ||||
|         string? severity, | ||||
|         bool exploitKnown, | ||||
|         IEnumerable<string>? aliases, | ||||
|         IEnumerable<AdvisoryReference>? references, | ||||
|         IEnumerable<AffectedPackage>? affectedPackages, | ||||
|         IEnumerable<CvssMetric>? cvssMetrics, | ||||
|         IEnumerable<AdvisoryProvenance>? provenance, | ||||
|         string? description = null, | ||||
|         IEnumerable<AdvisoryWeakness>? cwes = null, | ||||
|         string? canonicalMetricId = null) | ||||
|         : this( | ||||
|             advisoryKey, | ||||
|             title, | ||||
|             summary, | ||||
|             language, | ||||
|             published, | ||||
|             modified, | ||||
|             severity, | ||||
|             exploitKnown, | ||||
|             aliases, | ||||
|             Array.Empty<AdvisoryCredit>(), | ||||
|             references, | ||||
|             affectedPackages, | ||||
|             cvssMetrics, | ||||
|             provenance, | ||||
|             description, | ||||
|             cwes, | ||||
|             canonicalMetricId) | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public Advisory( | ||||
|         string advisoryKey, | ||||
|         string title, | ||||
|         string? summary, | ||||
|         string? language, | ||||
|         DateTimeOffset? published, | ||||
|         DateTimeOffset? modified, | ||||
|         string? severity, | ||||
|         bool exploitKnown, | ||||
|         IEnumerable<string>? aliases, | ||||
|         IEnumerable<AdvisoryCredit>? credits, | ||||
|         IEnumerable<AdvisoryReference>? references, | ||||
|         IEnumerable<AffectedPackage>? affectedPackages, | ||||
|         IEnumerable<CvssMetric>? cvssMetrics, | ||||
|         IEnumerable<AdvisoryProvenance>? provenance, | ||||
|         string? description = null, | ||||
|         IEnumerable<AdvisoryWeakness>? cwes = null, | ||||
|         string? canonicalMetricId = null) | ||||
|     { | ||||
|         AdvisoryKey = Validation.EnsureNotNullOrWhiteSpace(advisoryKey, nameof(advisoryKey)); | ||||
|         Title = Validation.EnsureNotNullOrWhiteSpace(title, nameof(title)); | ||||
|         Summary = Validation.TrimToNull(summary); | ||||
|         Description = Validation.TrimToNull(description); | ||||
|         Language = Validation.TrimToNull(language)?.ToLowerInvariant(); | ||||
|         Published = published?.ToUniversalTime(); | ||||
|         Modified = modified?.ToUniversalTime(); | ||||
|         Severity = SeverityNormalization.Normalize(severity); | ||||
|         ExploitKnown = exploitKnown; | ||||
|  | ||||
|         Aliases = (aliases ?? Array.Empty<string>()) | ||||
|             .Select(static alias => Validation.TryNormalizeAlias(alias, out var normalized) ? normalized! : null) | ||||
|             .Where(static alias => alias is not null) | ||||
|             .Distinct(StringComparer.Ordinal) | ||||
|             .OrderBy(static alias => alias, StringComparer.Ordinal) | ||||
|             .Select(static alias => alias!) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         Credits = (credits ?? Array.Empty<AdvisoryCredit>()) | ||||
|             .Where(static credit => credit is not null) | ||||
|             .OrderBy(static credit => credit.Role, StringComparer.Ordinal) | ||||
|             .ThenBy(static credit => credit.DisplayName, StringComparer.Ordinal) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         References = (references ?? Array.Empty<AdvisoryReference>()) | ||||
|             .Where(static reference => reference is not null) | ||||
|             .OrderBy(static reference => reference.Url, StringComparer.Ordinal) | ||||
|             .ThenBy(static reference => reference.Kind, StringComparer.Ordinal) | ||||
|             .ThenBy(static reference => reference.SourceTag, StringComparer.Ordinal) | ||||
|             .ThenBy(static reference => reference.Provenance.RecordedAt) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         AffectedPackages = (affectedPackages ?? Array.Empty<AffectedPackage>()) | ||||
|             .Where(static package => package is not null) | ||||
|             .OrderBy(static package => package.Type, StringComparer.Ordinal) | ||||
|             .ThenBy(static package => package.Identifier, StringComparer.Ordinal) | ||||
|             .ThenBy(static package => package.Platform, StringComparer.Ordinal) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         CvssMetrics = (cvssMetrics ?? Array.Empty<CvssMetric>()) | ||||
|             .Where(static metric => metric is not null) | ||||
|             .OrderBy(static metric => metric.Version, StringComparer.Ordinal) | ||||
|             .ThenBy(static metric => metric.Vector, StringComparer.Ordinal) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         Cwes = (cwes ?? Array.Empty<AdvisoryWeakness>()) | ||||
|             .Where(static weakness => weakness is not null) | ||||
|             .OrderBy(static weakness => weakness.Taxonomy, StringComparer.Ordinal) | ||||
|             .ThenBy(static weakness => weakness.Identifier, StringComparer.Ordinal) | ||||
|             .ThenBy(static weakness => weakness.Name, StringComparer.Ordinal) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         CanonicalMetricId = Validation.TrimToNull(canonicalMetricId); | ||||
|  | ||||
|         Provenance = (provenance ?? Array.Empty<AdvisoryProvenance>()) | ||||
|             .Where(static p => p is not null) | ||||
|             .OrderBy(static p => p.Source, StringComparer.Ordinal) | ||||
|             .ThenBy(static p => p.Kind, StringComparer.Ordinal) | ||||
|             .ThenBy(static p => p.RecordedAt) | ||||
|             .ToImmutableArray(); | ||||
|     } | ||||
|  | ||||
|     [JsonConstructor] | ||||
|     public Advisory( | ||||
|         string advisoryKey, | ||||
|         string title, | ||||
|         string? summary, | ||||
|         string? language, | ||||
|         DateTimeOffset? published, | ||||
|         DateTimeOffset? modified, | ||||
|         string? severity, | ||||
|         bool exploitKnown, | ||||
|         ImmutableArray<string> aliases, | ||||
|         ImmutableArray<AdvisoryCredit> credits, | ||||
|         ImmutableArray<AdvisoryReference> references, | ||||
|         ImmutableArray<AffectedPackage> affectedPackages, | ||||
|         ImmutableArray<CvssMetric> cvssMetrics, | ||||
|         ImmutableArray<AdvisoryProvenance> provenance, | ||||
|         string? description, | ||||
|         ImmutableArray<AdvisoryWeakness> cwes, | ||||
|         string? canonicalMetricId) | ||||
|         : this( | ||||
|             advisoryKey, | ||||
|             title, | ||||
|             summary, | ||||
|             language, | ||||
|             published, | ||||
|             modified, | ||||
|             severity, | ||||
|             exploitKnown, | ||||
|             aliases.IsDefault ? null : aliases.AsEnumerable(), | ||||
|             credits.IsDefault ? null : credits.AsEnumerable(), | ||||
|             references.IsDefault ? null : references.AsEnumerable(), | ||||
|             affectedPackages.IsDefault ? null : affectedPackages.AsEnumerable(), | ||||
|             cvssMetrics.IsDefault ? null : cvssMetrics.AsEnumerable(), | ||||
|             provenance.IsDefault ? null : provenance.AsEnumerable(), | ||||
|             description, | ||||
|             cwes.IsDefault ? null : cwes.AsEnumerable(), | ||||
|             canonicalMetricId) | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public string AdvisoryKey { get; } | ||||
|  | ||||
|     public string Title { get; } | ||||
|  | ||||
|     public string? Summary { get; } | ||||
|  | ||||
|     public string? Description { get; } | ||||
|  | ||||
|     public string? Language { get; } | ||||
|  | ||||
|     public DateTimeOffset? Published { get; } | ||||
|  | ||||
|     public DateTimeOffset? Modified { get; } | ||||
|  | ||||
|     public string? Severity { get; } | ||||
|  | ||||
|     public bool ExploitKnown { get; } | ||||
|  | ||||
|     public ImmutableArray<string> Aliases { get; } | ||||
|  | ||||
|     public ImmutableArray<AdvisoryCredit> Credits { get; } | ||||
|  | ||||
|     public ImmutableArray<AdvisoryReference> References { get; } | ||||
|  | ||||
|     public ImmutableArray<AffectedPackage> AffectedPackages { get; } | ||||
|  | ||||
|     public ImmutableArray<CvssMetric> CvssMetrics { get; } | ||||
|  | ||||
|     public ImmutableArray<AdvisoryWeakness> Cwes { get; } | ||||
|  | ||||
|     public string? CanonicalMetricId { get; } | ||||
|  | ||||
|     public ImmutableArray<AdvisoryProvenance> Provenance { get; } | ||||
| } | ||||
							
								
								
									
										101
									
								
								src/StellaOps.Concelier.Models/AdvisoryCredit.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/StellaOps.Concelier.Models/AdvisoryCredit.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,101 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Canonical acknowledgement/credit metadata associated with an advisory. | ||||
| /// </summary> | ||||
| public sealed record AdvisoryCredit | ||||
| { | ||||
|     public static AdvisoryCredit Empty { get; } = new("unknown", role: null, contacts: Array.Empty<string>(), AdvisoryProvenance.Empty); | ||||
|  | ||||
|     [JsonConstructor] | ||||
|     public AdvisoryCredit(string displayName, string? role, ImmutableArray<string> contacts, AdvisoryProvenance provenance) | ||||
|         : this(displayName, role, contacts.IsDefault ? null : contacts.AsEnumerable(), provenance) | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public AdvisoryCredit(string displayName, string? role, IEnumerable<string>? contacts, AdvisoryProvenance provenance) | ||||
|     { | ||||
|         DisplayName = Validation.EnsureNotNullOrWhiteSpace(displayName, nameof(displayName)); | ||||
|         Role = NormalizeRole(role); | ||||
|         Contacts = NormalizeContacts(contacts); | ||||
|         Provenance = provenance ?? AdvisoryProvenance.Empty; | ||||
|     } | ||||
|  | ||||
|     public string DisplayName { get; } | ||||
|  | ||||
|     public string? Role { get; } | ||||
|  | ||||
|     public ImmutableArray<string> Contacts { get; } | ||||
|  | ||||
|     public AdvisoryProvenance Provenance { get; } | ||||
|  | ||||
|     private static string? NormalizeRole(string? role) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(role)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var span = role.AsSpan(); | ||||
|         var buffer = new StringBuilder(span.Length); | ||||
|  | ||||
|         foreach (var ch in span) | ||||
|         { | ||||
|             if (char.IsLetterOrDigit(ch)) | ||||
|             { | ||||
|                 buffer.Append(char.ToLowerInvariant(ch)); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (ch is '-' or '_' or ' ') | ||||
|             { | ||||
|                 if (buffer.Length > 0 && buffer[^1] != '_') | ||||
|                 { | ||||
|                     buffer.Append('_'); | ||||
|                 } | ||||
|  | ||||
|                 continue; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         while (buffer.Length > 0 && buffer[^1] == '_') | ||||
|         { | ||||
|             buffer.Length--; | ||||
|         } | ||||
|  | ||||
|         return buffer.Length == 0 ? null : buffer.ToString(); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<string> NormalizeContacts(IEnumerable<string>? contacts) | ||||
|     { | ||||
|         if (contacts is null) | ||||
|         { | ||||
|             return ImmutableArray<string>.Empty; | ||||
|         } | ||||
|  | ||||
|         var set = new SortedSet<string>(StringComparer.Ordinal); | ||||
|         foreach (var contact in contacts) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(contact)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var trimmed = contact.Trim(); | ||||
|             if (trimmed.Length == 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             set.Add(trimmed); | ||||
|         } | ||||
|  | ||||
|         return set.Count == 0 ? ImmutableArray<string>.Empty : set.ToImmutableArray(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										70
									
								
								src/StellaOps.Concelier.Models/AdvisoryProvenance.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/StellaOps.Concelier.Models/AdvisoryProvenance.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Describes the origin of a canonical field and how/when it was captured. | ||||
| /// </summary> | ||||
| public sealed record AdvisoryProvenance | ||||
| { | ||||
|     public static AdvisoryProvenance Empty { get; } = new("unknown", "unspecified", string.Empty, DateTimeOffset.UnixEpoch); | ||||
|  | ||||
|     [JsonConstructor] | ||||
|     public AdvisoryProvenance( | ||||
|         string source, | ||||
|         string kind, | ||||
|         string value, | ||||
|         string? decisionReason, | ||||
|         DateTimeOffset recordedAt, | ||||
|         ImmutableArray<string> fieldMask) | ||||
|         : this(source, kind, value, recordedAt, fieldMask.IsDefault ? null : fieldMask.AsEnumerable(), decisionReason) | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public AdvisoryProvenance( | ||||
|         string source, | ||||
|         string kind, | ||||
|         string value, | ||||
|         DateTimeOffset recordedAt, | ||||
|         IEnumerable<string>? fieldMask = null, | ||||
|         string? decisionReason = null) | ||||
|     { | ||||
|         Source = Validation.EnsureNotNullOrWhiteSpace(source, nameof(source)); | ||||
|         Kind = Validation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)); | ||||
|         Value = Validation.TrimToNull(value); | ||||
|         DecisionReason = Validation.TrimToNull(decisionReason); | ||||
|         RecordedAt = recordedAt.ToUniversalTime(); | ||||
|         FieldMask = NormalizeFieldMask(fieldMask); | ||||
|     } | ||||
|  | ||||
|     public string Source { get; } | ||||
|  | ||||
|     public string Kind { get; } | ||||
|  | ||||
|     public string? Value { get; } | ||||
|  | ||||
|     public string? DecisionReason { get; } | ||||
|  | ||||
|     public DateTimeOffset RecordedAt { get; } | ||||
|  | ||||
|     public ImmutableArray<string> FieldMask { get; } | ||||
|  | ||||
|     private static ImmutableArray<string> NormalizeFieldMask(IEnumerable<string>? fieldMask) | ||||
|     { | ||||
|         if (fieldMask is null) | ||||
|         { | ||||
|             return ImmutableArray<string>.Empty; | ||||
|         } | ||||
|  | ||||
|         var buffer = fieldMask | ||||
|             .Where(static value => !string.IsNullOrWhiteSpace(value)) | ||||
|             .Select(static value => value.Trim().ToLowerInvariant()) | ||||
|             .Distinct(StringComparer.Ordinal) | ||||
|             .OrderBy(static value => value, StringComparer.Ordinal) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         return buffer.IsDefault ? ImmutableArray<string>.Empty : buffer; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										36
									
								
								src/StellaOps.Concelier.Models/AdvisoryReference.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/StellaOps.Concelier.Models/AdvisoryReference.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Canonical external reference associated with an advisory. | ||||
| /// </summary> | ||||
| public sealed record AdvisoryReference | ||||
| { | ||||
|     public static AdvisoryReference Empty { get; } = new("https://invalid.local/", kind: null, sourceTag: null, summary: null, provenance: AdvisoryProvenance.Empty); | ||||
|  | ||||
|     [JsonConstructor] | ||||
|     public AdvisoryReference(string url, string? kind, string? sourceTag, string? summary, AdvisoryProvenance provenance) | ||||
|     { | ||||
|         if (!Validation.LooksLikeHttpUrl(url)) | ||||
|         { | ||||
|             throw new ArgumentException("Reference URL must be an absolute http(s) URI.", nameof(url)); | ||||
|         } | ||||
|  | ||||
|         Url = url; | ||||
|         Kind = Validation.TrimToNull(kind); | ||||
|         SourceTag = Validation.TrimToNull(sourceTag); | ||||
|         Summary = Validation.TrimToNull(summary); | ||||
|         Provenance = provenance ?? AdvisoryProvenance.Empty; | ||||
|     } | ||||
|  | ||||
|     public string Url { get; } | ||||
|  | ||||
|     public string? Kind { get; } | ||||
|  | ||||
|     public string? SourceTag { get; } | ||||
|  | ||||
|     public string? Summary { get; } | ||||
|  | ||||
|     public AdvisoryProvenance Provenance { get; } | ||||
| } | ||||
							
								
								
									
										56
									
								
								src/StellaOps.Concelier.Models/AdvisoryWeakness.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/StellaOps.Concelier.Models/AdvisoryWeakness.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Canonical weakness (e.g., CWE entry) associated with an advisory. | ||||
| /// </summary> | ||||
| public sealed record AdvisoryWeakness | ||||
| { | ||||
|     public static AdvisoryWeakness Empty { get; } = new("cwe", "CWE-000", null, null, Array.Empty<AdvisoryProvenance>()); | ||||
|  | ||||
|     [JsonConstructor] | ||||
|     public AdvisoryWeakness(string taxonomy, string identifier, string? name, string? uri, ImmutableArray<AdvisoryProvenance> provenance) | ||||
|         : this(taxonomy, identifier, name, uri, provenance.IsDefault ? null : provenance.AsEnumerable()) | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public AdvisoryWeakness(string taxonomy, string identifier, string? name, string? uri, IEnumerable<AdvisoryProvenance>? provenance) | ||||
|     { | ||||
|         Taxonomy = NormalizeTaxonomy(taxonomy); | ||||
|         Identifier = NormalizeIdentifier(identifier); | ||||
|         Name = Validation.TrimToNull(name); | ||||
|         Uri = Validation.TrimToNull(uri); | ||||
|         Provenance = (provenance ?? Array.Empty<AdvisoryProvenance>()) | ||||
|             .Where(static value => value is not null) | ||||
|             .OrderBy(static value => value.Source, StringComparer.Ordinal) | ||||
|             .ThenBy(static value => value.Kind, StringComparer.Ordinal) | ||||
|             .ThenBy(static value => value.RecordedAt) | ||||
|             .ToImmutableArray(); | ||||
|     } | ||||
|  | ||||
|     public string Taxonomy { get; } | ||||
|  | ||||
|     public string Identifier { get; } | ||||
|  | ||||
|     public string? Name { get; } | ||||
|  | ||||
|     public string? Uri { get; } | ||||
|  | ||||
|     public ImmutableArray<AdvisoryProvenance> Provenance { get; } | ||||
|  | ||||
|     private static string NormalizeTaxonomy(string taxonomy) | ||||
|     { | ||||
|         var normalized = Validation.EnsureNotNullOrWhiteSpace(taxonomy, nameof(taxonomy)).Trim(); | ||||
|         return normalized.Length == 0 ? "cwe" : normalized.ToLowerInvariant(); | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeIdentifier(string identifier) | ||||
|     { | ||||
|         var normalized = Validation.EnsureNotNullOrWhiteSpace(identifier, nameof(identifier)).Trim(); | ||||
|         return normalized.ToUpperInvariant(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										97
									
								
								src/StellaOps.Concelier.Models/AffectedPackage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								src/StellaOps.Concelier.Models/AffectedPackage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Canonical affected package descriptor with deterministic ordering of ranges and provenance. | ||||
| /// </summary> | ||||
| public sealed record AffectedPackage | ||||
| { | ||||
|     public static AffectedPackage Empty { get; } = new( | ||||
|         AffectedPackageTypes.SemVer, | ||||
|         identifier: "unknown", | ||||
|         platform: null, | ||||
|         versionRanges: Array.Empty<AffectedVersionRange>(), | ||||
|         statuses: Array.Empty<AffectedPackageStatus>(), | ||||
|         provenance: Array.Empty<AdvisoryProvenance>(), | ||||
|         normalizedVersions: Array.Empty<NormalizedVersionRule>()); | ||||
|  | ||||
|     [JsonConstructor] | ||||
|     public AffectedPackage( | ||||
|         string type, | ||||
|         string identifier, | ||||
|         string? platform = null, | ||||
|         IEnumerable<AffectedVersionRange>? versionRanges = null, | ||||
|         IEnumerable<AffectedPackageStatus>? statuses = null, | ||||
|         IEnumerable<AdvisoryProvenance>? provenance = null, | ||||
|         IEnumerable<NormalizedVersionRule>? normalizedVersions = null) | ||||
|     { | ||||
|         Type = Validation.EnsureNotNullOrWhiteSpace(type, nameof(type)).ToLowerInvariant(); | ||||
|         Identifier = Validation.EnsureNotNullOrWhiteSpace(identifier, nameof(identifier)); | ||||
|         Platform = Validation.TrimToNull(platform); | ||||
|  | ||||
|         VersionRanges = (versionRanges ?? Array.Empty<AffectedVersionRange>()) | ||||
|             .Distinct(AffectedVersionRangeEqualityComparer.Instance) | ||||
|             .OrderBy(static range => range, AffectedVersionRangeComparer.Instance) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         Statuses = (statuses ?? Array.Empty<AffectedPackageStatus>()) | ||||
|             .Where(static status => status is not null) | ||||
|             .Distinct(AffectedPackageStatusEqualityComparer.Instance) | ||||
|             .OrderBy(static status => status.Status, StringComparer.Ordinal) | ||||
|             .ThenBy(static status => status.Provenance.Source, StringComparer.Ordinal) | ||||
|             .ThenBy(static status => status.Provenance.Kind, StringComparer.Ordinal) | ||||
|             .ThenBy(static status => status.Provenance.RecordedAt) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         NormalizedVersions = (normalizedVersions ?? Array.Empty<NormalizedVersionRule>()) | ||||
|             .Where(static rule => rule is not null) | ||||
|             .Distinct(NormalizedVersionRuleEqualityComparer.Instance) | ||||
|             .OrderBy(static rule => rule, NormalizedVersionRuleComparer.Instance) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         Provenance = (provenance ?? Array.Empty<AdvisoryProvenance>()) | ||||
|             .Where(static p => p is not null) | ||||
|             .OrderBy(static p => p.Source, StringComparer.Ordinal) | ||||
|             .ThenBy(static p => p.Kind, StringComparer.Ordinal) | ||||
|             .ThenBy(static p => p.RecordedAt) | ||||
|             .ToImmutableArray(); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Semantic type of the coordinates (rpm, deb, cpe, semver, vendor, ics-vendor). | ||||
|     /// </summary> | ||||
|     public string Type { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Canonical identifier for the package (NEVRA, PackageURL, CPE string, vendor slug, etc.). | ||||
|     /// </summary> | ||||
|     public string Identifier { get; } | ||||
|  | ||||
|     public string? Platform { get; } | ||||
|  | ||||
|     public ImmutableArray<AffectedVersionRange> VersionRanges { get; } | ||||
|  | ||||
|     public ImmutableArray<AffectedPackageStatus> Statuses { get; } | ||||
|  | ||||
|     public ImmutableArray<NormalizedVersionRule> NormalizedVersions { get; } | ||||
|  | ||||
|     public ImmutableArray<AdvisoryProvenance> Provenance { get; } | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Known values for <see cref="AffectedPackage.Type"/>. | ||||
| /// </summary> | ||||
| public static class AffectedPackageTypes | ||||
| { | ||||
|     public const string Rpm = "rpm"; | ||||
|     public const string Deb = "deb"; | ||||
|     public const string Cpe = "cpe"; | ||||
|     public const string SemVer = "semver"; | ||||
|     public const string Vendor = "vendor"; | ||||
|     public const string IcsVendor = "ics-vendor"; | ||||
| } | ||||
							
								
								
									
										46
									
								
								src/StellaOps.Concelier.Models/AffectedPackageStatus.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/StellaOps.Concelier.Models/AffectedPackageStatus.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a vendor-supplied status tag for an affected package when a concrete version range is unavailable or supplementary. | ||||
| /// </summary> | ||||
| public sealed record AffectedPackageStatus | ||||
| { | ||||
|     [JsonConstructor] | ||||
|     public AffectedPackageStatus(string status, AdvisoryProvenance provenance) | ||||
|     { | ||||
|         Status = AffectedPackageStatusCatalog.Normalize(status); | ||||
|         Provenance = provenance ?? AdvisoryProvenance.Empty; | ||||
|     } | ||||
|  | ||||
|     public string Status { get; } | ||||
|  | ||||
|     public AdvisoryProvenance Provenance { get; } | ||||
| } | ||||
|  | ||||
| public sealed class AffectedPackageStatusEqualityComparer : IEqualityComparer<AffectedPackageStatus> | ||||
| { | ||||
|     public static AffectedPackageStatusEqualityComparer Instance { get; } = new(); | ||||
|  | ||||
|     public bool Equals(AffectedPackageStatus? x, AffectedPackageStatus? y) | ||||
|     { | ||||
|         if (ReferenceEquals(x, y)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (x is null || y is null) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return string.Equals(x.Status, y.Status, StringComparison.Ordinal) | ||||
|             && EqualityComparer<AdvisoryProvenance>.Default.Equals(x.Provenance, y.Provenance); | ||||
|     } | ||||
|  | ||||
|     public int GetHashCode(AffectedPackageStatus obj) | ||||
|         => HashCode.Combine(obj.Status, obj.Provenance); | ||||
| } | ||||
							
								
								
									
										157
									
								
								src/StellaOps.Concelier.Models/AffectedPackageStatusCatalog.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								src/StellaOps.Concelier.Models/AffectedPackageStatusCatalog.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Central registry of allowed affected-package status labels to keep connectors consistent. | ||||
| /// </summary> | ||||
| public static class AffectedPackageStatusCatalog | ||||
| { | ||||
|     public const string KnownAffected = "known_affected"; | ||||
|     public const string KnownNotAffected = "known_not_affected"; | ||||
|     public const string UnderInvestigation = "under_investigation"; | ||||
|     public const string Fixed = "fixed"; | ||||
|     public const string FirstFixed = "first_fixed"; | ||||
|     public const string Mitigated = "mitigated"; | ||||
|     public const string NotApplicable = "not_applicable"; | ||||
|     public const string Affected = "affected"; | ||||
|     public const string NotAffected = "not_affected"; | ||||
|     public const string Pending = "pending"; | ||||
|     public const string Unknown = "unknown"; | ||||
|  | ||||
|     private static readonly string[] CanonicalStatuses = | ||||
|     { | ||||
|         KnownAffected, | ||||
|         KnownNotAffected, | ||||
|         UnderInvestigation, | ||||
|         Fixed, | ||||
|         FirstFixed, | ||||
|         Mitigated, | ||||
|         NotApplicable, | ||||
|         Affected, | ||||
|         NotAffected, | ||||
|         Pending, | ||||
|         Unknown, | ||||
|     }; | ||||
|  | ||||
|     private static readonly IReadOnlyList<string> AllowedStatuses = Array.AsReadOnly(CanonicalStatuses); | ||||
|  | ||||
|     private static readonly IReadOnlyDictionary<string, string> StatusMap = BuildStatusMap(); | ||||
|  | ||||
|     public static IReadOnlyList<string> Allowed => AllowedStatuses; | ||||
|  | ||||
|     public static string Normalize(string status) | ||||
|     { | ||||
|         if (!TryNormalize(status, out var normalized)) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(status), status, "Status is not part of the allowed affected-package status glossary."); | ||||
|         } | ||||
|  | ||||
|         return normalized; | ||||
|     } | ||||
|  | ||||
|     public static bool TryNormalize(string? status, [NotNullWhen(true)] out string? normalized) | ||||
|     { | ||||
|         normalized = null; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(status)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var token = Sanitize(status); | ||||
|         if (token.Length == 0) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!StatusMap.TryGetValue(token, out normalized)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public static bool IsAllowed(string? status) | ||||
|         => TryNormalize(status, out _); | ||||
|  | ||||
|     private static IReadOnlyDictionary<string, string> BuildStatusMap() | ||||
|     { | ||||
|         var map = new Dictionary<string, string>(StringComparer.Ordinal); | ||||
|         foreach (var status in CanonicalStatuses) | ||||
|         { | ||||
|             map[Sanitize(status)] = status; | ||||
|         } | ||||
|  | ||||
|         Add(map, "known not vulnerable", KnownNotAffected); | ||||
|         Add(map, "known unaffected", KnownNotAffected); | ||||
|         Add(map, "known not impacted", KnownNotAffected); | ||||
|         Add(map, "vulnerable", Affected); | ||||
|         Add(map, "impacted", Affected); | ||||
|         Add(map, "impacting", Affected); | ||||
|         Add(map, "not vulnerable", NotAffected); | ||||
|         Add(map, "unaffected", NotAffected); | ||||
|         Add(map, "not impacted", NotAffected); | ||||
|         Add(map, "no impact", NotAffected); | ||||
|         Add(map, "impact free", NotAffected); | ||||
|         Add(map, "investigating", UnderInvestigation); | ||||
|         Add(map, "analysis in progress", UnderInvestigation); | ||||
|         Add(map, "analysis pending", UnderInvestigation); | ||||
|         Add(map, "open", UnderInvestigation); | ||||
|         Add(map, "patch available", Fixed); | ||||
|         Add(map, "fix available", Fixed); | ||||
|         Add(map, "patched", Fixed); | ||||
|         Add(map, "resolved", Fixed); | ||||
|         Add(map, "remediated", Fixed); | ||||
|         Add(map, "workaround available", Mitigated); | ||||
|         Add(map, "mitigation available", Mitigated); | ||||
|         Add(map, "mitigation provided", Mitigated); | ||||
|         Add(map, "not applicable", NotApplicable); | ||||
|         Add(map, "n/a", NotApplicable); | ||||
|         Add(map, "na", NotApplicable); | ||||
|         Add(map, "does not apply", NotApplicable); | ||||
|         Add(map, "out of scope", NotApplicable); | ||||
|         Add(map, "pending fix", Pending); | ||||
|         Add(map, "awaiting fix", Pending); | ||||
|         Add(map, "awaiting patch", Pending); | ||||
|         Add(map, "scheduled", Pending); | ||||
|         Add(map, "planned", Pending); | ||||
|         Add(map, "tbd", Unknown); | ||||
|         Add(map, "to be determined", Unknown); | ||||
|         Add(map, "undetermined", Unknown); | ||||
|         Add(map, "not yet known", Unknown); | ||||
|  | ||||
|         return map; | ||||
|     } | ||||
|  | ||||
|     private static void Add(IDictionary<string, string> map, string alias, string canonical) | ||||
|     { | ||||
|         var key = Sanitize(alias); | ||||
|         if (key.Length == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         map[key] = canonical; | ||||
|     } | ||||
|  | ||||
|     private static string Sanitize(string value) | ||||
|     { | ||||
|         var span = value.AsSpan(); | ||||
|         var buffer = new char[span.Length]; | ||||
|         var index = 0; | ||||
|  | ||||
|         foreach (var ch in span) | ||||
|         { | ||||
|             if (char.IsLetterOrDigit(ch)) | ||||
|             { | ||||
|                 buffer[index++] = char.ToLowerInvariant(ch); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return index == 0 ? string.Empty : new string(buffer, 0, index); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										149
									
								
								src/StellaOps.Concelier.Models/AffectedVersionRange.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								src/StellaOps.Concelier.Models/AffectedVersionRange.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Describes a contiguous range of versions impacted by an advisory. | ||||
| /// </summary> | ||||
| public sealed record AffectedVersionRange | ||||
| { | ||||
|     [JsonConstructor] | ||||
|     public AffectedVersionRange( | ||||
|         string rangeKind, | ||||
|         string? introducedVersion, | ||||
|         string? fixedVersion, | ||||
|         string? lastAffectedVersion, | ||||
|         string? rangeExpression, | ||||
|         AdvisoryProvenance provenance, | ||||
|         RangePrimitives? primitives = null) | ||||
|     { | ||||
|         RangeKind = Validation.EnsureNotNullOrWhiteSpace(rangeKind, nameof(rangeKind)).ToLowerInvariant(); | ||||
|         IntroducedVersion = Validation.TrimToNull(introducedVersion); | ||||
|         FixedVersion = Validation.TrimToNull(fixedVersion); | ||||
|         LastAffectedVersion = Validation.TrimToNull(lastAffectedVersion); | ||||
|         RangeExpression = Validation.TrimToNull(rangeExpression); | ||||
|         Provenance = provenance ?? AdvisoryProvenance.Empty; | ||||
|         Primitives = primitives; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Semantic kind of the range (e.g., semver, nevra, evr). | ||||
|     /// </summary> | ||||
|     public string RangeKind { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Inclusive version where impact begins. | ||||
|     /// </summary> | ||||
|     public string? IntroducedVersion { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Exclusive version where impact ends due to a fix. | ||||
|     /// </summary> | ||||
|     public string? FixedVersion { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Inclusive upper bound where the vendor reports exposure (when no fix available). | ||||
|     /// </summary> | ||||
|     public string? LastAffectedVersion { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Normalized textual representation of the range (fallback). | ||||
|     /// </summary> | ||||
|     public string? RangeExpression { get; } | ||||
|  | ||||
|     public AdvisoryProvenance Provenance { get; } | ||||
|  | ||||
|     public RangePrimitives? Primitives { get; } | ||||
|  | ||||
|     public string CreateDeterministicKey() | ||||
|         => string.Join('|', RangeKind, IntroducedVersion ?? string.Empty, FixedVersion ?? string.Empty, LastAffectedVersion ?? string.Empty, RangeExpression ?? string.Empty); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Deterministic comparer for version ranges. Orders by introduced, fixed, last affected, expression, kind. | ||||
| /// </summary> | ||||
| public sealed class AffectedVersionRangeComparer : IComparer<AffectedVersionRange> | ||||
| { | ||||
|     public static AffectedVersionRangeComparer Instance { get; } = new(); | ||||
|  | ||||
|     private static readonly StringComparer Comparer = StringComparer.Ordinal; | ||||
|  | ||||
|     public int Compare(AffectedVersionRange? x, AffectedVersionRange? y) | ||||
|     { | ||||
|         if (ReferenceEquals(x, y)) | ||||
|         { | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         if (x is null) | ||||
|         { | ||||
|             return -1; | ||||
|         } | ||||
|  | ||||
|         if (y is null) | ||||
|         { | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         var compare = Comparer.Compare(x.IntroducedVersion, y.IntroducedVersion); | ||||
|         if (compare != 0) | ||||
|         { | ||||
|             return compare; | ||||
|         } | ||||
|  | ||||
|         compare = Comparer.Compare(x.FixedVersion, y.FixedVersion); | ||||
|         if (compare != 0) | ||||
|         { | ||||
|             return compare; | ||||
|         } | ||||
|  | ||||
|         compare = Comparer.Compare(x.LastAffectedVersion, y.LastAffectedVersion); | ||||
|         if (compare != 0) | ||||
|         { | ||||
|             return compare; | ||||
|         } | ||||
|  | ||||
|         compare = Comparer.Compare(x.RangeExpression, y.RangeExpression); | ||||
|         if (compare != 0) | ||||
|         { | ||||
|             return compare; | ||||
|         } | ||||
|  | ||||
|         return Comparer.Compare(x.RangeKind, y.RangeKind); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Equality comparer that ignores provenance differences. | ||||
| /// </summary> | ||||
| public sealed class AffectedVersionRangeEqualityComparer : IEqualityComparer<AffectedVersionRange> | ||||
| { | ||||
|     public static AffectedVersionRangeEqualityComparer Instance { get; } = new(); | ||||
|  | ||||
|     public bool Equals(AffectedVersionRange? x, AffectedVersionRange? y) | ||||
|     { | ||||
|         if (ReferenceEquals(x, y)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (x is null || y is null) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return string.Equals(x.RangeKind, y.RangeKind, StringComparison.Ordinal) | ||||
|             && string.Equals(x.IntroducedVersion, y.IntroducedVersion, StringComparison.Ordinal) | ||||
|             && string.Equals(x.FixedVersion, y.FixedVersion, StringComparison.Ordinal) | ||||
|             && string.Equals(x.LastAffectedVersion, y.LastAffectedVersion, StringComparison.Ordinal) | ||||
|             && string.Equals(x.RangeExpression, y.RangeExpression, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     public int GetHashCode(AffectedVersionRange obj) | ||||
|         => HashCode.Combine( | ||||
|             obj.RangeKind, | ||||
|             obj.IntroducedVersion, | ||||
|             obj.FixedVersion, | ||||
|             obj.LastAffectedVersion, | ||||
|             obj.RangeExpression); | ||||
| } | ||||
							
								
								
									
										221
									
								
								src/StellaOps.Concelier.Models/AffectedVersionRangeExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								src/StellaOps.Concelier.Models/AffectedVersionRangeExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,221 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Helpers for deriving normalized version rules from affected version ranges. | ||||
| /// </summary> | ||||
| public static class AffectedVersionRangeExtensions | ||||
| { | ||||
|     public static NormalizedVersionRule? ToNormalizedVersionRule(this AffectedVersionRange? range, string? notes = null) | ||||
|     { | ||||
|         if (range is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var primitives = range.Primitives; | ||||
|  | ||||
|         var semVerRule = primitives?.SemVer?.ToNormalizedVersionRule(notes); | ||||
|         if (semVerRule is not null) | ||||
|         { | ||||
|             return semVerRule; | ||||
|         } | ||||
|  | ||||
|         var nevraRule = primitives?.Nevra?.ToNormalizedVersionRule(notes); | ||||
|         if (nevraRule is not null) | ||||
|         { | ||||
|             return nevraRule; | ||||
|         } | ||||
|  | ||||
|         var evrRule = primitives?.Evr?.ToNormalizedVersionRule(notes); | ||||
|         if (evrRule is not null) | ||||
|         { | ||||
|             return evrRule; | ||||
|         } | ||||
|  | ||||
|         var scheme = Validation.TrimToNull(range.RangeKind)?.ToLowerInvariant(); | ||||
|         return scheme switch | ||||
|         { | ||||
|             NormalizedVersionSchemes.SemVer => BuildSemVerFallback(range, notes), | ||||
|             NormalizedVersionSchemes.Nevra => BuildNevraFallback(range, notes), | ||||
|             NormalizedVersionSchemes.Evr => BuildEvrFallback(range, notes), | ||||
|             _ => null, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static NormalizedVersionRule? BuildSemVerFallback(AffectedVersionRange range, string? notes) | ||||
|     { | ||||
|         var min = Validation.TrimToNull(range.IntroducedVersion); | ||||
|         var max = Validation.TrimToNull(range.FixedVersion); | ||||
|         var last = Validation.TrimToNull(range.LastAffectedVersion); | ||||
|         var resolvedNotes = Validation.TrimToNull(notes); | ||||
|  | ||||
|         if (string.IsNullOrEmpty(min) && string.IsNullOrEmpty(max) && string.IsNullOrEmpty(last)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(max)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.SemVer, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: min, | ||||
|                 minInclusive: min is null ? null : true, | ||||
|                 max: max, | ||||
|                 maxInclusive: false, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(last)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.SemVer, | ||||
|                 NormalizedVersionRuleTypes.LessThanOrEqual, | ||||
|                 max: last, | ||||
|                 maxInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(min)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.SemVer, | ||||
|                 NormalizedVersionRuleTypes.GreaterThanOrEqual, | ||||
|                 min: min, | ||||
|                 minInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static NormalizedVersionRule? BuildNevraFallback(AffectedVersionRange range, string? notes) | ||||
|     { | ||||
|         var resolvedNotes = Validation.TrimToNull(notes); | ||||
|         var introduced = Validation.TrimToNull(range.IntroducedVersion); | ||||
|         var fixedVersion = Validation.TrimToNull(range.FixedVersion); | ||||
|         var lastAffected = Validation.TrimToNull(range.LastAffectedVersion); | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(fixedVersion)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.Nevra, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 max: fixedVersion, | ||||
|                 maxInclusive: false, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.Nevra, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 max: lastAffected, | ||||
|                 maxInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.Nevra, | ||||
|                 NormalizedVersionRuleTypes.GreaterThanOrEqual, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(fixedVersion)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.Nevra, | ||||
|                 NormalizedVersionRuleTypes.LessThan, | ||||
|                 max: fixedVersion, | ||||
|                 maxInclusive: false, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.Nevra, | ||||
|                 NormalizedVersionRuleTypes.LessThanOrEqual, | ||||
|                 max: lastAffected, | ||||
|                 maxInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static NormalizedVersionRule? BuildEvrFallback(AffectedVersionRange range, string? notes) | ||||
|     { | ||||
|         var resolvedNotes = Validation.TrimToNull(notes); | ||||
|         var introduced = Validation.TrimToNull(range.IntroducedVersion); | ||||
|         var fixedVersion = Validation.TrimToNull(range.FixedVersion); | ||||
|         var lastAffected = Validation.TrimToNull(range.LastAffectedVersion); | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(fixedVersion)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.Evr, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 max: fixedVersion, | ||||
|                 maxInclusive: false, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.Evr, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 max: lastAffected, | ||||
|                 maxInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.Evr, | ||||
|                 NormalizedVersionRuleTypes.GreaterThanOrEqual, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(fixedVersion)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.Evr, | ||||
|                 NormalizedVersionRuleTypes.LessThan, | ||||
|                 max: fixedVersion, | ||||
|                 maxInclusive: false, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 NormalizedVersionSchemes.Evr, | ||||
|                 NormalizedVersionRuleTypes.LessThanOrEqual, | ||||
|                 max: lastAffected, | ||||
|                 maxInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										166
									
								
								src/StellaOps.Concelier.Models/AliasSchemeRegistry.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								src/StellaOps.Concelier.Models/AliasSchemeRegistry.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,166 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Text.RegularExpressions; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| public static class AliasSchemeRegistry | ||||
| { | ||||
|     private sealed record AliasScheme( | ||||
|         string Name, | ||||
|         Func<string?, bool> Predicate, | ||||
|         Func<string?, string> Normalizer); | ||||
|  | ||||
| private static readonly AliasScheme[] SchemeDefinitions = | ||||
|     { | ||||
|         BuildScheme(AliasSchemes.Cve, alias => alias is not null && Matches(CvERegex, alias), alias => alias is null ? string.Empty : NormalizePrefix(alias, "CVE")), | ||||
|         BuildScheme(AliasSchemes.Ghsa, alias => alias is not null && Matches(GhsaRegex, alias), alias => alias is null ? string.Empty : NormalizePrefix(alias, "GHSA")), | ||||
|         BuildScheme(AliasSchemes.OsV, alias => alias is not null && Matches(OsVRegex, alias), alias => alias is null ? string.Empty : NormalizePrefix(alias, "OSV")), | ||||
|         BuildScheme(AliasSchemes.Jvn, alias => alias is not null && Matches(JvnRegex, alias), alias => alias is null ? string.Empty : NormalizePrefix(alias, "JVN")), | ||||
|         BuildScheme(AliasSchemes.Jvndb, alias => alias is not null && Matches(JvndbRegex, alias), alias => alias is null ? string.Empty : NormalizePrefix(alias, "JVNDB")), | ||||
|         BuildScheme(AliasSchemes.Bdu, alias => alias is not null && Matches(BduRegex, alias), alias => alias is null ? string.Empty : NormalizePrefix(alias, "BDU")), | ||||
|         BuildScheme(AliasSchemes.Vu, alias => alias is not null && alias.StartsWith("VU#", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "VU", preserveSeparator: '#')), | ||||
|         BuildScheme(AliasSchemes.Msrc, alias => alias is not null && alias.StartsWith("MSRC-", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "MSRC")), | ||||
|         BuildScheme(AliasSchemes.CiscoSa, alias => alias is not null && alias.StartsWith("CISCO-SA-", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "CISCO-SA")), | ||||
|         BuildScheme(AliasSchemes.OracleCpu, alias => alias is not null && alias.StartsWith("ORACLE-CPU", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "ORACLE-CPU")), | ||||
|         BuildScheme(AliasSchemes.Apsb, alias => alias is not null && alias.StartsWith("APSB-", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "APSB")), | ||||
|         BuildScheme(AliasSchemes.Apa, alias => alias is not null && alias.StartsWith("APA-", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "APA")), | ||||
|         BuildScheme(AliasSchemes.AppleHt, alias => alias is not null && alias.StartsWith("APPLE-HT", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "APPLE-HT")), | ||||
|         BuildScheme(AliasSchemes.ChromiumPost, alias => alias is not null && (alias.StartsWith("CHROMIUM-POST", StringComparison.OrdinalIgnoreCase) || alias.StartsWith("CHROMIUM:", StringComparison.OrdinalIgnoreCase)), NormalizeChromium), | ||||
|         BuildScheme(AliasSchemes.Vmsa, alias => alias is not null && alias.StartsWith("VMSA-", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "VMSA")), | ||||
|         BuildScheme(AliasSchemes.Rhsa, alias => alias is not null && alias.StartsWith("RHSA-", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "RHSA")), | ||||
|         BuildScheme(AliasSchemes.Usn, alias => alias is not null && alias.StartsWith("USN-", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "USN")), | ||||
|         BuildScheme(AliasSchemes.Dsa, alias => alias is not null && alias.StartsWith("DSA-", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "DSA")), | ||||
|         BuildScheme(AliasSchemes.SuseSu, alias => alias is not null && alias.StartsWith("SUSE-SU-", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "SUSE-SU")), | ||||
|         BuildScheme(AliasSchemes.Icsa, alias => alias is not null && alias.StartsWith("ICSA-", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "ICSA")), | ||||
|         BuildScheme(AliasSchemes.Cwe, alias => alias is not null && Matches(CweRegex, alias), alias => alias is null ? string.Empty : NormalizePrefix(alias, "CWE")), | ||||
|         BuildScheme(AliasSchemes.Cpe, alias => alias is not null && alias.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "cpe", uppercase:false)), | ||||
|         BuildScheme(AliasSchemes.Purl, alias => alias is not null && alias.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase), alias => NormalizePrefix(alias, "pkg", uppercase:false)), | ||||
|     }; | ||||
|  | ||||
|     private static AliasScheme BuildScheme(string name, Func<string?, bool> predicate, Func<string?, string> normalizer) | ||||
|         => new( | ||||
|             name, | ||||
|             predicate, | ||||
|             alias => normalizer(alias)); | ||||
|  | ||||
|     private static readonly ImmutableHashSet<string> SchemeNames = SchemeDefinitions | ||||
|         .Select(static scheme => scheme.Name) | ||||
|         .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|     private static readonly Regex CvERegex = new("^CVE-\\d{4}-\\d{4,}$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); | ||||
|     private static readonly Regex GhsaRegex = new("^GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); | ||||
|     private static readonly Regex OsVRegex = new("^OSV-\\d{4}-\\d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); | ||||
|     private static readonly Regex JvnRegex = new("^JVN-\\d{4}-\\d{6}$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); | ||||
|     private static readonly Regex JvndbRegex = new("^JVNDB-\\d{4}-\\d{6}$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); | ||||
|     private static readonly Regex BduRegex = new("^BDU-\\d{4}-\\d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); | ||||
|     private static readonly Regex CweRegex = new("^CWE-\\d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); | ||||
|  | ||||
|     public static IReadOnlyCollection<string> KnownSchemes => SchemeNames; | ||||
|  | ||||
|     public static bool IsKnownScheme(string? scheme) | ||||
|         => !string.IsNullOrWhiteSpace(scheme) && SchemeNames.Contains(scheme); | ||||
|  | ||||
|     public static bool TryGetScheme(string? alias, out string scheme) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(alias)) | ||||
|         { | ||||
|             scheme = string.Empty; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var candidate = alias.Trim(); | ||||
|         foreach (var entry in SchemeDefinitions) | ||||
|         { | ||||
|             if (entry.Predicate(candidate)) | ||||
|             { | ||||
|                 scheme = entry.Name; | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         scheme = string.Empty; | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public static bool TryNormalize(string? alias, out string normalized, out string scheme) | ||||
|     { | ||||
|         normalized = string.Empty; | ||||
|         scheme = string.Empty; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(alias)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var candidate = alias.Trim(); | ||||
|         foreach (var entry in SchemeDefinitions) | ||||
|         { | ||||
|             if (entry.Predicate(candidate)) | ||||
|             { | ||||
|                 scheme = entry.Name; | ||||
|                 normalized = entry.Normalizer(candidate); | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         normalized = candidate; | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private static string NormalizePrefix(string? alias, string prefix, bool uppercase = true, char? preserveSeparator = null) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(alias)) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         var comparison = StringComparison.OrdinalIgnoreCase; | ||||
|         if (!alias.StartsWith(prefix, comparison)) | ||||
|         { | ||||
|             return uppercase ? alias : alias.ToLowerInvariant(); | ||||
|         } | ||||
|  | ||||
|         var remainder = alias[prefix.Length..]; | ||||
|         if (preserveSeparator is { } separator && remainder.Length > 0 && remainder[0] != separator) | ||||
|         { | ||||
|             // Edge case: alias is expected to use a specific separator but does not – return unchanged. | ||||
|             return uppercase ? prefix.ToUpperInvariant() + remainder : prefix + remainder; | ||||
|         } | ||||
|  | ||||
|         var normalizedPrefix = uppercase ? prefix.ToUpperInvariant() : prefix.ToLowerInvariant(); | ||||
|         return normalizedPrefix + remainder; | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeChromium(string? alias) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(alias)) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         if (alias.StartsWith("CHROMIUM-POST", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return NormalizePrefix(alias, "CHROMIUM-POST"); | ||||
|         } | ||||
|  | ||||
|         if (alias.StartsWith("CHROMIUM:", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             var remainder = alias["CHROMIUM".Length..]; | ||||
|             return "CHROMIUM" + remainder; | ||||
|         } | ||||
|  | ||||
|         return alias; | ||||
|     } | ||||
|     private static bool Matches(Regex? regex, string? candidate) | ||||
|     { | ||||
|         if (regex is null || string.IsNullOrWhiteSpace(candidate)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return regex.IsMatch(candidate); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										31
									
								
								src/StellaOps.Concelier.Models/AliasSchemes.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/StellaOps.Concelier.Models/AliasSchemes.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Well-known alias scheme identifiers referenced throughout the pipeline. | ||||
| /// </summary> | ||||
| public static class AliasSchemes | ||||
| { | ||||
|     public const string Cve = "CVE"; | ||||
|     public const string Ghsa = "GHSA"; | ||||
|     public const string OsV = "OSV"; | ||||
|     public const string Jvn = "JVN"; | ||||
|     public const string Jvndb = "JVNDB"; | ||||
|     public const string Bdu = "BDU"; | ||||
|     public const string Vu = "VU"; | ||||
|     public const string Msrc = "MSRC"; | ||||
|     public const string CiscoSa = "CISCO-SA"; | ||||
|     public const string OracleCpu = "ORACLE-CPU"; | ||||
|     public const string Apsb = "APSB"; | ||||
|     public const string Apa = "APA"; | ||||
|     public const string AppleHt = "APPLE-HT"; | ||||
|     public const string ChromiumPost = "CHROMIUM-POST"; | ||||
|     public const string Vmsa = "VMSA"; | ||||
|     public const string Rhsa = "RHSA"; | ||||
|     public const string Usn = "USN"; | ||||
|     public const string Dsa = "DSA"; | ||||
|     public const string SuseSu = "SUSE-SU"; | ||||
|     public const string Icsa = "ICSA"; | ||||
|     public const string Cwe = "CWE"; | ||||
|     public const string Cpe = "CPE"; | ||||
|     public const string Purl = "PURL"; | ||||
| } | ||||
							
								
								
									
										41
									
								
								src/StellaOps.Concelier.Models/BACKWARD_COMPATIBILITY.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/StellaOps.Concelier.Models/BACKWARD_COMPATIBILITY.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| # Canonical Model Backward-Compatibility Playbook | ||||
|  | ||||
| This playbook captures the policies and workflow required when evolving the canonical | ||||
| `StellaOps.Concelier.Models` surface. | ||||
|  | ||||
| ## Principles | ||||
|  | ||||
| - **Additive by default** – breaking field removals/renames are not allowed without a staged | ||||
|   migration plan. | ||||
| - **Version-the-writer** – any change to serialization that affects downstream consumers must bump | ||||
|   the exporter version string and update `CANONICAL_RECORDS.md`. | ||||
| - **Schema-first** – update documentation (`CANONICAL_RECORDS.md`) and corresponding tests before | ||||
|   shipping new fields. | ||||
| - **Dual-read period** – when introducing a new field, keep old readers working by: | ||||
|   1. Making the field optional in the canonical model. | ||||
|   2. Providing default behavior in exporters/mergers when the field is absent. | ||||
|   3. Communicating via release notes and toggles when the field will become required. | ||||
|  | ||||
| ## Workflow for Changes | ||||
|  | ||||
| 1. **Proposal** – raise an issue describing the motivation, affected records, and compatibility | ||||
|    impact. Link to the relevant task in `TASKS.md`. | ||||
| 2. **Docs + Tests first** – update `CANONICAL_RECORDS.md`, add/adjust golden fixtures, and extend | ||||
|    regression tests (hash comparisons, snapshot assertions) to capture the new shape. | ||||
| 3. **Implementation** – introduce the model change along with migration logic (e.g., mergers filling | ||||
|    defaults, exporters emitting the new payload). | ||||
| 4. **Exporter bump** – update exporter version manifests (`ExporterVersion.GetVersion`) whenever the | ||||
|    serialized payload differs. | ||||
| 5. **Announcement** – document the change in release notes, highlighting optional vs. required | ||||
|    timelines. | ||||
| 6. **Cleanup** – once consumers have migrated, remove transitional logic and update docs/tests to | ||||
|    reflect the permanent shape. | ||||
|  | ||||
| ## Testing Checklist | ||||
|  | ||||
| - `StellaOps.Concelier.Models.Tests` – update unit tests and golden examples. | ||||
| - `Serialization determinism` – ensure the hash regression tests cover the new fields. | ||||
| - Exporter integration (`Json`, `TrivyDb`) – confirm manifests include provenance + tree metadata | ||||
|   for the new shape. | ||||
|  | ||||
| Following this playbook keeps canonical payloads stable while allowing incremental evolution. | ||||
							
								
								
									
										144
									
								
								src/StellaOps.Concelier.Models/CANONICAL_RECORDS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								src/StellaOps.Concelier.Models/CANONICAL_RECORDS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,144 @@ | ||||
| # Canonical Record Definitions | ||||
|  | ||||
| > Source of truth for the normalized advisory schema emitted by `StellaOps.Concelier.Models`. | ||||
| > Keep this document in sync with the public record types under `StellaOps.Concelier.Models` and | ||||
| > update it whenever a new field is introduced or semantics change. | ||||
|  | ||||
| ## Advisory | ||||
|  | ||||
| | Field | Type | Required | Notes | | ||||
| |-------|------|----------|-------| | ||||
| | `advisoryKey` | string | yes | Globally unique identifier selected by the merge layer (often a CVE/GHSA/vendor key). Stored lowercased unless vendor casing is significant. | | ||||
| | `title` | string | yes | Human readable title. Must be non-empty and trimmed. | | ||||
| | `summary` | string? | optional | Short description; trimmed to `null` when empty. | | ||||
| | `language` | string? | optional | ISO language code (lowercase). | | ||||
| | `published` | DateTimeOffset? | optional | UTC timestamp when vendor originally published. | | ||||
| | `modified` | DateTimeOffset? | optional | UTC timestamp when vendor last updated. | | ||||
| | `severity` | string? | optional | Normalized severity label (`critical`, `high`, etc.). | | ||||
| | `exploitKnown` | bool | yes | Whether KEV/other sources confirm active exploitation. | | ||||
| | `aliases` | string[] | yes | Sorted, de-duplicated list of normalized aliases (see [Alias Schemes](#alias-schemes)). | | ||||
| | `credits` | AdvisoryCredit[] | yes | Deterministically ordered acknowledgements (role + contact metadata). | | ||||
| | `references` | AdvisoryReference[] | yes | Deterministically ordered reference set. | | ||||
| | `affectedPackages` | AffectedPackage[] | yes | Deterministically ordered affected packages. | | ||||
| | `cvssMetrics` | CvssMetric[] | yes | Deterministically ordered CVSS metrics (v3, v4 first). | | ||||
| | `provenance` | AdvisoryProvenance[] | yes | Normalized provenance entries sorted by source then kind then recorded timestamp. | | ||||
|  | ||||
| ### Invariants | ||||
| - Collections are immutable (`ImmutableArray<T>`) and always sorted deterministically. | ||||
| - `AdvisoryKey` and `Title` are mandatory and trimmed. | ||||
| - All timestamps are stored as UTC. | ||||
| - Aliases and references leverage helper registries for validation. | ||||
|  | ||||
| ## AdvisoryReference | ||||
|  | ||||
| | Field | Type | Required | Notes | | ||||
| |-------|------|----------|-------| | ||||
| | `url` | string | yes | Absolute HTTP/HTTPS URL. | | ||||
| | `kind` | string? | optional | Categorized reference role (e.g. `advisory`, `patch`, `changelog`). | | ||||
| | `sourceTag` | string? | optional | Free-form tag identifying originating source. | | ||||
| | `summary` | string? | optional | Short description. | | ||||
| | `provenance` | AdvisoryProvenance | yes | Provenance entry describing how the reference was mapped. | | ||||
|  | ||||
| Deterministic ordering: by `url`, then `kind`, then `sourceTag`, then `provenance.RecordedAt`. | ||||
|  | ||||
| ## AdvisoryCredit | ||||
|  | ||||
| | Field | Type | Required | Notes | | ||||
| |-------|------|----------|-------| | ||||
| | `displayName` | string | yes | Human-readable acknowledgement (reporter, maintainer, analyst, etc.). | | ||||
| | `role` | string? | optional | Normalized role token (lowercase with `_` separators). | | ||||
| | `contacts` | string[] | yes | Sorted set of vendor-supplied handles or URLs; may be empty. | | ||||
| | `provenance` | AdvisoryProvenance | yes | Provenance entry describing how the credit was captured. | | ||||
|  | ||||
| Deterministic ordering: by `role` (nulls first) then `displayName`. | ||||
|  | ||||
| ## AffectedPackage | ||||
|  | ||||
| | Field | Type | Required | Notes | | ||||
| |-------|------|----------|-------| | ||||
| | `type` | string | yes | Semantic type (`semver`, `rpm`, `deb`, `purl`, `cpe`, etc.). Lowercase. | | ||||
| | `identifier` | string | yes | Canonical identifier (package name, PURL, CPE, NEVRA, etc.). | | ||||
| | `platform` | string? | optional | Explicit platform / distro (e.g. `ubuntu`, `rhel-8`). | | ||||
| | `versionRanges` | AffectedVersionRange[] | yes | Deduplicated + sorted by introduced/fixed/last/expr/kind. | | ||||
| | `statuses` | AffectedPackageStatus[] | yes | Optional status flags (e.g. `fixed`, `affected`). | | ||||
| | `provenance` | AdvisoryProvenance[] | yes | Provenance entries for package level metadata. | | ||||
|  | ||||
| Deterministic ordering: packages sorted by `type`, then `identifier`, then `platform` (ordinal). | ||||
|  | ||||
| ## AffectedVersionRange | ||||
|  | ||||
| | Field | Type | Required | Notes | | ||||
| |-------|------|----------|-------| | ||||
| | `rangeKind` | string | yes | Classification of range semantics (`semver`, `evr`, `nevra`, `version`, `purl`). Lowercase. | | ||||
| | `introducedVersion` | string? | optional | Inclusive lower bound when impact begins. | | ||||
| | `fixedVersion` | string? | optional | Exclusive bounding version containing the fix. | | ||||
| | `lastAffectedVersion` | string? | optional | Inclusive upper bound when no fix exists. | | ||||
| | `rangeExpression` | string? | optional | Normalized textual expression for non-simple ranges. | | ||||
| | `provenance` | AdvisoryProvenance | yes | Provenance entry for the range. | | ||||
| | `primitives` | RangePrimitives? | optional | Structured metadata (SemVer/Nevra/Evr/vendor extensions) when available. | | ||||
|  | ||||
| Comparers/equality ignore provenance differences. | ||||
|  | ||||
| ## CvssMetric | ||||
|  | ||||
| | Field | Type | Required | Notes | | ||||
| |-------|------|----------|-------| | ||||
| | `version` | string | yes | `2.0`, `3.0`, `3.1`, `4.0`, etc. | | ||||
| | `vector` | string | yes | Official CVSS vector string. | | ||||
| | `score` | double | yes | CVSS base score (0.0-10.0). | | ||||
| | `severity` | string | yes | Severity label mapped from score or vendor metadata. | | ||||
| | `provenance` | AdvisoryProvenance | yes | Provenance entry. | | ||||
|  | ||||
| Sorted by version then vector for determinism. | ||||
|  | ||||
| ## AdvisoryProvenance | ||||
|  | ||||
| | Field | Type | Required | Notes | | ||||
| |-------|------|----------|-------| | ||||
| | `source` | string | yes | Logical source identifier (`nvd`, `redhat`, `osv`, etc.). | | ||||
| | `kind` | string | yes | Operation performed (`fetch`, `parse`, `map`, `merge`, `enrich`). | | ||||
| | `value` | string? | optional | Free-form pipeline detail (parser identifier, rule set, resume cursor). | | ||||
| | `recordedAt` | DateTimeOffset | yes | UTC timestamp when provenance was captured. | | ||||
| | `fieldMask` | string[] | optional | Canonical field coverage expressed as lowercase masks (e.g. `affectedpackages[]`, `affectedpackages[].versionranges[]`). | | ||||
|  | ||||
| ### Provenance Mask Expectations | ||||
| Each canonical field is expected to carry at least one provenance entry derived from the | ||||
| responsible pipeline stage. Populate `fieldMask` with the lowercase canonical mask(s) describing the | ||||
| covered field(s); downstream metrics and resume helpers rely on this signal to reason about | ||||
| coverage. When aggregating provenance from subcomponents (e.g., affected package ranges), merge code | ||||
| should ensure: | ||||
|  | ||||
| - Advisory level provenance documents the source document and merge actions. | ||||
| - References, packages, ranges, and metrics each include their own provenance entry reflecting | ||||
|   the most specific source (vendor feed, computed normalization, etc.). | ||||
| - Export-specific metadata (digest manifests, offline bundles) include exporter version alongside | ||||
|   the builder metadata. | ||||
|  | ||||
| ## Alias Schemes | ||||
|  | ||||
| Supported alias scheme prefixes: | ||||
|  | ||||
| - `CVE-` | ||||
| - `GHSA-` | ||||
| - `OSV-` | ||||
| - `JVN-`, `JVNDB-` | ||||
| - `BDU-` | ||||
| - `VU#` | ||||
| - `MSRC-` | ||||
| - `CISCO-SA-` | ||||
| - `ORACLE-CPU` | ||||
| - `APSB-`, `APA-` | ||||
| - `APPLE-HT` | ||||
| - `CHROMIUM:` / `CHROMIUM-` | ||||
| - `VMSA-` | ||||
| - `RHSA-` | ||||
| - `USN-` | ||||
| - `DSA-` | ||||
| - `SUSE-SU-` | ||||
| - `ICSA-` | ||||
| - `CWE-` | ||||
| - `cpe:` | ||||
| - `pkg:` (Package URL / PURL) | ||||
|  | ||||
| The registry exposed via `AliasSchemes` and `AliasSchemeRegistry` can be used to validate aliases and | ||||
| drive downstream conditionals without re-implementing pattern rules. | ||||
							
								
								
									
										175
									
								
								src/StellaOps.Concelier.Models/CanonicalJsonSerializer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								src/StellaOps.Concelier.Models/CanonicalJsonSerializer.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,175 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text.Encodings.Web; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Text.Json.Serialization.Metadata; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Deterministic JSON serializer tuned for canonical advisory output. | ||||
| /// </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[]> PropertyOrderOverrides = new Dictionary<Type, string[]> | ||||
|     { | ||||
|         { | ||||
|             typeof(AdvisoryProvenance), | ||||
|             new[] | ||||
|             { | ||||
|                 "source", | ||||
|                 "kind", | ||||
|                 "value", | ||||
|                 "decisionReason", | ||||
|                 "recordedAt", | ||||
|                 "fieldMask", | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             typeof(AffectedPackage), | ||||
|             new[] | ||||
|             { | ||||
|                 "type", | ||||
|                 "identifier", | ||||
|                 "platform", | ||||
|                 "versionRanges", | ||||
|                 "normalizedVersions", | ||||
|                 "statuses", | ||||
|                 "provenance", | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             typeof(AdvisoryCredit), | ||||
|             new[] | ||||
|             { | ||||
|                 "displayName", | ||||
|                 "role", | ||||
|                 "contacts", | ||||
|                 "provenance", | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             typeof(NormalizedVersionRule), | ||||
|             new[] | ||||
|             { | ||||
|                 "scheme", | ||||
|                 "type", | ||||
|                 "min", | ||||
|                 "minInclusive", | ||||
|                 "max", | ||||
|                 "maxInclusive", | ||||
|                 "value", | ||||
|                 "notes", | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             typeof(AdvisoryWeakness), | ||||
|             new[] | ||||
|             { | ||||
|                 "taxonomy", | ||||
|                 "identifier", | ||||
|                 "name", | ||||
|                 "uri", | ||||
|                 "provenance", | ||||
|             } | ||||
|         }, | ||||
|     }; | ||||
|  | ||||
|     public static string Serialize<T>(T value) | ||||
|         => JsonSerializer.Serialize(value, CompactOptions); | ||||
|  | ||||
|     public static string SerializeIndented<T>(T value) | ||||
|         => JsonSerializer.Serialize(value, PrettyOptions); | ||||
|  | ||||
|     public static Advisory Normalize(Advisory advisory) | ||||
|         => new( | ||||
|             advisory.AdvisoryKey, | ||||
|             advisory.Title, | ||||
|             advisory.Summary, | ||||
|             advisory.Language, | ||||
|             advisory.Published, | ||||
|             advisory.Modified, | ||||
|             advisory.Severity, | ||||
|             advisory.ExploitKnown, | ||||
|             advisory.Aliases, | ||||
|             advisory.Credits, | ||||
|             advisory.References, | ||||
|             advisory.AffectedPackages, | ||||
|             advisory.CvssMetrics, | ||||
|             advisory.Provenance, | ||||
|             advisory.Description, | ||||
|             advisory.Cwes, | ||||
|             advisory.CanonicalMetricId); | ||||
|  | ||||
|     public static T Deserialize<T>(string json) | ||||
|         => JsonSerializer.Deserialize<T>(json, PrettyOptions)! | ||||
|             ?? throw new InvalidOperationException($"Unable to deserialize type {typeof(T).Name}."); | ||||
|  | ||||
|     private static JsonSerializerOptions CreateOptions(bool writeIndented) | ||||
|     { | ||||
|         var options = new JsonSerializerOptions | ||||
|         { | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||||
|             DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, | ||||
|             DefaultIgnoreCondition = JsonIgnoreCondition.Never, | ||||
|             WriteIndented = writeIndented, | ||||
|             Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, | ||||
|         }; | ||||
|  | ||||
|         var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver(); | ||||
|         options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver); | ||||
|         options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)); | ||||
|         return options; | ||||
|     } | ||||
|  | ||||
|     private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver | ||||
|     { | ||||
|         private readonly IJsonTypeInfoResolver _inner; | ||||
|  | ||||
|         public DeterministicTypeInfoResolver(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 is { Count: > 1 }) | ||||
|             { | ||||
|                 var ordered = info.Properties | ||||
|                     .OrderBy(property => GetPropertyOrder(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 GetPropertyOrder(Type type, string propertyName) | ||||
|         { | ||||
|             if (PropertyOrderOverrides.TryGetValue(type, out var order) && | ||||
|                 Array.IndexOf(order, propertyName) is var index && | ||||
|                 index >= 0) | ||||
|             { | ||||
|                 return index; | ||||
|             } | ||||
|  | ||||
|             return int.MaxValue; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										31
									
								
								src/StellaOps.Concelier.Models/CvssMetric.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/StellaOps.Concelier.Models/CvssMetric.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Canonicalized CVSS metric details supporting deterministic serialization. | ||||
| /// </summary> | ||||
| public sealed record CvssMetric | ||||
| { | ||||
|     public static CvssMetric Empty { get; } = new("3.1", vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N", baseScore: 0, baseSeverity: "none", provenance: AdvisoryProvenance.Empty); | ||||
|  | ||||
|     [JsonConstructor] | ||||
|     public CvssMetric(string version, string vector, double baseScore, string baseSeverity, AdvisoryProvenance provenance) | ||||
|     { | ||||
|         Version = Validation.EnsureNotNullOrWhiteSpace(version, nameof(version)); | ||||
|         Vector = Validation.EnsureNotNullOrWhiteSpace(vector, nameof(vector)); | ||||
|         BaseSeverity = Validation.EnsureNotNullOrWhiteSpace(baseSeverity, nameof(baseSeverity)).ToLowerInvariant(); | ||||
|         BaseScore = Math.Round(baseScore, 1, MidpointRounding.AwayFromZero); | ||||
|         Provenance = provenance ?? AdvisoryProvenance.Empty; | ||||
|     } | ||||
|  | ||||
|     public string Version { get; } | ||||
|  | ||||
|     public string Vector { get; } | ||||
|  | ||||
|     public double BaseScore { get; } | ||||
|  | ||||
|     public string BaseSeverity { get; } | ||||
|  | ||||
|     public AdvisoryProvenance Provenance { get; } | ||||
| } | ||||
							
								
								
									
										87
									
								
								src/StellaOps.Concelier.Models/EvrPrimitiveExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/StellaOps.Concelier.Models/EvrPrimitiveExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Helper extensions for converting <see cref="EvrPrimitive"/> instances into normalized rules. | ||||
| /// </summary> | ||||
| public static class EvrPrimitiveExtensions | ||||
| { | ||||
|     public static NormalizedVersionRule? ToNormalizedVersionRule(this EvrPrimitive? primitive, string? notes = null) | ||||
|     { | ||||
|         if (primitive is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var resolvedNotes = Validation.TrimToNull(notes); | ||||
|         var introduced = Normalize(primitive.Introduced); | ||||
|         var fixedVersion = Normalize(primitive.Fixed); | ||||
|         var lastAffected = Normalize(primitive.LastAffected); | ||||
|         var scheme = NormalizedVersionSchemes.Evr; | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(fixedVersion)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 max: fixedVersion, | ||||
|                 maxInclusive: false, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 max: lastAffected, | ||||
|                 maxInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.GreaterThanOrEqual, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(fixedVersion)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.LessThan, | ||||
|                 max: fixedVersion, | ||||
|                 maxInclusive: false, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.LessThanOrEqual, | ||||
|                 max: lastAffected, | ||||
|                 maxInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static string? Normalize(EvrComponent? component) | ||||
|     { | ||||
|         if (component is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return Validation.TrimToNull(component.ToCanonicalString()); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										87
									
								
								src/StellaOps.Concelier.Models/NevraPrimitiveExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/StellaOps.Concelier.Models/NevraPrimitiveExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Helper extensions for converting <see cref="NevraPrimitive"/> instances into normalized rules. | ||||
| /// </summary> | ||||
| public static class NevraPrimitiveExtensions | ||||
| { | ||||
|     public static NormalizedVersionRule? ToNormalizedVersionRule(this NevraPrimitive? primitive, string? notes = null) | ||||
|     { | ||||
|         if (primitive is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var resolvedNotes = Validation.TrimToNull(notes); | ||||
|         var introduced = Normalize(primitive.Introduced); | ||||
|         var fixedVersion = Normalize(primitive.Fixed); | ||||
|         var lastAffected = Normalize(primitive.LastAffected); | ||||
|         var scheme = NormalizedVersionSchemes.Nevra; | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(fixedVersion)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 max: fixedVersion, | ||||
|                 maxInclusive: false, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 max: lastAffected, | ||||
|                 maxInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.GreaterThanOrEqual, | ||||
|                 min: introduced, | ||||
|                 minInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(fixedVersion)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.LessThan, | ||||
|                 max: fixedVersion, | ||||
|                 maxInclusive: false, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.LessThanOrEqual, | ||||
|                 max: lastAffected, | ||||
|                 maxInclusive: true, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static string? Normalize(NevraComponent? component) | ||||
|     { | ||||
|         if (component is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return Validation.TrimToNull(component.ToCanonicalString()); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										185
									
								
								src/StellaOps.Concelier.Models/NormalizedVersionRule.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								src/StellaOps.Concelier.Models/NormalizedVersionRule.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Canonical normalized version rule emitted by range builders for analytical queries. | ||||
| /// </summary> | ||||
| public sealed record NormalizedVersionRule | ||||
| { | ||||
|     public NormalizedVersionRule( | ||||
|         string scheme, | ||||
|         string type, | ||||
|         string? min = null, | ||||
|         bool? minInclusive = null, | ||||
|         string? max = null, | ||||
|         bool? maxInclusive = null, | ||||
|         string? value = null, | ||||
|         string? notes = null) | ||||
|     { | ||||
|         Scheme = Validation.EnsureNotNullOrWhiteSpace(scheme, nameof(scheme)).ToLowerInvariant(); | ||||
|         Type = Validation.EnsureNotNullOrWhiteSpace(type, nameof(type)).Replace('_', '-').ToLowerInvariant(); | ||||
|         Min = Validation.TrimToNull(min); | ||||
|         MinInclusive = minInclusive; | ||||
|         Max = Validation.TrimToNull(max); | ||||
|         MaxInclusive = maxInclusive; | ||||
|         Value = Validation.TrimToNull(value); | ||||
|         Notes = Validation.TrimToNull(notes); | ||||
|     } | ||||
|  | ||||
|     public string Scheme { get; } | ||||
|  | ||||
|     public string Type { get; } | ||||
|  | ||||
|     public string? Min { get; } | ||||
|  | ||||
|     public bool? MinInclusive { get; } | ||||
|  | ||||
|     public string? Max { get; } | ||||
|  | ||||
|     public bool? MaxInclusive { get; } | ||||
|  | ||||
|     public string? Value { get; } | ||||
|  | ||||
|     public string? Notes { get; } | ||||
| } | ||||
|  | ||||
| public sealed class NormalizedVersionRuleEqualityComparer : IEqualityComparer<NormalizedVersionRule> | ||||
| { | ||||
|     public static NormalizedVersionRuleEqualityComparer Instance { get; } = new(); | ||||
|  | ||||
|     public bool Equals(NormalizedVersionRule? x, NormalizedVersionRule? y) | ||||
|     { | ||||
|         if (ReferenceEquals(x, y)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (x is null || y is null) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return string.Equals(x.Scheme, y.Scheme, StringComparison.Ordinal) | ||||
|             && string.Equals(x.Type, y.Type, StringComparison.Ordinal) | ||||
|             && string.Equals(x.Min, y.Min, StringComparison.Ordinal) | ||||
|             && x.MinInclusive == y.MinInclusive | ||||
|             && string.Equals(x.Max, y.Max, StringComparison.Ordinal) | ||||
|             && x.MaxInclusive == y.MaxInclusive | ||||
|             && string.Equals(x.Value, y.Value, StringComparison.Ordinal) | ||||
|             && string.Equals(x.Notes, y.Notes, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     public int GetHashCode(NormalizedVersionRule obj) | ||||
|         => HashCode.Combine( | ||||
|             obj.Scheme, | ||||
|             obj.Type, | ||||
|             obj.Min, | ||||
|             obj.MinInclusive, | ||||
|             obj.Max, | ||||
|             obj.MaxInclusive, | ||||
|             obj.Value, | ||||
|             obj.Notes); | ||||
| } | ||||
|  | ||||
| public sealed class NormalizedVersionRuleComparer : IComparer<NormalizedVersionRule> | ||||
| { | ||||
|     public static NormalizedVersionRuleComparer Instance { get; } = new(); | ||||
|  | ||||
|     public int Compare(NormalizedVersionRule? x, NormalizedVersionRule? y) | ||||
|     { | ||||
|         if (ReferenceEquals(x, y)) | ||||
|         { | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         if (x is null) | ||||
|         { | ||||
|             return -1; | ||||
|         } | ||||
|  | ||||
|         if (y is null) | ||||
|         { | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         var schemeComparison = string.Compare(x.Scheme, y.Scheme, StringComparison.Ordinal); | ||||
|         if (schemeComparison != 0) | ||||
|         { | ||||
|             return schemeComparison; | ||||
|         } | ||||
|  | ||||
|         var typeComparison = string.Compare(x.Type, y.Type, StringComparison.Ordinal); | ||||
|         if (typeComparison != 0) | ||||
|         { | ||||
|             return typeComparison; | ||||
|         } | ||||
|  | ||||
|         var minComparison = string.Compare(x.Min, y.Min, StringComparison.Ordinal); | ||||
|         if (minComparison != 0) | ||||
|         { | ||||
|             return minComparison; | ||||
|         } | ||||
|  | ||||
|         var minInclusiveComparison = NullableBoolCompare(x.MinInclusive, y.MinInclusive); | ||||
|         if (minInclusiveComparison != 0) | ||||
|         { | ||||
|             return minInclusiveComparison; | ||||
|         } | ||||
|  | ||||
|         var maxComparison = string.Compare(x.Max, y.Max, StringComparison.Ordinal); | ||||
|         if (maxComparison != 0) | ||||
|         { | ||||
|             return maxComparison; | ||||
|         } | ||||
|  | ||||
|         var maxInclusiveComparison = NullableBoolCompare(x.MaxInclusive, y.MaxInclusive); | ||||
|         if (maxInclusiveComparison != 0) | ||||
|         { | ||||
|             return maxInclusiveComparison; | ||||
|         } | ||||
|  | ||||
|         var valueComparison = string.Compare(x.Value, y.Value, StringComparison.Ordinal); | ||||
|         if (valueComparison != 0) | ||||
|         { | ||||
|             return valueComparison; | ||||
|         } | ||||
|  | ||||
|         return string.Compare(x.Notes, y.Notes, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     private static int NullableBoolCompare(bool? x, bool? y) | ||||
|     { | ||||
|         if (x == y) | ||||
|         { | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         return (x, y) switch | ||||
|         { | ||||
|             (null, not null) => -1, | ||||
|             (not null, null) => 1, | ||||
|             (false, true) => -1, | ||||
|             (true, false) => 1, | ||||
|             _ => 0, | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|  | ||||
| public static class NormalizedVersionSchemes | ||||
| { | ||||
|     public const string SemVer = "semver"; | ||||
|     public const string Nevra = "nevra"; | ||||
|     public const string Evr = "evr"; | ||||
| } | ||||
|  | ||||
| public static class NormalizedVersionRuleTypes | ||||
| { | ||||
|     public const string Range = "range"; | ||||
|     public const string Exact = "exact"; | ||||
|     public const string LessThan = "lt"; | ||||
|     public const string LessThanOrEqual = "lte"; | ||||
|     public const string GreaterThan = "gt"; | ||||
|     public const string GreaterThanOrEqual = "gte"; | ||||
| } | ||||
							
								
								
									
										72
									
								
								src/StellaOps.Concelier.Models/OsvGhsaParityDiagnostics.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/StellaOps.Concelier.Models/OsvGhsaParityDiagnostics.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Diagnostics.Metrics; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Emits telemetry for OSV vs GHSA parity reports so QA dashboards can track regression trends. | ||||
| /// </summary> | ||||
| public static class OsvGhsaParityDiagnostics | ||||
| { | ||||
|     private static readonly Meter Meter = new("StellaOps.Concelier.Models.OsvGhsaParity"); | ||||
|     private static readonly Counter<long> TotalCounter = Meter.CreateCounter<long>( | ||||
|         "concelier.osv_ghsa.total", | ||||
|         unit: "count", | ||||
|         description: "Total GHSA identifiers evaluated for OSV parity."); | ||||
|     private static readonly Counter<long> IssueCounter = Meter.CreateCounter<long>( | ||||
|         "concelier.osv_ghsa.issues", | ||||
|         unit: "count", | ||||
|         description: "Parity issues grouped by dataset, issue kind, and field mask."); | ||||
|  | ||||
|     public static void RecordReport(OsvGhsaParityReport report, string dataset) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(report); | ||||
|         dataset = NormalizeDataset(dataset); | ||||
|  | ||||
|         if (report.TotalGhsaIds > 0) | ||||
|         { | ||||
|             TotalCounter.Add(report.TotalGhsaIds, CreateTotalTags(dataset)); | ||||
|         } | ||||
|  | ||||
|         if (!report.HasIssues) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         foreach (var issue in report.Issues) | ||||
|         { | ||||
|             IssueCounter.Add(1, CreateIssueTags(dataset, issue)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static KeyValuePair<string, object?>[] CreateTotalTags(string dataset) | ||||
|         => new[] | ||||
|         { | ||||
|             new KeyValuePair<string, object?>("dataset", dataset), | ||||
|         }; | ||||
|  | ||||
|     private static KeyValuePair<string, object?>[] CreateIssueTags(string dataset, OsvGhsaParityIssue issue) | ||||
|     { | ||||
|         var mask = issue.FieldMask.IsDefaultOrEmpty | ||||
|             ? "none" | ||||
|             : string.Join('|', issue.FieldMask); | ||||
|  | ||||
|         return new[] | ||||
|         { | ||||
|             new KeyValuePair<string, object?>("dataset", dataset), | ||||
|             new KeyValuePair<string, object?>("issueKind", issue.IssueKind), | ||||
|             new KeyValuePair<string, object?>("fieldMask", mask), | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeDataset(string dataset) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(dataset)) | ||||
|         { | ||||
|             return "default"; | ||||
|         } | ||||
|  | ||||
|         return dataset.Trim().ToLowerInvariant(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										183
									
								
								src/StellaOps.Concelier.Models/OsvGhsaParityInspector.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								src/StellaOps.Concelier.Models/OsvGhsaParityInspector.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,183 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Compares OSV and GHSA advisory datasets to surface mismatches in coverage, severity, or presence. | ||||
| /// </summary> | ||||
| public static class OsvGhsaParityInspector | ||||
| { | ||||
|     public static OsvGhsaParityReport Compare(IEnumerable<Advisory> osvAdvisories, IEnumerable<Advisory> ghsaAdvisories) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(osvAdvisories); | ||||
|         ArgumentNullException.ThrowIfNull(ghsaAdvisories); | ||||
|  | ||||
|         var osvByGhsa = BuildOsvMap(osvAdvisories); | ||||
|         var ghsaById = BuildGhsaMap(ghsaAdvisories); | ||||
|  | ||||
|         var union = osvByGhsa.Keys | ||||
|             .Union(ghsaById.Keys, StringComparer.OrdinalIgnoreCase) | ||||
|             .OrderBy(static key => key, StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|  | ||||
|         var issues = ImmutableArray.CreateBuilder<OsvGhsaParityIssue>(); | ||||
|  | ||||
|         foreach (var ghsaId in union) | ||||
|         { | ||||
|             osvByGhsa.TryGetValue(ghsaId, out var osv); | ||||
|             ghsaById.TryGetValue(ghsaId, out var ghsa); | ||||
|             var normalizedId = ghsaId.ToUpperInvariant(); | ||||
|  | ||||
|             if (osv is null) | ||||
|             { | ||||
|                 issues.Add(new OsvGhsaParityIssue( | ||||
|                     normalizedId, | ||||
|                     "missing_osv", | ||||
|                     "GHSA advisory missing from OSV dataset.", | ||||
|                     ImmutableArray.Create(ProvenanceFieldMasks.AffectedPackages))); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (ghsa is null) | ||||
|             { | ||||
|                 issues.Add(new OsvGhsaParityIssue( | ||||
|                     normalizedId, | ||||
|                     "missing_ghsa", | ||||
|                     "OSV mapped GHSA alias without a matching GHSA advisory.", | ||||
|                     ImmutableArray.Create(ProvenanceFieldMasks.AffectedPackages))); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!SeverityMatches(osv, ghsa)) | ||||
|             { | ||||
|                 var detail = $"Severity mismatch: OSV={osv.Severity ?? "(null)"}, GHSA={ghsa.Severity ?? "(null)"}."; | ||||
|                 issues.Add(new OsvGhsaParityIssue( | ||||
|                     normalizedId, | ||||
|                     "severity_mismatch", | ||||
|                     detail, | ||||
|                     ImmutableArray.Create(ProvenanceFieldMasks.Advisory))); | ||||
|             } | ||||
|  | ||||
|             if (!RangeCoverageMatches(osv, ghsa)) | ||||
|             { | ||||
|                 var detail = $"Range coverage mismatch: OSV ranges={CountRanges(osv)}, GHSA ranges={CountRanges(ghsa)}."; | ||||
|                 issues.Add(new OsvGhsaParityIssue( | ||||
|                     normalizedId, | ||||
|                     "range_mismatch", | ||||
|                     detail, | ||||
|                     ImmutableArray.Create(ProvenanceFieldMasks.VersionRanges))); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return new OsvGhsaParityReport(union.Length, issues.ToImmutable()); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyDictionary<string, Advisory> BuildOsvMap(IEnumerable<Advisory> advisories) | ||||
|     { | ||||
|         var comparer = StringComparer.OrdinalIgnoreCase; | ||||
|         var map = new Dictionary<string, Advisory>(comparer); | ||||
|  | ||||
|         foreach (var advisory in advisories) | ||||
|         { | ||||
|             if (advisory is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             foreach (var alias in advisory.Aliases) | ||||
|             { | ||||
|                 if (alias.StartsWith("ghsa-", StringComparison.OrdinalIgnoreCase)) | ||||
|                 { | ||||
|                     map.TryAdd(alias, advisory); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return map; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyDictionary<string, Advisory> BuildGhsaMap(IEnumerable<Advisory> advisories) | ||||
|     { | ||||
|         var comparer = StringComparer.OrdinalIgnoreCase; | ||||
|         var map = new Dictionary<string, Advisory>(comparer); | ||||
|  | ||||
|         foreach (var advisory in advisories) | ||||
|         { | ||||
|             if (advisory is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (advisory.AdvisoryKey.StartsWith("ghsa-", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 map.TryAdd(advisory.AdvisoryKey, advisory); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             foreach (var alias in advisory.Aliases) | ||||
|             { | ||||
|                 if (alias.StartsWith("ghsa-", StringComparison.OrdinalIgnoreCase)) | ||||
|                 { | ||||
|                     map.TryAdd(alias, advisory); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return map; | ||||
|     } | ||||
|  | ||||
|     private static bool SeverityMatches(Advisory osv, Advisory ghsa) | ||||
|         => string.Equals(osv.Severity, ghsa.Severity, StringComparison.OrdinalIgnoreCase); | ||||
|  | ||||
|     private static bool RangeCoverageMatches(Advisory osv, Advisory ghsa) | ||||
|     { | ||||
|         var osvRanges = CountRanges(osv); | ||||
|         var ghsaRanges = CountRanges(ghsa); | ||||
|         if (osvRanges == ghsaRanges) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // Consider zero-vs-nonzero mismatches as actionable even if raw counts differ. | ||||
|         return osvRanges == 0 && ghsaRanges == 0; | ||||
|     } | ||||
|  | ||||
|     private static int CountRanges(Advisory advisory) | ||||
|     { | ||||
|         if (advisory.AffectedPackages.IsDefaultOrEmpty) | ||||
|         { | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         var count = 0; | ||||
|         foreach (var package in advisory.AffectedPackages) | ||||
|         { | ||||
|             if (package.VersionRanges.IsDefaultOrEmpty) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             count += package.VersionRanges.Length; | ||||
|         } | ||||
|  | ||||
|         return count; | ||||
|     } | ||||
| } | ||||
|  | ||||
| public sealed record OsvGhsaParityIssue( | ||||
|     string GhsaId, | ||||
|     string IssueKind, | ||||
|     string Detail, | ||||
|     ImmutableArray<string> FieldMask); | ||||
|  | ||||
| public sealed record OsvGhsaParityReport(int TotalGhsaIds, ImmutableArray<OsvGhsaParityIssue> Issues) | ||||
| { | ||||
|     public bool HasIssues => !Issues.IsDefaultOrEmpty && Issues.Length > 0; | ||||
|  | ||||
|     public int MissingFromOsv => Issues.Count(issue => issue.IssueKind.Equals("missing_osv", StringComparison.OrdinalIgnoreCase)); | ||||
|  | ||||
|     public int MissingFromGhsa => Issues.Count(issue => issue.IssueKind.Equals("missing_ghsa", StringComparison.OrdinalIgnoreCase)); | ||||
| } | ||||
							
								
								
									
										15
									
								
								src/StellaOps.Concelier.Models/PROVENANCE_GUIDELINES.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/StellaOps.Concelier.Models/PROVENANCE_GUIDELINES.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| # Canonical Field Provenance Guidelines | ||||
|  | ||||
| - **Always attach provenance** when mapping any field into `StellaOps.Concelier.Models`. Use `AdvisoryProvenance` to capture `source` (feed identifier), `kind` (fetch|parse|map|merge), `value` (cursor or extractor hint), and the UTC timestamp when it was recorded. | ||||
| - **Per-field strategy** | ||||
|   - `Advisory` metadata (title, summary, severity) should record the connector responsible for the value. When merge overrides occur, add an additional provenance record rather than mutating the original. | ||||
|   - `References` must record whether the link originated from the primary advisory (`kind=advisory`), a vendor patch (`kind=patch`), or an enrichment feed (`kind=enrichment`). | ||||
|   - `AffectedPackage` records should capture the exact extraction routine (e.g., `map:oval`, `map:nvd`, `map:vendor`). | ||||
|   - `CvssMetric` provenance should include the scoring authority (e.g., `nvd`, `redhat`) and whether it was supplied or derived. | ||||
|   - `AffectedVersionRange` provenance anchors the transcript used to build the range. Preserve version strings as given by the source to aid debugging. | ||||
| - **Merge policy**: never discard provenance when merging; instead append a new `AdvisoryProvenance` entry with the merge routine (`source=merge.determine-precedence`). | ||||
| - **Determinism**: provenance collections are sorted by source → kind → recordedAt before serialization; avoid generating random identifiers inside provenance. | ||||
| - **Field masks**: populate `fieldMask` on each provenance entry using lowercase canonical masks (see `ProvenanceFieldMasks`). This powers metrics, parity checks, and resume diagnostics. Recent additions include `affectedpackages[].normalizedversions[]`, `affectedpackages[].versionranges[].primitives.semver`, and `credits[]`. | ||||
| - **Redaction**: keep provenance values free of secrets; prefer tokens or normalized descriptors when referencing authenticated fetches. | ||||
| - **Range telemetry**: each `AffectedVersionRange` is observed by the `concelier.range.primitives` metric. Emit the richest `RangePrimitives` possible (SemVer/NEVRA/EVR plus vendor extensions); the telemetry tags make it easy to spot connectors missing structured range data. | ||||
| - **Vendor extensions**: when vendor feeds surface bespoke status flags, capture them in `RangePrimitives.VendorExtensions`. SUSE advisories publish `suse.status` (open/resolved/investigating) and Ubuntu notices expose `ubuntu.pocket`/`ubuntu.release` to distinguish security vs ESM pockets; Adobe APSB bulletins emit `adobe.track`, `adobe.platform`, `adobe.priority`, `adobe.availability`, plus `adobe.affected.raw`/`adobe.updated.raw` to preserve PSIRT metadata while keeping the status catalog canonical. These values are exported for dashboards and alerting. | ||||
							
								
								
									
										17
									
								
								src/StellaOps.Concelier.Models/ProvenanceFieldMasks.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/StellaOps.Concelier.Models/ProvenanceFieldMasks.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Canonical field-mask identifiers for provenance coverage. | ||||
| /// </summary> | ||||
| public static class ProvenanceFieldMasks | ||||
| { | ||||
|     public const string Advisory = "advisory"; | ||||
|     public const string References = "references[]"; | ||||
|     public const string Credits = "credits[]"; | ||||
|     public const string AffectedPackages = "affectedpackages[]"; | ||||
|     public const string VersionRanges = "affectedpackages[].versionranges[]"; | ||||
|     public const string NormalizedVersions = "affectedpackages[].normalizedversions[]"; | ||||
|     public const string PackageStatuses = "affectedpackages[].statuses[]"; | ||||
|     public const string CvssMetrics = "cvssmetrics[]"; | ||||
|     public const string Weaknesses = "cwes[]"; | ||||
| } | ||||
							
								
								
									
										297
									
								
								src/StellaOps.Concelier.Models/ProvenanceInspector.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										297
									
								
								src/StellaOps.Concelier.Models/ProvenanceInspector.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,297 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Diagnostics.Metrics; | ||||
| using System.Linq; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| public static class ProvenanceInspector | ||||
| { | ||||
|     public static IReadOnlyList<MissingProvenance> FindMissingProvenance(Advisory advisory) | ||||
|     { | ||||
|         var results = new List<MissingProvenance>(); | ||||
|         var source = advisory.Provenance.FirstOrDefault()?.Source ?? "unknown"; | ||||
|  | ||||
|         if (advisory.Provenance.Length == 0) | ||||
|         { | ||||
|             results.Add(new MissingProvenance(source, "advisory", null, ImmutableArray.Create(ProvenanceFieldMasks.Advisory))); | ||||
|         } | ||||
|  | ||||
|         foreach (var reference in advisory.References) | ||||
|         { | ||||
|             if (IsMissing(reference.Provenance)) | ||||
|             { | ||||
|                 results.Add(new MissingProvenance( | ||||
|                     reference.Provenance.Source ?? source, | ||||
|                     $"reference:{reference.Url}", | ||||
|                     reference.Provenance.RecordedAt, | ||||
|                     NormalizeMask(reference.Provenance.FieldMask, ProvenanceFieldMasks.References))); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         foreach (var package in advisory.AffectedPackages) | ||||
|         { | ||||
|             if (package.Provenance.Length == 0) | ||||
|             { | ||||
|                 results.Add(new MissingProvenance( | ||||
|                     source, | ||||
|                     $"package:{package.Identifier}", | ||||
|                     null, | ||||
|                     ImmutableArray.Create(ProvenanceFieldMasks.AffectedPackages))); | ||||
|             } | ||||
|  | ||||
|             foreach (var range in package.VersionRanges) | ||||
|             { | ||||
|                 ProvenanceDiagnostics.RecordRangePrimitive(range.Provenance.Source ?? source, range); | ||||
|  | ||||
|                 if (IsMissing(range.Provenance)) | ||||
|                 { | ||||
|                     results.Add(new MissingProvenance( | ||||
|                         range.Provenance.Source ?? source, | ||||
|                         $"range:{package.Identifier}", | ||||
|                         range.Provenance.RecordedAt, | ||||
|                         NormalizeMask(range.Provenance.FieldMask, ProvenanceFieldMasks.VersionRanges))); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             foreach (var status in package.Statuses) | ||||
|             { | ||||
|                 if (IsMissing(status.Provenance)) | ||||
|                 { | ||||
|                     results.Add(new MissingProvenance( | ||||
|                         status.Provenance.Source ?? source, | ||||
|                         $"status:{package.Identifier}:{status.Status}", | ||||
|                         status.Provenance.RecordedAt, | ||||
|                         NormalizeMask(status.Provenance.FieldMask, ProvenanceFieldMasks.PackageStatuses))); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         foreach (var metric in advisory.CvssMetrics) | ||||
|         { | ||||
|             if (IsMissing(metric.Provenance)) | ||||
|             { | ||||
|                 results.Add(new MissingProvenance( | ||||
|                     metric.Provenance.Source ?? source, | ||||
|                     $"cvss:{metric.Version}", | ||||
|                     metric.Provenance.RecordedAt, | ||||
|                     NormalizeMask(metric.Provenance.FieldMask, ProvenanceFieldMasks.CvssMetrics))); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return results; | ||||
|     } | ||||
|  | ||||
|     private static bool IsMissing(AdvisoryProvenance provenance) | ||||
|     { | ||||
|         return provenance == AdvisoryProvenance.Empty | ||||
|             || string.IsNullOrWhiteSpace(provenance.Source) | ||||
|             || string.IsNullOrWhiteSpace(provenance.Kind); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<string> NormalizeMask(ImmutableArray<string> mask, string fallback) | ||||
|     { | ||||
|         if (mask.IsDefaultOrEmpty) | ||||
|         { | ||||
|             return ImmutableArray.Create(fallback); | ||||
|         } | ||||
|  | ||||
|         return mask; | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| public sealed record MissingProvenance( | ||||
|     string Source, | ||||
|     string Component, | ||||
|     DateTimeOffset? RecordedAt, | ||||
|     ImmutableArray<string> FieldMask); | ||||
|  | ||||
| public static class ProvenanceDiagnostics | ||||
| { | ||||
|     private static readonly Meter Meter = new("StellaOps.Concelier.Models.Provenance"); | ||||
|     private static readonly Counter<long> MissingCounter = Meter.CreateCounter<long>( | ||||
|         "concelier.provenance.missing", | ||||
|         unit: "count", | ||||
|         description: "Number of canonical objects missing provenance metadata."); | ||||
|     private static readonly Counter<long> RangePrimitiveCounter = Meter.CreateCounter<long>( | ||||
|         "concelier.range.primitives", | ||||
|         unit: "count", | ||||
|         description: "Range coverage by kind, primitive availability, and vendor extensions."); | ||||
|  | ||||
|     private static readonly object SyncRoot = new(); | ||||
|     private static readonly Dictionary<string, DateTimeOffset> EarliestMissing = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private static readonly HashSet<string> RecordedComponents = new(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|     public static void RecordMissing( | ||||
|         string source, | ||||
|         string component, | ||||
|         DateTimeOffset? recordedAt, | ||||
|         IReadOnlyList<string>? fieldMask = null) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(source)) | ||||
|         { | ||||
|             source = "unknown"; | ||||
|         } | ||||
|  | ||||
|         component = string.IsNullOrWhiteSpace(component) ? "unknown" : component.Trim(); | ||||
|         var maskKey = NormalizeMask(fieldMask); | ||||
|  | ||||
|         bool shouldRecord; | ||||
|         lock (SyncRoot) | ||||
|         { | ||||
|             var key = $"{source}|{component}|{maskKey}"; | ||||
|             shouldRecord = RecordedComponents.Add(key); | ||||
|  | ||||
|             if (recordedAt.HasValue) | ||||
|             { | ||||
|                 if (!EarliestMissing.TryGetValue(source, out var existing) || recordedAt.Value < existing) | ||||
|                 { | ||||
|                     EarliestMissing[source] = recordedAt.Value; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (!shouldRecord) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var category = DetermineCategory(component); | ||||
|         var severity = DetermineSeverity(category); | ||||
|  | ||||
|         var tags = new[] | ||||
|         { | ||||
|             new KeyValuePair<string, object?>("source", source), | ||||
|             new KeyValuePair<string, object?>("component", component), | ||||
|             new KeyValuePair<string, object?>("category", category), | ||||
|             new KeyValuePair<string, object?>("severity", severity), | ||||
|             new KeyValuePair<string, object?>("fieldMask", string.IsNullOrEmpty(maskKey) ? "none" : maskKey), | ||||
|         }; | ||||
|         MissingCounter.Add(1, tags); | ||||
|     } | ||||
|  | ||||
|     public static void ReportResumeWindow(string source, DateTimeOffset windowStart, ILogger logger) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(source) || logger is null) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         DateTimeOffset earliest; | ||||
|         var hasEntry = false; | ||||
|         lock (SyncRoot) | ||||
|         { | ||||
|             if (EarliestMissing.TryGetValue(source, out earliest)) | ||||
|             { | ||||
|                 hasEntry = true; | ||||
|                 if (windowStart <= earliest) | ||||
|                 { | ||||
|                     EarliestMissing.Remove(source); | ||||
|                     var prefix = source + "|"; | ||||
|                     RecordedComponents.RemoveWhere(entry => entry.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (!hasEntry) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (windowStart <= earliest) | ||||
|         { | ||||
|             logger.LogInformation( | ||||
|                 "Resume window starting {WindowStart:o} for {Source} may backfill missing provenance recorded at {Earliest:o}.", | ||||
|                 windowStart, | ||||
|                 source, | ||||
|                 earliest); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             logger.LogInformation( | ||||
|                 "Earliest missing provenance for {Source} remains at {Earliest:o}; current resume window begins at {WindowStart:o}. Consider widening overlap to backfill.", | ||||
|                 source, | ||||
|                 earliest, | ||||
|                 windowStart); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static void RecordRangePrimitive(string source, AffectedVersionRange range) | ||||
|     { | ||||
|         if (range is null) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         source = string.IsNullOrWhiteSpace(source) ? "unknown" : source.Trim(); | ||||
|  | ||||
|         var primitives = range.Primitives; | ||||
|         var primitiveKinds = DeterminePrimitiveKinds(primitives); | ||||
|         var vendorExtensions = primitives?.VendorExtensions?.Count ?? 0; | ||||
|  | ||||
|         var tags = new[] | ||||
|         { | ||||
|             new KeyValuePair<string, object?>("source", source), | ||||
|             new KeyValuePair<string, object?>("rangeKind", string.IsNullOrWhiteSpace(range.RangeKind) ? "unknown" : range.RangeKind), | ||||
|             new KeyValuePair<string, object?>("primitiveKinds", primitiveKinds), | ||||
|             new KeyValuePair<string, object?>("hasVendorExtensions", vendorExtensions > 0 ? "true" : "false"), | ||||
|         }; | ||||
|  | ||||
|         RangePrimitiveCounter.Add(1, tags); | ||||
|     } | ||||
|  | ||||
|     private static string DetermineCategory(string component) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(component)) | ||||
|         { | ||||
|             return "unknown"; | ||||
|         } | ||||
|  | ||||
|         var index = component.IndexOf(':'); | ||||
|         var category = index > 0 ? component[..index] : component; | ||||
|         return category.Trim().ToLowerInvariant(); | ||||
|     } | ||||
|  | ||||
|     private static string DetermineSeverity(string category) | ||||
|         => category switch | ||||
|         { | ||||
|             "advisory" => "critical", | ||||
|             "package" => "high", | ||||
|             "range" => "high", | ||||
|             "status" => "medium", | ||||
|             "cvss" => "medium", | ||||
|             "reference" => "low", | ||||
|             _ => "info", | ||||
|         }; | ||||
|  | ||||
|     private static string DeterminePrimitiveKinds(RangePrimitives? primitives) | ||||
|     { | ||||
|         return primitives is null ? "none" : primitives.GetCoverageTag(); | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeMask(IReadOnlyList<string>? fieldMask) | ||||
|     { | ||||
|         if (fieldMask is not { Count: > 0 }) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         if (fieldMask.Count == 1) | ||||
|         { | ||||
|             return fieldMask[0]; | ||||
|         } | ||||
|  | ||||
|         var ordered = fieldMask | ||||
|             .Where(static value => !string.IsNullOrWhiteSpace(value)) | ||||
|             .Select(static value => value.Trim().ToLowerInvariant()) | ||||
|             .Distinct(StringComparer.Ordinal) | ||||
|             .OrderBy(static value => value, StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
|  | ||||
|         return string.Join('|', ordered); | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										179
									
								
								src/StellaOps.Concelier.Models/RangePrimitives.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								src/StellaOps.Concelier.Models/RangePrimitives.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Optional structured representations of range semantics attached to <see cref="AffectedVersionRange"/>. | ||||
| /// </summary> | ||||
| public sealed record RangePrimitives( | ||||
|     SemVerPrimitive? SemVer, | ||||
|     NevraPrimitive? Nevra, | ||||
|     EvrPrimitive? Evr, | ||||
|     IReadOnlyDictionary<string, string>? VendorExtensions) | ||||
| { | ||||
|     public bool HasVendorExtensions => VendorExtensions is { Count: > 0 }; | ||||
|  | ||||
|     public string GetCoverageTag() | ||||
|     { | ||||
|         var kinds = new List<string>(3); | ||||
|         if (SemVer is not null) | ||||
|         { | ||||
|             kinds.Add("semver"); | ||||
|         } | ||||
|  | ||||
|         if (Nevra is not null) | ||||
|         { | ||||
|             kinds.Add("nevra"); | ||||
|         } | ||||
|  | ||||
|         if (Evr is not null) | ||||
|         { | ||||
|             kinds.Add("evr"); | ||||
|         } | ||||
|  | ||||
|         if (kinds.Count == 0) | ||||
|         { | ||||
|             return HasVendorExtensions ? "vendor" : "none"; | ||||
|         } | ||||
|  | ||||
|         kinds.Sort(StringComparer.Ordinal); | ||||
|         return string.Join('+', kinds); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Structured SemVer metadata for a version range. | ||||
| /// </summary> | ||||
| public sealed record SemVerPrimitive( | ||||
|     string? Introduced, | ||||
|     bool IntroducedInclusive, | ||||
|     string? Fixed, | ||||
|     bool FixedInclusive, | ||||
|     string? LastAffected, | ||||
|     bool LastAffectedInclusive, | ||||
|     string? ConstraintExpression, | ||||
|     string? ExactValue = null) | ||||
| { | ||||
|     public string Style => DetermineStyle( | ||||
|         Introduced, | ||||
|         IntroducedInclusive, | ||||
|         Fixed, | ||||
|         FixedInclusive, | ||||
|         LastAffected, | ||||
|         LastAffectedInclusive, | ||||
|         ConstraintExpression, | ||||
|         ExactValue); | ||||
|  | ||||
|     private static string DetermineStyle( | ||||
|         string? introduced, | ||||
|         bool introducedInclusive, | ||||
|         string? fixedVersion, | ||||
|         bool fixedInclusive, | ||||
|         string? lastAffected, | ||||
|         bool lastAffectedInclusive, | ||||
|         string? constraintExpression, | ||||
|         string? exactValue) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(exactValue)) | ||||
|         { | ||||
|             return SemVerPrimitiveStyles.Exact; | ||||
|         } | ||||
|  | ||||
|         var hasIntroduced = !string.IsNullOrWhiteSpace(introduced); | ||||
|         var hasFixed = !string.IsNullOrWhiteSpace(fixedVersion); | ||||
|         var hasLast = !string.IsNullOrWhiteSpace(lastAffected); | ||||
|  | ||||
|         if (hasIntroduced && hasFixed) | ||||
|         { | ||||
|             return SemVerPrimitiveStyles.Range; | ||||
|         } | ||||
|  | ||||
|         if (hasIntroduced) | ||||
|         { | ||||
|             return introducedInclusive | ||||
|                 ? SemVerPrimitiveStyles.GreaterThanOrEqual | ||||
|                 : SemVerPrimitiveStyles.GreaterThan; | ||||
|         } | ||||
|  | ||||
|         if (hasFixed) | ||||
|         { | ||||
|             return fixedInclusive | ||||
|                 ? SemVerPrimitiveStyles.LessThanOrEqual | ||||
|                 : SemVerPrimitiveStyles.LessThan; | ||||
|         } | ||||
|  | ||||
|         if (hasLast) | ||||
|         { | ||||
|             return lastAffectedInclusive | ||||
|                 ? SemVerPrimitiveStyles.LessThanOrEqual | ||||
|                 : SemVerPrimitiveStyles.LessThan; | ||||
|         } | ||||
|  | ||||
|         return string.IsNullOrWhiteSpace(constraintExpression) | ||||
|             ? SemVerPrimitiveStyles.Range | ||||
|             : SemVerPrimitiveStyles.Range; | ||||
|     } | ||||
| } | ||||
|  | ||||
| public static class SemVerPrimitiveStyles | ||||
| { | ||||
|     public const string Range = "range"; | ||||
|     public const string Exact = "exact"; | ||||
|     public const string LessThan = "lessThan"; | ||||
|     public const string LessThanOrEqual = "lessThanOrEqual"; | ||||
|     public const string GreaterThan = "greaterThan"; | ||||
|     public const string GreaterThanOrEqual = "greaterThanOrEqual"; | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Structured NEVRA metadata for a version range. | ||||
| /// </summary> | ||||
| public sealed record NevraPrimitive( | ||||
|     NevraComponent? Introduced, | ||||
|     NevraComponent? Fixed, | ||||
|     NevraComponent? LastAffected); | ||||
|  | ||||
| /// <summary> | ||||
| /// Structured Debian EVR metadata for a version range. | ||||
| /// </summary> | ||||
| public sealed record EvrPrimitive( | ||||
|     EvrComponent? Introduced, | ||||
|     EvrComponent? Fixed, | ||||
|     EvrComponent? LastAffected); | ||||
|  | ||||
| /// <summary> | ||||
| /// Normalized NEVRA component. | ||||
| /// </summary> | ||||
| public sealed record NevraComponent( | ||||
|     string Name, | ||||
|     int Epoch, | ||||
|     string Version, | ||||
|     string Release, | ||||
|     string? Architecture) | ||||
| { | ||||
|     public string ToCanonicalString() | ||||
|     { | ||||
|         var epochSegment = Epoch > 0 ? $"{Epoch}:" : string.Empty; | ||||
|         var architectureSegment = string.IsNullOrWhiteSpace(Architecture) ? string.Empty : $".{Architecture}"; | ||||
|         var releaseSegment = string.IsNullOrWhiteSpace(Release) ? string.Empty : Release; | ||||
|         var releaseSuffix = string.IsNullOrEmpty(releaseSegment) ? string.Empty : $"-{releaseSegment}"; | ||||
|         return $"{Name}-{epochSegment}{Version}{releaseSuffix}{architectureSegment}"; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Normalized EVR component (epoch:upstream revision). | ||||
| /// </summary> | ||||
| public sealed record EvrComponent( | ||||
|     int Epoch, | ||||
|     string UpstreamVersion, | ||||
|     string? Revision) | ||||
| { | ||||
|     public string ToCanonicalString() | ||||
|     { | ||||
|         var epochSegment = Epoch > 0 ? $"{Epoch}:" : string.Empty; | ||||
|         var revisionSegment = string.IsNullOrWhiteSpace(Revision) ? string.Empty : $"-{Revision}"; | ||||
|         return $"{epochSegment}{UpstreamVersion}{revisionSegment}"; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										102
									
								
								src/StellaOps.Concelier.Models/SemVerPrimitiveExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/StellaOps.Concelier.Models/SemVerPrimitiveExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,102 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Helper extensions for converting <see cref="SemVerPrimitive"/> values into normalized rules. | ||||
| /// </summary> | ||||
| public static class SemVerPrimitiveExtensions | ||||
| { | ||||
|     public static NormalizedVersionRule? ToNormalizedVersionRule(this SemVerPrimitive? primitive, string? notes = null) | ||||
|     { | ||||
|         if (primitive is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmedNotes = Validation.TrimToNull(notes); | ||||
|         var constraintNotes = Validation.TrimToNull(primitive.ConstraintExpression); | ||||
|         var resolvedNotes = trimmedNotes ?? constraintNotes; | ||||
|         var scheme = NormalizedVersionSchemes.SemVer; | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(primitive.ExactValue)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.Exact, | ||||
|                 value: primitive.ExactValue, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         var introduced = Validation.TrimToNull(primitive.Introduced); | ||||
|         var fixedVersion = Validation.TrimToNull(primitive.Fixed); | ||||
|         var lastAffected = Validation.TrimToNull(primitive.LastAffected); | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(fixedVersion)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: introduced, | ||||
|                 minInclusive: primitive.IntroducedInclusive, | ||||
|                 max: fixedVersion, | ||||
|                 maxInclusive: primitive.FixedInclusive, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && string.IsNullOrEmpty(fixedVersion) && !string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 min: introduced, | ||||
|                 minInclusive: primitive.IntroducedInclusive, | ||||
|                 max: lastAffected, | ||||
|                 maxInclusive: primitive.LastAffectedInclusive, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(introduced) && string.IsNullOrEmpty(fixedVersion) && string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             var type = primitive.IntroducedInclusive ? NormalizedVersionRuleTypes.GreaterThanOrEqual : NormalizedVersionRuleTypes.GreaterThan; | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 type, | ||||
|                 min: introduced, | ||||
|                 minInclusive: primitive.IntroducedInclusive, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(fixedVersion)) | ||||
|         { | ||||
|             var type = primitive.FixedInclusive ? NormalizedVersionRuleTypes.LessThanOrEqual : NormalizedVersionRuleTypes.LessThan; | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 type, | ||||
|                 max: fixedVersion, | ||||
|                 maxInclusive: primitive.FixedInclusive, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(lastAffected)) | ||||
|         { | ||||
|             var type = primitive.LastAffectedInclusive ? NormalizedVersionRuleTypes.LessThanOrEqual : NormalizedVersionRuleTypes.LessThan; | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 type, | ||||
|                 max: lastAffected, | ||||
|                 maxInclusive: primitive.LastAffectedInclusive, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(primitive.ConstraintExpression)) | ||||
|         { | ||||
|             return new NormalizedVersionRule( | ||||
|                 scheme, | ||||
|                 NormalizedVersionRuleTypes.Range, | ||||
|                 notes: resolvedNotes); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										152
									
								
								src/StellaOps.Concelier.Models/SeverityNormalization.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								src/StellaOps.Concelier.Models/SeverityNormalization.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,152 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Provides helpers to normalize vendor-provided severity labels into canonical values. | ||||
| /// </summary> | ||||
| public static class SeverityNormalization | ||||
| { | ||||
|     private static readonly IReadOnlyDictionary<string, string> SeverityMap = new Dictionary<string, string> | ||||
|     { | ||||
|         ["critical"] = "critical", | ||||
|         ["crit"] = "critical", | ||||
|         ["sevcritical"] = "critical", | ||||
|         ["extreme"] = "critical", | ||||
|         ["verycritical"] = "critical", | ||||
|         ["veryhigh"] = "critical", | ||||
|         ["p0"] = "critical", | ||||
|         ["priority0"] = "critical", | ||||
|         ["high"] = "high", | ||||
|         ["sevhigh"] = "high", | ||||
|         ["important"] = "high", | ||||
|         ["severe"] = "high", | ||||
|         ["major"] = "high", | ||||
|         ["urgent"] = "high", | ||||
|         ["elevated"] = "high", | ||||
|         ["p1"] = "high", | ||||
|         ["priority1"] = "high", | ||||
|         ["medium"] = "medium", | ||||
|         ["moderate"] = "medium", | ||||
|         ["normal"] = "medium", | ||||
|         ["avg"] = "medium", | ||||
|         ["average"] = "medium", | ||||
|         ["standard"] = "medium", | ||||
|         ["p2"] = "medium", | ||||
|         ["priority2"] = "medium", | ||||
|         ["low"] = "low", | ||||
|         ["minor"] = "low", | ||||
|         ["minimal"] = "low", | ||||
|         ["limited"] = "low", | ||||
|         ["p3"] = "low", | ||||
|         ["priority3"] = "low", | ||||
|         ["informational"] = "informational", | ||||
|         ["info"] = "informational", | ||||
|         ["informative"] = "informational", | ||||
|         ["notice"] = "informational", | ||||
|         ["advisory"] = "informational", | ||||
|         ["none"] = "none", | ||||
|         ["negligible"] = "none", | ||||
|         ["insignificant"] = "none", | ||||
|         ["notapplicable"] = "none", | ||||
|         ["na"] = "none", | ||||
|         ["unknown"] = "unknown", | ||||
|         ["undetermined"] = "unknown", | ||||
|         ["notdefined"] = "unknown", | ||||
|         ["notspecified"] = "unknown", | ||||
|         ["pending"] = "unknown", | ||||
|         ["tbd"] = "unknown", | ||||
|     }; | ||||
|  | ||||
|     private static readonly char[] TokenSeparators = | ||||
|     { | ||||
|         ' ', | ||||
|         '/', | ||||
|         '\\', | ||||
|         '-', | ||||
|         '_', | ||||
|         ',', | ||||
|         ';', | ||||
|         ':', | ||||
|         '(', | ||||
|         ')', | ||||
|         '[', | ||||
|         ']', | ||||
|         '{', | ||||
|         '}', | ||||
|         '|', | ||||
|         '+', | ||||
|         '&' | ||||
|     }; | ||||
|  | ||||
|     public static readonly IReadOnlyCollection<string> CanonicalLevels = new[] | ||||
|     { | ||||
|         "critical", | ||||
|         "high", | ||||
|         "medium", | ||||
|         "low", | ||||
|         "informational", | ||||
|         "none", | ||||
|         "unknown", | ||||
|     }; | ||||
|  | ||||
|     public static string? Normalize(string? severity) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(severity)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmed = severity.Trim(); | ||||
|  | ||||
|         if (TryNormalizeToken(trimmed, out var mapped)) | ||||
|         { | ||||
|             return mapped; | ||||
|         } | ||||
|  | ||||
|         foreach (var token in trimmed.Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) | ||||
|         { | ||||
|             if (TryNormalizeToken(token, out mapped)) | ||||
|             { | ||||
|                 return mapped; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return trimmed.ToLowerInvariant(); | ||||
|     } | ||||
|  | ||||
|     private static bool TryNormalizeToken(string value, out string mapped) | ||||
|     { | ||||
|         var normalized = NormalizeToken(value); | ||||
|         if (normalized.Length == 0) | ||||
|         { | ||||
|             mapped = string.Empty; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!SeverityMap.TryGetValue(normalized, out var mappedValue)) | ||||
|         { | ||||
|             mapped = string.Empty; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         mapped = mappedValue; | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeToken(string value) | ||||
|     { | ||||
|         var builder = new StringBuilder(value.Length); | ||||
|         foreach (var ch in value) | ||||
|         { | ||||
|             if (char.IsLetterOrDigit(ch)) | ||||
|             { | ||||
|                 builder.Append(char.ToLowerInvariant(ch)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return builder.ToString(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										27
									
								
								src/StellaOps.Concelier.Models/SnapshotSerializer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/StellaOps.Concelier.Models/SnapshotSerializer.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Helper for tests/fixtures that need deterministic JSON snapshots. | ||||
| /// </summary> | ||||
| public static class SnapshotSerializer | ||||
| { | ||||
|     public static string ToSnapshot<T>(T value) | ||||
|         => CanonicalJsonSerializer.SerializeIndented(value); | ||||
|  | ||||
|     public static void AppendSnapshot<T>(StringBuilder builder, T value) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(builder); | ||||
|         builder.AppendLine(ToSnapshot(value)); | ||||
|     } | ||||
|  | ||||
|     public static async Task WriteSnapshotAsync<T>(Stream destination, T value, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(destination); | ||||
|         await using var writer = new StreamWriter(destination, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), leaveOpen: true); | ||||
|         await writer.WriteAsync(ToSnapshot(value).AsMemory(), cancellationToken).ConfigureAwait(false); | ||||
|         await writer.FlushAsync().ConfigureAwait(false); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
							
								
								
									
										19
									
								
								src/StellaOps.Concelier.Models/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/StellaOps.Concelier.Models/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| # TASKS | ||||
| | Task | Owner(s) | Depends on | Notes | | ||||
| |---|---|---|---| | ||||
| |Canonical JSON serializer with stable ordering|BE-Merge|Models|DONE – `CanonicalJsonSerializer` ensures deterministic property ordering.| | ||||
| |Equality/comparison helpers for ranges|BE-Merge|Models|DONE – added `AffectedVersionRangeComparer` & equality comparer.| | ||||
| |Type enums/constants for AffectedPackage.Type|BE-Merge|Models|DONE – introduced `AffectedPackageTypes`.| | ||||
| |Validation helpers (lightweight)|BE-Merge|Models|DONE – added `Validation` static helpers and URL guard.| | ||||
| |Snapshot serializer for tests|QA|Models|DONE – `SnapshotSerializer` emits canonical JSON.| | ||||
| |Docs: field provenance guidelines|BE-Merge|Models|DONE – see `PROVENANCE_GUIDELINES.md`.| | ||||
| |Canonical record definitions kept in sync|BE-Merge|Models|DONE – documented in `CANONICAL_RECORDS.md`; update alongside model changes.| | ||||
| |Alias scheme registry and validation helpers|BE-Merge|Models|DONE – see `AliasSchemes` & `AliasSchemeRegistry` plus validation integration/tests.| | ||||
| |Range primitives for SemVer/EVR/NEVRA metadata|BE-Merge|Models|DONE – SemVer/Evr/Nevra primitives now project canonical normalized rules; range helpers emit fallback rules for legacy inputs and tests cover canonical string generation so connectors can populate `normalizedVersions` deterministically.| | ||||
| |Provenance envelope field masks|BE-Merge|Models|DONE – `AdvisoryProvenance.fieldMask` added with diagnostics/tests/docs refreshed; connectors can now emit canonical masks for QA dashboards.| | ||||
| |Backward-compatibility playbook|BE-Merge, QA|Models|DONE – see `BACKWARD_COMPATIBILITY.md` for evolution policy/test checklist.| | ||||
| |Golden canonical examples|QA|Models|DONE – added `/p:UpdateGoldens=true` test hook wiring `UPDATE_GOLDENS=1` so canonical fixtures regenerate via `dotnet test`; docs/tests unchanged.| | ||||
| |Serialization determinism regression tests|QA|Models|DONE – locale-stability tests hash canonical serializer output across multiple cultures and runs.| | ||||
| |Severity normalization helpers|BE-Merge|Models|DONE – helper now normalizes compound vendor labels/priority tiers with expanded synonym coverage and regression tests.| | ||||
| |AffectedPackage status glossary & guardrails|BE-Merge|Models|DONE – catalog now exposes deterministic listing, TryNormalize helpers, and synonym coverage for vendor phrases (not vulnerable, workaround available, etc.).| | ||||
| |Advisory schema parity (description, CWE collection, canonical metric id)|BE-Merge, BE-Core|Core, Exporters|DONE (2025-10-15) – extended `Advisory`/related records with description/CWEs/canonical metric id plus serializer/tests updated; exporters validated via new coverage.| | ||||
							
								
								
									
										57
									
								
								src/StellaOps.Concelier.Models/Validation.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/StellaOps.Concelier.Models/Validation.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Text.RegularExpressions; | ||||
|  | ||||
| namespace StellaOps.Concelier.Models; | ||||
|  | ||||
| /// <summary> | ||||
| /// Lightweight validation helpers shared across canonical model constructors. | ||||
| /// </summary> | ||||
| public static partial class Validation | ||||
| { | ||||
|     public static string EnsureNotNullOrWhiteSpace(string value, string paramName) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             throw new ArgumentException($"Value cannot be null or whitespace.", paramName); | ||||
|         } | ||||
|  | ||||
|         return value.Trim(); | ||||
|     } | ||||
|  | ||||
|     public static string? TrimToNull(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); | ||||
|  | ||||
|     public static bool LooksLikeHttpUrl(string? value) | ||||
|         => value is not null && Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme is "http" or "https"); | ||||
|  | ||||
|     public static bool TryNormalizeAlias(string? value, [NotNullWhen(true)] out string? normalized) | ||||
|     { | ||||
|         normalized = TrimToNull(value); | ||||
|         if (normalized is null) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (AliasSchemeRegistry.TryNormalize(normalized, out var canonical, out _)) | ||||
|         { | ||||
|             normalized = canonical; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public static bool TryNormalizeIdentifier(string? value, [NotNullWhen(true)] out string? normalized) | ||||
|     { | ||||
|         normalized = TrimToNull(value); | ||||
|         return normalized is not null; | ||||
|     } | ||||
|  | ||||
|     [GeneratedRegex(@"\s+")] | ||||
|     private static partial Regex CollapseWhitespaceRegex(); | ||||
|  | ||||
|     public static string CollapseWhitespace(string value) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(value); | ||||
|         return CollapseWhitespaceRegex().Replace(value, " ").Trim(); | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user