audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories

This commit is contained in:
master
2026-01-07 18:49:59 +02:00
parent 04ec098046
commit 608a7f85c0
866 changed files with 56323 additions and 6231 deletions

View File

@@ -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]/(.+)")]

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

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

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

View File

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

View File

@@ -7,4 +7,9 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View File

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

View File

@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>