audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
@@ -25,6 +25,5 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -1867,7 +1868,7 @@ app.MapPatch("/api/v1/findings/{findingId}/state", async Task<Results<Ok<StateTr
|
||||
var payload = new JsonObject { ["status"] = targetState, ["previous_status"] = previousStatus };
|
||||
if (!string.IsNullOrWhiteSpace(request.Justification)) payload["justification"] = request.Justification;
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes)) payload["notes"] = request.Notes;
|
||||
if (request.DueDate.HasValue) payload["due_date"] = request.DueDate.Value.ToString("O");
|
||||
if (request.DueDate.HasValue) payload["due_date"] = request.DueDate.Value.ToString("O", CultureInfo.InvariantCulture);
|
||||
if (request.Tags is { Count: > 0 })
|
||||
{
|
||||
var tagsArray = new JsonArray();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -49,8 +50,8 @@ public sealed class AttestationQueryService
|
||||
["finding_id"] = request.FindingId,
|
||||
["attestation_id"] = request.AttestationId,
|
||||
["status"] = request.Status,
|
||||
["since_recorded_at"] = request.SinceRecordedAt?.ToString("O"),
|
||||
["until_recorded_at"] = request.UntilRecordedAt?.ToString("O"),
|
||||
["since_recorded_at"] = request.SinceRecordedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["until_recorded_at"] = request.UntilRecordedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["limit"] = request.Limit.ToString()
|
||||
};
|
||||
|
||||
@@ -119,7 +120,7 @@ public sealed class AttestationQueryService
|
||||
FiltersHash = filtersHash,
|
||||
Last = new AttestationPageKey
|
||||
{
|
||||
RecordedAt = key.RecordedAt.ToString("O"),
|
||||
RecordedAt = key.RecordedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
AttestationId = key.AttestationId
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
@@ -39,10 +40,10 @@ public sealed class ExportQueryService
|
||||
["shape"] = request.Shape,
|
||||
["since_sequence"] = request.SinceSequence?.ToString(),
|
||||
["until_sequence"] = request.UntilSequence?.ToString(),
|
||||
["since_observed_at"] = request.SinceObservedAt?.ToString("O"),
|
||||
["until_observed_at"] = request.UntilObservedAt?.ToString("O"),
|
||||
["since_observed_at"] = request.SinceObservedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["until_observed_at"] = request.UntilObservedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["status"] = request.Status,
|
||||
["severity"] = request.Severity?.ToString()
|
||||
["severity"] = request.Severity?.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
return ExportPaging.ComputeFiltersHash(filters);
|
||||
@@ -55,8 +56,8 @@ public sealed class ExportQueryService
|
||||
["shape"] = request.Shape,
|
||||
["since_sequence"] = request.SinceSequence?.ToString(),
|
||||
["until_sequence"] = request.UntilSequence?.ToString(),
|
||||
["since_observed_at"] = request.SinceObservedAt?.ToString("O"),
|
||||
["until_observed_at"] = request.UntilObservedAt?.ToString("O"),
|
||||
["since_observed_at"] = request.SinceObservedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["until_observed_at"] = request.UntilObservedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["product_id"] = request.ProductId,
|
||||
["advisory_id"] = request.AdvisoryId,
|
||||
["status"] = request.Status,
|
||||
@@ -73,8 +74,8 @@ public sealed class ExportQueryService
|
||||
["shape"] = request.Shape,
|
||||
["since_sequence"] = request.SinceSequence?.ToString(),
|
||||
["until_sequence"] = request.UntilSequence?.ToString(),
|
||||
["since_observed_at"] = request.SinceObservedAt?.ToString("O"),
|
||||
["until_observed_at"] = request.UntilObservedAt?.ToString("O"),
|
||||
["since_observed_at"] = request.SinceObservedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["until_observed_at"] = request.UntilObservedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["severity"] = request.Severity,
|
||||
["source"] = request.Source,
|
||||
["cwe_id"] = request.CweId,
|
||||
@@ -94,8 +95,8 @@ public sealed class ExportQueryService
|
||||
["shape"] = request.Shape,
|
||||
["since_sequence"] = request.SinceSequence?.ToString(),
|
||||
["until_sequence"] = request.UntilSequence?.ToString(),
|
||||
["since_observed_at"] = request.SinceObservedAt?.ToString("O"),
|
||||
["until_observed_at"] = request.UntilObservedAt?.ToString("O"),
|
||||
["since_observed_at"] = request.SinceObservedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["until_observed_at"] = request.UntilObservedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["subject_digest"] = request.SubjectDigest,
|
||||
["sbom_format"] = request.SbomFormat,
|
||||
["component_purl"] = request.ComponentPurl,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Exports;
|
||||
@@ -152,7 +153,7 @@ internal static class LedgerTimeline
|
||||
newFindings,
|
||||
resolvedFindings,
|
||||
criticalDelta,
|
||||
timeAnchor.ToString("O"),
|
||||
timeAnchor.ToString("O", CultureInfo.InvariantCulture),
|
||||
sealedMode);
|
||||
}
|
||||
|
||||
@@ -300,7 +301,7 @@ internal static class LedgerTimeline
|
||||
snapshot.ActivationId ?? string.Empty,
|
||||
snapshot.Actor ?? string.Empty,
|
||||
snapshot.Reason ?? string.Empty,
|
||||
snapshot.ExpiresAt?.ToString("O") ?? string.Empty,
|
||||
snapshot.ExpiresAt?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
snapshot.RetentionExtensionDays,
|
||||
wasReactivation);
|
||||
}
|
||||
@@ -321,8 +322,8 @@ internal static class LedgerTimeline
|
||||
sample.EventType,
|
||||
sample.PolicyVersion,
|
||||
sample.LagSeconds,
|
||||
sample.RecordedAt.ToString("O"),
|
||||
sample.ObservedAt.ToString("O"));
|
||||
sample.RecordedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
sample.ObservedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public static void EmitIncidentConflictSnapshot(ILogger logger, ConflictSnapshot snapshot)
|
||||
@@ -345,7 +346,7 @@ internal static class LedgerTimeline
|
||||
snapshot.ExpectedSequence,
|
||||
snapshot.ActorId ?? string.Empty,
|
||||
snapshot.ActorType ?? string.Empty,
|
||||
snapshot.ObservedAt.ToString("O"));
|
||||
snapshot.ObservedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public static void EmitIncidentReplayTrace(ILogger logger, ReplayTraceSample sample)
|
||||
@@ -366,6 +367,6 @@ internal static class LedgerTimeline
|
||||
sample.HasMore,
|
||||
sample.ChainFilterCount,
|
||||
sample.EventTypeFilterCount,
|
||||
sample.ObservedAt.ToString("O"));
|
||||
sample.ObservedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IObservationRepository.cs
|
||||
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
|
||||
// Task: DBI-012 - Create IObservationRepository in Findings
|
||||
// Description: Repository interface for finding observations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observations;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an observation in the findings ledger.
|
||||
/// </summary>
|
||||
public sealed record Observation
|
||||
{
|
||||
/// <summary>Unique observation ID.</summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>The CVE ID.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>The product (purl or cpe).</summary>
|
||||
public required string Product { get; init; }
|
||||
|
||||
/// <summary>Tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Finding ID (if linked to a finding).</summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>Current state.</summary>
|
||||
public required ObservationState State { get; init; }
|
||||
|
||||
/// <summary>Previous state (for transitions).</summary>
|
||||
public ObservationState? PreviousState { get; init; }
|
||||
|
||||
/// <summary>Reason for the current state.</summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>User who made the observation.</summary>
|
||||
public string? UserId { get; init; }
|
||||
|
||||
/// <summary>Evidence bundle reference.</summary>
|
||||
public string? EvidenceRef { get; init; }
|
||||
|
||||
/// <summary>Signal snapshot at time of observation.</summary>
|
||||
public SignalSnapshotSummary? Signals { get; init; }
|
||||
|
||||
/// <summary>When the observation was created.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Expiration time (if temporary).</summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observation state.
|
||||
/// </summary>
|
||||
public enum ObservationState
|
||||
{
|
||||
/// <summary>Under review.</summary>
|
||||
UnderReview,
|
||||
|
||||
/// <summary>Confirmed affected.</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>Confirmed not affected.</summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>Fixed.</summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>Mitigated.</summary>
|
||||
Mitigated,
|
||||
|
||||
/// <summary>Risk accepted.</summary>
|
||||
Accepted,
|
||||
|
||||
/// <summary>False positive.</summary>
|
||||
FalsePositive,
|
||||
|
||||
/// <summary>Deferred.</summary>
|
||||
Deferred
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of signals at time of observation.
|
||||
/// </summary>
|
||||
public sealed record SignalSnapshotSummary
|
||||
{
|
||||
/// <summary>EPSS score.</summary>
|
||||
public double? EpssScore { get; init; }
|
||||
|
||||
/// <summary>EPSS percentile.</summary>
|
||||
public double? EpssPercentile { get; init; }
|
||||
|
||||
/// <summary>Is in KEV.</summary>
|
||||
public bool? IsInKev { get; init; }
|
||||
|
||||
/// <summary>VEX status.</summary>
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>Reachability status.</summary>
|
||||
public string? ReachabilityStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository for observations in the findings ledger.
|
||||
/// </summary>
|
||||
public interface IObservationRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an observation.
|
||||
/// </summary>
|
||||
Task<Observation> CreateAsync(Observation observation, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an observation by ID.
|
||||
/// </summary>
|
||||
Task<Observation?> GetByIdAsync(string id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets observations for a CVE.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Observation>> GetByCveAsync(
|
||||
string cveId,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets observations for a product.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Observation>> GetByProductAsync(
|
||||
string product,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets observations for a finding.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Observation>> GetByFindingAsync(
|
||||
string findingId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest observation for a CVE/product pair.
|
||||
/// </summary>
|
||||
Task<Observation?> GetLatestAsync(
|
||||
string cveId,
|
||||
string product,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets observation history for a CVE/product pair.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Observation>> GetHistoryAsync(
|
||||
string cveId,
|
||||
string product,
|
||||
string tenantId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets observations by state.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Observation>> GetByStateAsync(
|
||||
ObservationState state,
|
||||
string tenantId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets expiring observations.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Observation>> GetExpiringAsync(
|
||||
DateTimeOffset before,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts observations by state.
|
||||
/// </summary>
|
||||
Task<IDictionary<ObservationState, int>> CountByStateAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresObservationRepository.cs
|
||||
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
|
||||
// Task: DBI-013 - Implement PostgresObservationRepository
|
||||
// Description: PostgreSQL implementation of observation repository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observations;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of observation repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresObservationRepository : IObservationRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PostgresObservationRepository> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public PostgresObservationRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PostgresObservationRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Observation> CreateAsync(
|
||||
Observation observation,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO observations (
|
||||
id, cve_id, product, tenant_id, finding_id, state, previous_state,
|
||||
reason, user_id, evidence_ref, signals, created_at, expires_at
|
||||
) VALUES (
|
||||
@id, @cve_id, @product, @tenant_id, @finding_id, @state, @previous_state,
|
||||
@reason, @user_id, @evidence_ref, @signals, @created_at, @expires_at
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", observation.Id);
|
||||
cmd.Parameters.AddWithValue("cve_id", observation.CveId);
|
||||
cmd.Parameters.AddWithValue("product", observation.Product);
|
||||
cmd.Parameters.AddWithValue("tenant_id", observation.TenantId);
|
||||
cmd.Parameters.AddWithValue("finding_id", (object?)observation.FindingId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("state", observation.State.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("previous_state",
|
||||
(object?)observation.PreviousState?.ToString().ToLowerInvariant() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("reason", (object?)observation.Reason ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("user_id", (object?)observation.UserId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("evidence_ref", (object?)observation.EvidenceRef ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("signals", SerializeSignals(observation.Signals));
|
||||
cmd.Parameters.AddWithValue("created_at", observation.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("expires_at", (object?)observation.ExpiresAt ?? DBNull.Value);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return MapFromReader(reader);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Insert did not return a row");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Observation?> GetByIdAsync(
|
||||
string id,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return MapFromReader(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Observation>> GetByCveAsync(
|
||||
string cveId,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE cve_id = @cve_id AND tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, new { cve_id = cveId, tenant_id = tenantId }, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Observation>> GetByProductAsync(
|
||||
string product,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE product = @product AND tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, new { product, tenant_id = tenantId }, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Observation>> GetByFindingAsync(
|
||||
string findingId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE finding_id = @finding_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, new { finding_id = findingId }, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Observation?> GetLatestAsync(
|
||||
string cveId,
|
||||
string product,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE cve_id = @cve_id AND product = @product AND tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
var results = await ExecuteQueryAsync(sql, new { cve_id = cveId, product, tenant_id = tenantId }, ct);
|
||||
return results.Count > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Observation>> GetHistoryAsync(
|
||||
string cveId,
|
||||
string product,
|
||||
string tenantId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE cve_id = @cve_id AND product = @product AND tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, new { cve_id = cveId, product, tenant_id = tenantId, limit }, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Observation>> GetByStateAsync(
|
||||
ObservationState state,
|
||||
string tenantId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE state = @state AND tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, new
|
||||
{
|
||||
state = state.ToString().ToLowerInvariant(),
|
||||
tenant_id = tenantId,
|
||||
limit,
|
||||
offset
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Observation>> GetExpiringAsync(
|
||||
DateTimeOffset before,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at <= @before
|
||||
AND tenant_id = @tenant_id
|
||||
ORDER BY expires_at ASC
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, new { before, tenant_id = tenantId }, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IDictionary<ObservationState, int>> CountByStateAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT state, COUNT(*) as count
|
||||
FROM observations
|
||||
WHERE tenant_id = @tenant_id
|
||||
GROUP BY state
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = new Dictionary<ObservationState, int>();
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var stateStr = reader.GetString(0);
|
||||
var count = reader.GetInt32(1);
|
||||
|
||||
if (Enum.TryParse<ObservationState>(stateStr, ignoreCase: true, out var state))
|
||||
{
|
||||
result[state] = count;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<Observation>> ExecuteQueryAsync(
|
||||
string sql,
|
||||
object parameters,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var results = new List<Observation>();
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
foreach (var prop in parameters.GetType().GetProperties())
|
||||
{
|
||||
var value = prop.GetValue(parameters);
|
||||
cmd.Parameters.AddWithValue(prop.Name, value ?? DBNull.Value);
|
||||
}
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapFromReader(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Observation MapFromReader(NpgsqlDataReader reader)
|
||||
{
|
||||
var previousStateOrdinal = reader.GetOrdinal("previous_state");
|
||||
ObservationState? previousState = null;
|
||||
if (!reader.IsDBNull(previousStateOrdinal))
|
||||
{
|
||||
var prevStr = reader.GetString(previousStateOrdinal);
|
||||
if (Enum.TryParse<ObservationState>(prevStr, ignoreCase: true, out var ps))
|
||||
{
|
||||
previousState = ps;
|
||||
}
|
||||
}
|
||||
|
||||
return new Observation
|
||||
{
|
||||
Id = reader.GetString(reader.GetOrdinal("id")),
|
||||
CveId = reader.GetString(reader.GetOrdinal("cve_id")),
|
||||
Product = reader.GetString(reader.GetOrdinal("product")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
FindingId = GetNullableString(reader, "finding_id"),
|
||||
State = Enum.Parse<ObservationState>(
|
||||
reader.GetString(reader.GetOrdinal("state")),
|
||||
ignoreCase: true),
|
||||
PreviousState = previousState,
|
||||
Reason = GetNullableString(reader, "reason"),
|
||||
UserId = GetNullableString(reader, "user_id"),
|
||||
EvidenceRef = GetNullableString(reader, "evidence_ref"),
|
||||
Signals = DeserializeSignals(reader),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
ExpiresAt = GetNullableDateTime(reader, "expires_at")
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetNullableString(NpgsqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetNullableDateTime(NpgsqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetFieldValue<DateTimeOffset>(ordinal);
|
||||
}
|
||||
|
||||
private static object SerializeSignals(SignalSnapshotSummary? signals)
|
||||
{
|
||||
if (signals is null) return DBNull.Value;
|
||||
return JsonSerializer.Serialize(signals, JsonOptions);
|
||||
}
|
||||
|
||||
private static SignalSnapshotSummary? DeserializeSignals(NpgsqlDataReader reader)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal("signals");
|
||||
if (reader.IsDBNull(ordinal)) return null;
|
||||
var json = reader.GetString(ordinal);
|
||||
return JsonSerializer.Deserialize<SignalSnapshotSummary>(json, JsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISignalSnapshotBuilder.cs
|
||||
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
|
||||
// Tasks: DBI-014, DBI-015, DBI-016, DBI-017 - Signal snapshot builder
|
||||
// Description: Builds signal snapshots by fetching signals in parallel
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observations;
|
||||
|
||||
/// <summary>
|
||||
/// Builds signal snapshots by fetching signals in parallel.
|
||||
/// </summary>
|
||||
public interface ISignalSnapshotBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a signal snapshot for a CVE/product pair.
|
||||
/// </summary>
|
||||
Task<SignalSnapshot> BuildAsync(
|
||||
string cveId,
|
||||
string product,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Builds signal snapshots for multiple CVE/product pairs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<(string CveId, string Product), SignalSnapshot>> BuildBatchAsync(
|
||||
IReadOnlyList<(string CveId, string Product)> pairs,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete signal snapshot with all signal states.
|
||||
/// </summary>
|
||||
public sealed record SignalSnapshot
|
||||
{
|
||||
/// <summary>CVE ID.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Product (purl or cpe).</summary>
|
||||
public required string Product { get; init; }
|
||||
|
||||
/// <summary>EPSS signal state.</summary>
|
||||
public required SignalResult Epss { get; init; }
|
||||
|
||||
/// <summary>KEV signal state.</summary>
|
||||
public required SignalResult Kev { get; init; }
|
||||
|
||||
/// <summary>VEX signal state.</summary>
|
||||
public required SignalResult Vex { get; init; }
|
||||
|
||||
/// <summary>Reachability signal state.</summary>
|
||||
public required SignalResult Reachability { get; init; }
|
||||
|
||||
/// <summary>Exploit signal state.</summary>
|
||||
public SignalResult? Exploit { get; init; }
|
||||
|
||||
/// <summary>When the snapshot was taken.</summary>
|
||||
public required DateTimeOffset SnapshotAt { get; init; }
|
||||
|
||||
/// <summary>List of failed signals.</summary>
|
||||
public IReadOnlyList<string> FailedSignals { get; init; } = [];
|
||||
|
||||
/// <summary>Computed uncertainty score.</summary>
|
||||
public double UncertaintyScore => ComputeUncertainty();
|
||||
|
||||
private double ComputeUncertainty()
|
||||
{
|
||||
// Start with full uncertainty
|
||||
var uncertainty = 1.0;
|
||||
var signalWeight = 0.25; // 4 main signals
|
||||
|
||||
// Reduce uncertainty for each available signal
|
||||
if (Epss.Status == SignalResultStatus.Available) uncertainty -= signalWeight * 0.9;
|
||||
if (Kev.Status == SignalResultStatus.Available) uncertainty -= signalWeight * 0.95;
|
||||
if (Vex.Status == SignalResultStatus.Available) uncertainty -= signalWeight * 0.85;
|
||||
if (Reachability.Status == SignalResultStatus.Available) uncertainty -= signalWeight * 0.9;
|
||||
|
||||
return Math.Max(0.0, Math.Min(1.0, uncertainty));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of fetching a signal.
|
||||
/// </summary>
|
||||
public sealed record SignalResult
|
||||
{
|
||||
/// <summary>Signal status.</summary>
|
||||
public required SignalResultStatus Status { get; init; }
|
||||
|
||||
/// <summary>Summary value (for display).</summary>
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>Raw value (JSON-serializable).</summary>
|
||||
public object? RawValue { get; init; }
|
||||
|
||||
/// <summary>Source of the signal.</summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>When captured.</summary>
|
||||
public DateTimeOffset? CapturedAt { get; init; }
|
||||
|
||||
/// <summary>Error message if failed.</summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static SignalResult Available(string summary, object? rawValue = null, string? source = null, DateTimeOffset? capturedAt = null)
|
||||
=> new() { Status = SignalResultStatus.Available, Summary = summary, RawValue = rawValue, Source = source, CapturedAt = capturedAt };
|
||||
|
||||
public static SignalResult NotFound(string? source = null)
|
||||
=> new() { Status = SignalResultStatus.NotFound, Source = source };
|
||||
|
||||
public static SignalResult Failed(string error, string? source = null)
|
||||
=> new() { Status = SignalResultStatus.Failed, Error = error, Source = source };
|
||||
|
||||
public static SignalResult NotConfigured()
|
||||
=> new() { Status = SignalResultStatus.NotConfigured };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal result status.
|
||||
/// </summary>
|
||||
public enum SignalResultStatus
|
||||
{
|
||||
Available,
|
||||
NotFound,
|
||||
Failed,
|
||||
NotConfigured
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider for a specific signal type.
|
||||
/// </summary>
|
||||
public interface ISignalProvider
|
||||
{
|
||||
/// <summary>Signal type name.</summary>
|
||||
string SignalType { get; }
|
||||
|
||||
/// <summary>Fetches the signal.</summary>
|
||||
Task<SignalResult> FetchAsync(string cveId, string? product, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of signal snapshot builder.
|
||||
/// </summary>
|
||||
public sealed class SignalSnapshotBuilder : ISignalSnapshotBuilder
|
||||
{
|
||||
private readonly ISignalProvider? _epssProvider;
|
||||
private readonly ISignalProvider? _kevProvider;
|
||||
private readonly ISignalProvider? _vexProvider;
|
||||
private readonly ISignalProvider? _reachabilityProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SignalSnapshotBuilder> _logger;
|
||||
|
||||
public SignalSnapshotBuilder(
|
||||
IEnumerable<ISignalProvider> providers,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SignalSnapshotBuilder> logger)
|
||||
{
|
||||
var providerDict = providers.ToDictionary(p => p.SignalType, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
providerDict.TryGetValue("epss", out _epssProvider);
|
||||
providerDict.TryGetValue("kev", out _kevProvider);
|
||||
providerDict.TryGetValue("vex", out _vexProvider);
|
||||
providerDict.TryGetValue("reachability", out _reachabilityProvider);
|
||||
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SignalSnapshot> BuildAsync(
|
||||
string cveId,
|
||||
string product,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var failedSignals = new List<string>();
|
||||
|
||||
// Fetch all signals in parallel
|
||||
var epssTask = FetchSignalAsync(_epssProvider, "epss", cveId, product, ct);
|
||||
var kevTask = FetchSignalAsync(_kevProvider, "kev", cveId, product, ct);
|
||||
var vexTask = FetchSignalAsync(_vexProvider, "vex", cveId, product, ct);
|
||||
var reachabilityTask = FetchSignalAsync(_reachabilityProvider, "reachability", cveId, product, ct);
|
||||
|
||||
await Task.WhenAll(epssTask, kevTask, vexTask, reachabilityTask);
|
||||
|
||||
var epss = await epssTask;
|
||||
var kev = await kevTask;
|
||||
var vex = await vexTask;
|
||||
var reachability = await reachabilityTask;
|
||||
|
||||
if (epss.Status == SignalResultStatus.Failed) failedSignals.Add("epss");
|
||||
if (kev.Status == SignalResultStatus.Failed) failedSignals.Add("kev");
|
||||
if (vex.Status == SignalResultStatus.Failed) failedSignals.Add("vex");
|
||||
if (reachability.Status == SignalResultStatus.Failed) failedSignals.Add("reachability");
|
||||
|
||||
return new SignalSnapshot
|
||||
{
|
||||
CveId = cveId,
|
||||
Product = product,
|
||||
Epss = epss,
|
||||
Kev = kev,
|
||||
Vex = vex,
|
||||
Reachability = reachability,
|
||||
SnapshotAt = now,
|
||||
FailedSignals = failedSignals
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<(string CveId, string Product), SignalSnapshot>> BuildBatchAsync(
|
||||
IReadOnlyList<(string CveId, string Product)> pairs,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tasks = pairs.Select(async pair =>
|
||||
{
|
||||
var snapshot = await BuildAsync(pair.CveId, pair.Product, ct);
|
||||
return (pair, snapshot);
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
return results.ToDictionary(r => r.pair, r => r.snapshot);
|
||||
}
|
||||
|
||||
private async Task<SignalResult> FetchSignalAsync(
|
||||
ISignalProvider? provider,
|
||||
string signalType,
|
||||
string cveId,
|
||||
string product,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (provider is null)
|
||||
{
|
||||
return SignalResult.NotConfigured();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await provider.FetchAsync(cveId, product, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch {SignalType} for {CveId}", signalType, cveId);
|
||||
return SignalResult.Failed(ex.Message, signalType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EPSS signal provider adapter.
|
||||
/// </summary>
|
||||
public sealed class EpssSignalProvider : ISignalProvider
|
||||
{
|
||||
private readonly IEpssService _service;
|
||||
|
||||
public string SignalType => "epss";
|
||||
|
||||
public EpssSignalProvider(IEpssService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
public async Task<SignalResult> FetchAsync(string cveId, string? product, CancellationToken ct)
|
||||
{
|
||||
var epss = await _service.GetEpssAsync(cveId, ct);
|
||||
if (epss is null)
|
||||
{
|
||||
return SignalResult.NotFound("epss-feed");
|
||||
}
|
||||
|
||||
var summary = $"Score: {epss.Score:P1}, Percentile: {epss.Percentile:P0}";
|
||||
return SignalResult.Available(summary, epss, "epss-feed", epss.CapturedAt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// KEV signal provider adapter.
|
||||
/// </summary>
|
||||
public sealed class KevSignalProvider : ISignalProvider
|
||||
{
|
||||
private readonly IKevService _service;
|
||||
|
||||
public string SignalType => "kev";
|
||||
|
||||
public KevSignalProvider(IKevService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
public async Task<SignalResult> FetchAsync(string cveId, string? product, CancellationToken ct)
|
||||
{
|
||||
var isInKev = await _service.IsInKevAsync(cveId, ct);
|
||||
|
||||
var summary = isInKev ? "IN KEV - actively exploited" : "Not in KEV";
|
||||
return SignalResult.Available(summary, new { IsInKev = isInKev }, "cisa-kev");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX signal provider adapter.
|
||||
/// </summary>
|
||||
public sealed class VexSignalProvider : ISignalProvider
|
||||
{
|
||||
private readonly IVexService _service;
|
||||
|
||||
public string SignalType => "vex";
|
||||
|
||||
public VexSignalProvider(IVexService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
public async Task<SignalResult> FetchAsync(string cveId, string? product, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(product))
|
||||
{
|
||||
return SignalResult.NotFound("vex");
|
||||
}
|
||||
|
||||
var vex = await _service.GetVexAsync(cveId, product, ct);
|
||||
if (vex is null)
|
||||
{
|
||||
return SignalResult.NotFound("vex");
|
||||
}
|
||||
|
||||
return SignalResult.Available(vex.Status, vex, "vex");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability signal provider adapter.
|
||||
/// </summary>
|
||||
public sealed class ReachabilitySignalProvider : ISignalProvider
|
||||
{
|
||||
private readonly IReachabilityService _service;
|
||||
|
||||
public string SignalType => "reachability";
|
||||
|
||||
public ReachabilitySignalProvider(IReachabilityService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
public async Task<SignalResult> FetchAsync(string cveId, string? product, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(product))
|
||||
{
|
||||
return SignalResult.NotFound("reachability");
|
||||
}
|
||||
|
||||
var reachability = await _service.GetReachabilityAsync(cveId, product, ct);
|
||||
if (reachability is null)
|
||||
{
|
||||
return SignalResult.NotFound("reachability");
|
||||
}
|
||||
|
||||
return SignalResult.Available(reachability.Status, reachability, "reachability-graph");
|
||||
}
|
||||
}
|
||||
|
||||
// Service interfaces for signal providers (to be implemented by respective modules)
|
||||
|
||||
public interface IEpssService
|
||||
{
|
||||
Task<EpssResult?> GetEpssAsync(string cveId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public record EpssResult(double Score, double Percentile, DateTimeOffset CapturedAt);
|
||||
|
||||
public interface IKevService
|
||||
{
|
||||
Task<bool> IsInKevAsync(string cveId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IVexService
|
||||
{
|
||||
Task<VexResult?> GetVexAsync(string cveId, string product, CancellationToken ct);
|
||||
}
|
||||
|
||||
public record VexResult(string Status, string? Justification);
|
||||
|
||||
public interface IReachabilityService
|
||||
{
|
||||
Task<ReachabilityResult?> GetReachabilityAsync(string cveId, string product, CancellationToken ct);
|
||||
}
|
||||
|
||||
public record ReachabilityResult(string Status, bool IsReachable);
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
@@ -66,7 +67,7 @@ public sealed class AirgapImportService
|
||||
["bundleId"] = input.BundleId,
|
||||
["mirrorGeneration"] = input.MirrorGeneration,
|
||||
["merkleRoot"] = input.MerkleRoot,
|
||||
["timeAnchor"] = input.TimeAnchor.ToUniversalTime().ToString("O"),
|
||||
["timeAnchor"] = input.TimeAnchor.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||
["publisher"] = input.Publisher,
|
||||
["hashAlgorithm"] = input.HashAlgorithm,
|
||||
["contents"] = new JsonArray(input.Contents.Select(c => (JsonNode)c).ToArray())
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
@@ -68,7 +69,7 @@ public sealed class AirgapTimelineService
|
||||
["highDelta"] = impact.HighDelta,
|
||||
["mediumDelta"] = impact.MediumDelta,
|
||||
["lowDelta"] = impact.LowDelta,
|
||||
["timeAnchor"] = input.TimeAnchor.ToString("O"),
|
||||
["timeAnchor"] = input.TimeAnchor.ToString("O", CultureInfo.InvariantCulture),
|
||||
["sealedMode"] = input.SealedMode
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
@@ -80,7 +81,7 @@ public sealed class EvidenceSnapshotService
|
||||
{
|
||||
["bundleUri"] = input.BundleUri,
|
||||
["dsseDigest"] = input.DsseDigest,
|
||||
["expiresAt"] = expiresAt?.ToString("O")
|
||||
["expiresAt"] = expiresAt?.ToString("O", CultureInfo.InvariantCulture)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Hashing;
|
||||
@@ -74,8 +75,8 @@ public sealed class OrchestratorExportService
|
||||
["jobType"] = input.JobType,
|
||||
["artifactHash"] = input.ArtifactHash,
|
||||
["policyHash"] = input.PolicyHash,
|
||||
["startedAt"] = input.StartedAt.ToUniversalTime().ToString("O"),
|
||||
["completedAt"] = input.CompletedAt?.ToUniversalTime().ToString("O"),
|
||||
["startedAt"] = input.StartedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||
["completedAt"] = input.CompletedAt?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||
["status"] = input.Status,
|
||||
["manifestPath"] = input.ManifestPath,
|
||||
["logsPath"] = input.LogsPath
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -91,13 +92,13 @@ public sealed class ScoredFindingsExportService : IScoredFindingsExportService
|
||||
return new MemoryStream(result.Data);
|
||||
}
|
||||
|
||||
private static byte[] ExportToJson(IReadOnlyList<ScoredFinding> findings, ScoredFindingsExportRequest request)
|
||||
private byte[] ExportToJson(IReadOnlyList<ScoredFinding> findings, ScoredFindingsExportRequest request)
|
||||
{
|
||||
var envelope = new JsonObject
|
||||
{
|
||||
["version"] = "1.0",
|
||||
["tenant_id"] = request.TenantId,
|
||||
["generated_at"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
["generated_at"] = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
|
||||
["record_count"] = findings.Count,
|
||||
["findings"] = new JsonArray(findings.Select(MapToJsonNode).ToArray())
|
||||
};
|
||||
@@ -130,7 +131,7 @@ public sealed class ScoredFindingsExportService : IScoredFindingsExportService
|
||||
finding.RiskScore?.ToString("F4") ?? "",
|
||||
EscapeCsv(finding.RiskSeverity ?? ""),
|
||||
EscapeCsv(finding.RiskProfileVersion ?? ""),
|
||||
finding.UpdatedAt.ToString("O")));
|
||||
finding.UpdatedAt.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetBytes(sb.ToString());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -422,10 +423,10 @@ public sealed class SnapshotService
|
||||
metadata["incident.mode"] = "enabled";
|
||||
metadata["incident.activationId"] = incident.ActivationId ?? string.Empty;
|
||||
metadata["incident.retentionExtensionDays"] = incident.RetentionExtensionDays;
|
||||
metadata["incident.changedAt"] = incident.ChangedAt.ToString("O");
|
||||
metadata["incident.changedAt"] = incident.ChangedAt.ToString("O", CultureInfo.InvariantCulture);
|
||||
if (incident.ExpiresAt is not null)
|
||||
{
|
||||
metadata["incident.expiresAt"] = incident.ExpiresAt.Value.ToString("O");
|
||||
metadata["incident.expiresAt"] = incident.ExpiresAt.Value.ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SignalSnapshotBuilderTests.cs
|
||||
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
|
||||
// Task: DBI-019 - Write unit tests for SignalSnapshotBuilder
|
||||
// Description: Unit tests for parallel signal snapshot building
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Findings.Ledger.Observations;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Observations;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SignalSnapshotBuilderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly Mock<ISignalProvider> _epssMock = new();
|
||||
private readonly Mock<ISignalProvider> _kevMock = new();
|
||||
private readonly Mock<ISignalProvider> _vexMock = new();
|
||||
private readonly Mock<ISignalProvider> _reachabilityMock = new();
|
||||
|
||||
public SignalSnapshotBuilderTests()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_epssMock.Setup(x => x.SignalType).Returns("epss");
|
||||
_kevMock.Setup(x => x.SignalType).Returns("kev");
|
||||
_vexMock.Setup(x => x.SignalType).Returns("vex");
|
||||
_reachabilityMock.Setup(x => x.SignalType).Returns("reachability");
|
||||
}
|
||||
|
||||
private SignalSnapshotBuilder CreateBuilder(params ISignalProvider[] providers)
|
||||
{
|
||||
return new SignalSnapshotBuilder(
|
||||
providers,
|
||||
_timeProvider,
|
||||
NullLogger<SignalSnapshotBuilder>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_FetchesAllSignalsInParallel()
|
||||
{
|
||||
// Arrange
|
||||
var cveId = "CVE-2024-1234";
|
||||
var product = "pkg:npm/lodash@4.17.0";
|
||||
|
||||
_epssMock
|
||||
.Setup(x => x.FetchAsync(cveId, product, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("Score: 0.85", new { Score = 0.85 }));
|
||||
|
||||
_kevMock
|
||||
.Setup(x => x.FetchAsync(cveId, product, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("IN KEV", new { IsInKev = true }));
|
||||
|
||||
_vexMock
|
||||
.Setup(x => x.FetchAsync(cveId, product, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("not_affected"));
|
||||
|
||||
_reachabilityMock
|
||||
.Setup(x => x.FetchAsync(cveId, product, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("Reachable"));
|
||||
|
||||
var builder = CreateBuilder(_epssMock.Object, _kevMock.Object, _vexMock.Object, _reachabilityMock.Object);
|
||||
|
||||
// Act
|
||||
var snapshot = await builder.BuildAsync(cveId, product);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(cveId, snapshot.CveId);
|
||||
Assert.Equal(product, snapshot.Product);
|
||||
Assert.Equal(SignalResultStatus.Available, snapshot.Epss.Status);
|
||||
Assert.Equal(SignalResultStatus.Available, snapshot.Kev.Status);
|
||||
Assert.Equal(SignalResultStatus.Available, snapshot.Vex.Status);
|
||||
Assert.Equal(SignalResultStatus.Available, snapshot.Reachability.Status);
|
||||
Assert.Empty(snapshot.FailedSignals);
|
||||
|
||||
_epssMock.Verify(x => x.FetchAsync(cveId, product, It.IsAny<CancellationToken>()), Times.Once);
|
||||
_kevMock.Verify(x => x.FetchAsync(cveId, product, It.IsAny<CancellationToken>()), Times.Once);
|
||||
_vexMock.Verify(x => x.FetchAsync(cveId, product, It.IsAny<CancellationToken>()), Times.Once);
|
||||
_reachabilityMock.Verify(x => x.FetchAsync(cveId, product, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_MissingProvider_ReturnsNotConfigured()
|
||||
{
|
||||
// Arrange - only EPSS provider available
|
||||
_epssMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("Score: 0.5"));
|
||||
|
||||
var builder = CreateBuilder(_epssMock.Object);
|
||||
|
||||
// Act
|
||||
var snapshot = await builder.BuildAsync("CVE-2024-1234", "pkg:npm/test@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SignalResultStatus.Available, snapshot.Epss.Status);
|
||||
Assert.Equal(SignalResultStatus.NotConfigured, snapshot.Kev.Status);
|
||||
Assert.Equal(SignalResultStatus.NotConfigured, snapshot.Vex.Status);
|
||||
Assert.Equal(SignalResultStatus.NotConfigured, snapshot.Reachability.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ProviderThrows_ReturnsFailed()
|
||||
{
|
||||
// Arrange
|
||||
_epssMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Connection refused"));
|
||||
|
||||
var builder = CreateBuilder(_epssMock.Object);
|
||||
|
||||
// Act
|
||||
var snapshot = await builder.BuildAsync("CVE-2024-1234", "pkg:npm/test@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SignalResultStatus.Failed, snapshot.Epss.Status);
|
||||
Assert.Equal("Connection refused", snapshot.Epss.Error);
|
||||
Assert.Contains("epss", snapshot.FailedSignals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ProviderNotFound_ReturnsNotFound()
|
||||
{
|
||||
// Arrange
|
||||
_epssMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.NotFound("epss-feed"));
|
||||
|
||||
var builder = CreateBuilder(_epssMock.Object);
|
||||
|
||||
// Act
|
||||
var snapshot = await builder.BuildAsync("CVE-2099-9999", "pkg:npm/test@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SignalResultStatus.NotFound, snapshot.Epss.Status);
|
||||
Assert.DoesNotContain("epss", snapshot.FailedSignals); // NotFound is not a failure
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ComputesUncertaintyScore()
|
||||
{
|
||||
// Arrange - all signals available = low uncertainty
|
||||
SetupAllSignalsAvailable();
|
||||
|
||||
var builder = CreateBuilder(_epssMock.Object, _kevMock.Object, _vexMock.Object, _reachabilityMock.Object);
|
||||
|
||||
// Act
|
||||
var snapshot = await builder.BuildAsync("CVE-2024-1234", "pkg:npm/test@1.0.0");
|
||||
|
||||
// Assert - with all signals, uncertainty should be low
|
||||
Assert.True(snapshot.UncertaintyScore < 0.2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_NoSignals_HighUncertainty()
|
||||
{
|
||||
// Arrange - no providers configured
|
||||
var builder = CreateBuilder();
|
||||
|
||||
// Act
|
||||
var snapshot = await builder.BuildAsync("CVE-2024-1234", "pkg:npm/test@1.0.0");
|
||||
|
||||
// Assert - with no signals, uncertainty should be high
|
||||
Assert.Equal(1.0, snapshot.UncertaintyScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SnapshotAtUsesTimeProvider()
|
||||
{
|
||||
// Arrange
|
||||
var expectedTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
|
||||
var builder = CreateBuilder();
|
||||
|
||||
// Act
|
||||
var snapshot = await builder.BuildAsync("CVE-2024-1234", "pkg:npm/test@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedTime, snapshot.SnapshotAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildBatchAsync_ProcessesMultiplePairs()
|
||||
{
|
||||
// Arrange
|
||||
SetupAllSignalsAvailable();
|
||||
|
||||
var builder = CreateBuilder(_epssMock.Object, _kevMock.Object, _vexMock.Object, _reachabilityMock.Object);
|
||||
|
||||
var pairs = new List<(string CveId, string Product)>
|
||||
{
|
||||
("CVE-2024-0001", "pkg:npm/foo@1.0.0"),
|
||||
("CVE-2024-0002", "pkg:npm/bar@2.0.0"),
|
||||
("CVE-2024-0003", "pkg:npm/baz@3.0.0")
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = await builder.BuildBatchAsync(pairs);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.All(pairs, pair => Assert.True(results.ContainsKey(pair)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_TracksFailedSignals()
|
||||
{
|
||||
// Arrange
|
||||
_epssMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("EPSS error"));
|
||||
|
||||
_kevMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("Not in KEV"));
|
||||
|
||||
_vexMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("VEX error"));
|
||||
|
||||
_reachabilityMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("Reachable"));
|
||||
|
||||
var builder = CreateBuilder(_epssMock.Object, _kevMock.Object, _vexMock.Object, _reachabilityMock.Object);
|
||||
|
||||
// Act
|
||||
var snapshot = await builder.BuildAsync("CVE-2024-1234", "pkg:npm/test@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, snapshot.FailedSignals.Count);
|
||||
Assert.Contains("epss", snapshot.FailedSignals);
|
||||
Assert.Contains("vex", snapshot.FailedSignals);
|
||||
}
|
||||
|
||||
private void SetupAllSignalsAvailable()
|
||||
{
|
||||
_epssMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("Score: 0.5"));
|
||||
|
||||
_kevMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("Not in KEV"));
|
||||
|
||||
_vexMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("not_affected"));
|
||||
|
||||
_reachabilityMock
|
||||
.Setup(x => x.FetchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SignalResult.Available("Reachable"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LedgerReplayHarness;
|
||||
@@ -117,7 +118,7 @@ public sealed class HarnessRunner
|
||||
cpuPercentMax = 0,
|
||||
memoryMbMax = 0,
|
||||
status = hashesValid && merkleOk ? "pass" : "fail",
|
||||
timestamp = _timeProvider.GetUtcNow().ToString("O"),
|
||||
timestamp = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
|
||||
hashSummary = stats.ToReport(),
|
||||
merkleRoot = computedRoot,
|
||||
merkleExpected = expectedMerkleRoot
|
||||
|
||||
Reference in New Issue
Block a user