Rename Feedser to Concelier

This commit is contained in:
2025-10-18 20:04:15 +03:00
parent 7e1b10d3b2
commit 0137856fdb
1208 changed files with 4370 additions and 4370 deletions

View 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.

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

View 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();
}
}

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

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

View 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();
}
}

View 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";
}

View 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);
}

View 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);
}
}

View 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);
}

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

View 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);
}
}

View 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";
}

View 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.

View 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.

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

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

View 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());
}
}

View 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());
}
}

View 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";
}

View 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();
}
}

View 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));
}

View 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.

View 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[]";
}

View 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);
}
}

View 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}";
}
}

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

View 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();
}
}

View 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);
}
}

View File

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

View 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.|

View 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();
}
}