audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
namespace StellaOps.Feedser.Core;
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -19,8 +21,10 @@ public static partial class HunkSigExtractor
|
||||
string cveId,
|
||||
string upstreamRepo,
|
||||
string commitSha,
|
||||
string unifiedDiff)
|
||||
string unifiedDiff,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
var time = timeProvider ?? TimeProvider.System;
|
||||
var hunks = ParseUnifiedDiff(unifiedDiff);
|
||||
var normalizedHunks = hunks.Select(NormalizeHunk).ToList();
|
||||
var hunkHash = ComputeHunkHash(normalizedHunks);
|
||||
@@ -31,6 +35,9 @@ public static partial class HunkSigExtractor
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Extract affected functions from patch context
|
||||
var affectedFunctions = ExtractAffectedFunctions(normalizedHunks);
|
||||
|
||||
return new PatchSignature
|
||||
{
|
||||
PatchSigId = $"sha256:{hunkHash}",
|
||||
@@ -40,12 +47,40 @@ public static partial class HunkSigExtractor
|
||||
Hunks = normalizedHunks,
|
||||
HunkHash = hunkHash,
|
||||
AffectedFiles = affectedFiles,
|
||||
AffectedFunctions = null, // TODO: Extract from context
|
||||
ExtractedAt = DateTimeOffset.UtcNow,
|
||||
AffectedFunctions = affectedFunctions.Count > 0 ? affectedFunctions : null,
|
||||
ExtractedAt = time.GetUtcNow(),
|
||||
ExtractorVersion = Version
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts affected function names from patch hunks using FunctionSignatureExtractor.
|
||||
/// </summary>
|
||||
private static List<string> ExtractAffectedFunctions(IReadOnlyList<PatchHunk> hunks)
|
||||
{
|
||||
var functions = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var hunk in hunks)
|
||||
{
|
||||
var contextLines = string.IsNullOrEmpty(hunk.Context)
|
||||
? Array.Empty<string>()
|
||||
: hunk.Context.Split('\n');
|
||||
|
||||
var extracted = FunctionSignatureExtractor.ExtractFunctionsFromContext(
|
||||
contextLines,
|
||||
hunk.AddedLines,
|
||||
hunk.RemovedLines,
|
||||
hunk.FilePath);
|
||||
|
||||
foreach (var func in extracted)
|
||||
{
|
||||
functions.Add(func.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return functions.OrderBy(f => f, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static List<PatchHunk> ParseUnifiedDiff(string diff)
|
||||
{
|
||||
var hunks = new List<PatchHunk>();
|
||||
@@ -203,7 +238,7 @@ public static partial class HunkSigExtractor
|
||||
{
|
||||
// "@@ -123,45 +123,47 @@"
|
||||
var match = HunkHeaderRegex().Match(line);
|
||||
return match.Success ? int.Parse(match.Groups[1].Value) : 0;
|
||||
return match.Success ? int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture) : 0;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\+\+\+ [ab]/(.+)")]
|
||||
|
||||
207
src/Feedser/StellaOps.Feedser.Core/Signals/EpssSignalAttacher.cs
Normal file
207
src/Feedser/StellaOps.Feedser.Core/Signals/EpssSignalAttacher.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EpssSignalAttacher.cs
|
||||
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
|
||||
// Task: DBI-002 - Implement EpssSignalAttacher with event emission
|
||||
// Description: Attaches EPSS signals to the determinization pipeline
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Feedser.Core.Signals;
|
||||
|
||||
/// <summary>
|
||||
/// Input for EPSS signal lookup.
|
||||
/// </summary>
|
||||
public sealed record EpssLookupInput
|
||||
{
|
||||
/// <summary>The CVE ID to look up.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Optional date for historical lookup.</summary>
|
||||
public DateOnly? AsOfDate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EPSS signal value.
|
||||
/// </summary>
|
||||
public sealed record EpssSignal
|
||||
{
|
||||
/// <summary>The CVE ID.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>EPSS probability (0.0-1.0).</summary>
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>EPSS percentile (0.0-100.0).</summary>
|
||||
public required double Percentile { get; init; }
|
||||
|
||||
/// <summary>The date of the EPSS score.</summary>
|
||||
public required DateOnly ScoreDate { get; init; }
|
||||
|
||||
/// <summary>Model version used to compute the score.</summary>
|
||||
public string? ModelVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches EPSS signals from the EPSS feed.
|
||||
/// </summary>
|
||||
public sealed class EpssSignalAttacher : ISignalAttacher<EpssLookupInput, EpssSignal>
|
||||
{
|
||||
private readonly IEpssDataSource _dataSource;
|
||||
private readonly ISignalEventEmitter _eventEmitter;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<EpssSignalAttacher> _logger;
|
||||
|
||||
public string SignalType => "epss";
|
||||
|
||||
public EpssSignalAttacher(
|
||||
IEpssDataSource dataSource,
|
||||
ISignalEventEmitter eventEmitter,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EpssSignalAttacher> logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_eventEmitter = eventEmitter;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SignalState<EpssSignal>> AttachAsync(
|
||||
EpssLookupInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var epss = await _dataSource.GetEpssAsync(input.CveId, input.AsOfDate, ct);
|
||||
|
||||
if (epss is null)
|
||||
{
|
||||
_logger.LogDebug("EPSS not found for CVE {CveId}", input.CveId);
|
||||
|
||||
var notFoundState = SignalState<EpssSignal>.NotFound(now, "epss-feed");
|
||||
|
||||
await _eventEmitter.EmitAsync(new SignalUpdatedEvent
|
||||
{
|
||||
SignalType = SignalType,
|
||||
CveId = input.CveId,
|
||||
Status = SignalStatus.NotFound,
|
||||
UpdatedAt = now
|
||||
}, ct);
|
||||
|
||||
return notFoundState;
|
||||
}
|
||||
|
||||
var signal = new EpssSignal
|
||||
{
|
||||
CveId = input.CveId,
|
||||
Score = epss.Score,
|
||||
Percentile = epss.Percentile,
|
||||
ScoreDate = epss.ScoreDate,
|
||||
ModelVersion = epss.ModelVersion
|
||||
};
|
||||
|
||||
var state = SignalState<EpssSignal>.Success(
|
||||
signal,
|
||||
now,
|
||||
"epss-feed",
|
||||
TimeSpan.FromHours(24)); // EPSS updates daily
|
||||
|
||||
_logger.LogDebug(
|
||||
"EPSS attached for CVE {CveId}: score={Score}, percentile={Percentile}",
|
||||
input.CveId, epss.Score, epss.Percentile);
|
||||
|
||||
await _eventEmitter.EmitAsync(new SignalUpdatedEvent
|
||||
{
|
||||
SignalType = SignalType,
|
||||
CveId = input.CveId,
|
||||
Status = SignalStatus.Available,
|
||||
UpdatedAt = now
|
||||
}, ct);
|
||||
|
||||
return state;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to attach EPSS for CVE {CveId}", input.CveId);
|
||||
|
||||
await _eventEmitter.EmitAsync(new SignalUpdatedEvent
|
||||
{
|
||||
SignalType = SignalType,
|
||||
CveId = input.CveId,
|
||||
Status = SignalStatus.Failed,
|
||||
UpdatedAt = now
|
||||
}, ct);
|
||||
|
||||
return SignalState<EpssSignal>.Failed(ex.Message, now, "epss-feed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SignalState<EpssSignal>>> AttachBatchAsync(
|
||||
IReadOnlyList<EpssLookupInput> inputs,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<SignalState<EpssSignal>>(inputs.Count);
|
||||
|
||||
// Process in parallel with bounded concurrency
|
||||
var tasks = inputs.Select(async input =>
|
||||
{
|
||||
return await AttachAsync(input, ct);
|
||||
});
|
||||
|
||||
var states = await Task.WhenAll(tasks);
|
||||
results.AddRange(states);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data source for EPSS scores.
|
||||
/// </summary>
|
||||
public interface IEpssDataSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets EPSS data for a CVE.
|
||||
/// </summary>
|
||||
Task<EpssData?> GetEpssAsync(string cveId, DateOnly? asOfDate = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets EPSS data for multiple CVEs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, EpssData>> GetEpssBatchAsync(
|
||||
IReadOnlyList<string> cveIds,
|
||||
DateOnly? asOfDate = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw EPSS data from the feed.
|
||||
/// </summary>
|
||||
public sealed record EpssData
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public required double Percentile { get; init; }
|
||||
public required DateOnly ScoreDate { get; init; }
|
||||
public string? ModelVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits signal update events.
|
||||
/// </summary>
|
||||
public interface ISignalEventEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits a signal updated event.
|
||||
/// </summary>
|
||||
Task EmitAsync(SignalUpdatedEvent @event, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits multiple events in batch.
|
||||
/// </summary>
|
||||
Task EmitBatchAsync(IReadOnlyList<SignalUpdatedEvent> events, CancellationToken ct = default);
|
||||
}
|
||||
154
src/Feedser/StellaOps.Feedser.Core/Signals/ISignalAttacher.cs
Normal file
154
src/Feedser/StellaOps.Feedser.Core/Signals/ISignalAttacher.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISignalAttacher.cs
|
||||
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
|
||||
// Task: DBI-001 - Create ISignalAttacher<T> interface in Feedser
|
||||
// Description: Interface for attaching signals to determinization pipeline
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Feedser.Core.Signals;
|
||||
|
||||
/// <summary>
|
||||
/// Attaches signals from feed data to the determinization pipeline.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The feed data input type.</typeparam>
|
||||
/// <typeparam name="TSignal">The signal output type.</typeparam>
|
||||
public interface ISignalAttacher<TInput, TSignal>
|
||||
{
|
||||
/// <summary>
|
||||
/// Attaches a signal from the feed data.
|
||||
/// </summary>
|
||||
/// <param name="input">The feed data input.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The signal state wrapping the result.</returns>
|
||||
Task<SignalState<TSignal>> AttachAsync(TInput input, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches signals in batch.
|
||||
/// </summary>
|
||||
/// <param name="inputs">The feed data inputs.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Signal states for each input.</returns>
|
||||
Task<IReadOnlyList<SignalState<TSignal>>> AttachBatchAsync(
|
||||
IReadOnlyList<TInput> inputs,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// The signal type identifier.
|
||||
/// </summary>
|
||||
string SignalType { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a signal in the determinization pipeline.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The signal value type.</typeparam>
|
||||
public sealed record SignalState<T>
|
||||
{
|
||||
/// <summary>The signal value (if available).</summary>
|
||||
public T? Value { get; init; }
|
||||
|
||||
/// <summary>The status of the signal.</summary>
|
||||
public required SignalStatus Status { get; init; }
|
||||
|
||||
/// <summary>When the signal was captured.</summary>
|
||||
public required DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
/// <summary>Time-to-live for the signal.</summary>
|
||||
public TimeSpan? Ttl { get; init; }
|
||||
|
||||
/// <summary>Error message if status is Failed.</summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>Source identifier for provenance.</summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>Creates a successful signal state.</summary>
|
||||
public static SignalState<T> Success(T value, DateTimeOffset capturedAt, string? source = null, TimeSpan? ttl = null)
|
||||
=> new()
|
||||
{
|
||||
Value = value,
|
||||
Status = SignalStatus.Available,
|
||||
CapturedAt = capturedAt,
|
||||
Source = source,
|
||||
Ttl = ttl
|
||||
};
|
||||
|
||||
/// <summary>Creates a not-found signal state.</summary>
|
||||
public static SignalState<T> NotFound(DateTimeOffset capturedAt, string? source = null)
|
||||
=> new()
|
||||
{
|
||||
Status = SignalStatus.NotFound,
|
||||
CapturedAt = capturedAt,
|
||||
Source = source
|
||||
};
|
||||
|
||||
/// <summary>Creates a failed signal state.</summary>
|
||||
public static SignalState<T> Failed(string error, DateTimeOffset capturedAt, string? source = null)
|
||||
=> new()
|
||||
{
|
||||
Status = SignalStatus.Failed,
|
||||
Error = error,
|
||||
CapturedAt = capturedAt,
|
||||
Source = source
|
||||
};
|
||||
|
||||
/// <summary>Creates a pending signal state.</summary>
|
||||
public static SignalState<T> Pending(DateTimeOffset capturedAt, string? source = null)
|
||||
=> new()
|
||||
{
|
||||
Status = SignalStatus.Pending,
|
||||
CapturedAt = capturedAt,
|
||||
Source = source
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a signal in the determinization pipeline.
|
||||
/// </summary>
|
||||
public enum SignalStatus
|
||||
{
|
||||
/// <summary>Signal is available and has a value.</summary>
|
||||
Available,
|
||||
|
||||
/// <summary>Signal lookup returned no result.</summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>Signal lookup failed with an error.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Signal is pending (async lookup in progress).</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Signal has expired (past TTL).</summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>Signal source is not configured.</summary>
|
||||
NotConfigured
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when a signal is updated.
|
||||
/// </summary>
|
||||
public sealed record SignalUpdatedEvent
|
||||
{
|
||||
/// <summary>The signal type.</summary>
|
||||
public required string SignalType { get; init; }
|
||||
|
||||
/// <summary>The CVE ID (if applicable).</summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>The package URL (if applicable).</summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>The new status.</summary>
|
||||
public required SignalStatus Status { get; init; }
|
||||
|
||||
/// <summary>When the update occurred.</summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>Previous status (for transitions).</summary>
|
||||
public SignalStatus? PreviousStatus { get; init; }
|
||||
|
||||
/// <summary>Correlation ID for tracing.</summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
242
src/Feedser/StellaOps.Feedser.Core/Signals/KevSignalAttacher.cs
Normal file
242
src/Feedser/StellaOps.Feedser.Core/Signals/KevSignalAttacher.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KevSignalAttacher.cs
|
||||
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
|
||||
// Task: DBI-003 - Implement KevSignalAttacher
|
||||
// Description: Attaches KEV (Known Exploited Vulnerabilities) signals
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Feedser.Core.Signals;
|
||||
|
||||
/// <summary>
|
||||
/// Input for KEV signal lookup.
|
||||
/// </summary>
|
||||
public sealed record KevLookupInput
|
||||
{
|
||||
/// <summary>The CVE ID to look up.</summary>
|
||||
public required string CveId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// KEV signal value.
|
||||
/// </summary>
|
||||
public sealed record KevSignal
|
||||
{
|
||||
/// <summary>The CVE ID.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Whether the CVE is in KEV.</summary>
|
||||
public required bool IsInKev { get; init; }
|
||||
|
||||
/// <summary>Date added to KEV (if in KEV).</summary>
|
||||
public DateOnly? DateAdded { get; init; }
|
||||
|
||||
/// <summary>Required action date (if in KEV).</summary>
|
||||
public DateOnly? DueDate { get; init; }
|
||||
|
||||
/// <summary>Vendor/Project name.</summary>
|
||||
public string? VendorProject { get; init; }
|
||||
|
||||
/// <summary>Product name.</summary>
|
||||
public string? Product { get; init; }
|
||||
|
||||
/// <summary>Short description of the vulnerability.</summary>
|
||||
public string? VulnerabilityName { get; init; }
|
||||
|
||||
/// <summary>Known ransomware campaign use.</summary>
|
||||
public bool? KnownRansomwareCampaignUse { get; init; }
|
||||
|
||||
/// <summary>Required action.</summary>
|
||||
public string? RequiredAction { get; init; }
|
||||
|
||||
/// <summary>Notes.</summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches KEV signals from the CISA KEV catalog.
|
||||
/// </summary>
|
||||
public sealed class KevSignalAttacher : ISignalAttacher<KevLookupInput, KevSignal>
|
||||
{
|
||||
private readonly IKevDataSource _dataSource;
|
||||
private readonly ISignalEventEmitter _eventEmitter;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<KevSignalAttacher> _logger;
|
||||
|
||||
public string SignalType => "kev";
|
||||
|
||||
public KevSignalAttacher(
|
||||
IKevDataSource dataSource,
|
||||
ISignalEventEmitter eventEmitter,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<KevSignalAttacher> logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_eventEmitter = eventEmitter;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SignalState<KevSignal>> AttachAsync(
|
||||
KevLookupInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var kev = await _dataSource.GetKevAsync(input.CveId, ct);
|
||||
|
||||
if (kev is null)
|
||||
{
|
||||
// Not in KEV is a valid signal (not an error)
|
||||
var notInKevSignal = new KevSignal
|
||||
{
|
||||
CveId = input.CveId,
|
||||
IsInKev = false
|
||||
};
|
||||
|
||||
_logger.LogDebug("CVE {CveId} not in KEV catalog", input.CveId);
|
||||
|
||||
return SignalState<KevSignal>.Success(
|
||||
notInKevSignal,
|
||||
now,
|
||||
"cisa-kev",
|
||||
TimeSpan.FromHours(12)); // KEV updates frequently
|
||||
}
|
||||
|
||||
var signal = new KevSignal
|
||||
{
|
||||
CveId = input.CveId,
|
||||
IsInKev = true,
|
||||
DateAdded = kev.DateAdded,
|
||||
DueDate = kev.DueDate,
|
||||
VendorProject = kev.VendorProject,
|
||||
Product = kev.Product,
|
||||
VulnerabilityName = kev.VulnerabilityName,
|
||||
KnownRansomwareCampaignUse = kev.KnownRansomwareCampaignUse,
|
||||
RequiredAction = kev.RequiredAction,
|
||||
Notes = kev.Notes
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"CVE {CveId} IS IN KEV: added={DateAdded}, due={DueDate}",
|
||||
input.CveId, kev.DateAdded, kev.DueDate);
|
||||
|
||||
await _eventEmitter.EmitAsync(new SignalUpdatedEvent
|
||||
{
|
||||
SignalType = SignalType,
|
||||
CveId = input.CveId,
|
||||
Status = SignalStatus.Available,
|
||||
UpdatedAt = now
|
||||
}, ct);
|
||||
|
||||
return SignalState<KevSignal>.Success(
|
||||
signal,
|
||||
now,
|
||||
"cisa-kev",
|
||||
TimeSpan.FromHours(12));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to attach KEV for CVE {CveId}", input.CveId);
|
||||
|
||||
await _eventEmitter.EmitAsync(new SignalUpdatedEvent
|
||||
{
|
||||
SignalType = SignalType,
|
||||
CveId = input.CveId,
|
||||
Status = SignalStatus.Failed,
|
||||
UpdatedAt = now
|
||||
}, ct);
|
||||
|
||||
return SignalState<KevSignal>.Failed(ex.Message, now, "cisa-kev");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SignalState<KevSignal>>> AttachBatchAsync(
|
||||
IReadOnlyList<KevLookupInput> inputs,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cveIds = inputs.Select(i => i.CveId).ToList();
|
||||
var kevData = await _dataSource.GetKevBatchAsync(cveIds, ct);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var results = new List<SignalState<KevSignal>>(inputs.Count);
|
||||
|
||||
foreach (var input in inputs)
|
||||
{
|
||||
if (kevData.TryGetValue(input.CveId, out var kev))
|
||||
{
|
||||
var signal = new KevSignal
|
||||
{
|
||||
CveId = input.CveId,
|
||||
IsInKev = true,
|
||||
DateAdded = kev.DateAdded,
|
||||
DueDate = kev.DueDate,
|
||||
VendorProject = kev.VendorProject,
|
||||
Product = kev.Product,
|
||||
VulnerabilityName = kev.VulnerabilityName,
|
||||
KnownRansomwareCampaignUse = kev.KnownRansomwareCampaignUse,
|
||||
RequiredAction = kev.RequiredAction,
|
||||
Notes = kev.Notes
|
||||
};
|
||||
|
||||
results.Add(SignalState<KevSignal>.Success(signal, now, "cisa-kev", TimeSpan.FromHours(12)));
|
||||
}
|
||||
else
|
||||
{
|
||||
var notInKevSignal = new KevSignal
|
||||
{
|
||||
CveId = input.CveId,
|
||||
IsInKev = false
|
||||
};
|
||||
|
||||
results.Add(SignalState<KevSignal>.Success(notInKevSignal, now, "cisa-kev", TimeSpan.FromHours(12)));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data source for KEV catalog.
|
||||
/// </summary>
|
||||
public interface IKevDataSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets KEV data for a CVE.
|
||||
/// </summary>
|
||||
Task<KevData?> GetKevAsync(string cveId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets KEV data for multiple CVEs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, KevData>> GetKevBatchAsync(
|
||||
IReadOnlyList<string> cveIds,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all CVE IDs currently in KEV.
|
||||
/// </summary>
|
||||
Task<IReadOnlySet<string>> GetAllKevCveIdsAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw KEV data from the catalog.
|
||||
/// </summary>
|
||||
public sealed record KevData
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required DateOnly DateAdded { get; init; }
|
||||
public DateOnly? DueDate { get; init; }
|
||||
public string? VendorProject { get; init; }
|
||||
public string? Product { get; init; }
|
||||
public string? VulnerabilityName { get; init; }
|
||||
public bool? KnownRansomwareCampaignUse { get; init; }
|
||||
public string? RequiredAction { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SignalAttacherServiceExtensions.cs
|
||||
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
|
||||
// Task: DBI-004 - Create SignalAttacherServiceExtensions for DI
|
||||
// Description: DI registration extensions for signal attachers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Feedser.Core.Signals;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for signal attachers.
|
||||
/// </summary>
|
||||
public static class SignalAttacherServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds all signal attacher services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSignalAttachers(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ISignalAttacher<EpssLookupInput, EpssSignal>, EpssSignalAttacher>();
|
||||
services.AddSingleton<ISignalAttacher<KevLookupInput, KevSignal>, KevSignalAttacher>();
|
||||
services.AddSingleton<ISignalEventEmitter, InMemorySignalEventEmitter>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds EPSS signal attacher only.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddEpssSignalAttacher(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ISignalAttacher<EpssLookupInput, EpssSignal>, EpssSignalAttacher>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds KEV signal attacher only.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddKevSignalAttacher(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ISignalAttacher<KevLookupInput, KevSignal>, KevSignalAttacher>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds signal event emitter with custom implementation.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSignalEventEmitter<TEmitter>(this IServiceCollection services)
|
||||
where TEmitter : class, ISignalEventEmitter
|
||||
{
|
||||
services.AddSingleton<ISignalEventEmitter, TEmitter>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory signal event emitter for local processing.
|
||||
/// </summary>
|
||||
public sealed class InMemorySignalEventEmitter : ISignalEventEmitter
|
||||
{
|
||||
private readonly List<Func<SignalUpdatedEvent, CancellationToken, Task>> _handlers = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes a handler to signal events.
|
||||
/// </summary>
|
||||
public void Subscribe(Func<SignalUpdatedEvent, CancellationToken, Task> handler)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_handlers.Add(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EmitAsync(SignalUpdatedEvent @event, CancellationToken ct = default)
|
||||
{
|
||||
List<Func<SignalUpdatedEvent, CancellationToken, Task>> handlers;
|
||||
lock (_lock)
|
||||
{
|
||||
handlers = [.. _handlers];
|
||||
}
|
||||
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
await handler(@event, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EmitBatchAsync(IReadOnlyList<SignalUpdatedEvent> events, CancellationToken ct = default)
|
||||
{
|
||||
foreach (var @event in events)
|
||||
{
|
||||
await EmitAsync(@event, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,9 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EpssSignalAttacherTests.cs
|
||||
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
|
||||
// Task: DBI-018 - Write unit tests for EpssSignalAttacher
|
||||
// Description: Unit tests for EPSS signal attacher
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Feedser.Core.Signals;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Feedser.Core.Tests.Signals;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EpssSignalAttacherTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly Mock<IEpssDataSource> _dataSourceMock = new();
|
||||
private readonly Mock<ISignalEventEmitter> _emitterMock = new();
|
||||
private readonly EpssSignalAttacher _attacher;
|
||||
|
||||
public EpssSignalAttacherTests()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_attacher = new EpssSignalAttacher(
|
||||
_dataSourceMock.Object,
|
||||
_emitterMock.Object,
|
||||
_timeProvider,
|
||||
NullLogger<EpssSignalAttacher>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalType_ReturnsEpss()
|
||||
{
|
||||
Assert.Equal("epss", _attacher.SignalType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachAsync_WhenDataFound_ReturnsAvailableState()
|
||||
{
|
||||
// Arrange
|
||||
var cveId = "CVE-2024-1234";
|
||||
var input = new EpssLookupInput { CveId = cveId };
|
||||
var epssData = new EpssData
|
||||
{
|
||||
CveId = cveId,
|
||||
Score = 0.85,
|
||||
Percentile = 99.2,
|
||||
ScoreDate = new DateOnly(2026, 1, 6),
|
||||
ModelVersion = "2024.10"
|
||||
};
|
||||
|
||||
_dataSourceMock
|
||||
.Setup(x => x.GetEpssAsync(cveId, null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(epssData);
|
||||
|
||||
// Act
|
||||
var result = await _attacher.AttachAsync(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SignalStatus.Available, result.Status);
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal(cveId, result.Value.CveId);
|
||||
Assert.Equal(0.85, result.Value.Score);
|
||||
Assert.Equal(99.2, result.Value.Percentile);
|
||||
Assert.Equal("epss-feed", result.Source);
|
||||
Assert.NotNull(result.Ttl);
|
||||
Assert.Equal(TimeSpan.FromHours(24), result.Ttl);
|
||||
|
||||
_emitterMock.Verify(x => x.EmitAsync(
|
||||
It.Is<SignalUpdatedEvent>(e =>
|
||||
e.SignalType == "epss" &&
|
||||
e.CveId == cveId &&
|
||||
e.Status == SignalStatus.Available),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachAsync_WhenDataNotFound_ReturnsNotFoundState()
|
||||
{
|
||||
// Arrange
|
||||
var cveId = "CVE-2099-9999";
|
||||
var input = new EpssLookupInput { CveId = cveId };
|
||||
|
||||
_dataSourceMock
|
||||
.Setup(x => x.GetEpssAsync(cveId, null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((EpssData?)null);
|
||||
|
||||
// Act
|
||||
var result = await _attacher.AttachAsync(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SignalStatus.NotFound, result.Status);
|
||||
Assert.Null(result.Value);
|
||||
Assert.Equal("epss-feed", result.Source);
|
||||
|
||||
_emitterMock.Verify(x => x.EmitAsync(
|
||||
It.Is<SignalUpdatedEvent>(e =>
|
||||
e.SignalType == "epss" &&
|
||||
e.Status == SignalStatus.NotFound),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachAsync_WhenDataSourceThrows_ReturnsFailedState()
|
||||
{
|
||||
// Arrange
|
||||
var cveId = "CVE-2024-1234";
|
||||
var input = new EpssLookupInput { CveId = cveId };
|
||||
|
||||
_dataSourceMock
|
||||
.Setup(x => x.GetEpssAsync(cveId, null, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Network error"));
|
||||
|
||||
// Act
|
||||
var result = await _attacher.AttachAsync(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SignalStatus.Failed, result.Status);
|
||||
Assert.Null(result.Value);
|
||||
Assert.Equal("Network error", result.Error);
|
||||
Assert.Equal("epss-feed", result.Source);
|
||||
|
||||
_emitterMock.Verify(x => x.EmitAsync(
|
||||
It.Is<SignalUpdatedEvent>(e =>
|
||||
e.SignalType == "epss" &&
|
||||
e.Status == SignalStatus.Failed),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachAsync_WithAsOfDate_PassesDateToDataSource()
|
||||
{
|
||||
// Arrange
|
||||
var cveId = "CVE-2024-1234";
|
||||
var asOfDate = new DateOnly(2025, 12, 15);
|
||||
var input = new EpssLookupInput { CveId = cveId, AsOfDate = asOfDate };
|
||||
|
||||
_dataSourceMock
|
||||
.Setup(x => x.GetEpssAsync(cveId, asOfDate, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EpssData
|
||||
{
|
||||
CveId = cveId,
|
||||
Score = 0.5,
|
||||
Percentile = 75.0,
|
||||
ScoreDate = asOfDate
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _attacher.AttachAsync(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SignalStatus.Available, result.Status);
|
||||
Assert.Equal(asOfDate, result.Value!.ScoreDate);
|
||||
|
||||
_dataSourceMock.Verify(x => x.GetEpssAsync(cveId, asOfDate, It.IsAny<CancellationToken>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachBatchAsync_ProcessesAllInputs()
|
||||
{
|
||||
// Arrange
|
||||
var inputs = new List<EpssLookupInput>
|
||||
{
|
||||
new() { CveId = "CVE-2024-0001" },
|
||||
new() { CveId = "CVE-2024-0002" },
|
||||
new() { CveId = "CVE-2024-0003" }
|
||||
};
|
||||
|
||||
_dataSourceMock
|
||||
.Setup(x => x.GetEpssAsync(It.IsAny<string>(), null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string cve, DateOnly? _, CancellationToken _) => new EpssData
|
||||
{
|
||||
CveId = cve,
|
||||
Score = 0.5,
|
||||
Percentile = 50.0,
|
||||
ScoreDate = new DateOnly(2026, 1, 6)
|
||||
});
|
||||
|
||||
// Act
|
||||
var results = await _attacher.AttachBatchAsync(inputs);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.All(results, r => Assert.Equal(SignalStatus.Available, r.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachAsync_CapturedAtUsesTimeProvider()
|
||||
{
|
||||
// Arrange
|
||||
var expectedTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
|
||||
var input = new EpssLookupInput { CveId = "CVE-2024-1234" };
|
||||
|
||||
_dataSourceMock
|
||||
.Setup(x => x.GetEpssAsync(It.IsAny<string>(), null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((EpssData?)null);
|
||||
|
||||
// Act
|
||||
var result = await _attacher.AttachAsync(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedTime, result.CapturedAt);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user