up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
master
2025-11-28 18:21:46 +02:00
parent 05da719048
commit d1cbb905f8
103 changed files with 49604 additions and 105 deletions

View File

@@ -0,0 +1,377 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Default implementation of <see cref="IAdvisoryFieldChangeEmitter"/>.
/// Per CONCELIER-RISK-69-001, emits notifications on upstream advisory field changes
/// with observation IDs and provenance; no severity inference.
/// </summary>
public sealed class AdvisoryFieldChangeEmitter : IAdvisoryFieldChangeEmitter
{
private readonly IAdvisoryFieldChangeNotificationPublisher _publisher;
private readonly ILogger<AdvisoryFieldChangeEmitter> _logger;
private readonly TimeProvider _timeProvider;
public AdvisoryFieldChangeEmitter(
IAdvisoryFieldChangeNotificationPublisher publisher,
ILogger<AdvisoryFieldChangeEmitter> logger,
TimeProvider? timeProvider = null)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<AdvisoryFieldChangeNotification?> EmitChangesAsync(
string tenantId,
string observationId,
VendorRiskSignal? previousSignal,
VendorRiskSignal currentSignal,
string? linksetId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
ArgumentNullException.ThrowIfNull(currentSignal);
var changes = DetectChanges(previousSignal, currentSignal);
if (changes.IsDefaultOrEmpty)
{
_logger.LogDebug(
"No field changes detected for observation {ObservationId}",
observationId);
return null;
}
var now = _timeProvider.GetUtcNow();
var changeType = DetermineChangeType(changes);
var provenance = BuildProvenance(previousSignal, currentSignal);
var notification = new AdvisoryFieldChangeNotification(
NotificationId: Guid.NewGuid(),
TenantId: tenantId,
AdvisoryId: currentSignal.AdvisoryId,
ObservationId: observationId,
LinksetId: linksetId,
ChangeType: changeType,
Changes: changes,
Provenance: provenance,
DetectedAt: now,
EmittedAt: now);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Emitted field change notification for observation {ObservationId}: type={ChangeType}, fields=[{Fields}]",
observationId, changeType, string.Join(", ", notification.ChangedFields));
return notification;
}
/// <inheritdoc />
public async Task<IReadOnlyList<AdvisoryFieldChangeNotification>> EmitLinksetChangesAsync(
string tenantId,
string linksetId,
IReadOnlyList<VendorRiskSignal> previousSignals,
IReadOnlyList<VendorRiskSignal> currentSignals,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(linksetId);
ArgumentNullException.ThrowIfNull(currentSignals);
var previousByObservation = (previousSignals ?? [])
.ToDictionary(s => s.ObservationId, StringComparer.Ordinal);
var notifications = new List<AdvisoryFieldChangeNotification>();
foreach (var currentSignal in currentSignals)
{
previousByObservation.TryGetValue(currentSignal.ObservationId, out var previousSignal);
var notification = await EmitChangesAsync(
tenantId,
currentSignal.ObservationId,
previousSignal,
currentSignal,
linksetId,
cancellationToken).ConfigureAwait(false);
if (notification is not null)
{
notifications.Add(notification);
}
}
// Check for withdrawn observations
var currentObservationIds = currentSignals
.Select(s => s.ObservationId)
.ToHashSet(StringComparer.Ordinal);
foreach (var previousSignal in previousSignals ?? [])
{
if (!currentObservationIds.Contains(previousSignal.ObservationId))
{
var withdrawnNotification = await EmitWithdrawnAsync(
tenantId,
previousSignal,
linksetId,
cancellationToken).ConfigureAwait(false);
if (withdrawnNotification is not null)
{
notifications.Add(withdrawnNotification);
}
}
}
return notifications;
}
private async Task<AdvisoryFieldChangeNotification?> EmitWithdrawnAsync(
string tenantId,
VendorRiskSignal previousSignal,
string? linksetId,
CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var provenance = new AdvisoryFieldChangeProvenance(
Vendor: previousSignal.Provenance.Vendor,
Source: previousSignal.Provenance.Source,
ObservationHash: previousSignal.Provenance.ObservationHash,
FetchedAt: previousSignal.Provenance.FetchedAt,
IngestJobId: previousSignal.Provenance.IngestJobId,
UpstreamId: previousSignal.Provenance.UpstreamId,
PreviousObservationHash: null);
var change = new AdvisoryFieldChange(
Field: "observation_status",
PreviousValue: "active",
CurrentValue: "withdrawn",
Category: AdvisoryFieldChangeCategory.Metadata,
Provenance: provenance);
var notification = new AdvisoryFieldChangeNotification(
NotificationId: Guid.NewGuid(),
TenantId: tenantId,
AdvisoryId: previousSignal.AdvisoryId,
ObservationId: previousSignal.ObservationId,
LinksetId: linksetId,
ChangeType: AdvisoryFieldChangeType.ObservationWithdrawn,
Changes: [change],
Provenance: provenance,
DetectedAt: now,
EmittedAt: now);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Emitted withdrawn observation notification for {ObservationId}",
previousSignal.ObservationId);
return notification;
}
private static ImmutableArray<AdvisoryFieldChange> DetectChanges(
VendorRiskSignal? previousSignal,
VendorRiskSignal currentSignal)
{
var changes = ImmutableArray.CreateBuilder<AdvisoryFieldChange>();
var currentProvenance = MapProvenance(currentSignal.Provenance, previousSignal?.Provenance.ObservationHash);
// New observation
if (previousSignal is null)
{
changes.Add(new AdvisoryFieldChange(
Field: "observation_status",
PreviousValue: null,
CurrentValue: "active",
Category: AdvisoryFieldChangeCategory.Metadata,
Provenance: currentProvenance));
// Report initial fix availability if present
if (currentSignal.HasFixAvailable)
{
var fixVersion = currentSignal.FixAvailability.FirstOrDefault(f => f.Status == FixStatus.Available)?.FixedVersion;
changes.Add(new AdvisoryFieldChange(
Field: "fix_availability",
PreviousValue: null,
CurrentValue: fixVersion ?? "available",
Category: AdvisoryFieldChangeCategory.Remediation,
Provenance: currentProvenance));
}
// Report initial KEV status if present
if (currentSignal.IsKnownExploited)
{
changes.Add(new AdvisoryFieldChange(
Field: "kev_status",
PreviousValue: null,
CurrentValue: "in_kev",
Category: AdvisoryFieldChangeCategory.Threat,
Provenance: currentProvenance));
}
// Report initial severity if present
if (currentSignal.HighestCvssScore is not null)
{
changes.Add(new AdvisoryFieldChange(
Field: "severity",
PreviousValue: null,
CurrentValue: currentSignal.HighestCvssScore.EffectiveSeverity,
Category: AdvisoryFieldChangeCategory.Risk,
Provenance: currentProvenance));
}
return changes.ToImmutable();
}
// Compare fix availability
var previousHasFix = previousSignal.HasFixAvailable;
var currentHasFix = currentSignal.HasFixAvailable;
if (previousHasFix != currentHasFix)
{
var previousValue = previousHasFix ? GetFixVersion(previousSignal) ?? "available" : "not_available";
var currentValue = currentHasFix ? GetFixVersion(currentSignal) ?? "available" : "not_available";
changes.Add(new AdvisoryFieldChange(
Field: "fix_availability",
PreviousValue: previousValue,
CurrentValue: currentValue,
Category: AdvisoryFieldChangeCategory.Remediation,
Provenance: currentProvenance));
}
else if (currentHasFix)
{
// Both have fixes - check if version changed
var previousVersion = GetFixVersion(previousSignal);
var currentVersion = GetFixVersion(currentSignal);
if (!string.Equals(previousVersion, currentVersion, StringComparison.Ordinal))
{
changes.Add(new AdvisoryFieldChange(
Field: "fix_version",
PreviousValue: previousVersion,
CurrentValue: currentVersion,
Category: AdvisoryFieldChangeCategory.Remediation,
Provenance: currentProvenance));
}
}
// Compare KEV status
var previousInKev = previousSignal.IsKnownExploited;
var currentInKev = currentSignal.IsKnownExploited;
if (previousInKev != currentInKev)
{
changes.Add(new AdvisoryFieldChange(
Field: "kev_status",
PreviousValue: previousInKev ? "in_kev" : "not_in_kev",
CurrentValue: currentInKev ? "in_kev" : "not_in_kev",
Category: AdvisoryFieldChangeCategory.Threat,
Provenance: currentProvenance));
}
// Compare severity
var previousSeverity = previousSignal.HighestCvssScore?.EffectiveSeverity;
var currentSeverity = currentSignal.HighestCvssScore?.EffectiveSeverity;
if (!string.Equals(previousSeverity, currentSeverity, StringComparison.OrdinalIgnoreCase))
{
changes.Add(new AdvisoryFieldChange(
Field: "severity",
PreviousValue: previousSeverity,
CurrentValue: currentSeverity,
Category: AdvisoryFieldChangeCategory.Risk,
Provenance: currentProvenance));
}
// Compare CVSS score (if both have scores)
var previousScore = previousSignal.HighestCvssScore?.Score;
var currentScore = currentSignal.HighestCvssScore?.Score;
if (previousScore.HasValue && currentScore.HasValue &&
Math.Abs(previousScore.Value - currentScore.Value) >= 0.1)
{
changes.Add(new AdvisoryFieldChange(
Field: "cvss_score",
PreviousValue: previousScore.Value.ToString("F1"),
CurrentValue: currentScore.Value.ToString("F1"),
Category: AdvisoryFieldChangeCategory.Risk,
Provenance: currentProvenance));
}
return changes.ToImmutable();
}
private static string? GetFixVersion(VendorRiskSignal signal)
{
return signal.FixAvailability
.Where(f => f.Status == FixStatus.Available)
.Select(f => f.FixedVersion)
.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
private static AdvisoryFieldChangeType DetermineChangeType(ImmutableArray<AdvisoryFieldChange> changes)
{
if (changes.Length == 0)
{
return AdvisoryFieldChangeType.Unknown;
}
if (changes.Length > 1)
{
return AdvisoryFieldChangeType.MultipleChanges;
}
var change = changes[0];
return change.Field switch
{
"observation_status" when change.CurrentValue == "active" => AdvisoryFieldChangeType.NewObservation,
"observation_status" when change.CurrentValue == "withdrawn" => AdvisoryFieldChangeType.ObservationWithdrawn,
"fix_availability" or "fix_version" => AdvisoryFieldChangeType.FixAvailabilityChanged,
"kev_status" => AdvisoryFieldChangeType.KevStatusChanged,
"severity" or "cvss_score" => AdvisoryFieldChangeType.SeverityChanged,
_ => AdvisoryFieldChangeType.Unknown
};
}
private static AdvisoryFieldChangeProvenance BuildProvenance(
VendorRiskSignal? previousSignal,
VendorRiskSignal currentSignal)
{
return new AdvisoryFieldChangeProvenance(
Vendor: currentSignal.Provenance.Vendor,
Source: currentSignal.Provenance.Source,
ObservationHash: currentSignal.Provenance.ObservationHash,
FetchedAt: currentSignal.Provenance.FetchedAt,
IngestJobId: currentSignal.Provenance.IngestJobId,
UpstreamId: currentSignal.Provenance.UpstreamId,
PreviousObservationHash: previousSignal?.Provenance.ObservationHash);
}
private static AdvisoryFieldChangeProvenance MapProvenance(
VendorRiskProvenance provenance,
string? previousHash)
{
return new AdvisoryFieldChangeProvenance(
Vendor: provenance.Vendor,
Source: provenance.Source,
ObservationHash: provenance.ObservationHash,
FetchedAt: provenance.FetchedAt,
IngestJobId: provenance.IngestJobId,
UpstreamId: provenance.UpstreamId,
PreviousObservationHash: previousHash);
}
}

View File

@@ -0,0 +1,142 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Notification for upstream advisory field changes.
/// Per CONCELIER-RISK-69-001, emits notifications on field changes (e.g., fix availability)
/// with observation IDs and provenance; no severity inference.
/// </summary>
/// <remarks>
/// This notification is fact-only: surfaces vendor-published changes with provenance.
/// No inference, weighting, or exploitability assumptions.
/// </remarks>
public sealed record AdvisoryFieldChangeNotification(
Guid NotificationId,
string TenantId,
string AdvisoryId,
string ObservationId,
string? LinksetId,
AdvisoryFieldChangeType ChangeType,
ImmutableArray<AdvisoryFieldChange> Changes,
AdvisoryFieldChangeProvenance Provenance,
DateTimeOffset DetectedAt,
DateTimeOffset EmittedAt)
{
/// <summary>
/// Event kind for notification routing.
/// </summary>
public const string EventKind = "advisory.field.changed";
/// <summary>
/// Event version.
/// </summary>
public const string EventVersion = "1";
/// <summary>
/// Indicates if any critical field changed (fix availability, KEV status).
/// </summary>
public bool HasCriticalChange => Changes.Any(c =>
c.Field is "fix_availability" or "kev_status" or "severity");
/// <summary>
/// Gets all unique fields that changed.
/// </summary>
public ImmutableArray<string> ChangedFields =>
Changes.Select(c => c.Field).Distinct(StringComparer.Ordinal).ToImmutableArray();
}
/// <summary>
/// Type of field change notification.
/// </summary>
public enum AdvisoryFieldChangeType
{
/// <summary>Unknown change type.</summary>
Unknown,
/// <summary>Fix availability changed (became available, version updated, etc.).</summary>
FixAvailabilityChanged,
/// <summary>KEV status changed (added to or removed from KEV list).</summary>
KevStatusChanged,
/// <summary>Severity score changed.</summary>
SeverityChanged,
/// <summary>New observation added from upstream.</summary>
NewObservation,
/// <summary>Observation withdrawn by upstream.</summary>
ObservationWithdrawn,
/// <summary>Advisory link/reference added.</summary>
ReferenceAdded,
/// <summary>Multiple fields changed.</summary>
MultipleChanges
}
/// <summary>
/// A specific field change in an advisory.
/// </summary>
public sealed record AdvisoryFieldChange(
string Field,
string? PreviousValue,
string? CurrentValue,
AdvisoryFieldChangeCategory Category,
AdvisoryFieldChangeProvenance Provenance)
{
/// <summary>
/// Indicates if the value transitioned from null/empty to having a value.
/// </summary>
public bool IsNewValue => string.IsNullOrWhiteSpace(PreviousValue) && !string.IsNullOrWhiteSpace(CurrentValue);
/// <summary>
/// Indicates if the value was removed (non-null to null/empty).
/// </summary>
public bool IsValueRemoved => !string.IsNullOrWhiteSpace(PreviousValue) && string.IsNullOrWhiteSpace(CurrentValue);
/// <summary>
/// Indicates if the value changed between two non-null values.
/// </summary>
public bool IsValueUpdated => !string.IsNullOrWhiteSpace(PreviousValue) &&
!string.IsNullOrWhiteSpace(CurrentValue) &&
!string.Equals(PreviousValue, CurrentValue, StringComparison.Ordinal);
}
/// <summary>
/// Category of field change for filtering/routing.
/// </summary>
public enum AdvisoryFieldChangeCategory
{
/// <summary>Unknown category.</summary>
Unknown,
/// <summary>Remediation-related (fix version, patch URL, etc.).</summary>
Remediation,
/// <summary>Threat-related (KEV, exploitation evidence).</summary>
Threat,
/// <summary>Risk-related (severity, CVSS score).</summary>
Risk,
/// <summary>Metadata-related (references, aliases, description).</summary>
Metadata,
/// <summary>Scope-related (affected packages, versions).</summary>
Scope
}
/// <summary>
/// Provenance anchor for field change notifications.
/// </summary>
public sealed record AdvisoryFieldChangeProvenance(
string Vendor,
string Source,
string ObservationHash,
DateTimeOffset FetchedAt,
string? IngestJobId,
string? UpstreamId,
string? PreviousObservationHash);

View File

@@ -0,0 +1,341 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Default implementation of <see cref="IFixAvailabilityEmitter"/>.
/// Per CONCELIER-RISK-66-002, emits structured fix-availability metadata per observation/linkset
/// without guessing exploitability.
/// </summary>
public sealed class FixAvailabilityEmitter : IFixAvailabilityEmitter
{
private readonly IVendorRiskSignalProvider _riskSignalProvider;
private readonly ILogger<FixAvailabilityEmitter> _logger;
private readonly TimeProvider _timeProvider;
public FixAvailabilityEmitter(
IVendorRiskSignalProvider riskSignalProvider,
ILogger<FixAvailabilityEmitter> logger,
TimeProvider? timeProvider = null)
{
_riskSignalProvider = riskSignalProvider ?? throw new ArgumentNullException(nameof(riskSignalProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<FixAvailabilityMetadata?> EmitByObservationAsync(
string tenantId,
string observationId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
var riskSignal = await _riskSignalProvider.GetByObservationAsync(
tenantId, observationId, cancellationToken).ConfigureAwait(false);
if (riskSignal is null)
{
_logger.LogDebug(
"No risk signal found for observation {ObservationId} in tenant {TenantId}",
observationId, tenantId);
return null;
}
return EmitFromRiskSignal(riskSignal, linksetId: null);
}
/// <inheritdoc />
public async Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByLinksetAsync(
string tenantId,
string linksetId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(linksetId);
var riskSignals = await _riskSignalProvider.GetByLinksetAsync(
tenantId, linksetId, cancellationToken).ConfigureAwait(false);
if (riskSignals.Count == 0)
{
_logger.LogDebug(
"No risk signals found for linkset {LinksetId} in tenant {TenantId}",
linksetId, tenantId);
return [];
}
var results = new List<FixAvailabilityMetadata>(riskSignals.Count);
foreach (var signal in riskSignals)
{
results.Add(EmitFromRiskSignal(signal, linksetId));
}
return results;
}
/// <inheritdoc />
public async Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByAdvisoryAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryId);
var riskSignals = await _riskSignalProvider.GetByAdvisoryAsync(
tenantId, advisoryId, cancellationToken).ConfigureAwait(false);
if (riskSignals.Count == 0)
{
_logger.LogDebug(
"No risk signals found for advisory {AdvisoryId} in tenant {TenantId}",
advisoryId, tenantId);
return [];
}
var results = new List<FixAvailabilityMetadata>(riskSignals.Count);
foreach (var signal in riskSignals)
{
results.Add(EmitFromRiskSignal(signal, linksetId: null));
}
return results;
}
private FixAvailabilityMetadata EmitFromRiskSignal(VendorRiskSignal signal, string? linksetId)
{
var now = _timeProvider.GetUtcNow();
var provenance = MapProvenance(signal.Provenance);
if (signal.FixAvailability.IsDefaultOrEmpty)
{
_logger.LogDebug(
"Emitting empty fix-availability for observation {ObservationId} (no fix data in signal)",
signal.ObservationId);
return FixAvailabilityMetadata.Empty(
signal.TenantId,
signal.AdvisoryId,
signal.ObservationId,
linksetId,
provenance,
now);
}
var releases = MapReleases(signal.FixAvailability, provenance);
var advisoryLinks = ExtractAdvisoryLinks(signal.FixAvailability, provenance);
var status = DetermineOverallStatus(signal.FixAvailability);
_logger.LogDebug(
"Emitting fix-availability for observation {ObservationId}: status={Status}, releases={ReleaseCount}, links={LinkCount}",
signal.ObservationId, status, releases.Length, advisoryLinks.Length);
return new FixAvailabilityMetadata(
TenantId: signal.TenantId,
AdvisoryId: signal.AdvisoryId,
ObservationId: signal.ObservationId,
LinksetId: linksetId,
Status: status,
Releases: releases,
AdvisoryLinks: advisoryLinks,
Provenance: provenance,
EmittedAt: now);
}
private static FixAvailabilityProvenance MapProvenance(VendorRiskProvenance vendorProvenance)
{
return new FixAvailabilityProvenance(
Vendor: vendorProvenance.Vendor,
Source: vendorProvenance.Source,
ObservationHash: vendorProvenance.ObservationHash,
FetchedAt: vendorProvenance.FetchedAt,
IngestJobId: vendorProvenance.IngestJobId,
UpstreamId: vendorProvenance.UpstreamId);
}
private static ImmutableArray<FixRelease> MapReleases(
ImmutableArray<VendorFixAvailability> vendorFixes,
FixAvailabilityProvenance provenance)
{
var builder = ImmutableArray.CreateBuilder<FixRelease>();
foreach (var vendorFix in vendorFixes)
{
if (vendorFix.Status != FixStatus.Available || string.IsNullOrWhiteSpace(vendorFix.FixedVersion))
{
continue;
}
var fixProvenance = MapProvenance(vendorFix.Provenance);
var releaseType = DetermineReleaseType(vendorFix.FixedVersion);
builder.Add(new FixRelease(
FixedVersion: vendorFix.FixedVersion,
Package: vendorFix.Package,
Ecosystem: vendorFix.Ecosystem,
ReleasedAt: vendorFix.FixReleasedAt,
Type: releaseType,
Provenance: fixProvenance));
}
// Sort by release date, then by version for deterministic ordering
return builder
.OrderBy(r => r.ReleasedAt ?? DateTimeOffset.MaxValue)
.ThenBy(r => r.FixedVersion, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<FixAdvisoryLink> ExtractAdvisoryLinks(
ImmutableArray<VendorFixAvailability> vendorFixes,
FixAvailabilityProvenance provenance)
{
var builder = ImmutableArray.CreateBuilder<FixAdvisoryLink>();
var seenUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var vendorFix in vendorFixes)
{
if (string.IsNullOrWhiteSpace(vendorFix.AdvisoryUrl))
{
continue;
}
if (!seenUrls.Add(vendorFix.AdvisoryUrl))
{
continue; // Deduplicate
}
var fixProvenance = MapProvenance(vendorFix.Provenance);
var linkType = DetermineLinkType(vendorFix.AdvisoryUrl);
builder.Add(new FixAdvisoryLink(
Url: vendorFix.AdvisoryUrl,
Title: null, // Not available from VendorFixAvailability
Type: linkType,
PublishedAt: vendorFix.FixReleasedAt,
Provenance: fixProvenance));
}
// Sort by published date for deterministic ordering
return builder
.OrderBy(l => l.PublishedAt ?? DateTimeOffset.MaxValue)
.ThenBy(l => l.Url, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static FixAvailabilityStatus DetermineOverallStatus(
ImmutableArray<VendorFixAvailability> vendorFixes)
{
if (vendorFixes.IsDefaultOrEmpty)
{
return FixAvailabilityStatus.Unknown;
}
// Check for available fixes first
if (vendorFixes.Any(f => f.Status == FixStatus.Available))
{
return FixAvailabilityStatus.Available;
}
// Check for in-progress fixes
if (vendorFixes.Any(f => f.Status == FixStatus.InProgress))
{
return FixAvailabilityStatus.InProgress;
}
// Check for will-not-fix
if (vendorFixes.Any(f => f.Status == FixStatus.WillNotFix))
{
return FixAvailabilityStatus.WillNotFix;
}
// Check for not-available
if (vendorFixes.Any(f => f.Status == FixStatus.NotAvailable))
{
return FixAvailabilityStatus.NotAvailable;
}
return FixAvailabilityStatus.Unknown;
}
private static FixReleaseType DetermineReleaseType(string version)
{
// Basic heuristics for release type
var lowerVersion = version.ToLowerInvariant();
if (lowerVersion.Contains("hotfix") || lowerVersion.Contains("patch"))
{
return FixReleaseType.Hotfix;
}
if (lowerVersion.Contains("backport"))
{
return FixReleaseType.Backport;
}
// Could add SemVer analysis here for minor/major detection
// For now, default to Patch as most security fixes are patches
return FixReleaseType.Patch;
}
private static FixAdvisoryLinkType DetermineLinkType(string url)
{
var lowerUrl = url.ToLowerInvariant();
// Distribution security notices
if (lowerUrl.Contains("access.redhat.com/errata") ||
lowerUrl.Contains("ubuntu.com/security") ||
lowerUrl.Contains("debian.org/security") ||
lowerUrl.Contains("suse.com/security"))
{
return FixAdvisoryLinkType.DistributionNotice;
}
// Commit references
if (lowerUrl.Contains("/commit/") ||
lowerUrl.Contains("/commits/"))
{
return FixAdvisoryLinkType.Commit;
}
// Patch URLs
if (lowerUrl.EndsWith(".patch") ||
lowerUrl.Contains("/patches/") ||
lowerUrl.Contains("/diff/"))
{
return FixAdvisoryLinkType.PatchUrl;
}
// Release notes
if (lowerUrl.Contains("/releases/") ||
lowerUrl.Contains("/release-notes") ||
lowerUrl.Contains("/changelog"))
{
return FixAdvisoryLinkType.ReleaseNotes;
}
// Vendor advisories (common patterns)
if (lowerUrl.Contains("/security/") ||
lowerUrl.Contains("/advisory/") ||
lowerUrl.Contains("/cve-") ||
lowerUrl.Contains("/vuln"))
{
return FixAdvisoryLinkType.VendorAdvisory;
}
// GitHub Security Advisories
if (lowerUrl.Contains("github.com") && lowerUrl.Contains("/advisories/"))
{
return FixAdvisoryLinkType.UpstreamAdvisory;
}
return FixAdvisoryLinkType.Unknown;
}
}

View File

@@ -0,0 +1,190 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Structured fix-availability metadata per observation/linkset.
/// Per CONCELIER-RISK-66-002, emits release version, advisory link, and evidence timestamp
/// without guessing exploitability.
/// </summary>
/// <remarks>
/// This model is fact-only: surfaces vendor-published fix information with provenance.
/// No inference, weighting, or exploitability assumptions.
/// </remarks>
public sealed record FixAvailabilityMetadata(
string TenantId,
string AdvisoryId,
string ObservationId,
string? LinksetId,
FixAvailabilityStatus Status,
ImmutableArray<FixRelease> Releases,
ImmutableArray<FixAdvisoryLink> AdvisoryLinks,
FixAvailabilityProvenance Provenance,
DateTimeOffset EmittedAt)
{
/// <summary>
/// Creates an empty fix-availability metadata for observations without fix data.
/// </summary>
public static FixAvailabilityMetadata Empty(
string tenantId,
string advisoryId,
string observationId,
string? linksetId,
FixAvailabilityProvenance provenance,
DateTimeOffset emittedAt)
{
return new FixAvailabilityMetadata(
TenantId: tenantId,
AdvisoryId: advisoryId,
ObservationId: observationId,
LinksetId: linksetId,
Status: FixAvailabilityStatus.Unknown,
Releases: ImmutableArray<FixRelease>.Empty,
AdvisoryLinks: ImmutableArray<FixAdvisoryLink>.Empty,
Provenance: provenance,
EmittedAt: emittedAt);
}
/// <summary>
/// Indicates if any fix release is available.
/// </summary>
public bool HasFixAvailable => Status == FixAvailabilityStatus.Available && !Releases.IsDefaultOrEmpty;
/// <summary>
/// Gets the earliest fix release if available.
/// </summary>
public FixRelease? EarliestRelease => Releases.IsDefaultOrEmpty
? null
: Releases.OrderBy(r => r.ReleasedAt ?? DateTimeOffset.MaxValue).FirstOrDefault();
}
/// <summary>
/// Provenance anchor for fix-availability metadata.
/// </summary>
public sealed record FixAvailabilityProvenance(
string Vendor,
string Source,
string ObservationHash,
DateTimeOffset FetchedAt,
string? IngestJobId,
string? UpstreamId);
/// <summary>
/// A fix release with version, timestamp, and provenance.
/// </summary>
public sealed record FixRelease(
string? FixedVersion,
string? Package,
string? Ecosystem,
DateTimeOffset? ReleasedAt,
FixReleaseType Type,
FixAvailabilityProvenance Provenance)
{
/// <summary>
/// Normalizes the ecosystem name to a standard format.
/// </summary>
public string? NormalizedEcosystem => Ecosystem?.ToLowerInvariant() switch
{
"npm" or "node" or "nodejs" => "npm",
"pypi" or "pip" or "python" => "pypi",
"maven" or "java" => "maven",
"nuget" or ".net" or "dotnet" => "nuget",
"rubygems" or "gem" or "ruby" => "rubygems",
"crates.io" or "cargo" or "rust" => "crates.io",
"go" or "golang" => "go",
"packagist" or "composer" or "php" => "packagist",
var e => e
};
}
/// <summary>
/// Type of fix release.
/// </summary>
public enum FixReleaseType
{
/// <summary>Unknown release type.</summary>
Unknown,
/// <summary>Patch release addressing the vulnerability.</summary>
Patch,
/// <summary>Minor version upgrade with fix.</summary>
MinorUpgrade,
/// <summary>Major version upgrade with fix.</summary>
MajorUpgrade,
/// <summary>Backported fix to older version line.</summary>
Backport,
/// <summary>Vendor-specific hotfix.</summary>
Hotfix
}
/// <summary>
/// Advisory link providing fix guidance.
/// </summary>
public sealed record FixAdvisoryLink(
string Url,
string? Title,
FixAdvisoryLinkType Type,
DateTimeOffset? PublishedAt,
FixAvailabilityProvenance Provenance);
/// <summary>
/// Type of advisory link.
/// </summary>
public enum FixAdvisoryLinkType
{
/// <summary>Unknown link type.</summary>
Unknown,
/// <summary>Vendor security advisory.</summary>
VendorAdvisory,
/// <summary>Upstream project advisory.</summary>
UpstreamAdvisory,
/// <summary>Distribution security notice (e.g., RHSA, DSA).</summary>
DistributionNotice,
/// <summary>Patch URL.</summary>
PatchUrl,
/// <summary>Release notes.</summary>
ReleaseNotes,
/// <summary>Commit reference.</summary>
Commit,
/// <summary>Mitigation/workaround guidance.</summary>
Mitigation
}
/// <summary>
/// Overall fix availability status.
/// </summary>
public enum FixAvailabilityStatus
{
/// <summary>Fix status unknown.</summary>
Unknown,
/// <summary>Fix is available.</summary>
Available,
/// <summary>No fix available yet.</summary>
NotAvailable,
/// <summary>Will not be fixed (end of life, wontfix, etc.).</summary>
WillNotFix,
/// <summary>Fix is in progress.</summary>
InProgress,
/// <summary>Deferred - fix planned for future release.</summary>
Deferred,
/// <summary>Not affected - no fix needed.</summary>
NotAffected
}

View File

@@ -0,0 +1,62 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Emitter interface for advisory field change notifications.
/// Per CONCELIER-RISK-69-001, emits notifications on upstream advisory field changes
/// (e.g., fix availability) with observation IDs and provenance; no severity inference.
/// </summary>
public interface IAdvisoryFieldChangeEmitter
{
/// <summary>
/// Detects and emits field change notifications for an observation.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="observationId">Observation identifier.</param>
/// <param name="previousSignal">Previous risk signal state (null if new observation).</param>
/// <param name="currentSignal">Current risk signal state.</param>
/// <param name="linksetId">Optional linkset identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Emitted notification, or null if no changes detected.</returns>
Task<AdvisoryFieldChangeNotification?> EmitChangesAsync(
string tenantId,
string observationId,
VendorRiskSignal? previousSignal,
VendorRiskSignal currentSignal,
string? linksetId,
CancellationToken cancellationToken);
/// <summary>
/// Detects and emits field change notifications for all observations in a linkset.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="linksetId">Linkset identifier.</param>
/// <param name="previousSignals">Previous risk signal states.</param>
/// <param name="currentSignals">Current risk signal states.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of emitted notifications.</returns>
Task<IReadOnlyList<AdvisoryFieldChangeNotification>> EmitLinksetChangesAsync(
string tenantId,
string linksetId,
IReadOnlyList<VendorRiskSignal> previousSignals,
IReadOnlyList<VendorRiskSignal> currentSignals,
CancellationToken cancellationToken);
}
/// <summary>
/// Publisher interface for advisory field change notifications.
/// </summary>
public interface IAdvisoryFieldChangeNotificationPublisher
{
/// <summary>
/// Publishes a field change notification to the notification system.
/// </summary>
/// <param name="notification">The notification to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishAsync(
AdvisoryFieldChangeNotification notification,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,129 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Emitter interface for structured fix-availability metadata.
/// Per CONCELIER-RISK-66-002, emits release version, advisory link, and evidence timestamp
/// per observation/linkset without guessing exploitability.
/// </summary>
public interface IFixAvailabilityEmitter
{
/// <summary>
/// Emits fix-availability metadata for a specific observation.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="observationId">Observation identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Emitted fix-availability metadata.</returns>
Task<FixAvailabilityMetadata?> EmitByObservationAsync(
string tenantId,
string observationId,
CancellationToken cancellationToken);
/// <summary>
/// Emits fix-availability metadata for all observations in a linkset.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="linksetId">Linkset identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of emitted fix-availability metadata from all linked observations.</returns>
Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByLinksetAsync(
string tenantId,
string linksetId,
CancellationToken cancellationToken);
/// <summary>
/// Emits fix-availability metadata for all observations of an advisory.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryId">Advisory identifier (e.g., CVE-2024-1234).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of emitted fix-availability metadata.</returns>
Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByAdvisoryAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken);
}
/// <summary>
/// Aggregated fix-availability view combining metadata from multiple observations.
/// </summary>
public sealed record AggregatedFixAvailabilityView(
string TenantId,
string AdvisoryId,
string? LinksetId,
IReadOnlyList<FixAvailabilityMetadata> ObservationMetadata)
{
/// <summary>
/// Gets the overall fix availability status across all observations.
/// Returns the most favorable status (Available > InProgress > NotAvailable > Unknown).
/// </summary>
public FixAvailabilityStatus OverallStatus
{
get
{
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.Available))
return FixAvailabilityStatus.Available;
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.InProgress))
return FixAvailabilityStatus.InProgress;
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.Deferred))
return FixAvailabilityStatus.Deferred;
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.NotAffected))
return FixAvailabilityStatus.NotAffected;
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.NotAvailable))
return FixAvailabilityStatus.NotAvailable;
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.WillNotFix))
return FixAvailabilityStatus.WillNotFix;
return FixAvailabilityStatus.Unknown;
}
}
/// <summary>
/// Indicates if any observation reports a fix available.
/// </summary>
public bool HasFixAvailable => OverallStatus == FixAvailabilityStatus.Available;
/// <summary>
/// Gets all unique fix releases across observations.
/// </summary>
public IReadOnlyList<FixRelease> AllReleases =>
ObservationMetadata
.SelectMany(m => m.Releases)
.OrderBy(r => r.ReleasedAt ?? DateTimeOffset.MaxValue)
.ThenBy(r => r.FixedVersion, StringComparer.Ordinal)
.ToList();
/// <summary>
/// Gets all unique advisory links across observations.
/// </summary>
public IReadOnlyList<FixAdvisoryLink> AllAdvisoryLinks =>
ObservationMetadata
.SelectMany(m => m.AdvisoryLinks)
.DistinctBy(l => l.Url)
.OrderBy(l => l.PublishedAt ?? DateTimeOffset.MaxValue)
.ToList();
/// <summary>
/// Gets vendors that contributed fix information.
/// </summary>
public IReadOnlyList<string> ContributingVendors =>
ObservationMetadata
.Select(m => m.Provenance.Vendor)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(v => v, StringComparer.OrdinalIgnoreCase)
.ToList();
/// <summary>
/// Gets the earliest available fix release across all observations.
/// </summary>
public FixRelease? EarliestRelease => AllReleases.FirstOrDefault();
}

View File

@@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Publisher interface for per-source coverage and conflict metrics.
/// Per CONCELIER-RISK-67-001, publishes counts and disagreements so explainers
/// cite which upstream statements exist; no weighting applied.
/// </summary>
public interface ISourceCoverageMetricsPublisher
{
/// <summary>
/// Computes and publishes coverage metrics for all observations of an advisory.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryId">Advisory identifier (e.g., CVE-2024-1234).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Computed coverage metrics.</returns>
Task<SourceCoverageMetrics> PublishByAdvisoryAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken);
/// <summary>
/// Computes and publishes coverage metrics for all observations in a linkset.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="linksetId">Linkset identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Computed coverage metrics.</returns>
Task<SourceCoverageMetrics> PublishByLinksetAsync(
string tenantId,
string linksetId,
CancellationToken cancellationToken);
/// <summary>
/// Gets the latest published coverage metrics for an advisory.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryId">Advisory identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Latest coverage metrics, or null if not computed.</returns>
Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken);
/// <summary>
/// Gets the latest published coverage metrics for a linkset.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="linksetId">Linkset identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Latest coverage metrics, or null if not computed.</returns>
Task<SourceCoverageMetrics?> GetByLinksetAsync(
string tenantId,
string linksetId,
CancellationToken cancellationToken);
}
/// <summary>
/// Storage interface for coverage metrics persistence.
/// </summary>
public interface ISourceCoverageMetricsStore
{
/// <summary>
/// Stores coverage metrics.
/// </summary>
Task StoreAsync(
SourceCoverageMetrics metrics,
CancellationToken cancellationToken);
/// <summary>
/// Retrieves coverage metrics by advisory.
/// </summary>
Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken);
/// <summary>
/// Retrieves coverage metrics by linkset.
/// </summary>
Task<SourceCoverageMetrics?> GetByLinksetAsync(
string tenantId,
string linksetId,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,76 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// In-memory implementation of <see cref="IAdvisoryFieldChangeNotificationPublisher"/> for testing and development.
/// </summary>
public sealed class InMemoryAdvisoryFieldChangeNotificationPublisher : IAdvisoryFieldChangeNotificationPublisher
{
private readonly ConcurrentQueue<AdvisoryFieldChangeNotification> _notifications = new();
/// <inheritdoc />
public Task PublishAsync(AdvisoryFieldChangeNotification notification, CancellationToken cancellationToken)
{
_notifications.Enqueue(notification);
return Task.CompletedTask;
}
/// <summary>
/// Gets all published notifications (for testing).
/// </summary>
public IReadOnlyList<AdvisoryFieldChangeNotification> GetAllNotifications()
{
return _notifications.ToList();
}
/// <summary>
/// Gets notifications for a specific advisory (for testing).
/// </summary>
public IReadOnlyList<AdvisoryFieldChangeNotification> GetNotificationsByAdvisory(string advisoryId)
{
return _notifications
.Where(n => string.Equals(n.AdvisoryId, advisoryId, System.StringComparison.Ordinal))
.ToList();
}
/// <summary>
/// Gets notifications for a specific tenant (for testing).
/// </summary>
public IReadOnlyList<AdvisoryFieldChangeNotification> GetNotificationsByTenant(string tenantId)
{
return _notifications
.Where(n => string.Equals(n.TenantId, tenantId, System.StringComparison.Ordinal))
.ToList();
}
/// <summary>
/// Gets notifications by change type (for testing).
/// </summary>
public IReadOnlyList<AdvisoryFieldChangeNotification> GetNotificationsByChangeType(AdvisoryFieldChangeType changeType)
{
return _notifications
.Where(n => n.ChangeType == changeType)
.ToList();
}
/// <summary>
/// Clears all notifications (for testing).
/// </summary>
public void Clear()
{
while (_notifications.TryDequeue(out _))
{
// Clear the queue
}
}
/// <summary>
/// Gets the count of notifications (for testing).
/// </summary>
public int Count => _notifications.Count;
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// In-memory implementation of <see cref="ISourceCoverageMetricsStore"/> for testing and development.
/// </summary>
public sealed class InMemorySourceCoverageMetricsStore : ISourceCoverageMetricsStore
{
private readonly ConcurrentDictionary<string, SourceCoverageMetrics> _byAdvisory = new();
private readonly ConcurrentDictionary<string, SourceCoverageMetrics> _byLinkset = new();
/// <inheritdoc />
public Task StoreAsync(SourceCoverageMetrics metrics, CancellationToken cancellationToken)
{
var advisoryKey = BuildAdvisoryKey(metrics.TenantId, metrics.AdvisoryId);
_byAdvisory[advisoryKey] = metrics;
if (!string.IsNullOrWhiteSpace(metrics.LinksetId))
{
var linksetKey = BuildLinksetKey(metrics.TenantId, metrics.LinksetId);
_byLinkset[linksetKey] = metrics;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken)
{
var key = BuildAdvisoryKey(tenantId, advisoryId);
_byAdvisory.TryGetValue(key, out var metrics);
return Task.FromResult(metrics);
}
/// <inheritdoc />
public Task<SourceCoverageMetrics?> GetByLinksetAsync(
string tenantId,
string linksetId,
CancellationToken cancellationToken)
{
var key = BuildLinksetKey(tenantId, linksetId);
_byLinkset.TryGetValue(key, out var metrics);
return Task.FromResult(metrics);
}
/// <summary>
/// Clears all stored metrics (for testing).
/// </summary>
public void Clear()
{
_byAdvisory.Clear();
_byLinkset.Clear();
}
private static string BuildAdvisoryKey(string tenantId, string advisoryId)
=> $"{tenantId}:{advisoryId}";
private static string BuildLinksetKey(string tenantId, string linksetId)
=> $"{tenantId}:linkset:{linksetId}";
}

View File

@@ -0,0 +1,113 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Service collection extensions for risk-related services.
/// </summary>
public static class RiskServiceCollectionExtensions
{
/// <summary>
/// Adds risk signal and fix-availability services to the service collection.
/// Per CONCELIER-RISK-66-002, CONCELIER-RISK-67-001, and CONCELIER-RISK-69-001.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddConcelierRiskServices(this IServiceCollection services)
{
// Register fix-availability emitter (CONCELIER-RISK-66-002)
services.TryAddSingleton<IFixAvailabilityEmitter, FixAvailabilityEmitter>();
// Register coverage metrics services (CONCELIER-RISK-67-001)
services.TryAddSingleton<ISourceCoverageMetricsStore, InMemorySourceCoverageMetricsStore>();
services.TryAddSingleton<ISourceCoverageMetricsPublisher, SourceCoverageMetricsPublisher>();
// Register field change notification services (CONCELIER-RISK-69-001)
services.TryAddSingleton<IAdvisoryFieldChangeNotificationPublisher, InMemoryAdvisoryFieldChangeNotificationPublisher>();
services.TryAddSingleton<IAdvisoryFieldChangeEmitter, AdvisoryFieldChangeEmitter>();
// TimeProvider is typically registered elsewhere, but ensure it exists
services.TryAddSingleton(TimeProvider.System);
return services;
}
/// <summary>
/// Adds a custom implementation of <see cref="IVendorRiskSignalProvider"/>.
/// </summary>
/// <typeparam name="TProvider">The provider implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVendorRiskSignalProvider<TProvider>(this IServiceCollection services)
where TProvider : class, IVendorRiskSignalProvider
{
services.TryAddSingleton<IVendorRiskSignalProvider, TProvider>();
return services;
}
/// <summary>
/// Adds a custom implementation of <see cref="IFixAvailabilityEmitter"/>.
/// </summary>
/// <typeparam name="TEmitter">The emitter implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFixAvailabilityEmitter<TEmitter>(this IServiceCollection services)
where TEmitter : class, IFixAvailabilityEmitter
{
services.AddSingleton<IFixAvailabilityEmitter, TEmitter>();
return services;
}
/// <summary>
/// Adds a custom implementation of <see cref="ISourceCoverageMetricsStore"/>.
/// </summary>
/// <typeparam name="TStore">The store implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSourceCoverageMetricsStore<TStore>(this IServiceCollection services)
where TStore : class, ISourceCoverageMetricsStore
{
services.AddSingleton<ISourceCoverageMetricsStore, TStore>();
return services;
}
/// <summary>
/// Adds a custom implementation of <see cref="ISourceCoverageMetricsPublisher"/>.
/// </summary>
/// <typeparam name="TPublisher">The publisher implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSourceCoverageMetricsPublisher<TPublisher>(this IServiceCollection services)
where TPublisher : class, ISourceCoverageMetricsPublisher
{
services.AddSingleton<ISourceCoverageMetricsPublisher, TPublisher>();
return services;
}
/// <summary>
/// Adds a custom implementation of <see cref="IAdvisoryFieldChangeNotificationPublisher"/>.
/// </summary>
/// <typeparam name="TPublisher">The publisher implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAdvisoryFieldChangeNotificationPublisher<TPublisher>(this IServiceCollection services)
where TPublisher : class, IAdvisoryFieldChangeNotificationPublisher
{
services.AddSingleton<IAdvisoryFieldChangeNotificationPublisher, TPublisher>();
return services;
}
/// <summary>
/// Adds a custom implementation of <see cref="IAdvisoryFieldChangeEmitter"/>.
/// </summary>
/// <typeparam name="TEmitter">The emitter implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAdvisoryFieldChangeEmitter<TEmitter>(this IServiceCollection services)
where TEmitter : class, IAdvisoryFieldChangeEmitter
{
services.AddSingleton<IAdvisoryFieldChangeEmitter, TEmitter>();
return services;
}
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Per-source coverage and conflict metrics for advisory observations.
/// Per CONCELIER-RISK-67-001, publishes counts and disagreements so explainers
/// cite which upstream statements exist; no weighting applied.
/// </summary>
/// <remarks>
/// This model is fact-only: no inference, weighting, or prioritization.
/// All data traces back to specific vendor observations with provenance.
/// </remarks>
public sealed record SourceCoverageMetrics(
string TenantId,
string AdvisoryId,
string? LinksetId,
ImmutableArray<SourceContribution> Sources,
SourceAgreementSummary Agreement,
ImmutableArray<SourceConflict> Conflicts,
DateTimeOffset ComputedAt)
{
/// <summary>
/// Total number of contributing sources.
/// </summary>
public int SourceCount => Sources.Length;
/// <summary>
/// Total number of observations across all sources.
/// </summary>
public int TotalObservations => Sources.Sum(s => s.ObservationCount);
/// <summary>
/// Total number of conflicts detected.
/// </summary>
public int ConflictCount => Conflicts.Length;
/// <summary>
/// Indicates if all sources agree (no conflicts).
/// </summary>
public bool AllSourcesAgree => Conflicts.IsDefaultOrEmpty;
/// <summary>
/// Creates empty coverage metrics when no sources are available.
/// </summary>
public static SourceCoverageMetrics Empty(
string tenantId,
string advisoryId,
string? linksetId,
DateTimeOffset computedAt)
{
return new SourceCoverageMetrics(
TenantId: tenantId,
AdvisoryId: advisoryId,
LinksetId: linksetId,
Sources: ImmutableArray<SourceContribution>.Empty,
Agreement: SourceAgreementSummary.Empty,
Conflicts: ImmutableArray<SourceConflict>.Empty,
ComputedAt: computedAt);
}
}
/// <summary>
/// Contribution from a single source/vendor.
/// </summary>
public sealed record SourceContribution(
string SourceId,
string Vendor,
int ObservationCount,
ImmutableArray<string> ObservationIds,
SourceCoverageDetail Coverage,
DateTimeOffset LatestObservationAt)
{
/// <summary>
/// Indicates if this source has full coverage (CVSS, fix info, affected data).
/// </summary>
public bool HasFullCoverage =>
Coverage.HasCvssData &&
Coverage.HasFixData &&
Coverage.HasAffectedData;
}
/// <summary>
/// Detail of what data a source provides.
/// </summary>
public sealed record SourceCoverageDetail(
bool HasCvssData,
bool HasKevData,
bool HasFixData,
bool HasAffectedData,
bool HasReferenceData,
ImmutableArray<string> CvssVersions,
ImmutableArray<string> AffectedEcosystems);
/// <summary>
/// Summary of source agreement/disagreement.
/// </summary>
public sealed record SourceAgreementSummary(
int TotalFields,
int AgreeingFields,
int DisagreeingFields,
ImmutableArray<string> AgreedFieldNames,
ImmutableArray<string> DisagreedFieldNames)
{
/// <summary>
/// Agreement ratio (0.0 - 1.0).
/// </summary>
public double AgreementRatio => TotalFields > 0
? (double)AgreeingFields / TotalFields
: 1.0;
/// <summary>
/// Indicates high agreement (>= 90%).
/// </summary>
public bool HighAgreement => AgreementRatio >= 0.9;
/// <summary>
/// Empty agreement summary when no fields to compare.
/// </summary>
public static SourceAgreementSummary Empty => new(
TotalFields: 0,
AgreeingFields: 0,
DisagreeingFields: 0,
AgreedFieldNames: ImmutableArray<string>.Empty,
DisagreedFieldNames: ImmutableArray<string>.Empty);
}
/// <summary>
/// A specific conflict between sources.
/// </summary>
public sealed record SourceConflict(
string Field,
ConflictType Type,
ImmutableArray<SourceConflictValue> Values,
string? Resolution)
{
/// <summary>
/// Number of sources involved in this conflict.
/// </summary>
public int SourceCount => Values.Length;
}
/// <summary>
/// Type of conflict between sources.
/// </summary>
public enum ConflictType
{
/// <summary>Sources report different values for the same field.</summary>
ValueMismatch,
/// <summary>One source has data, another does not.</summary>
MissingData,
/// <summary>Severity scores differ significantly.</summary>
SeverityDivergence,
/// <summary>Fix availability status differs.</summary>
FixStatusDivergence,
/// <summary>Affected version ranges conflict.</summary>
AffectedRangeConflict,
/// <summary>KEV status differs between sources.</summary>
KevStatusConflict
}
/// <summary>
/// A source's value in a conflict.
/// </summary>
public sealed record SourceConflictValue(
string SourceId,
string Vendor,
string? Value,
string? ObservationId,
DateTimeOffset? ObservedAt);

View File

@@ -0,0 +1,414 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Default implementation of <see cref="ISourceCoverageMetricsPublisher"/>.
/// Per CONCELIER-RISK-67-001, publishes per-source coverage/conflict metrics
/// so explainers cite which upstream statements exist; no weighting applied.
/// </summary>
public sealed class SourceCoverageMetricsPublisher : ISourceCoverageMetricsPublisher
{
private readonly IVendorRiskSignalProvider _riskSignalProvider;
private readonly ISourceCoverageMetricsStore _store;
private readonly ILogger<SourceCoverageMetricsPublisher> _logger;
private readonly TimeProvider _timeProvider;
public SourceCoverageMetricsPublisher(
IVendorRiskSignalProvider riskSignalProvider,
ISourceCoverageMetricsStore store,
ILogger<SourceCoverageMetricsPublisher> logger,
TimeProvider? timeProvider = null)
{
_riskSignalProvider = riskSignalProvider ?? throw new ArgumentNullException(nameof(riskSignalProvider));
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<SourceCoverageMetrics> PublishByAdvisoryAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryId);
var signals = await _riskSignalProvider.GetByAdvisoryAsync(
tenantId, advisoryId, cancellationToken).ConfigureAwait(false);
var metrics = ComputeMetrics(tenantId, advisoryId, linksetId: null, signals);
await _store.StoreAsync(metrics, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Published coverage metrics for advisory {AdvisoryId}: {SourceCount} sources, {ConflictCount} conflicts",
advisoryId, metrics.SourceCount, metrics.ConflictCount);
return metrics;
}
/// <inheritdoc />
public async Task<SourceCoverageMetrics> PublishByLinksetAsync(
string tenantId,
string linksetId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(linksetId);
var signals = await _riskSignalProvider.GetByLinksetAsync(
tenantId, linksetId, cancellationToken).ConfigureAwait(false);
var advisoryId = signals.FirstOrDefault()?.AdvisoryId ?? "unknown";
var metrics = ComputeMetrics(tenantId, advisoryId, linksetId, signals);
await _store.StoreAsync(metrics, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Published coverage metrics for linkset {LinksetId}: {SourceCount} sources, {ConflictCount} conflicts",
linksetId, metrics.SourceCount, metrics.ConflictCount);
return metrics;
}
/// <inheritdoc />
public Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken)
{
return _store.GetByAdvisoryAsync(tenantId, advisoryId, cancellationToken);
}
/// <inheritdoc />
public Task<SourceCoverageMetrics?> GetByLinksetAsync(
string tenantId,
string linksetId,
CancellationToken cancellationToken)
{
return _store.GetByLinksetAsync(tenantId, linksetId, cancellationToken);
}
private SourceCoverageMetrics ComputeMetrics(
string tenantId,
string advisoryId,
string? linksetId,
IReadOnlyList<VendorRiskSignal> signals)
{
var now = _timeProvider.GetUtcNow();
if (signals.Count == 0)
{
return SourceCoverageMetrics.Empty(tenantId, advisoryId, linksetId, now);
}
var sources = ComputeSourceContributions(signals);
var agreement = ComputeAgreementSummary(signals);
var conflicts = DetectConflicts(signals);
return new SourceCoverageMetrics(
TenantId: tenantId,
AdvisoryId: advisoryId,
LinksetId: linksetId,
Sources: sources,
Agreement: agreement,
Conflicts: conflicts,
ComputedAt: now);
}
private static ImmutableArray<SourceContribution> ComputeSourceContributions(
IReadOnlyList<VendorRiskSignal> signals)
{
var bySource = signals
.GroupBy(s => (s.Provenance.Source, s.Provenance.Vendor))
.OrderBy(g => g.Key.Source, StringComparer.OrdinalIgnoreCase)
.ThenBy(g => g.Key.Vendor, StringComparer.OrdinalIgnoreCase);
var contributions = ImmutableArray.CreateBuilder<SourceContribution>();
foreach (var group in bySource)
{
var sourceSignals = group.ToList();
var observationIds = sourceSignals
.Select(s => s.ObservationId)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var coverage = ComputeCoverageDetail(sourceSignals);
var latestAt = sourceSignals.Max(s => s.ExtractedAt);
contributions.Add(new SourceContribution(
SourceId: group.Key.Source,
Vendor: group.Key.Vendor,
ObservationCount: observationIds.Length,
ObservationIds: observationIds,
Coverage: coverage,
LatestObservationAt: latestAt));
}
return contributions.ToImmutable();
}
private static SourceCoverageDetail ComputeCoverageDetail(List<VendorRiskSignal> signals)
{
var hasCvss = signals.Any(s => !s.CvssScores.IsDefaultOrEmpty);
var hasKev = signals.Any(s => s.KevStatus is not null);
var hasFix = signals.Any(s => !s.FixAvailability.IsDefaultOrEmpty);
var hasAffected = signals.Any(s => !s.FixAvailability.IsDefaultOrEmpty &&
s.FixAvailability.Any(f => !string.IsNullOrWhiteSpace(f.Package)));
var hasReferences = signals.Any(s => !s.FixAvailability.IsDefaultOrEmpty &&
s.FixAvailability.Any(f => !string.IsNullOrWhiteSpace(f.AdvisoryUrl)));
var cvssVersions = signals
.SelectMany(s => s.CvssScores)
.Select(c => c.NormalizedSystem)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(v => v, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var ecosystems = signals
.SelectMany(s => s.FixAvailability)
.Where(f => !string.IsNullOrWhiteSpace(f.Ecosystem))
.Select(f => f.Ecosystem!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(e => e, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new SourceCoverageDetail(
HasCvssData: hasCvss,
HasKevData: hasKev,
HasFixData: hasFix,
HasAffectedData: hasAffected,
HasReferenceData: hasReferences,
CvssVersions: cvssVersions,
AffectedEcosystems: ecosystems);
}
private static SourceAgreementSummary ComputeAgreementSummary(IReadOnlyList<VendorRiskSignal> signals)
{
if (signals.Count <= 1)
{
return SourceAgreementSummary.Empty;
}
var agreedFields = new List<string>();
var disagreedFields = new List<string>();
// Check CVSS severity agreement
var severities = signals
.Where(s => s.HighestCvssScore is not null)
.Select(s => s.HighestCvssScore!.EffectiveSeverity)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (severities.Count > 0)
{
if (severities.Count == 1)
{
agreedFields.Add("severity");
}
else
{
disagreedFields.Add("severity");
}
}
// Check KEV status agreement
var kevStatuses = signals
.Where(s => s.KevStatus is not null)
.Select(s => s.KevStatus!.InKev)
.Distinct()
.ToList();
if (kevStatuses.Count > 0)
{
if (kevStatuses.Count == 1)
{
agreedFields.Add("kev_status");
}
else
{
disagreedFields.Add("kev_status");
}
}
// Check fix availability agreement
var fixStatuses = signals
.Select(s => s.HasFixAvailable)
.Distinct()
.ToList();
if (fixStatuses.Count == 1)
{
agreedFields.Add("fix_availability");
}
else if (fixStatuses.Count > 1)
{
disagreedFields.Add("fix_availability");
}
var totalFields = agreedFields.Count + disagreedFields.Count;
return new SourceAgreementSummary(
TotalFields: totalFields,
AgreeingFields: agreedFields.Count,
DisagreeingFields: disagreedFields.Count,
AgreedFieldNames: agreedFields.ToImmutableArray(),
DisagreedFieldNames: disagreedFields.ToImmutableArray());
}
private static ImmutableArray<SourceConflict> DetectConflicts(IReadOnlyList<VendorRiskSignal> signals)
{
if (signals.Count <= 1)
{
return ImmutableArray<SourceConflict>.Empty;
}
var conflicts = ImmutableArray.CreateBuilder<SourceConflict>();
// Detect severity divergence
var severityConflict = DetectSeverityConflict(signals);
if (severityConflict is not null)
{
conflicts.Add(severityConflict);
}
// Detect KEV status conflict
var kevConflict = DetectKevConflict(signals);
if (kevConflict is not null)
{
conflicts.Add(kevConflict);
}
// Detect fix status divergence
var fixConflict = DetectFixStatusConflict(signals);
if (fixConflict is not null)
{
conflicts.Add(fixConflict);
}
return conflicts.ToImmutable();
}
private static SourceConflict? DetectSeverityConflict(IReadOnlyList<VendorRiskSignal> signals)
{
var signalsWithSeverity = signals
.Where(s => s.HighestCvssScore is not null)
.ToList();
if (signalsWithSeverity.Count <= 1)
{
return null;
}
var severities = signalsWithSeverity
.Select(s => s.HighestCvssScore!.EffectiveSeverity)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (severities.Count <= 1)
{
return null;
}
var values = signalsWithSeverity
.Select(s => new SourceConflictValue(
SourceId: s.Provenance.Source,
Vendor: s.Provenance.Vendor,
Value: s.HighestCvssScore!.EffectiveSeverity,
ObservationId: s.ObservationId,
ObservedAt: s.ExtractedAt))
.OrderBy(v => v.SourceId, StringComparer.OrdinalIgnoreCase)
.ThenBy(v => v.Vendor, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new SourceConflict(
Field: "severity",
Type: ConflictType.SeverityDivergence,
Values: values,
Resolution: null);
}
private static SourceConflict? DetectKevConflict(IReadOnlyList<VendorRiskSignal> signals)
{
var signalsWithKev = signals
.Where(s => s.KevStatus is not null)
.ToList();
if (signalsWithKev.Count <= 1)
{
return null;
}
var kevStatuses = signalsWithKev
.Select(s => s.KevStatus!.InKev)
.Distinct()
.ToList();
if (kevStatuses.Count <= 1)
{
return null;
}
var values = signalsWithKev
.Select(s => new SourceConflictValue(
SourceId: s.Provenance.Source,
Vendor: s.Provenance.Vendor,
Value: s.KevStatus!.InKev ? "in_kev" : "not_in_kev",
ObservationId: s.ObservationId,
ObservedAt: s.ExtractedAt))
.OrderBy(v => v.SourceId, StringComparer.OrdinalIgnoreCase)
.ThenBy(v => v.Vendor, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new SourceConflict(
Field: "kev_status",
Type: ConflictType.KevStatusConflict,
Values: values,
Resolution: null);
}
private static SourceConflict? DetectFixStatusConflict(IReadOnlyList<VendorRiskSignal> signals)
{
var fixStatuses = signals
.Select(s => (Signal: s, HasFix: s.HasFixAvailable))
.ToList();
var distinctStatuses = fixStatuses
.Select(x => x.HasFix)
.Distinct()
.ToList();
if (distinctStatuses.Count <= 1)
{
return null;
}
var values = fixStatuses
.Select(x => new SourceConflictValue(
SourceId: x.Signal.Provenance.Source,
Vendor: x.Signal.Provenance.Vendor,
Value: x.HasFix ? "fix_available" : "no_fix",
ObservationId: x.Signal.ObservationId,
ObservedAt: x.Signal.ExtractedAt))
.OrderBy(v => v.SourceId, StringComparer.OrdinalIgnoreCase)
.ThenBy(v => v.Vendor, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new SourceConflict(
Field: "fix_availability",
Type: ConflictType.FixStatusDivergence,
Values: values,
Resolution: null);
}
}