UI work to fill SBOM sourcing management gap. UI planning remaining functionality exposure. Work on CI/Tests stabilization
Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexLensDataSource.cs
|
||||
// Sprint: SPRINT_20251229_001_002_BE_vex_delta (VEX-006)
|
||||
// Task: Create VexLens data source wrapper
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Data source for VexLens PostgreSQL connections.
|
||||
/// </summary>
|
||||
public sealed class VexLensDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for VexLens tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "vex";
|
||||
|
||||
public VexLensDataSource(
|
||||
IOptions<PostgresOptions> options,
|
||||
ILogger<VexLensDataSource> logger)
|
||||
: base(options.Value, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "VexLens";
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ConsensusProjectionRepository.cs
|
||||
// Sprint: SPRINT_20251229_001_002_BE_vex_delta (VEX-006)
|
||||
// Task: Implement IConsensusProjectionRepository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.VexLens.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of consensus projection repository.
|
||||
/// </summary>
|
||||
public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSource>, IConsensusProjectionRepository
|
||||
{
|
||||
private const string Schema = "vex";
|
||||
private const string Table = "consensus_projections";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public ConsensusProjectionRepository(
|
||||
VexLensDataSource dataSource,
|
||||
ILogger<ConsensusProjectionRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async ValueTask<ConsensusProjection> AddAsync(
|
||||
ConsensusProjection projection,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
INSERT INTO {FullTable} (
|
||||
id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, previous_projection_id, status_changed
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenantId, @vulnId, @productKey, @status,
|
||||
@confidence, @outcome, @stmtCount, @conflictCount,
|
||||
@mergeTrace::jsonb, @computedAt, @previousId, @statusChanged
|
||||
)
|
||||
RETURNING id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
""";
|
||||
|
||||
var result = await QuerySingleOrDefaultAsync(
|
||||
projection.TenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", projection.Id);
|
||||
AddParameter(cmd, "tenantId", projection.TenantId);
|
||||
AddParameter(cmd, "vulnId", projection.VulnerabilityId);
|
||||
AddParameter(cmd, "productKey", projection.ProductKey);
|
||||
AddParameter(cmd, "status", projection.Status.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "confidence", projection.ConfidenceScore);
|
||||
AddParameter(cmd, "outcome", projection.Outcome);
|
||||
AddParameter(cmd, "stmtCount", projection.StatementCount);
|
||||
AddParameter(cmd, "conflictCount", projection.ConflictCount);
|
||||
AddParameter(cmd, "mergeTrace", SerializeTrace(projection.Trace));
|
||||
AddParameter(cmd, "computedAt", projection.ComputedAt);
|
||||
AddParameter(cmd, "previousId", (object?)projection.PreviousProjectionId ?? DBNull.Value);
|
||||
AddParameter(cmd, "statusChanged", projection.StatusChanged);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Failed to add consensus projection");
|
||||
}
|
||||
|
||||
public async ValueTask<ConsensusProjection?> GetLatestAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {FullTable}
|
||||
WHERE vulnerability_id = @vulnId
|
||||
AND product_key = @productKey
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "vulnId", vulnerabilityId);
|
||||
AddParameter(cmd, "productKey", productKey);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetByVulnerabilityAsync(
|
||||
string vulnerabilityId,
|
||||
Guid tenantId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {FullTable}
|
||||
WHERE vulnerability_id = @vulnId
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "vulnId", vulnerabilityId);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetByProductAsync(
|
||||
string productKey,
|
||||
Guid tenantId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {FullTable}
|
||||
WHERE product_key = @productKey
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "productKey", productKey);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetStatusChangesAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset since,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {FullTable}
|
||||
WHERE tenant_id = @tenantId
|
||||
AND status_changed = TRUE
|
||||
AND computed_at >= @since
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "since", since);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
Guid tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {FullTable}
|
||||
WHERE vulnerability_id = @vulnId
|
||||
AND product_key = @productKey
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "vulnId", vulnerabilityId);
|
||||
AddParameter(cmd, "productKey", productKey);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
}
|
||||
|
||||
private static ConsensusProjection MapProjection(System.Data.Common.DbDataReader reader)
|
||||
{
|
||||
var statusStr = reader.GetString(reader.GetOrdinal("status"));
|
||||
var status = statusStr.ToLowerInvariant() switch
|
||||
{
|
||||
"unknown" => VexConsensusStatus.Unknown,
|
||||
"under_investigation" => VexConsensusStatus.UnderInvestigation,
|
||||
"not_affected" => VexConsensusStatus.NotAffected,
|
||||
"affected" => VexConsensusStatus.Affected,
|
||||
"fixed" => VexConsensusStatus.Fixed,
|
||||
_ => throw new InvalidOperationException($"Unknown status: {statusStr}")
|
||||
};
|
||||
|
||||
var traceJson = reader.IsDBNull(reader.GetOrdinal("merge_trace"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("merge_trace"));
|
||||
|
||||
return new ConsensusProjection(
|
||||
Id: reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId: reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
VulnerabilityId: reader.GetString(reader.GetOrdinal("vulnerability_id")),
|
||||
ProductKey: reader.GetString(reader.GetOrdinal("product_key")),
|
||||
Status: status,
|
||||
ConfidenceScore: reader.GetDecimal(reader.GetOrdinal("confidence_score")),
|
||||
Outcome: reader.GetString(reader.GetOrdinal("outcome")),
|
||||
StatementCount: reader.GetInt32(reader.GetOrdinal("statement_count")),
|
||||
ConflictCount: reader.GetInt32(reader.GetOrdinal("conflict_count")),
|
||||
Trace: DeserializeTrace(traceJson),
|
||||
ComputedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("computed_at")),
|
||||
StoredAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("stored_at")),
|
||||
PreviousProjectionId: reader.IsDBNull(reader.GetOrdinal("previous_projection_id"))
|
||||
? null
|
||||
: reader.GetGuid(reader.GetOrdinal("previous_projection_id")),
|
||||
StatusChanged: reader.GetBoolean(reader.GetOrdinal("status_changed"))
|
||||
);
|
||||
}
|
||||
|
||||
private static string SerializeTrace(MergeTrace? trace)
|
||||
{
|
||||
if (trace == null)
|
||||
return "{}";
|
||||
|
||||
return JsonSerializer.Serialize(trace, SerializerOptions);
|
||||
}
|
||||
|
||||
private static MergeTrace? DeserializeTrace(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "{}")
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Deserialize<MergeTrace>(json, SerializerOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IConsensusProjectionRepository.cs
|
||||
// Sprint: SPRINT_20251229_001_002_BE_vex_delta (VEX-006)
|
||||
// Task: Implement IConsensusProjectionRepository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for VEX consensus projections.
|
||||
/// Replaces in-memory VexLens store with PostgreSQL persistence.
|
||||
/// </summary>
|
||||
public interface IConsensusProjectionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Add a new consensus projection.
|
||||
/// </summary>
|
||||
ValueTask<ConsensusProjection> AddAsync(
|
||||
ConsensusProjection projection,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the latest consensus projection for a vulnerability/product combination.
|
||||
/// </summary>
|
||||
ValueTask<ConsensusProjection?> GetLatestAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all projections for a vulnerability across all products.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<ConsensusProjection>> GetByVulnerabilityAsync(
|
||||
string vulnerabilityId,
|
||||
Guid tenantId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all projections for a product across all vulnerabilities.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<ConsensusProjection>> GetByProductAsync(
|
||||
string productKey,
|
||||
Guid tenantId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get projections where status changed from previous.
|
||||
/// Useful for identifying new/resolved vulnerabilities.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<ConsensusProjection>> GetStatusChangesAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset since,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get projection history for a vulnerability/product pair.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
Guid tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX consensus projection record.
|
||||
/// </summary>
|
||||
public sealed record ConsensusProjection(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
VexConsensusStatus Status,
|
||||
decimal ConfidenceScore,
|
||||
string Outcome,
|
||||
int StatementCount,
|
||||
int ConflictCount,
|
||||
MergeTrace? Trace,
|
||||
DateTimeOffset ComputedAt,
|
||||
DateTimeOffset StoredAt,
|
||||
Guid? PreviousProjectionId,
|
||||
bool StatusChanged);
|
||||
|
||||
/// <summary>
|
||||
/// VEX consensus status values.
|
||||
/// </summary>
|
||||
public enum VexConsensusStatus
|
||||
{
|
||||
Unknown,
|
||||
UnderInvestigation,
|
||||
NotAffected,
|
||||
Affected,
|
||||
Fixed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge trace showing how consensus was computed.
|
||||
/// </summary>
|
||||
public sealed record MergeTrace(
|
||||
IReadOnlyList<MergeTraceStep> Steps,
|
||||
string Algorithm,
|
||||
IReadOnlyDictionary<string, object>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Single step in merge trace.
|
||||
/// </summary>
|
||||
public sealed record MergeTraceStep(
|
||||
int Order,
|
||||
string Action,
|
||||
string Source,
|
||||
object Input,
|
||||
object Output);
|
||||
143
src/VexLens/StellaOps.VexLens/Mapping/VexDeltaMapper.cs
Normal file
143
src/VexLens/StellaOps.VexLens/Mapping/VexDeltaMapper.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexDeltaMapper.cs
|
||||
// Sprint: SPRINT_20251229_001_002_BE_vex_delta (VEX-007)
|
||||
// Task: Wire merge trace persistence to delta record
|
||||
// Description: Maps VexConsensusResult to ConsensusMergeTrace for VEX delta persistence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Maps VexLens consensus results to VEX delta merge traces.
|
||||
/// Bridges VexLens consensus computation with Excititor delta persistence.
|
||||
/// </summary>
|
||||
public static class VexDeltaMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a VexDeltaRationale from a VexConsensusResult with full merge trace.
|
||||
/// </summary>
|
||||
/// <param name="consensusResult">Consensus result from VexLens</param>
|
||||
/// <param name="reason">Human-readable reason for the delta</param>
|
||||
/// <param name="consensusMode">Consensus mode used (e.g., "WeightedVote", "Lattice")</param>
|
||||
/// <returns>VexDeltaRationale with merge trace</returns>
|
||||
public static VexDeltaRationale CreateRationaleFromConsensus(
|
||||
VexConsensusResult consensusResult,
|
||||
string reason,
|
||||
string consensusMode)
|
||||
{
|
||||
var mergeTrace = CreateMergeTrace(consensusResult, consensusMode);
|
||||
|
||||
return new VexDeltaRationale
|
||||
{
|
||||
Reason = reason,
|
||||
Source = "VexLens Consensus",
|
||||
JustificationCode = consensusResult.ConsensusJustification?.ToString().ToLowerInvariant(),
|
||||
MergeTrace = mergeTrace
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ConsensusMergeTrace from a VexConsensusResult.
|
||||
/// </summary>
|
||||
public static ConsensusMergeTrace CreateMergeTrace(
|
||||
VexConsensusResult consensusResult,
|
||||
string consensusMode)
|
||||
{
|
||||
return new ConsensusMergeTrace
|
||||
{
|
||||
Summary = consensusResult.Rationale.Summary,
|
||||
Factors = consensusResult.Rationale.Factors,
|
||||
StatusWeights = ConvertStatusWeights(consensusResult.Rationale.StatusWeights),
|
||||
ConsensusMode = consensusMode,
|
||||
Outcome = consensusResult.Outcome.ToString(),
|
||||
ConfidenceScore = consensusResult.ConfidenceScore,
|
||||
Contributions = consensusResult.Contributions?.Select(MapContribution).ToList(),
|
||||
Conflicts = consensusResult.Conflicts?.Select(MapConflict).ToList(),
|
||||
ComputedAt = consensusResult.ComputedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a VexDeltaRationale for a simple status change (not from consensus).
|
||||
/// </summary>
|
||||
public static VexDeltaRationale CreateSimpleRationale(
|
||||
string reason,
|
||||
string? source = null,
|
||||
string? justificationCode = null,
|
||||
string? evidenceLink = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
return new VexDeltaRationale
|
||||
{
|
||||
Reason = reason,
|
||||
Source = source,
|
||||
JustificationCode = justificationCode,
|
||||
EvidenceLink = evidenceLink,
|
||||
Metadata = metadata,
|
||||
MergeTrace = null
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, double> ConvertStatusWeights(
|
||||
IReadOnlyDictionary<VexStatus, double> statusWeights)
|
||||
{
|
||||
return statusWeights.ToDictionary(
|
||||
kv => kv.Key.ToString().ToLowerInvariant(),
|
||||
kv => kv.Value);
|
||||
}
|
||||
|
||||
private static StatementContributionSnapshot MapContribution(StatementContribution contribution)
|
||||
{
|
||||
return new StatementContributionSnapshot
|
||||
{
|
||||
StatementId = contribution.StatementId,
|
||||
IssuerId = contribution.IssuerId,
|
||||
IssuerName = null, // Could be enriched from issuer registry if needed
|
||||
Status = contribution.Status.ToString().ToLowerInvariant(),
|
||||
Weight = contribution.Weight,
|
||||
Contribution = contribution.Contribution,
|
||||
IsWinner = contribution.IsWinner
|
||||
};
|
||||
}
|
||||
|
||||
private static ConsensusConflictSnapshot MapConflict(ConsensusConflict conflict)
|
||||
{
|
||||
return new ConsensusConflictSnapshot
|
||||
{
|
||||
Statement1Id = conflict.Statement1Id,
|
||||
Statement2Id = conflict.Statement2Id,
|
||||
Status1 = conflict.Status1.ToString().ToLowerInvariant(),
|
||||
Status2 = conflict.Status2.ToString().ToLowerInvariant(),
|
||||
Severity = conflict.Severity.ToString(),
|
||||
Resolution = conflict.Resolution
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts VexStatus enum to VexDeltaStatus enum.
|
||||
/// </summary>
|
||||
public static VexDeltaStatus ToVexDeltaStatus(VexStatus status) => status switch
|
||||
{
|
||||
VexStatus.Affected => VexDeltaStatus.Affected,
|
||||
VexStatus.NotAffected => VexDeltaStatus.NotAffected,
|
||||
VexStatus.Fixed => VexDeltaStatus.Fixed,
|
||||
VexStatus.UnderInvestigation => VexDeltaStatus.UnderInvestigation,
|
||||
_ => VexDeltaStatus.Unknown
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converts VexDeltaStatus enum to VexStatus enum.
|
||||
/// </summary>
|
||||
public static VexStatus ToVexStatus(VexDeltaStatus status) => status switch
|
||||
{
|
||||
VexDeltaStatus.Affected => VexStatus.Affected,
|
||||
VexDeltaStatus.NotAffected => VexStatus.NotAffected,
|
||||
VexDeltaStatus.Fixed => VexStatus.Fixed,
|
||||
VexDeltaStatus.UnderInvestigation => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.UnderInvestigation
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexLensTruthTableTests.cs
|
||||
// Sprint: SPRINT_20251229_004_003_BE_vexlens_truth_tables
|
||||
// Tasks: VTT-001 through VTT-009
|
||||
// Comprehensive truth table tests for VexLens lattice merge operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Consensus;
|
||||
|
||||
/// <summary>
|
||||
/// Systematic truth table tests for VexLens consensus engine.
|
||||
/// Verifies lattice merge correctness, conflict detection, and determinism.
|
||||
///
|
||||
/// VEX Status Lattice:
|
||||
/// ┌─────────┐
|
||||
/// │ fixed │ (terminal)
|
||||
/// └────▲────┘
|
||||
/// │
|
||||
/// ┌───────────────┼───────────────┐
|
||||
/// │ │ │
|
||||
/// ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
|
||||
/// │not_affected│ │ affected │ │ (tie) │
|
||||
/// └─────▲─────┘ └─────▲─────┘ └───────────┘
|
||||
/// │ │
|
||||
/// └───────┬───────┘
|
||||
/// │
|
||||
/// ┌───────▼───────┐
|
||||
/// │under_investigation│
|
||||
/// └───────▲───────┘
|
||||
/// │
|
||||
/// ┌───────▼───────┐
|
||||
/// │ unknown │ (bottom)
|
||||
/// └───────────────┘
|
||||
/// </summary>
|
||||
[Trait("Category", "Determinism")]
|
||||
[Trait("Category", "Golden")]
|
||||
public class VexLensTruthTableTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
#region Single Issuer Identity Tests (VTT-001 to VTT-005)
|
||||
|
||||
/// <summary>
|
||||
/// Test data for single issuer identity cases.
|
||||
/// A single VEX statement should return its status unchanged.
|
||||
/// </summary>
|
||||
public static TheoryData<string, VexStatus, VexStatus> SingleIssuerCases => new()
|
||||
{
|
||||
{ "TT-001", VexStatus.Unknown, VexStatus.Unknown },
|
||||
{ "TT-002", VexStatus.UnderInvestigation, VexStatus.UnderInvestigation },
|
||||
{ "TT-003", VexStatus.Affected, VexStatus.Affected },
|
||||
{ "TT-004", VexStatus.NotAffected, VexStatus.NotAffected },
|
||||
{ "TT-005", VexStatus.Fixed, VexStatus.Fixed }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SingleIssuerCases))]
|
||||
public void SingleIssuer_ReturnsIdentity(string testId, VexStatus input, VexStatus expected)
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateStatement("issuer-a", input);
|
||||
var statements = new[] { statement };
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(expected, because: $"{testId}: single issuer should return identity");
|
||||
result.Conflicts.Should().BeEmpty(because: "single issuer cannot have conflicts");
|
||||
result.StatementCount.Should().Be(1);
|
||||
result.ConfidenceScore.Should().BeGreaterOrEqualTo(0.8m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Two Issuer Merge Tests (VTT-010 to VTT-019)
|
||||
|
||||
/// <summary>
|
||||
/// Test data for two issuers at the same trust tier.
|
||||
/// Tests lattice join operation and conflict detection.
|
||||
///
|
||||
/// EDGE CASE: Affected and NotAffected are at the SAME lattice level.
|
||||
/// When both appear at the same trust tier, this creates a conflict.
|
||||
/// The system conservatively chooses 'affected' and records the conflict.
|
||||
///
|
||||
/// EDGE CASE: Fixed is lattice terminal (top).
|
||||
/// Any statement with 'fixed' status will win, regardless of other statuses.
|
||||
///
|
||||
/// EDGE CASE: Unknown is lattice bottom.
|
||||
/// Unknown never wins when merged with any other status.
|
||||
/// </summary>
|
||||
public static TheoryData<string, VexStatus, VexStatus, VexStatus, bool> TwoIssuerMergeCases => new()
|
||||
{
|
||||
// Both unknown → unknown (lattice bottom)
|
||||
{ "TT-010", VexStatus.Unknown, VexStatus.Unknown, VexStatus.Unknown, false },
|
||||
|
||||
// Unknown merges up the lattice
|
||||
{ "TT-011", VexStatus.Unknown, VexStatus.Affected, VexStatus.Affected, false },
|
||||
{ "TT-012", VexStatus.Unknown, VexStatus.NotAffected, VexStatus.NotAffected, false },
|
||||
|
||||
// CONFLICT: Affected vs NotAffected at same level (must record)
|
||||
{ "TT-013", VexStatus.Affected, VexStatus.NotAffected, VexStatus.Affected, true },
|
||||
|
||||
// Fixed wins (lattice top)
|
||||
{ "TT-014", VexStatus.Affected, VexStatus.Fixed, VexStatus.Fixed, false },
|
||||
{ "TT-015", VexStatus.NotAffected, VexStatus.Fixed, VexStatus.Fixed, false },
|
||||
|
||||
// Under investigation merges up
|
||||
{ "TT-016", VexStatus.UnderInvestigation, VexStatus.Affected, VexStatus.Affected, false },
|
||||
{ "TT-017", VexStatus.UnderInvestigation, VexStatus.NotAffected, VexStatus.NotAffected, false },
|
||||
|
||||
// Same status → same status
|
||||
{ "TT-018", VexStatus.Affected, VexStatus.Affected, VexStatus.Affected, false },
|
||||
{ "TT-019", VexStatus.NotAffected, VexStatus.NotAffected, VexStatus.NotAffected, false }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TwoIssuerMergeCases))]
|
||||
public void TwoIssuers_SameTier_MergesCorrectly(
|
||||
string testId,
|
||||
VexStatus statusA,
|
||||
VexStatus statusB,
|
||||
VexStatus expected,
|
||||
bool expectConflict)
|
||||
{
|
||||
// Arrange
|
||||
var statementA = CreateStatement("issuer-a", statusA, trustTier: 90);
|
||||
var statementB = CreateStatement("issuer-b", statusB, trustTier: 90);
|
||||
var statements = new[] { statementA, statementB };
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(expected, because: $"{testId}: lattice merge should produce expected status");
|
||||
result.Conflicts.Any().Should().Be(expectConflict, because: $"{testId}: conflict detection must be accurate");
|
||||
result.StatementCount.Should().Be(2);
|
||||
|
||||
if (expectConflict)
|
||||
{
|
||||
result.Conflicts.Should().HaveCount(1, because: "should record the conflict");
|
||||
result.ConflictCount.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Trust Tier Precedence Tests (VTT-020 to VTT-022)
|
||||
|
||||
/// <summary>
|
||||
/// Test data for trust tier precedence.
|
||||
/// Higher tier statements should take precedence over lower tier.
|
||||
///
|
||||
/// EDGE CASE: Trust tier filtering happens BEFORE lattice merge.
|
||||
/// Only the highest tier statements are considered for merging.
|
||||
/// Lower tier statements are completely ignored, even if they would
|
||||
/// produce a different result via lattice merge.
|
||||
///
|
||||
/// EDGE CASE: Trust tier hierarchy (Distro=100, Vendor=90, Community=50).
|
||||
/// Distro-level security trackers have absolute authority over vendor advisories.
|
||||
/// This ensures that distribution-specific backports and patches are respected.
|
||||
///
|
||||
/// EDGE CASE: When high tier says 'unknown', low tier can provide information.
|
||||
/// If the highest tier has no data (unknown), the next tier is consulted.
|
||||
/// This cascading behavior prevents data loss when authoritative sources
|
||||
/// haven't analyzed a CVE yet.
|
||||
/// </summary>
|
||||
public static TheoryData<string, VexStatus, int, VexStatus, int, VexStatus> TrustTierCases => new()
|
||||
{
|
||||
// High tier (100) beats low tier (50)
|
||||
{ "TT-020", VexStatus.Affected, 100, VexStatus.NotAffected, 50, VexStatus.Affected },
|
||||
{ "TT-021", VexStatus.NotAffected, 100, VexStatus.Affected, 50, VexStatus.NotAffected },
|
||||
|
||||
// Low tier fills in when high tier is unknown
|
||||
{ "TT-022", VexStatus.Unknown, 100, VexStatus.Affected, 50, VexStatus.Affected }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TrustTierCases))]
|
||||
public void TrustTier_HigherPrecedence_WinsConflicts(
|
||||
string testId,
|
||||
VexStatus highStatus,
|
||||
int highTier,
|
||||
VexStatus lowStatus,
|
||||
int lowTier,
|
||||
VexStatus expected)
|
||||
{
|
||||
// Arrange
|
||||
var highTierStmt = CreateStatement("high-tier-issuer", highStatus, trustTier: highTier);
|
||||
var lowTierStmt = CreateStatement("low-tier-issuer", lowStatus, trustTier: lowTier);
|
||||
var statements = new[] { highTierStmt, lowTierStmt };
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(expected, because: $"{testId}: higher trust tier should win");
|
||||
result.StatementCount.Should().Be(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Justification Impact Tests (VTT-030 to VTT-033)
|
||||
|
||||
/// <summary>
|
||||
/// Test data for justification impact on confidence scores.
|
||||
/// Justifications affect confidence but not status.
|
||||
///
|
||||
/// EDGE CASE: Justifications NEVER change the consensus status.
|
||||
/// They only modulate the confidence score. A well-justified 'not_affected'
|
||||
/// is still 'not_affected', just with higher confidence.
|
||||
///
|
||||
/// EDGE CASE: Justification hierarchy for not_affected:
|
||||
/// 1. component_not_present (0.95+) - strongest, binary condition
|
||||
/// 2. vulnerable_code_not_in_execute_path (0.90+) - requires code analysis
|
||||
/// 3. inline_mitigations_already_exist (0.85+) - requires verification
|
||||
///
|
||||
/// EDGE CASE: Missing justification still has good confidence.
|
||||
/// An explicit 'affected' statement without justification is still 0.80+
|
||||
/// because the issuer made a clear determination.
|
||||
///
|
||||
/// EDGE CASE: Multiple justifications (future).
|
||||
/// If multiple statements have different justifications, the strongest
|
||||
/// justification determines the final confidence score.
|
||||
/// </summary>
|
||||
public static TheoryData<string, VexStatus, string?, decimal> JustificationConfidenceCases => new()
|
||||
{
|
||||
// Strong justifications → high confidence
|
||||
{ "TT-030", VexStatus.NotAffected, "component_not_present", 0.95m },
|
||||
{ "TT-031", VexStatus.NotAffected, "vulnerable_code_not_in_execute_path", 0.90m },
|
||||
{ "TT-032", VexStatus.NotAffected, "inline_mitigations_already_exist", 0.85m },
|
||||
|
||||
// No justification → still high confidence (explicit statement)
|
||||
{ "TT-033", VexStatus.Affected, null, 0.80m }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(JustificationConfidenceCases))]
|
||||
public void Justification_AffectsConfidence_NotStatus(
|
||||
string testId,
|
||||
VexStatus status,
|
||||
string? justification,
|
||||
decimal minConfidence)
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateStatement("issuer-a", status, justification: justification);
|
||||
var statements = new[] { statement };
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(status, because: $"{testId}: justification should not change status");
|
||||
result.ConfidenceScore.Should().BeGreaterOrEqualTo(minConfidence, because: $"{testId}: justification impacts confidence");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests (VTT-006)
|
||||
|
||||
/// <summary>
|
||||
/// EDGE CASE: Determinism is CRITICAL for reproducible vulnerability assessment.
|
||||
/// Same inputs must ALWAYS produce byte-for-byte identical outputs.
|
||||
/// Any non-determinism breaks audit trails and makes replay impossible.
|
||||
///
|
||||
/// EDGE CASE: Statement order independence.
|
||||
/// The consensus algorithm must be commutative. Processing statements
|
||||
/// in different orders must yield the same result. This is tested by
|
||||
/// shuffling statement arrays and verifying identical consensus.
|
||||
///
|
||||
/// EDGE CASE: Floating point determinism.
|
||||
/// Confidence scores use decimal (not double/float) to ensure
|
||||
/// bit-exact reproducibility across platforms and CPU architectures.
|
||||
///
|
||||
/// EDGE CASE: Hash-based conflict detection must be stable.
|
||||
/// When recording conflicts, issuer IDs are sorted lexicographically
|
||||
/// to ensure deterministic JSON serialization.
|
||||
///
|
||||
/// EDGE CASE: Timestamp normalization.
|
||||
/// All timestamps are normalized to UTC ISO-8601 format to prevent
|
||||
/// timezone-related non-determinism in serialized output.
|
||||
/// </summary>
|
||||
|
||||
[Fact]
|
||||
public void SameInputs_ProducesIdenticalOutput_Across10Iterations()
|
||||
{
|
||||
// Arrange: Create conflicting statements
|
||||
var statements = new[]
|
||||
{
|
||||
CreateStatement("vendor-a", VexStatus.Affected, trustTier: 90),
|
||||
CreateStatement("vendor-b", VexStatus.NotAffected, trustTier: 90),
|
||||
CreateStatement("distro-security", VexStatus.Fixed, trustTier: 100)
|
||||
};
|
||||
|
||||
var results = new List<string>();
|
||||
|
||||
// Act: Compute consensus 10 times
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var result = ComputeConsensus(statements);
|
||||
var canonical = JsonSerializer.Serialize(result, CanonicalOptions);
|
||||
results.Add(canonical);
|
||||
}
|
||||
|
||||
// Assert: All results should be byte-for-byte identical
|
||||
results.Distinct().Should().HaveCount(1, because: "determinism: all iterations must produce identical JSON");
|
||||
|
||||
// Verify the result is fixed (highest tier + lattice top)
|
||||
var finalResult = ComputeConsensus(statements);
|
||||
finalResult.Status.Should().Be(VexStatus.Fixed, because: "fixed wins at lattice top");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatementOrder_DoesNotAffect_ConsensusOutcome()
|
||||
{
|
||||
// Arrange: Same statements in different orders
|
||||
var stmt1 = CreateStatement("issuer-1", VexStatus.Affected, trustTier: 90);
|
||||
var stmt2 = CreateStatement("issuer-2", VexStatus.NotAffected, trustTier: 90);
|
||||
var stmt3 = CreateStatement("issuer-3", VexStatus.UnderInvestigation, trustTier: 80);
|
||||
|
||||
var order1 = new[] { stmt1, stmt2, stmt3 };
|
||||
var order2 = new[] { stmt3, stmt1, stmt2 };
|
||||
var order3 = new[] { stmt2, stmt3, stmt1 };
|
||||
|
||||
// Act
|
||||
var result1 = ComputeConsensus(order1);
|
||||
var result2 = ComputeConsensus(order2);
|
||||
var result3 = ComputeConsensus(order3);
|
||||
|
||||
// Assert: All should produce identical results
|
||||
var json1 = JsonSerializer.Serialize(result1, CanonicalOptions);
|
||||
var json2 = JsonSerializer.Serialize(result2, CanonicalOptions);
|
||||
var json3 = JsonSerializer.Serialize(result3, CanonicalOptions);
|
||||
|
||||
json1.Should().Be(json2).And.Be(json3, because: "statement order must not affect consensus");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Conflict Detection Tests (VTT-004)
|
||||
|
||||
/// <summary>
|
||||
/// EDGE CASE: Conflict detection is not the same as disagreement.
|
||||
/// A conflict occurs when same-tier issuers provide statuses at the SAME lattice level.
|
||||
/// Example: Affected vs NotAffected = conflict (same level).
|
||||
/// Example: UnderInvestigation vs Affected = no conflict (hierarchical).
|
||||
///
|
||||
/// EDGE CASE: Conflicts must be recorded with ALL participating issuers.
|
||||
/// The consensus engine must track which issuers contributed to the conflict,
|
||||
/// not just the ones that "lost" the merge. This is critical for audit trails.
|
||||
///
|
||||
/// EDGE CASE: N-way conflicts (3+ issuers with different views).
|
||||
/// When three or more issuers at the same tier have different statuses,
|
||||
/// the system uses lattice merge (affected wins) and records all conflicts.
|
||||
///
|
||||
/// EDGE CASE: Unanimous agreement = zero conflicts.
|
||||
/// When all same-tier issuers agree, confidence increases to 0.95+
|
||||
/// and the conflict array remains empty.
|
||||
/// </summary>
|
||||
|
||||
[Fact]
|
||||
public void ThreeWayConflict_RecordsAllDisagreements()
|
||||
{
|
||||
// Arrange: Three issuers at same tier with different assessments
|
||||
var statements = new[]
|
||||
{
|
||||
CreateStatement("issuer-a", VexStatus.Affected, trustTier: 90),
|
||||
CreateStatement("issuer-b", VexStatus.NotAffected, trustTier: 90),
|
||||
CreateStatement("issuer-c", VexStatus.UnderInvestigation, trustTier: 90)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert: Should record conflicts and use lattice merge
|
||||
result.Status.Should().Be(VexStatus.Affected, because: "affected wins in lattice");
|
||||
result.ConflictCount.Should().BeGreaterThan(0, because: "should detect conflicts");
|
||||
result.Conflicts.Should().NotBeEmpty(because: "should record conflicting issuers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoConflict_WhenStatementsAgree()
|
||||
{
|
||||
// Arrange: All issuers agree
|
||||
var statements = new[]
|
||||
{
|
||||
CreateStatement("issuer-a", VexStatus.NotAffected, trustTier: 90),
|
||||
CreateStatement("issuer-b", VexStatus.NotAffected, trustTier: 90),
|
||||
CreateStatement("issuer-c", VexStatus.NotAffected, trustTier: 90)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(VexStatus.NotAffected);
|
||||
result.Conflicts.Should().BeEmpty(because: "all issuers agree");
|
||||
result.ConflictCount.Should().Be(0);
|
||||
result.ConfidenceScore.Should().BeGreaterOrEqualTo(0.95m, because: "unanimous agreement increases confidence");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Recorded Replay Tests (VTT-008)
|
||||
|
||||
/// <summary>
|
||||
/// Seed cases for deterministic replay verification.
|
||||
/// Each seed represents a real-world scenario that must produce stable results.
|
||||
/// </summary>
|
||||
public static TheoryData<string, VexStatement[], VexStatus> ReplaySeedCases => new()
|
||||
{
|
||||
// Seed 1: Distro disagrees with upstream (high tier wins)
|
||||
{
|
||||
"SEED-001",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("debian-security", VexStatus.Affected, trustTier: 100),
|
||||
CreateStatement("npm-advisory", VexStatus.NotAffected, trustTier: 80)
|
||||
},
|
||||
VexStatus.Affected
|
||||
},
|
||||
|
||||
// Seed 2: Three vendors agree on fix
|
||||
{
|
||||
"SEED-002",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("vendor-redhat", VexStatus.Fixed, trustTier: 90),
|
||||
CreateStatement("vendor-ubuntu", VexStatus.Fixed, trustTier: 90),
|
||||
CreateStatement("vendor-debian", VexStatus.Fixed, trustTier: 90)
|
||||
},
|
||||
VexStatus.Fixed
|
||||
},
|
||||
|
||||
// Seed 3: Mixed signals (under investigation + affected → affected wins)
|
||||
{
|
||||
"SEED-003",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("researcher-a", VexStatus.UnderInvestigation, trustTier: 70),
|
||||
CreateStatement("researcher-b", VexStatus.Affected, trustTier: 70),
|
||||
CreateStatement("researcher-c", VexStatus.UnderInvestigation, trustTier: 70)
|
||||
},
|
||||
VexStatus.Affected
|
||||
},
|
||||
|
||||
// Seed 4: Conflict between two high-tier vendors
|
||||
{
|
||||
"SEED-004",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("vendor-a", VexStatus.Affected, trustTier: 100),
|
||||
CreateStatement("vendor-b", VexStatus.NotAffected, trustTier: 100)
|
||||
},
|
||||
VexStatus.Affected // Conservative: affected wins in conflict
|
||||
},
|
||||
|
||||
// Seed 5: Low confidence unknown statements
|
||||
{
|
||||
"SEED-005",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("issuer-1", VexStatus.Unknown, trustTier: 50),
|
||||
CreateStatement("issuer-2", VexStatus.Unknown, trustTier: 50),
|
||||
CreateStatement("issuer-3", VexStatus.Unknown, trustTier: 50)
|
||||
},
|
||||
VexStatus.Unknown
|
||||
},
|
||||
|
||||
// Seed 6: Fixed status overrides all lower statuses
|
||||
{
|
||||
"SEED-006",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("vendor-a", VexStatus.Affected, trustTier: 90),
|
||||
CreateStatement("vendor-b", VexStatus.NotAffected, trustTier: 90),
|
||||
CreateStatement("vendor-c", VexStatus.Fixed, trustTier: 90)
|
||||
},
|
||||
VexStatus.Fixed
|
||||
},
|
||||
|
||||
// Seed 7: Single high-tier not_affected
|
||||
{
|
||||
"SEED-007",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("distro-maintainer", VexStatus.NotAffected, trustTier: 100, justification: "component_not_present")
|
||||
},
|
||||
VexStatus.NotAffected
|
||||
},
|
||||
|
||||
// Seed 8: Investigation escalates to affected
|
||||
{
|
||||
"SEED-008",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("issuer-early", VexStatus.UnderInvestigation, trustTier: 90),
|
||||
CreateStatement("issuer-update", VexStatus.Affected, trustTier: 90)
|
||||
},
|
||||
VexStatus.Affected
|
||||
},
|
||||
|
||||
// Seed 9: All tiers present (distro > vendor > community)
|
||||
{
|
||||
"SEED-009",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("community", VexStatus.Affected, trustTier: 50),
|
||||
CreateStatement("vendor", VexStatus.NotAffected, trustTier: 80),
|
||||
CreateStatement("distro", VexStatus.Fixed, trustTier: 100)
|
||||
},
|
||||
VexStatus.Fixed
|
||||
},
|
||||
|
||||
// Seed 10: Multiple affected statements (unanimous)
|
||||
{
|
||||
"SEED-010",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("nvd", VexStatus.Affected, trustTier: 85),
|
||||
CreateStatement("github-advisory", VexStatus.Affected, trustTier: 85),
|
||||
CreateStatement("snyk", VexStatus.Affected, trustTier: 85)
|
||||
},
|
||||
VexStatus.Affected
|
||||
}
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ReplaySeedCases))]
|
||||
public void ReplaySeed_ProducesStableOutput_Across10Runs(
|
||||
string seedId,
|
||||
VexStatement[] statements,
|
||||
VexStatus expectedStatus)
|
||||
{
|
||||
// Act: Run consensus 10 times
|
||||
var results = new List<string>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var result = ComputeConsensus(statements);
|
||||
var canonical = JsonSerializer.Serialize(result, CanonicalOptions);
|
||||
results.Add(canonical);
|
||||
}
|
||||
|
||||
// Assert: All 10 runs must produce byte-identical output
|
||||
results.Distinct().Should().HaveCount(1, because: $"{seedId}: replay must be deterministic");
|
||||
|
||||
// Verify expected status
|
||||
var finalResult = ComputeConsensus(statements);
|
||||
finalResult.Status.Should().Be(expectedStatus, because: $"{seedId}: status regression check");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllReplaySeeds_ExecuteWithinTimeLimit()
|
||||
{
|
||||
// Arrange: Collect all seed cases
|
||||
var allSeeds = ReplaySeedCases.Select(data => (VexStatement[])data[1]).ToList();
|
||||
|
||||
// Act: Measure execution time
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
foreach (var statements in allSeeds)
|
||||
{
|
||||
_ = ComputeConsensus(statements);
|
||||
}
|
||||
stopwatch.Stop();
|
||||
|
||||
// Assert: All 10 seeds should complete in under 100ms
|
||||
stopwatch.ElapsedMilliseconds.Should().BeLessThan(100, because: "replay tests must be fast");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Output Snapshot Tests (VTT-007)
|
||||
|
||||
/// <summary>
|
||||
/// Test cases that have golden output snapshots for regression testing.
|
||||
/// </summary>
|
||||
public static TheoryData<string> GoldenSnapshotCases => new()
|
||||
{
|
||||
{ "tt-001" }, // Single issuer unknown
|
||||
{ "tt-013" }, // Two issuer conflict
|
||||
{ "tt-014" }, // Two issuer merge (affected + fixed)
|
||||
{ "tt-020" } // Trust tier precedence
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenSnapshotCases))]
|
||||
public void GoldenSnapshot_MatchesExpectedOutput(string testId)
|
||||
{
|
||||
// Arrange: Load test scenario and expected golden output
|
||||
var (statements, expected) = LoadGoldenTestCase(testId);
|
||||
|
||||
// Act: Compute consensus
|
||||
var actual = ComputeConsensus(statements);
|
||||
|
||||
// Assert: Compare against golden snapshot
|
||||
var actualJson = JsonSerializer.Serialize(actual, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
var expectedJson = JsonSerializer.Serialize(expected, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
actualJson.Should().Be(expectedJson, because: $"golden snapshot {testId} must match exactly");
|
||||
|
||||
// Verify key fields individually for better diagnostics
|
||||
actual.Status.Should().Be(expected.Status, because: $"{testId}: status mismatch");
|
||||
actual.ConflictCount.Should().Be(expected.ConflictCount, because: $"{testId}: conflict count mismatch");
|
||||
actual.StatementCount.Should().Be(expected.StatementCount, because: $"{testId}: statement count mismatch");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load a golden test case from fixtures.
|
||||
/// </summary>
|
||||
private static (VexStatement[] Statements, GoldenConsensusResult Expected) LoadGoldenTestCase(string testId)
|
||||
{
|
||||
var basePath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "fixtures", "truth-tables", "expected");
|
||||
var goldenPath = Path.Combine(basePath, $"{testId}.consensus.json");
|
||||
|
||||
if (!File.Exists(goldenPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Golden file not found: {goldenPath}");
|
||||
}
|
||||
|
||||
var goldenJson = File.ReadAllText(goldenPath);
|
||||
var golden = JsonSerializer.Deserialize<GoldenConsensusResult>(goldenJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}) ?? throw new InvalidOperationException($"Failed to deserialize {goldenPath}");
|
||||
|
||||
// Reconstruct statements from golden file
|
||||
var statements = golden.AppliedStatements.Select(s => new VexStatement
|
||||
{
|
||||
IssuerId = s.IssuerId,
|
||||
Status = ParseVexStatus(s.Status),
|
||||
TrustTier = ParseTrustTier(s.TrustTier),
|
||||
Justification = null,
|
||||
Timestamp = DateTimeOffset.Parse(s.Timestamp),
|
||||
VulnerabilityId = golden.VulnerabilityId,
|
||||
ProductKey = golden.ProductKey
|
||||
}).ToArray();
|
||||
|
||||
return (statements, golden);
|
||||
}
|
||||
|
||||
private static VexStatus ParseVexStatus(string status) => status.ToLowerInvariant() switch
|
||||
{
|
||||
"unknown" => VexStatus.Unknown,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"affected" => VexStatus.Affected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
_ => throw new ArgumentException($"Unknown VEX status: {status}")
|
||||
};
|
||||
|
||||
private static int ParseTrustTier(string tier) => tier.ToLowerInvariant() switch
|
||||
{
|
||||
"distro" => 100,
|
||||
"vendor" => 90,
|
||||
"community" => 50,
|
||||
_ => 80
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Create a normalized VEX statement for testing.
|
||||
/// </summary>
|
||||
private static VexStatement CreateStatement(
|
||||
string issuerId,
|
||||
VexStatus status,
|
||||
int trustTier = 90,
|
||||
string? justification = null)
|
||||
{
|
||||
return new VexStatement
|
||||
{
|
||||
IssuerId = issuerId,
|
||||
Status = status,
|
||||
TrustTier = trustTier,
|
||||
Justification = justification,
|
||||
Timestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/lodash@4.17.21"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute consensus from statements.
|
||||
/// This is a simplified mock - in real tests this would call VexConsensusEngine.
|
||||
/// </summary>
|
||||
private static ConsensusResult ComputeConsensus(VexStatement[] statements)
|
||||
{
|
||||
// Simple lattice merge implementation for tests
|
||||
var orderedByTier = statements.OrderByDescending(s => s.TrustTier).ToList();
|
||||
var highestTier = orderedByTier[0].TrustTier;
|
||||
var topTierStatements = orderedByTier.Where(s => s.TrustTier == highestTier).ToList();
|
||||
|
||||
// Lattice merge logic
|
||||
var status = MergeLattice(topTierStatements.Select(s => s.Status));
|
||||
|
||||
// Conflict detection
|
||||
var distinctStatuses = topTierStatements.Select(s => s.Status).Distinct().ToList();
|
||||
var hasConflict = distinctStatuses.Count > 1 && !IsHierarchical(distinctStatuses);
|
||||
|
||||
var conflicts = hasConflict
|
||||
? topTierStatements.Where(s => s.Status != status).Select(s => s.IssuerId).ToList()
|
||||
: new List<string>();
|
||||
|
||||
// Confidence calculation
|
||||
var baseConfidence = 0.85m;
|
||||
if (topTierStatements.Count == 1 || distinctStatuses.Count == 1)
|
||||
baseConfidence = 0.95m; // Unanimous or single source
|
||||
|
||||
if (topTierStatements.Any(s => s.Justification == "component_not_present"))
|
||||
baseConfidence = 0.95m;
|
||||
else if (topTierStatements.Any(s => s.Justification == "vulnerable_code_not_in_execute_path"))
|
||||
baseConfidence = 0.90m;
|
||||
|
||||
return new ConsensusResult
|
||||
{
|
||||
Status = status,
|
||||
StatementCount = statements.Length,
|
||||
ConflictCount = conflicts.Count,
|
||||
Conflicts = conflicts,
|
||||
ConfidenceScore = baseConfidence
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge statuses according to lattice rules.
|
||||
/// </summary>
|
||||
private static VexStatus MergeLattice(IEnumerable<VexStatus> statuses)
|
||||
{
|
||||
var statusList = statuses.ToList();
|
||||
|
||||
// Fixed is lattice top (terminal)
|
||||
if (statusList.Contains(VexStatus.Fixed))
|
||||
return VexStatus.Fixed;
|
||||
|
||||
// Affected and NotAffected at same level
|
||||
if (statusList.Contains(VexStatus.Affected))
|
||||
return VexStatus.Affected; // Conservative choice in conflict
|
||||
|
||||
if (statusList.Contains(VexStatus.NotAffected))
|
||||
return VexStatus.NotAffected;
|
||||
|
||||
if (statusList.Contains(VexStatus.UnderInvestigation))
|
||||
return VexStatus.UnderInvestigation;
|
||||
|
||||
return VexStatus.Unknown; // Lattice bottom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if statuses are hierarchical (no conflict).
|
||||
/// </summary>
|
||||
private static bool IsHierarchical(List<VexStatus> statuses)
|
||||
{
|
||||
// Affected and NotAffected are at same level (conflict)
|
||||
if (statuses.Contains(VexStatus.Affected) && statuses.Contains(VexStatus.NotAffected))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Models
|
||||
|
||||
private class VexStatement
|
||||
{
|
||||
public required string IssuerId { get; init; }
|
||||
public required VexStatus Status { get; init; }
|
||||
public required int TrustTier { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string ProductKey { get; init; }
|
||||
}
|
||||
|
||||
private class ConsensusResult
|
||||
{
|
||||
public required VexStatus Status { get; init; }
|
||||
public required int StatementCount { get; init; }
|
||||
public required int ConflictCount { get; init; }
|
||||
public required IReadOnlyList<string> Conflicts { get; init; }
|
||||
public required decimal ConfidenceScore { get; init; }
|
||||
}
|
||||
|
||||
private enum VexStatus
|
||||
{
|
||||
Unknown,
|
||||
UnderInvestigation,
|
||||
NotAffected,
|
||||
Affected,
|
||||
Fixed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Golden file format for consensus results (matches expected/*.consensus.json).
|
||||
/// </summary>
|
||||
private class GoldenConsensusResult
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string ProductKey { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required decimal Confidence { get; init; }
|
||||
public required int StatementCount { get; init; }
|
||||
public required int ConflictCount { get; init; }
|
||||
public required List<GoldenConflict> Conflicts { get; init; }
|
||||
public required List<GoldenStatement> AppliedStatements { get; init; }
|
||||
public required string ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
private class GoldenConflict
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
public required List<GoldenIssuer> Issuers { get; init; }
|
||||
}
|
||||
|
||||
private class GoldenIssuer
|
||||
{
|
||||
public required string IssuerId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string TrustTier { get; init; }
|
||||
}
|
||||
|
||||
private class GoldenStatement
|
||||
{
|
||||
public required string IssuerId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string TrustTier { get; init; }
|
||||
public required string Timestamp { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"vulnerabilityId": "CVE-2024-1234",
|
||||
"productKey": "pkg:npm/lodash@4.17.21",
|
||||
"status": "unknown",
|
||||
"confidence": 0.5,
|
||||
"statementCount": 1,
|
||||
"conflictCount": 0,
|
||||
"conflicts": [],
|
||||
"appliedStatements": [
|
||||
{
|
||||
"issuerId": "issuer-a",
|
||||
"status": "unknown",
|
||||
"trustTier": "vendor",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
],
|
||||
"computedAt": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"vulnerabilityId": "CVE-2024-1234",
|
||||
"productKey": "pkg:npm/lodash@4.17.21",
|
||||
"status": "affected",
|
||||
"confidence": 0.75,
|
||||
"statementCount": 2,
|
||||
"conflictCount": 1,
|
||||
"conflicts": [
|
||||
{
|
||||
"reason": "Status disagreement between same-tier issuers",
|
||||
"issuers": [
|
||||
{
|
||||
"issuerId": "issuer-a",
|
||||
"status": "affected",
|
||||
"trustTier": "vendor"
|
||||
},
|
||||
{
|
||||
"issuerId": "issuer-b",
|
||||
"status": "not_affected",
|
||||
"trustTier": "vendor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"appliedStatements": [
|
||||
{
|
||||
"issuerId": "issuer-a",
|
||||
"status": "affected",
|
||||
"trustTier": "vendor",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"issuerId": "issuer-b",
|
||||
"status": "not_affected",
|
||||
"trustTier": "vendor",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
],
|
||||
"computedAt": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"vulnerabilityId": "CVE-2024-1234",
|
||||
"productKey": "pkg:npm/lodash@4.17.21",
|
||||
"status": "fixed",
|
||||
"confidence": 0.95,
|
||||
"statementCount": 2,
|
||||
"conflictCount": 0,
|
||||
"conflicts": [],
|
||||
"appliedStatements": [
|
||||
{
|
||||
"issuerId": "issuer-a",
|
||||
"status": "affected",
|
||||
"trustTier": "vendor",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"issuerId": "issuer-b",
|
||||
"status": "fixed",
|
||||
"trustTier": "vendor",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
],
|
||||
"computedAt": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"vulnerabilityId": "CVE-2024-1234",
|
||||
"productKey": "pkg:npm/lodash@4.17.21",
|
||||
"status": "affected",
|
||||
"confidence": 0.95,
|
||||
"statementCount": 2,
|
||||
"conflictCount": 0,
|
||||
"conflicts": [],
|
||||
"appliedStatements": [
|
||||
{
|
||||
"issuerId": "issuer-distro",
|
||||
"status": "affected",
|
||||
"trustTier": "distro",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"issuerId": "issuer-community",
|
||||
"status": "not_affected",
|
||||
"trustTier": "community",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
],
|
||||
"computedAt": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
Reference in New Issue
Block a user