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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user