Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
-- VexLens Schema Migration 001: Consensus Projections
|
||||
-- Creates the vexlens schema for VEX consensus persistence
|
||||
-- Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-020)
|
||||
|
||||
-- Create schema
|
||||
CREATE SCHEMA IF NOT EXISTS vexlens;
|
||||
|
||||
-- Enable extensions for trigram search if not present
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- Consensus projections table
|
||||
-- Stores computed VEX consensus results with full history support
|
||||
CREATE TABLE IF NOT EXISTS vexlens.consensus_projections (
|
||||
-- Primary key
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Core identity (forms unique compound key for latest lookup)
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
product_key TEXT NOT NULL,
|
||||
tenant_id TEXT,
|
||||
|
||||
-- Consensus result
|
||||
status TEXT NOT NULL CHECK (status IN ('not_affected', 'affected', 'fixed', 'under_investigation')),
|
||||
justification TEXT CHECK (justification IS NULL OR justification IN (
|
||||
'component_not_present',
|
||||
'vulnerable_code_not_present',
|
||||
'vulnerable_code_not_in_execute_path',
|
||||
'vulnerable_code_cannot_be_controlled_by_adversary',
|
||||
'inline_mitigations_already_exist'
|
||||
)),
|
||||
confidence_score DOUBLE PRECISION NOT NULL CHECK (confidence_score >= 0.0 AND confidence_score <= 1.0),
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('unanimous', 'majority', 'conflict', 'single_source', 'none')),
|
||||
|
||||
-- Aggregation metadata
|
||||
statement_count INT NOT NULL DEFAULT 0,
|
||||
conflict_count INT NOT NULL DEFAULT 0,
|
||||
rationale_summary TEXT,
|
||||
|
||||
-- Timestamps (use UTC)
|
||||
computed_at TIMESTAMPTZ NOT NULL,
|
||||
stored_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- History tracking
|
||||
previous_projection_id UUID REFERENCES vexlens.consensus_projections(id),
|
||||
status_changed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Delta attestation reference
|
||||
attestation_digest TEXT,
|
||||
|
||||
-- Full input hash for replay verification
|
||||
input_hash TEXT
|
||||
);
|
||||
|
||||
-- Unique constraint for latest projection lookup
|
||||
-- Only one "active" projection per vulnerability-product-tenant at a time
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_consensus_projections_latest
|
||||
ON vexlens.consensus_projections (vulnerability_id, product_key, COALESCE(tenant_id, ''))
|
||||
WHERE previous_projection_id IS NULL OR status_changed = TRUE;
|
||||
|
||||
-- Indexes for common query patterns
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_vuln_id
|
||||
ON vexlens.consensus_projections (vulnerability_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_product_key
|
||||
ON vexlens.consensus_projections (product_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_tenant_id
|
||||
ON vexlens.consensus_projections (tenant_id)
|
||||
WHERE tenant_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_status
|
||||
ON vexlens.consensus_projections (status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_outcome
|
||||
ON vexlens.consensus_projections (outcome);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_computed_at
|
||||
ON vexlens.consensus_projections (computed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_stored_at
|
||||
ON vexlens.consensus_projections (stored_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_confidence
|
||||
ON vexlens.consensus_projections (confidence_score DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_status_changed
|
||||
ON vexlens.consensus_projections (status_changed)
|
||||
WHERE status_changed = TRUE;
|
||||
|
||||
-- Composite index for history queries
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_history
|
||||
ON vexlens.consensus_projections (vulnerability_id, product_key, tenant_id, computed_at DESC);
|
||||
|
||||
-- Trigram index for fuzzy vulnerability ID search
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_projections_vuln_id_trgm
|
||||
ON vexlens.consensus_projections USING gin (vulnerability_id gin_trgm_ops);
|
||||
|
||||
-- Trigger to auto-update stored_at timestamp
|
||||
CREATE OR REPLACE FUNCTION vexlens.update_stored_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.stored_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_consensus_projections_update_stored_at
|
||||
BEFORE UPDATE ON vexlens.consensus_projections
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION vexlens.update_stored_at();
|
||||
|
||||
-- Consensus input statements table (tracks which VEX statements contributed)
|
||||
CREATE TABLE IF NOT EXISTS vexlens.consensus_inputs (
|
||||
projection_id UUID NOT NULL REFERENCES vexlens.consensus_projections(id) ON DELETE CASCADE,
|
||||
statement_id TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
confidence DOUBLE PRECISION,
|
||||
weight DOUBLE PRECISION,
|
||||
|
||||
PRIMARY KEY (projection_id, statement_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_inputs_projection
|
||||
ON vexlens.consensus_inputs (projection_id);
|
||||
|
||||
-- Consensus conflicts table (detailed conflict records)
|
||||
CREATE TABLE IF NOT EXISTS vexlens.consensus_conflicts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
projection_id UUID NOT NULL REFERENCES vexlens.consensus_projections(id) ON DELETE CASCADE,
|
||||
issuer1 TEXT NOT NULL,
|
||||
issuer2 TEXT NOT NULL,
|
||||
status1 TEXT NOT NULL,
|
||||
status2 TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL')),
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_conflicts_projection
|
||||
ON vexlens.consensus_conflicts (projection_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consensus_conflicts_severity
|
||||
ON vexlens.consensus_conflicts (severity);
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON TABLE vexlens.consensus_projections IS 'VEX consensus computation results with history tracking';
|
||||
COMMENT ON COLUMN vexlens.consensus_projections.confidence_score IS 'Aggregated confidence score from 0.0 to 1.0';
|
||||
COMMENT ON COLUMN vexlens.consensus_projections.outcome IS 'Consensus outcome: unanimous (all agree), majority (>50% agree), conflict (tie), single_source, none';
|
||||
COMMENT ON COLUMN vexlens.consensus_projections.status_changed IS 'TRUE if this projection changed status from previous';
|
||||
COMMENT ON COLUMN vexlens.consensus_projections.input_hash IS 'SHA256 hash of sorted inputs for replay verification';
|
||||
COMMENT ON TABLE vexlens.consensus_inputs IS 'VEX statements that contributed to a consensus projection';
|
||||
COMMENT ON TABLE vexlens.consensus_conflicts IS 'Conflicts detected during consensus computation';
|
||||
@@ -0,0 +1,582 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IConsensusProjectionStore"/>.
|
||||
/// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-021)
|
||||
/// </summary>
|
||||
public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Persistence.PostgresConsensusProjectionStore");
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly IConsensusEventEmitter? _eventEmitter;
|
||||
private readonly ILogger<PostgresConsensusProjectionStore> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresConsensusProjectionStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresConsensusProjectionStore> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IConsensusEventEmitter? eventEmitter = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_eventEmitter = eventEmitter;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection> StoreAsync(
|
||||
VexConsensusResult result,
|
||||
StoreProjectionOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("StoreAsync");
|
||||
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
||||
activity?.SetTag("productKey", result.ProductKey);
|
||||
|
||||
var projectionId = Guid.NewGuid();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for previous projection to track history
|
||||
ConsensusProjection? previous = null;
|
||||
if (options.TrackHistory)
|
||||
{
|
||||
previous = await GetLatestAsync(
|
||||
result.VulnerabilityId,
|
||||
result.ProductKey,
|
||||
options.TenantId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var statusChanged = previous is not null &&
|
||||
!string.Equals(previous.Status.ToString(), result.ConsensusStatus.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Insert the projection
|
||||
await using var cmd = new NpgsqlCommand(InsertProjectionSql, connection, transaction);
|
||||
cmd.Parameters.AddWithValue("id", projectionId);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", result.VulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", result.ProductKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", options.TenantId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status", MapStatus(result.ConsensusStatus));
|
||||
cmd.Parameters.AddWithValue("justification", result.ConsensusJustification.HasValue ? MapJustification(result.ConsensusJustification.Value) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("confidence_score", result.ConfidenceScore);
|
||||
cmd.Parameters.AddWithValue("outcome", MapOutcome(result.Outcome));
|
||||
cmd.Parameters.AddWithValue("statement_count", result.Contributions.Count);
|
||||
cmd.Parameters.AddWithValue("conflict_count", result.Conflicts?.Count ?? 0);
|
||||
cmd.Parameters.AddWithValue("rationale_summary", result.Rationale.Summary ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("computed_at", result.ComputedAt);
|
||||
cmd.Parameters.AddWithValue("stored_at", now);
|
||||
cmd.Parameters.AddWithValue("previous_projection_id", previous?.ProjectionId is not null ? Guid.Parse(previous.ProjectionId) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status_changed", statusChanged);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
var projection = new ConsensusProjection(
|
||||
ProjectionId: projectionId.ToString(),
|
||||
VulnerabilityId: result.VulnerabilityId,
|
||||
ProductKey: result.ProductKey,
|
||||
TenantId: options.TenantId,
|
||||
Status: result.ConsensusStatus,
|
||||
Justification: result.ConsensusJustification,
|
||||
ConfidenceScore: result.ConfidenceScore,
|
||||
Outcome: result.Outcome,
|
||||
StatementCount: result.Contributions.Count,
|
||||
ConflictCount: result.Conflicts?.Count ?? 0,
|
||||
RationaleSummary: result.Rationale.Summary ?? string.Empty,
|
||||
ComputedAt: result.ComputedAt,
|
||||
StoredAt: now,
|
||||
PreviousProjectionId: previous?.ProjectionId,
|
||||
StatusChanged: statusChanged);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}",
|
||||
projectionId, result.VulnerabilityId, result.ProductKey);
|
||||
|
||||
// Emit events if configured
|
||||
if (options.EmitEvent && _eventEmitter is not null)
|
||||
{
|
||||
await EmitEventsAsync(projection, previous, cancellationToken);
|
||||
}
|
||||
|
||||
return projection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection?> GetAsync(
|
||||
string projectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectionId);
|
||||
|
||||
if (!Guid.TryParse(projectionId, out var id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var activity = ActivitySource.StartActivity("GetAsync");
|
||||
activity?.SetTag("projectionId", projectionId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectByIdSql, connection);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return MapProjection(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection?> GetLatestAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("GetLatestAsync");
|
||||
activity?.SetTag("vulnerabilityId", vulnerabilityId);
|
||||
activity?.SetTag("productKey", productKey);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectLatestSql, connection);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", productKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return MapProjection(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProjectionListResult> ListAsync(
|
||||
ProjectionQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("ListAsync");
|
||||
|
||||
var (sql, countSql, parameters) = BuildListQuery(query);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
// Get total count
|
||||
await using var countCmd = new NpgsqlCommand(countSql, connection);
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
countCmd.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken));
|
||||
|
||||
// Get projections
|
||||
var projections = new List<ConsensusProjection>();
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
cmd.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
projections.Add(MapProjection(reader));
|
||||
}
|
||||
|
||||
return new ProjectionListResult(projections, totalCount, query.Offset, query.Limit);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("GetHistoryAsync");
|
||||
activity?.SetTag("vulnerabilityId", vulnerabilityId);
|
||||
activity?.SetTag("productKey", productKey);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectHistorySql, connection);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", productKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("limit", limit ?? 100);
|
||||
|
||||
var projections = new List<ConsensusProjection>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
projections.Add(MapProjection(reader));
|
||||
}
|
||||
|
||||
return projections;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeAsync(
|
||||
DateTimeOffset olderThan,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("PurgeAsync");
|
||||
activity?.SetTag("olderThan", olderThan.ToString("O"));
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
tenantId is null ? PurgeSql : PurgeByTenantSql,
|
||||
connection);
|
||||
cmd.Parameters.AddWithValue("older_than", olderThan);
|
||||
if (tenantId is not null)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
}
|
||||
|
||||
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
_logger.LogInformation("Purged {Count} consensus projections older than {OlderThan}", deleted, olderThan);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
#region SQL Queries
|
||||
|
||||
private const string InsertProjectionSql = """
|
||||
INSERT INTO vexlens.consensus_projections (
|
||||
id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
) VALUES (
|
||||
@id, @vulnerability_id, @product_key, @tenant_id, @status, @justification,
|
||||
@confidence_score, @outcome, @statement_count, @conflict_count,
|
||||
@rationale_summary, @computed_at, @stored_at, @previous_projection_id, @status_changed
|
||||
)
|
||||
""";
|
||||
|
||||
private const string SelectByIdSql = """
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM vexlens.consensus_projections
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
private const string SelectLatestSql = """
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM vexlens.consensus_projections
|
||||
WHERE vulnerability_id = @vulnerability_id
|
||||
AND product_key = @product_key
|
||||
AND (tenant_id = @tenant_id OR (@tenant_id IS NULL AND tenant_id IS NULL))
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
private const string SelectHistorySql = """
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM vexlens.consensus_projections
|
||||
WHERE vulnerability_id = @vulnerability_id
|
||||
AND product_key = @product_key
|
||||
AND (tenant_id = @tenant_id OR (@tenant_id IS NULL AND tenant_id IS NULL))
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
private const string PurgeSql = """
|
||||
DELETE FROM vexlens.consensus_projections
|
||||
WHERE computed_at < @older_than
|
||||
""";
|
||||
|
||||
private const string PurgeByTenantSql = """
|
||||
DELETE FROM vexlens.consensus_projections
|
||||
WHERE computed_at < @older_than AND tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static ConsensusProjection MapProjection(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ConsensusProjection(
|
||||
ProjectionId: reader.GetGuid(0).ToString(),
|
||||
VulnerabilityId: reader.GetString(1),
|
||||
ProductKey: reader.GetString(2),
|
||||
TenantId: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
Status: ParseStatus(reader.GetString(4)),
|
||||
Justification: reader.IsDBNull(5) ? null : ParseJustification(reader.GetString(5)),
|
||||
ConfidenceScore: reader.GetDouble(6),
|
||||
Outcome: ParseOutcome(reader.GetString(7)),
|
||||
StatementCount: reader.GetInt32(8),
|
||||
ConflictCount: reader.GetInt32(9),
|
||||
RationaleSummary: reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
||||
ComputedAt: reader.GetFieldValue<DateTimeOffset>(11),
|
||||
StoredAt: reader.GetFieldValue<DateTimeOffset>(12),
|
||||
PreviousProjectionId: reader.IsDBNull(13) ? null : reader.GetGuid(13).ToString(),
|
||||
StatusChanged: reader.GetBoolean(14));
|
||||
}
|
||||
|
||||
private static string MapStatus(VexStatus status) => status switch
|
||||
{
|
||||
VexStatus.NotAffected => "not_affected",
|
||||
VexStatus.Affected => "affected",
|
||||
VexStatus.Fixed => "fixed",
|
||||
VexStatus.UnderInvestigation => "under_investigation",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status))
|
||||
};
|
||||
|
||||
private static VexStatus ParseStatus(string status) => status switch
|
||||
{
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"affected" => VexStatus.Affected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status))
|
||||
};
|
||||
|
||||
private static string MapJustification(VexJustification justification) => justification switch
|
||||
{
|
||||
VexJustification.ComponentNotPresent => "component_not_present",
|
||||
VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present",
|
||||
VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path",
|
||||
VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary",
|
||||
VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(justification))
|
||||
};
|
||||
|
||||
private static VexJustification ParseJustification(string justification) => justification switch
|
||||
{
|
||||
"component_not_present" => VexJustification.ComponentNotPresent,
|
||||
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
|
||||
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
|
||||
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
||||
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(justification))
|
||||
};
|
||||
|
||||
private static string MapOutcome(ConsensusOutcome outcome) => outcome switch
|
||||
{
|
||||
ConsensusOutcome.Unanimous => "unanimous",
|
||||
ConsensusOutcome.Majority => "majority",
|
||||
ConsensusOutcome.Plurality => "plurality",
|
||||
ConsensusOutcome.ConflictResolved => "conflict_resolved",
|
||||
ConsensusOutcome.NoData => "no_data",
|
||||
ConsensusOutcome.Indeterminate => "indeterminate",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(outcome))
|
||||
};
|
||||
|
||||
private static ConsensusOutcome ParseOutcome(string outcome) => outcome switch
|
||||
{
|
||||
"unanimous" => ConsensusOutcome.Unanimous,
|
||||
"majority" => ConsensusOutcome.Majority,
|
||||
"plurality" => ConsensusOutcome.Plurality,
|
||||
"conflict_resolved" => ConsensusOutcome.ConflictResolved,
|
||||
"no_data" => ConsensusOutcome.NoData,
|
||||
"indeterminate" => ConsensusOutcome.Indeterminate,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(outcome))
|
||||
};
|
||||
|
||||
private static (string sql, string countSql, List<(string name, object value)> parameters) BuildListQuery(ProjectionQuery query)
|
||||
{
|
||||
var conditions = new List<string>();
|
||||
var parameters = new List<(string name, object value)>();
|
||||
|
||||
if (query.TenantId is not null)
|
||||
{
|
||||
conditions.Add("tenant_id = @tenant_id");
|
||||
parameters.Add(("tenant_id", query.TenantId));
|
||||
}
|
||||
|
||||
if (query.VulnerabilityId is not null)
|
||||
{
|
||||
conditions.Add("vulnerability_id = @vulnerability_id");
|
||||
parameters.Add(("vulnerability_id", query.VulnerabilityId));
|
||||
}
|
||||
|
||||
if (query.ProductKey is not null)
|
||||
{
|
||||
conditions.Add("product_key = @product_key");
|
||||
parameters.Add(("product_key", query.ProductKey));
|
||||
}
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
conditions.Add("status = @status");
|
||||
parameters.Add(("status", MapStatus(query.Status.Value)));
|
||||
}
|
||||
|
||||
if (query.Outcome.HasValue)
|
||||
{
|
||||
conditions.Add("outcome = @outcome");
|
||||
parameters.Add(("outcome", MapOutcome(query.Outcome.Value)));
|
||||
}
|
||||
|
||||
if (query.MinimumConfidence.HasValue)
|
||||
{
|
||||
conditions.Add("confidence_score >= @min_confidence");
|
||||
parameters.Add(("min_confidence", query.MinimumConfidence.Value));
|
||||
}
|
||||
|
||||
if (query.ComputedAfter.HasValue)
|
||||
{
|
||||
conditions.Add("computed_at >= @computed_after");
|
||||
parameters.Add(("computed_after", query.ComputedAfter.Value));
|
||||
}
|
||||
|
||||
if (query.ComputedBefore.HasValue)
|
||||
{
|
||||
conditions.Add("computed_at <= @computed_before");
|
||||
parameters.Add(("computed_before", query.ComputedBefore.Value));
|
||||
}
|
||||
|
||||
if (query.StatusChanged.HasValue)
|
||||
{
|
||||
conditions.Add("status_changed = @status_changed");
|
||||
parameters.Add(("status_changed", query.StatusChanged.Value));
|
||||
}
|
||||
|
||||
var whereClause = conditions.Count > 0
|
||||
? $"WHERE {string.Join(" AND ", conditions)}"
|
||||
: string.Empty;
|
||||
|
||||
var sortColumn = query.SortBy switch
|
||||
{
|
||||
ProjectionSortField.ComputedAt => "computed_at",
|
||||
ProjectionSortField.StoredAt => "stored_at",
|
||||
ProjectionSortField.VulnerabilityId => "vulnerability_id",
|
||||
ProjectionSortField.ProductKey => "product_key",
|
||||
ProjectionSortField.ConfidenceScore => "confidence_score",
|
||||
_ => "computed_at"
|
||||
};
|
||||
|
||||
var sortDirection = query.SortDescending ? "DESC" : "ASC";
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM vexlens.consensus_projections
|
||||
{whereClause}
|
||||
ORDER BY {sortColumn} {sortDirection}
|
||||
LIMIT {query.Limit} OFFSET {query.Offset}
|
||||
""";
|
||||
|
||||
var countSql = $"SELECT COUNT(*) FROM vexlens.consensus_projections {whereClause}";
|
||||
|
||||
return (sql, countSql, parameters);
|
||||
}
|
||||
|
||||
private async Task EmitEventsAsync(
|
||||
ConsensusProjection projection,
|
||||
ConsensusProjection? previous,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_eventEmitter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Always emit computed event
|
||||
await _eventEmitter.EmitConsensusComputedAsync(
|
||||
new ConsensusComputedEvent(
|
||||
EventId: Guid.NewGuid().ToString(),
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
Status: projection.Status,
|
||||
Justification: projection.Justification,
|
||||
ConfidenceScore: projection.ConfidenceScore,
|
||||
Outcome: projection.Outcome,
|
||||
StatementCount: projection.StatementCount,
|
||||
ComputedAt: projection.ComputedAt,
|
||||
EmittedAt: now),
|
||||
cancellationToken);
|
||||
|
||||
// Emit status changed if applicable
|
||||
if (projection.StatusChanged && previous is not null)
|
||||
{
|
||||
await _eventEmitter.EmitStatusChangedAsync(
|
||||
new ConsensusStatusChangedEvent(
|
||||
EventId: Guid.NewGuid().ToString(),
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
PreviousStatus: previous.Status,
|
||||
NewStatus: projection.Status,
|
||||
ChangeReason: null,
|
||||
ComputedAt: projection.ComputedAt,
|
||||
EmittedAt: now),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Emit conflict detected if there are conflicts
|
||||
if (projection.ConflictCount > 0)
|
||||
{
|
||||
await _eventEmitter.EmitConflictDetectedAsync(
|
||||
new ConsensusConflictDetectedEvent(
|
||||
EventId: Guid.NewGuid().ToString(),
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
ConflictCount: projection.ConflictCount,
|
||||
MaxSeverity: ConflictSeverity.Medium, // Default, would be computed from actual conflicts
|
||||
Conflicts: [],
|
||||
DetectedAt: projection.ComputedAt,
|
||||
EmittedAt: now),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-020) -->
|
||||
<RootNamespace>StellaOps.VexLens.Persistence</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.VexLens\StellaOps.VexLens.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
291
src/VexLens/StellaOps.VexLens.sln
Normal file
291
src/VexLens/StellaOps.VexLens.sln
Normal file
@@ -0,0 +1,291 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.VexLens", "StellaOps.VexLens", "{4D88C818-3BB3-BC3B-D3D1-F21617D09654}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.VexLens.Persistence", "StellaOps.VexLens.Persistence", "{A08207CC-507B-A415-392F-6FF2DB6342CA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.VexLens.Core", "StellaOps.VexLens.Core", "{B0727DBA-83FD-619F-5D33-3F4652753F2E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{CC393719-C997-B814-42B3-0BA762B74BF7}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.VexLens.Core.Tests", "StellaOps.VexLens.Core.Tests", "{C314BF02-26DE-60BD-BAA5-AA4C52869724}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aoc", "Aoc", "{03DFF14F-7321-1784-D4C7-4E99D4120F48}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{BDD326D6-7616-84F0-B914-74743BFBA520}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Aoc", "StellaOps.Aoc", "{EC506DBE-AB6D-492E-786E-8B176021BF2E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Attestor", "Attestor", "{5AC09D9A-F2A5-9CFA-B3C5-8D25F257651C}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor", "StellaOps.Attestor", "{33B1AE27-692A-1778-48C1-CCEC2B9BC78F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Envelope", "StellaOps.Attestor.Envelope", "{018E0E11-1CCE-A2BE-641D-21EE14D2E90D}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Core", "StellaOps.Attestor.Core", "{5F27FB4E-CF09-3A6B-F5B4-BF5A709FA609}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB67BDB9-D701-3AC9-9CDF-ECCDCCD8DB6D}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.ProofChain", "StellaOps.Attestor.ProofChain", "{45F7FA87-7451-6970-7F6E-F8BAE45E081B}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Concelier", "Concelier", "{157C3671-CA0B-69FA-A7C9-74A1FDA97B99}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{F39E09D6-BF93-B64A-CFE7-2BA92815C0FE}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.RawModels", "StellaOps.Concelier.RawModels", "{1DCF4EBB-DBC4-752C-13D4-D1EECE4E8907}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.SourceIntel", "StellaOps.Concelier.SourceIntel", "{F2B58F4E-6F28-A25F-5BFB-CDEBAD6B9A3E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Excititor", "Excititor", "{7D49FA52-6EA1-EAC8-4C5A-AC07188D6C57}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{C9CF27FC-12DB-954F-863C-576BA8E309A5}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Core", "StellaOps.Excititor.Core", "{6DCAF6F3-717F-27A9-D96C-F2BFA5550347}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Feedser", "Feedser", "{C4A90603-BE42-0044-CAB4-3EB910AD51A5}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.BinaryAnalysis", "StellaOps.Feedser.BinaryAnalysis", "{054761F9-16D3-B2F8-6F4D-EFC2248805CD}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.Core", "StellaOps.Feedser.Core", "{B54CE64C-4167-1DD1-B7D6-2FD7A5AEF715}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Policy", "Policy", "{8E6B774C-CC4E-CE7C-AD4B-8AF7C92889A6}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Policy.RiskProfile", "StellaOps.Policy.RiskProfile", "{BC12ED55-6015-7C8B-8384-B39CE93C76D6}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{FF70543D-AFF9-1D38-4950-4F8EE18D60BB}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Policy", "StellaOps.Policy", "{831265B0-8896-9C95-3488-E12FD9F6DC53}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Provenance", "Provenance", "{316BBD0A-04D2-85C9-52EA-7993CC6C8930}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Provenance.Attestation", "StellaOps.Provenance.Attestation", "{9D6AB85A-85EA-D85A-5566-A121D34016E6}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Signer", "Signer", "{3247EE0D-B3E9-9C11-B0AE-FE719410390B}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signer", "StellaOps.Signer", "{CD7C09DA-FEC8-2CC5-D00C-E525638DFF4A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signer.Core", "StellaOps.Signer.Core", "{79B10804-91E9-972E-1913-EE0F0B11663E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Kms", "StellaOps.Cryptography.Kms", "{5AC9EE40-1881-5F8A-46A2-2C303950D3C8}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres", "StellaOps.Infrastructure.Postgres", "{61B23570-4F2D-B060-BE1F-37995682E494}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Ingestion.Telemetry", "StellaOps.Ingestion.Telemetry", "{1182764D-2143-EEF0-9270-3DCE392F5D06}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "E:\dev\git.stella-ops.org\src\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{776E2142-804F-03B9-C804-D061D64C6092}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core", "E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj", "{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope", "E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj", "{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain", "E:\dev\git.stella-ops.org\src\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj", "{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{34EFF636-81A7-8DF6-7CC9-4DA784BAC7F3}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SourceIntel", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj", "{EB093C48-CDAC-106B-1196-AE34809B34C0}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Kms", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj", "{F3A27846-6DE0-3448-222C-25A273E86B2E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Core", "E:\dev\git.stella-ops.org\src\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj", "{9151601C-8784-01A6-C2E7-A5C0FAAB0AEF}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.BinaryAnalysis", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj", "{CB296A20-2732-77C1-7F23-27D5BAEDD0C7}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{0DBEC9BA-FE1D-3898-B2C6-E4357DC23E0F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{8C594D82-3463-3367-4F06-900AC707753D}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{9588FBF9-C37E-D16E-2E8F-CFA226EAC01D}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "E:\dev\git.stella-ops.org\src\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{19868E2D-7163-2108-1094-F13887C4F070}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.RiskProfile", "E:\dev\git.stella-ops.org\src\Policy\StellaOps.Policy.RiskProfile\StellaOps.Policy.RiskProfile.csproj", "{CC319FC5-F4B1-C3DD-7310-4DAD343E0125}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance.Attestation", "E:\dev\git.stella-ops.org\src\Provenance\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj", "{A78EBC0F-C62C-8F56-95C0-330E376242A2}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signer.Core", "E:\dev\git.stella-ops.org\src\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj", "{0AF13355-173C-3128-5AFC-D32E540DA3EF}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexLens", "StellaOps.VexLens\StellaOps.VexLens.csproj", "{33565FF8-EBD5-53F8-B786-95111ACDF65F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexLens.Core", "StellaOps.VexLens\StellaOps.VexLens.Core\StellaOps.VexLens.Core.csproj", "{12F72803-F28C-8F72-1BA0-3911231DD8AF}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexLens.Core.Tests", "StellaOps.VexLens\__Tests\StellaOps.VexLens.Core.Tests\StellaOps.VexLens.Core.Tests.csproj", "{3A4678E5-957B-1E59-9A19-50C8A60F53DF}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexLens.Persistence", "StellaOps.VexLens.Persistence\StellaOps.VexLens.Persistence.csproj", "{0F9CBD78-C279-951B-A38F-A0AA57B62517}"
|
||||
|
||||
EndProject
|
||||
|
||||
Global
|
||||
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
||||
Release|Any CPU = Release|Any CPU
|
||||
|
||||
EndGlobalSection
|
||||
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
|
||||
{776E2142-804F-03B9-C804-D061D64C6092}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{776E2142-804F-03B9-C804-D061D64C6092}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{776E2142-804F-03B9-C804-D061D64C6092}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{776E2142-804F-03B9-C804-D061D64C6092}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.VexLens.Api;
|
||||
using StellaOps.VexLens.Caching;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
@@ -169,9 +172,16 @@ public static class VexLensServiceCollectionExtensions
|
||||
IServiceCollection services,
|
||||
VexLensStorageOptions options)
|
||||
{
|
||||
var driver = (options.Driver ?? "memory").Trim();
|
||||
var driver = (options.Driver ?? "memory").Trim().ToLowerInvariant();
|
||||
|
||||
switch (driver.ToLowerInvariant())
|
||||
// Feature flag: UsePostgres upgrades memory driver to dual-write automatically
|
||||
// Sprint: LIN-BE-022
|
||||
if (options.UsePostgres && driver == "memory")
|
||||
{
|
||||
driver = "dual-write";
|
||||
}
|
||||
|
||||
switch (driver)
|
||||
{
|
||||
case "memory":
|
||||
services.TryAddSingleton<IConsensusProjectionStore>(sp =>
|
||||
@@ -181,9 +191,105 @@ public static class VexLensServiceCollectionExtensions
|
||||
});
|
||||
break;
|
||||
|
||||
case "postgres":
|
||||
// PostgreSQL-only mode (post-migration)
|
||||
RegisterPostgresStore(services, options);
|
||||
break;
|
||||
|
||||
case "dual-write":
|
||||
// Dual-write mode for migration from memory to postgres
|
||||
RegisterDualWriteStore(services, options);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported VexLens storage driver: '{options.Driver}'. Supported drivers: memory.");
|
||||
$"Unsupported VexLens storage driver: '{options.Driver}'. Supported drivers: memory, postgres, dual-write.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers PostgreSQL-only consensus projection store.
|
||||
/// </summary>
|
||||
private static void RegisterPostgresStore(
|
||||
IServiceCollection services,
|
||||
VexLensStorageOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"VexLens:Storage:ConnectionString is required when using postgres driver.");
|
||||
}
|
||||
|
||||
// Register NpgsqlDataSource for the connection
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
var connStringBuilder = new NpgsqlConnectionStringBuilder(options.ConnectionString)
|
||||
{
|
||||
CommandTimeout = options.CommandTimeoutSeconds
|
||||
};
|
||||
var builder = new NpgsqlDataSourceBuilder(connStringBuilder.ConnectionString);
|
||||
return builder.Build();
|
||||
});
|
||||
|
||||
// Register the PostgreSQL store
|
||||
// Note: The actual PostgresConsensusProjectionStore is in StellaOps.VexLens.Persistence
|
||||
// This registers a factory that creates it
|
||||
services.TryAddSingleton<IConsensusProjectionStore>(sp =>
|
||||
{
|
||||
var dataSource = sp.GetRequiredService<NpgsqlDataSource>();
|
||||
var logger = sp.GetRequiredService<ILogger<PostgresConsensusProjectionStoreProxy>>();
|
||||
var emitter = sp.GetService<IConsensusEventEmitter>();
|
||||
|
||||
return new PostgresConsensusProjectionStoreProxy(dataSource, logger, emitter, options);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers dual-write consensus projection store for migration.
|
||||
/// Sprint: LIN-BE-022
|
||||
/// </summary>
|
||||
private static void RegisterDualWriteStore(
|
||||
IServiceCollection services,
|
||||
VexLensStorageOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"VexLens:Storage:ConnectionString is required when using dual-write driver.");
|
||||
}
|
||||
|
||||
// Register NpgsqlDataSource
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
var connStringBuilder = new NpgsqlConnectionStringBuilder(options.ConnectionString)
|
||||
{
|
||||
CommandTimeout = options.CommandTimeoutSeconds
|
||||
};
|
||||
var builder = new NpgsqlDataSourceBuilder(connStringBuilder.ConnectionString);
|
||||
return builder.Build();
|
||||
});
|
||||
|
||||
// Register options for DualWriteStore
|
||||
services.TryAddSingleton(Microsoft.Extensions.Options.Options.Create(options));
|
||||
|
||||
// Register dual-write store
|
||||
services.TryAddSingleton<IConsensusProjectionStore>(sp =>
|
||||
{
|
||||
var emitter = sp.GetRequiredService<IConsensusEventEmitter>();
|
||||
var memoryStore = new InMemoryConsensusProjectionStore(emitter);
|
||||
|
||||
var dataSource = sp.GetRequiredService<NpgsqlDataSource>();
|
||||
var postgresLogger = sp.GetRequiredService<ILogger<PostgresConsensusProjectionStoreProxy>>();
|
||||
var postgresStore = new PostgresConsensusProjectionStoreProxy(dataSource, postgresLogger, emitter, options);
|
||||
|
||||
var dualWriteOptions = sp.GetRequiredService<IOptions<VexLensStorageOptions>>();
|
||||
var dualWriteLogger = sp.GetRequiredService<ILogger<DualWriteConsensusProjectionStore>>();
|
||||
|
||||
return new DualWriteConsensusProjectionStore(
|
||||
memoryStore,
|
||||
postgresStore,
|
||||
dualWriteOptions,
|
||||
dualWriteLogger);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,10 +48,19 @@ public sealed class VexLensStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Storage driver for consensus projections.
|
||||
/// Supported drivers: "memory" (default) until a persistent provider is introduced.
|
||||
/// Supported drivers: "memory" (default), "postgres", "dual-write".
|
||||
/// Use "dual-write" for migration from memory to postgres.
|
||||
/// </summary>
|
||||
public string Driver { get; set; } = "memory";
|
||||
|
||||
/// <summary>
|
||||
/// Enable PostgreSQL storage for consensus projections.
|
||||
/// When true and Driver is "memory", automatically upgrades to "dual-write" mode.
|
||||
/// Feature flag: VexLens:Storage:UsePostgres
|
||||
/// Sprint: LIN-BE-022
|
||||
/// </summary>
|
||||
public bool UsePostgres { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional connection string for persistent storage drivers (unused for in-memory).
|
||||
/// </summary>
|
||||
@@ -76,6 +85,18 @@ public sealed class VexLensStorageOptions
|
||||
/// Command timeout in seconds.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// During dual-write mode, which store to use for reads.
|
||||
/// Options: "memory" (default during migration), "postgres".
|
||||
/// Once migration is verified, switch to "postgres" then to postgres-only driver.
|
||||
/// </summary>
|
||||
public string DualWriteReadFrom { get; set; } = "memory";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to log dual-write discrepancies for migration validation.
|
||||
/// </summary>
|
||||
public bool LogDualWriteDiscrepancies { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
318
src/VexLens/StellaOps.VexLens/Services/VexDeltaComputeService.cs
Normal file
318
src/VexLens/StellaOps.VexLens/Services/VexDeltaComputeService.cs
Normal file
@@ -0,0 +1,318 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexDeltaComputeService.cs
|
||||
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-009)
|
||||
// Updated: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-026)
|
||||
// Task: Compute and store VEX deltas on consensus status change + attestations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Delta;
|
||||
using StellaOps.Excititor.Persistence.Repositories;
|
||||
using SignerPredicates = StellaOps.Signer.Core.Predicates;
|
||||
|
||||
namespace StellaOps.VexLens.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service that computes and stores VEX deltas when consensus status changes.
|
||||
/// Called by the consensus projection store when StatusChanged=true.
|
||||
/// </summary>
|
||||
public interface IVexDeltaComputeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes and stores a VEX delta for a consensus status change.
|
||||
/// </summary>
|
||||
Task ComputeAndStoreAsync(VexStatusChangeContext context, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for a VEX status change event.
|
||||
/// </summary>
|
||||
public sealed record VexStatusChangeContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Projection ID that changed.
|
||||
/// </summary>
|
||||
public required Guid ProjectionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID (CVE).
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product key (artifact reference).
|
||||
/// </summary>
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact digest.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous artifact digest (parent in lineage).
|
||||
/// </summary>
|
||||
public string? PreviousArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous VEX status.
|
||||
/// </summary>
|
||||
public required string PreviousStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New VEX status.
|
||||
/// </summary>
|
||||
public required string NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the status.
|
||||
/// </summary>
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change reason summary.
|
||||
/// </summary>
|
||||
public string? ChangeReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score.
|
||||
/// </summary>
|
||||
public double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the change was computed.
|
||||
/// </summary>
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IVexDeltaComputeService"/>.
|
||||
/// </summary>
|
||||
public sealed class VexDeltaComputeService : IVexDeltaComputeService
|
||||
{
|
||||
private readonly IVexDeltaRepository _deltaRepository;
|
||||
private readonly IDeltaAttestationService? _attestationService;
|
||||
private readonly ILogger<VexDeltaComputeService> _logger;
|
||||
|
||||
public VexDeltaComputeService(
|
||||
IVexDeltaRepository deltaRepository,
|
||||
ILogger<VexDeltaComputeService> logger,
|
||||
IDeltaAttestationService? attestationService = null)
|
||||
{
|
||||
_deltaRepository = deltaRepository ?? throw new ArgumentNullException(nameof(deltaRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_attestationService = attestationService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ComputeAndStoreAsync(VexStatusChangeContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// Skip if no previous version to compare against
|
||||
if (string.IsNullOrWhiteSpace(context.PreviousArtifactDigest))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping VEX delta for {Cve}: no previous artifact digest for {ArtifactDigest}",
|
||||
context.VulnerabilityId,
|
||||
context.ArtifactDigest);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if status hasn't actually changed
|
||||
if (string.Equals(context.PreviousStatus, context.NewStatus, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping VEX delta for {Cve}: status unchanged ({Status})",
|
||||
context.VulnerabilityId,
|
||||
context.NewStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build rationale
|
||||
var rationale = new VexDeltaRationale
|
||||
{
|
||||
Reason = context.ChangeReason ?? $"Status changed from {context.PreviousStatus} to {context.NewStatus}",
|
||||
Source = "vexlens-consensus",
|
||||
Confidence = context.ConfidenceScore,
|
||||
Notes = context.Justification
|
||||
};
|
||||
|
||||
// Compute replay hash for reproducibility
|
||||
var replayHash = ComputeReplayHash(context);
|
||||
|
||||
var delta = new VexDelta
|
||||
{
|
||||
FromArtifactDigest = context.PreviousArtifactDigest,
|
||||
ToArtifactDigest = context.ArtifactDigest,
|
||||
Cve = context.VulnerabilityId,
|
||||
FromStatus = context.PreviousStatus,
|
||||
ToStatus = context.NewStatus,
|
||||
Rationale = rationale,
|
||||
ReplayHash = replayHash,
|
||||
TenantId = context.TenantId,
|
||||
CreatedAt = context.ComputedAt
|
||||
};
|
||||
|
||||
var success = await _deltaRepository.AddAsync(delta, ct).ConfigureAwait(false);
|
||||
|
||||
if (success)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Stored VEX delta for {Cve}: {PreviousStatus} -> {NewStatus} ({FromDigest} -> {ToDigest})",
|
||||
context.VulnerabilityId,
|
||||
context.PreviousStatus,
|
||||
context.NewStatus,
|
||||
TruncateDigest(context.PreviousArtifactDigest),
|
||||
TruncateDigest(context.ArtifactDigest));
|
||||
|
||||
// Create signed attestation for the delta
|
||||
await CreateDeltaAttestationAsync(delta, context, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to store VEX delta for {Cve}: {PreviousStatus} -> {NewStatus}",
|
||||
context.VulnerabilityId,
|
||||
context.PreviousStatus,
|
||||
context.NewStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a signed attestation for a VEX delta.
|
||||
/// </summary>
|
||||
private async Task CreateDeltaAttestationAsync(VexDelta delta, VexStatusChangeContext context, CancellationToken ct)
|
||||
{
|
||||
if (_attestationService is null)
|
||||
{
|
||||
_logger.LogDebug("Delta attestation service not available; skipping attestation for delta {Id}", delta.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Determine if this is a status upgrade (e.g., affected -> not_affected)
|
||||
var isResolved = string.Equals(delta.ToStatus, "not_affected", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(delta.ToStatus, "fixed", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Build the VEX delta predicate using the Signer.Core model
|
||||
var predicate = new SignerPredicates.VexDeltaPredicate
|
||||
{
|
||||
FromDigest = delta.FromArtifactDigest,
|
||||
ToDigest = delta.ToArtifactDigest,
|
||||
ComputedAt = delta.CreatedAt,
|
||||
TenantId = context.TenantId,
|
||||
StatusChanges = ImmutableArray.Create(new SignerPredicates.VexStatusChange
|
||||
{
|
||||
Cve = delta.Cve,
|
||||
FromStatus = delta.FromStatus,
|
||||
ToStatus = delta.ToStatus,
|
||||
Reason = delta.Rationale?.Reason
|
||||
}),
|
||||
ResolvedVulnerabilities = isResolved
|
||||
? ImmutableArray.Create(new SignerPredicates.VexVulnerabilityEntry
|
||||
{
|
||||
Cve = delta.Cve,
|
||||
Status = delta.ToStatus
|
||||
})
|
||||
: ImmutableArray<SignerPredicates.VexVulnerabilityEntry>.Empty,
|
||||
Summary = new SignerPredicates.VexDeltaSummary
|
||||
{
|
||||
StatusChangeCount = 1,
|
||||
NewVulnCount = 0,
|
||||
ResolvedVulnCount = isResolved ? 1 : 0,
|
||||
CriticalNew = 0,
|
||||
HighNew = 0
|
||||
}
|
||||
};
|
||||
|
||||
var request = new VexDeltaAttestationRequest
|
||||
{
|
||||
FromDigest = delta.FromArtifactDigest,
|
||||
ToDigest = delta.ToArtifactDigest,
|
||||
TenantId = context.TenantId,
|
||||
Delta = predicate,
|
||||
UseKeyless = true,
|
||||
UseTransparencyLog = true
|
||||
};
|
||||
|
||||
var result = await _attestationService.CreateVexDeltaAttestationAsync(request, ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Success && !string.IsNullOrEmpty(result.AttestationDigest))
|
||||
{
|
||||
// Update the delta with the attestation digest
|
||||
var updated = await _deltaRepository.UpdateAttestationDigestAsync(delta.Id, result.AttestationDigest, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (updated)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Created attestation for VEX delta {DeltaId}: digest={AttestationDigest}",
|
||||
delta.Id,
|
||||
TruncateDigest(result.AttestationDigest));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to update attestation digest for delta {DeltaId}",
|
||||
delta.Id);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to create attestation for VEX delta {DeltaId}: {Error}",
|
||||
delta.Id,
|
||||
result.Error ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log but don't fail the delta storage on attestation failure
|
||||
_logger.LogError(ex, "Error creating attestation for VEX delta {DeltaId}", delta.Id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic replay hash for the delta.
|
||||
/// </summary>
|
||||
private static string ComputeReplayHash(VexStatusChangeContext context)
|
||||
{
|
||||
// Create a deterministic input string
|
||||
var input = string.Join("|",
|
||||
context.VulnerabilityId,
|
||||
context.PreviousArtifactDigest,
|
||||
context.ArtifactDigest,
|
||||
context.PreviousStatus,
|
||||
context.NewStatus,
|
||||
context.ComputedAt.ToUniversalTime().ToString("O"));
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
|
||||
{
|
||||
return $"{digest[..(colonIndex + 13)]}...";
|
||||
}
|
||||
|
||||
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,11 +10,22 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<!-- LIN-BE-022: PostgreSQL support for consensus projection store -->
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- LIN-BE-026: Delta attestation support -->
|
||||
<ProjectReference Include="..\..\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||
<!-- VEX delta repository and models from Excititor -->
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Exclude legacy folders with external dependencies -->
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Options;
|
||||
|
||||
namespace StellaOps.VexLens.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Dual-write implementation of <see cref="IConsensusProjectionStore"/> for migration.
|
||||
/// Writes to both in-memory and PostgreSQL stores, reads from configurable primary.
|
||||
/// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-022)
|
||||
/// </summary>
|
||||
public sealed class DualWriteConsensusProjectionStore : IConsensusProjectionStore
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Storage.DualWriteConsensusProjectionStore");
|
||||
|
||||
private readonly IConsensusProjectionStore _memoryStore;
|
||||
private readonly IConsensusProjectionStore _postgresStore;
|
||||
private readonly VexLensStorageOptions _options;
|
||||
private readonly ILogger<DualWriteConsensusProjectionStore> _logger;
|
||||
private readonly bool _readFromPostgres;
|
||||
private readonly bool _logDiscrepancies;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a dual-write store for migration from in-memory to PostgreSQL.
|
||||
/// </summary>
|
||||
/// <param name="memoryStore">The in-memory store (primary during migration).</param>
|
||||
/// <param name="postgresStore">The PostgreSQL store (target for migration).</param>
|
||||
/// <param name="options">Storage options including read preference.</param>
|
||||
/// <param name="logger">Logger for discrepancy reporting.</param>
|
||||
public DualWriteConsensusProjectionStore(
|
||||
IConsensusProjectionStore memoryStore,
|
||||
IConsensusProjectionStore postgresStore,
|
||||
IOptions<VexLensStorageOptions> options,
|
||||
ILogger<DualWriteConsensusProjectionStore> logger)
|
||||
{
|
||||
_memoryStore = memoryStore ?? throw new ArgumentNullException(nameof(memoryStore));
|
||||
_postgresStore = postgresStore ?? throw new ArgumentNullException(nameof(postgresStore));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_readFromPostgres = string.Equals(_options.DualWriteReadFrom, "postgres", StringComparison.OrdinalIgnoreCase);
|
||||
_logDiscrepancies = _options.LogDualWriteDiscrepancies;
|
||||
|
||||
_logger.LogInformation(
|
||||
"DualWriteConsensusProjectionStore initialized. ReadFrom={ReadFrom}, LogDiscrepancies={LogDiscrepancies}",
|
||||
_options.DualWriteReadFrom, _logDiscrepancies);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary store for reads based on configuration.
|
||||
/// </summary>
|
||||
private IConsensusProjectionStore PrimaryStore => _readFromPostgres ? _postgresStore : _memoryStore;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the secondary store for writes (opposite of primary).
|
||||
/// </summary>
|
||||
private IConsensusProjectionStore SecondaryStore => _readFromPostgres ? _memoryStore : _postgresStore;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection> StoreAsync(
|
||||
VexConsensusResult result,
|
||||
StoreProjectionOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("StoreAsync.DualWrite");
|
||||
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
||||
activity?.SetTag("productKey", result.ProductKey);
|
||||
|
||||
// Write to primary first
|
||||
var primaryResult = await PrimaryStore.StoreAsync(result, options, cancellationToken);
|
||||
activity?.SetTag("primaryProjectionId", primaryResult.ProjectionId);
|
||||
|
||||
// Write to secondary (fire-and-forget with error logging)
|
||||
try
|
||||
{
|
||||
var secondaryResult = await SecondaryStore.StoreAsync(result, options, cancellationToken);
|
||||
activity?.SetTag("secondaryProjectionId", secondaryResult.ProjectionId);
|
||||
|
||||
if (_logDiscrepancies)
|
||||
{
|
||||
await ValidateStoreResultsAsync(primaryResult, secondaryResult, activity);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Dual-write to secondary store failed for {VulnerabilityId}/{ProductKey}. Primary write succeeded.",
|
||||
result.VulnerabilityId, result.ProductKey);
|
||||
activity?.SetTag("secondaryWriteFailed", true);
|
||||
}
|
||||
|
||||
return primaryResult;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection?> GetAsync(
|
||||
string projectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("GetAsync.DualWrite");
|
||||
activity?.SetTag("projectionId", projectionId);
|
||||
activity?.SetTag("readFrom", _readFromPostgres ? "postgres" : "memory");
|
||||
|
||||
var result = await PrimaryStore.GetAsync(projectionId, cancellationToken);
|
||||
|
||||
if (_logDiscrepancies && result is not null)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var secondaryResult = await SecondaryStore.GetAsync(projectionId, CancellationToken.None);
|
||||
if (secondaryResult is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Dual-write discrepancy: Projection {ProjectionId} found in primary but not in secondary",
|
||||
projectionId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Secondary lookup failed during discrepancy check for {ProjectionId}", projectionId);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection?> GetLatestAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("GetLatestAsync.DualWrite");
|
||||
activity?.SetTag("vulnerabilityId", vulnerabilityId);
|
||||
activity?.SetTag("productKey", productKey);
|
||||
activity?.SetTag("readFrom", _readFromPostgres ? "postgres" : "memory");
|
||||
|
||||
var result = await PrimaryStore.GetLatestAsync(vulnerabilityId, productKey, tenantId, cancellationToken);
|
||||
|
||||
if (_logDiscrepancies)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var secondaryResult = await SecondaryStore.GetLatestAsync(
|
||||
vulnerabilityId, productKey, tenantId, CancellationToken.None);
|
||||
|
||||
ValidateProjectionConsistency(result, secondaryResult, vulnerabilityId, productKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Secondary lookup failed during discrepancy check for {VulnerabilityId}/{ProductKey}",
|
||||
vulnerabilityId, productKey);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProjectionListResult> ListAsync(
|
||||
ProjectionQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("ListAsync.DualWrite");
|
||||
activity?.SetTag("readFrom", _readFromPostgres ? "postgres" : "memory");
|
||||
|
||||
// List operations only read from primary for consistency
|
||||
return await PrimaryStore.ListAsync(query, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("GetHistoryAsync.DualWrite");
|
||||
activity?.SetTag("vulnerabilityId", vulnerabilityId);
|
||||
activity?.SetTag("productKey", productKey);
|
||||
activity?.SetTag("readFrom", _readFromPostgres ? "postgres" : "memory");
|
||||
|
||||
return await PrimaryStore.GetHistoryAsync(vulnerabilityId, productKey, tenantId, limit, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeAsync(
|
||||
DateTimeOffset olderThan,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("PurgeAsync.DualWrite");
|
||||
activity?.SetTag("olderThan", olderThan.ToString("O"));
|
||||
|
||||
// Purge from both stores
|
||||
var primaryCount = await PrimaryStore.PurgeAsync(olderThan, tenantId, cancellationToken);
|
||||
activity?.SetTag("primaryPurgedCount", primaryCount);
|
||||
|
||||
try
|
||||
{
|
||||
var secondaryCount = await SecondaryStore.PurgeAsync(olderThan, tenantId, cancellationToken);
|
||||
activity?.SetTag("secondaryPurgedCount", secondaryCount);
|
||||
|
||||
if (_logDiscrepancies && primaryCount != secondaryCount)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Dual-write discrepancy: Purge count differs. Primary={PrimaryCount}, Secondary={SecondaryCount}",
|
||||
primaryCount, secondaryCount);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Dual-write purge failed for secondary store");
|
||||
}
|
||||
|
||||
return primaryCount;
|
||||
}
|
||||
|
||||
private async Task ValidateStoreResultsAsync(
|
||||
ConsensusProjection primary,
|
||||
ConsensusProjection secondary,
|
||||
Activity? activity)
|
||||
{
|
||||
// Basic validation - the key fields should match
|
||||
if (primary.VulnerabilityId != secondary.VulnerabilityId ||
|
||||
primary.ProductKey != secondary.ProductKey ||
|
||||
primary.Status != secondary.Status ||
|
||||
Math.Abs(primary.ConfidenceScore - secondary.ConfidenceScore) > 0.001)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Dual-write discrepancy detected for {VulnerabilityId}/{ProductKey}. " +
|
||||
"Primary: Status={PrimaryStatus}, Confidence={PrimaryConfidence}. " +
|
||||
"Secondary: Status={SecondaryStatus}, Confidence={SecondaryConfidence}",
|
||||
primary.VulnerabilityId,
|
||||
primary.ProductKey,
|
||||
primary.Status,
|
||||
primary.ConfidenceScore,
|
||||
secondary.Status,
|
||||
secondary.ConfidenceScore);
|
||||
|
||||
activity?.SetTag("discrepancyDetected", true);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ValidateProjectionConsistency(
|
||||
ConsensusProjection? primary,
|
||||
ConsensusProjection? secondary,
|
||||
string vulnerabilityId,
|
||||
string productKey)
|
||||
{
|
||||
if (primary is null && secondary is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (primary is null && secondary is not null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Dual-write discrepancy: Projection for {VulnerabilityId}/{ProductKey} exists in secondary but not primary",
|
||||
vulnerabilityId, productKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (primary is not null && secondary is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Dual-write discrepancy: Projection for {VulnerabilityId}/{ProductKey} exists in primary but not secondary",
|
||||
vulnerabilityId, productKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Both exist - compare key fields
|
||||
if (primary!.Status != secondary!.Status)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Dual-write discrepancy: Status mismatch for {VulnerabilityId}/{ProductKey}. Primary={PrimaryStatus}, Secondary={SecondaryStatus}",
|
||||
vulnerabilityId, productKey, primary.Status, secondary.Status);
|
||||
}
|
||||
|
||||
if (Math.Abs(primary.ConfidenceScore - secondary.ConfidenceScore) > 0.001)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Dual-write discrepancy: Confidence mismatch for {VulnerabilityId}/{ProductKey}. Primary={PrimaryConfidence}, Secondary={SecondaryConfidence}",
|
||||
vulnerabilityId, productKey, primary.ConfidenceScore, secondary.ConfidenceScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Services;
|
||||
|
||||
namespace StellaOps.VexLens.Storage;
|
||||
|
||||
@@ -13,10 +14,15 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
||||
private readonly ConcurrentDictionary<string, ConsensusProjection> _projectionsById = new();
|
||||
private readonly ConcurrentDictionary<string, List<ConsensusProjection>> _projectionsByKey = new();
|
||||
private readonly IConsensusEventEmitter? _eventEmitter;
|
||||
// LIN-BE-009: Delta service for computing VEX deltas on status change
|
||||
private readonly IVexDeltaComputeService? _deltaComputeService;
|
||||
|
||||
public InMemoryConsensusProjectionStore(IConsensusEventEmitter? eventEmitter = null)
|
||||
public InMemoryConsensusProjectionStore(
|
||||
IConsensusEventEmitter? eventEmitter = null,
|
||||
IVexDeltaComputeService? deltaComputeService = null)
|
||||
{
|
||||
_eventEmitter = eventEmitter;
|
||||
_deltaComputeService = deltaComputeService;
|
||||
}
|
||||
|
||||
public async Task<ConsensusProjection> StoreAsync(
|
||||
@@ -312,6 +318,28 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
||||
ComputedAt: projection.ComputedAt,
|
||||
EmittedAt: now),
|
||||
cancellationToken);
|
||||
|
||||
// LIN-BE-009: Compute and store VEX delta on status change
|
||||
if (_deltaComputeService != null)
|
||||
{
|
||||
await _deltaComputeService.ComputeAndStoreAsync(
|
||||
new VexStatusChangeContext
|
||||
{
|
||||
ProjectionId = Guid.TryParse(projection.ProjectionId, out var pid) ? pid : Guid.NewGuid(),
|
||||
VulnerabilityId = projection.VulnerabilityId,
|
||||
ProductKey = projection.ProductKey,
|
||||
ArtifactDigest = projection.ProductKey, // Use ProductKey as artifact identifier
|
||||
PreviousArtifactDigest = previous.ProductKey, // Use ProductKey as artifact identifier
|
||||
PreviousStatus = previous.Status.ToString(),
|
||||
NewStatus = projection.Status.ToString(),
|
||||
TenantId = projection.TenantId ?? "default",
|
||||
Justification = projection.Justification?.ToString(),
|
||||
ChangeReason = $"Consensus updated: {result.Rationale.Summary}",
|
||||
ConfidenceScore = projection.ConfidenceScore,
|
||||
ComputedAt = projection.ComputedAt
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit conflict event if conflicts detected
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Options;
|
||||
|
||||
namespace StellaOps.VexLens.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IConsensusProjectionStore"/>.
|
||||
/// This proxy implementation lives in the core VexLens project to enable DI registration.
|
||||
/// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-022)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For full-featured implementation with advanced queries, use PostgresConsensusProjectionStore
|
||||
/// from StellaOps.VexLens.Persistence package.
|
||||
/// </remarks>
|
||||
public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjectionStore
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Storage.PostgresConsensusProjectionStoreProxy");
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly IConsensusEventEmitter? _eventEmitter;
|
||||
private readonly ILogger<PostgresConsensusProjectionStoreProxy> _logger;
|
||||
private readonly VexLensStorageOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresConsensusProjectionStoreProxy(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresConsensusProjectionStoreProxy> logger,
|
||||
IConsensusEventEmitter? eventEmitter = null,
|
||||
VexLensStorageOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventEmitter = eventEmitter;
|
||||
_options = options ?? new VexLensStorageOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
private const string Schema = "vexlens";
|
||||
|
||||
private const string InsertProjectionSql = $"""
|
||||
INSERT INTO {Schema}.consensus_projections (
|
||||
id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
) VALUES (
|
||||
@id, @vulnerability_id, @product_key, @tenant_id, @status, @justification,
|
||||
@confidence_score, @outcome, @statement_count, @conflict_count,
|
||||
@rationale_summary, @computed_at, @stored_at, @previous_projection_id, @status_changed
|
||||
)
|
||||
""";
|
||||
|
||||
private const string SelectByIdSql = $"""
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {Schema}.consensus_projections
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
private const string SelectLatestSql = $"""
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {Schema}.consensus_projections
|
||||
WHERE vulnerability_id = @vulnerability_id
|
||||
AND product_key = @product_key
|
||||
AND (@tenant_id IS NULL OR tenant_id = @tenant_id)
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
private const string SelectHistorySql = $"""
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {Schema}.consensus_projections
|
||||
WHERE vulnerability_id = @vulnerability_id
|
||||
AND product_key = @product_key
|
||||
AND (@tenant_id IS NULL OR tenant_id = @tenant_id)
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
private const string PurgeSql = $"""
|
||||
DELETE FROM {Schema}.consensus_projections
|
||||
WHERE computed_at < @older_than
|
||||
AND (@tenant_id IS NULL OR tenant_id = @tenant_id)
|
||||
""";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection> StoreAsync(
|
||||
VexConsensusResult result,
|
||||
StoreProjectionOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("StoreAsync");
|
||||
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
||||
activity?.SetTag("productKey", result.ProductKey);
|
||||
|
||||
var projectionId = Guid.NewGuid();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for previous projection to track history
|
||||
ConsensusProjection? previous = null;
|
||||
if (options.TrackHistory)
|
||||
{
|
||||
previous = await GetLatestAsync(
|
||||
result.VulnerabilityId,
|
||||
result.ProductKey,
|
||||
options.TenantId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var statusChanged = previous is not null && previous.Status != result.ConsensusStatus;
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(InsertProjectionSql, connection, transaction);
|
||||
cmd.Parameters.AddWithValue("id", projectionId);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", result.VulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", result.ProductKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", options.TenantId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status", MapStatus(result.ConsensusStatus));
|
||||
cmd.Parameters.AddWithValue("justification", result.ConsensusJustification.HasValue ? MapJustification(result.ConsensusJustification.Value) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("confidence_score", result.ConfidenceScore);
|
||||
cmd.Parameters.AddWithValue("outcome", MapOutcome(result.Outcome));
|
||||
cmd.Parameters.AddWithValue("statement_count", result.Contributions.Count);
|
||||
cmd.Parameters.AddWithValue("conflict_count", result.Conflicts?.Count ?? 0);
|
||||
cmd.Parameters.AddWithValue("rationale_summary", result.Rationale.Summary ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("computed_at", result.ComputedAt);
|
||||
cmd.Parameters.AddWithValue("stored_at", now);
|
||||
cmd.Parameters.AddWithValue("previous_projection_id", previous?.ProjectionId is not null ? Guid.Parse(previous.ProjectionId) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status_changed", statusChanged);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
var projection = new ConsensusProjection(
|
||||
ProjectionId: projectionId.ToString(),
|
||||
VulnerabilityId: result.VulnerabilityId,
|
||||
ProductKey: result.ProductKey,
|
||||
TenantId: options.TenantId,
|
||||
Status: result.ConsensusStatus,
|
||||
Justification: result.ConsensusJustification,
|
||||
ConfidenceScore: result.ConfidenceScore,
|
||||
Outcome: result.Outcome,
|
||||
StatementCount: result.Contributions.Count,
|
||||
ConflictCount: result.Conflicts?.Count ?? 0,
|
||||
RationaleSummary: result.Rationale.Summary ?? string.Empty,
|
||||
ComputedAt: result.ComputedAt,
|
||||
StoredAt: now,
|
||||
PreviousProjectionId: previous?.ProjectionId,
|
||||
StatusChanged: statusChanged);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}",
|
||||
projectionId, result.VulnerabilityId, result.ProductKey);
|
||||
|
||||
// Emit events
|
||||
if (options.EmitEvent && _eventEmitter is not null)
|
||||
{
|
||||
await EmitEventsAsync(projection, previous, cancellationToken);
|
||||
}
|
||||
|
||||
return projection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection?> GetAsync(
|
||||
string projectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectionId);
|
||||
|
||||
if (!Guid.TryParse(projectionId, out var id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var activity = ActivitySource.StartActivity("GetAsync");
|
||||
activity?.SetTag("projectionId", projectionId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectByIdSql, connection);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return MapProjection(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConsensusProjection?> GetLatestAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("GetLatestAsync");
|
||||
activity?.SetTag("vulnerabilityId", vulnerabilityId);
|
||||
activity?.SetTag("productKey", productKey);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectLatestSql, connection);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", productKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return MapProjection(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProjectionListResult> ListAsync(
|
||||
ProjectionQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("ListAsync");
|
||||
|
||||
var (sql, countSql, parameters) = BuildListQuery(query);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
// Get total count
|
||||
await using var countCmd = new NpgsqlCommand(countSql, connection);
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
countCmd.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken));
|
||||
|
||||
// Get results
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
cmd.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
cmd.Parameters.AddWithValue("limit", query.Limit);
|
||||
cmd.Parameters.AddWithValue("offset", query.Offset);
|
||||
|
||||
var projections = new List<ConsensusProjection>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
projections.Add(MapProjection(reader));
|
||||
}
|
||||
|
||||
return new ProjectionListResult(projections, totalCount, query.Offset, query.Limit);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("GetHistoryAsync");
|
||||
activity?.SetTag("vulnerabilityId", vulnerabilityId);
|
||||
activity?.SetTag("productKey", productKey);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectHistorySql, connection);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", productKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("limit", limit ?? _options.MaxHistoryEntries);
|
||||
|
||||
var projections = new List<ConsensusProjection>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
projections.Add(MapProjection(reader));
|
||||
}
|
||||
|
||||
return projections;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeAsync(
|
||||
DateTimeOffset olderThan,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("PurgeAsync");
|
||||
activity?.SetTag("olderThan", olderThan.ToString("O"));
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(PurgeSql, connection);
|
||||
cmd.Parameters.AddWithValue("older_than", olderThan);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
||||
|
||||
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Purged {Count} consensus projections older than {OlderThan}", deleted, olderThan);
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private static ConsensusProjection MapProjection(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ConsensusProjection(
|
||||
ProjectionId: reader.GetGuid(0).ToString(),
|
||||
VulnerabilityId: reader.GetString(1),
|
||||
ProductKey: reader.GetString(2),
|
||||
TenantId: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
Status: ParseStatus(reader.GetString(4)),
|
||||
Justification: reader.IsDBNull(5) ? null : ParseJustification(reader.GetString(5)),
|
||||
ConfidenceScore: reader.GetDouble(6),
|
||||
Outcome: ParseOutcome(reader.GetString(7)),
|
||||
StatementCount: reader.GetInt32(8),
|
||||
ConflictCount: reader.GetInt32(9),
|
||||
RationaleSummary: reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
||||
ComputedAt: reader.GetDateTime(11),
|
||||
StoredAt: reader.GetDateTime(12),
|
||||
PreviousProjectionId: reader.IsDBNull(13) ? null : reader.GetGuid(13).ToString(),
|
||||
StatusChanged: reader.GetBoolean(14));
|
||||
}
|
||||
|
||||
private static string MapStatus(VexStatus status) => status switch
|
||||
{
|
||||
VexStatus.NotAffected => "not_affected",
|
||||
VexStatus.Affected => "affected",
|
||||
VexStatus.Fixed => "fixed",
|
||||
VexStatus.UnderInvestigation => "under_investigation",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static VexStatus ParseStatus(string status) => status switch
|
||||
{
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"affected" => VexStatus.Affected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.UnderInvestigation
|
||||
};
|
||||
|
||||
private static string MapJustification(VexJustification justification) => justification switch
|
||||
{
|
||||
VexJustification.ComponentNotPresent => "component_not_present",
|
||||
VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present",
|
||||
VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary",
|
||||
VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path",
|
||||
VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static VexJustification ParseJustification(string justification) => justification switch
|
||||
{
|
||||
"component_not_present" => VexJustification.ComponentNotPresent,
|
||||
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
|
||||
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
||||
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
|
||||
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => VexJustification.VulnerableCodeNotPresent
|
||||
};
|
||||
|
||||
private static string MapOutcome(ConsensusOutcome outcome) => outcome switch
|
||||
{
|
||||
ConsensusOutcome.Unanimous => "unanimous",
|
||||
ConsensusOutcome.Majority => "majority",
|
||||
ConsensusOutcome.Plurality => "plurality",
|
||||
ConsensusOutcome.ConflictResolved => "conflict_resolved",
|
||||
ConsensusOutcome.NoData => "no_data",
|
||||
ConsensusOutcome.Indeterminate => "indeterminate",
|
||||
_ => "indeterminate"
|
||||
};
|
||||
|
||||
private static ConsensusOutcome ParseOutcome(string outcome) => outcome switch
|
||||
{
|
||||
"unanimous" => ConsensusOutcome.Unanimous,
|
||||
"majority" => ConsensusOutcome.Majority,
|
||||
"plurality" => ConsensusOutcome.Plurality,
|
||||
"conflict_resolved" => ConsensusOutcome.ConflictResolved,
|
||||
"no_data" => ConsensusOutcome.NoData,
|
||||
"indeterminate" => ConsensusOutcome.Indeterminate,
|
||||
_ => ConsensusOutcome.Indeterminate
|
||||
};
|
||||
|
||||
private static (string sql, string countSql, List<(string name, object value)> parameters) BuildListQuery(ProjectionQuery query)
|
||||
{
|
||||
var parameters = new List<(string name, object value)>();
|
||||
var conditions = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TenantId))
|
||||
{
|
||||
conditions.Add("tenant_id = @tenant_id");
|
||||
parameters.Add(("tenant_id", query.TenantId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.VulnerabilityId))
|
||||
{
|
||||
conditions.Add("vulnerability_id ILIKE @vulnerability_id");
|
||||
parameters.Add(("vulnerability_id", $"%{query.VulnerabilityId}%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ProductKey))
|
||||
{
|
||||
conditions.Add("product_key ILIKE @product_key");
|
||||
parameters.Add(("product_key", $"%{query.ProductKey}%"));
|
||||
}
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
conditions.Add("status = @status");
|
||||
parameters.Add(("status", MapStatus(query.Status.Value)));
|
||||
}
|
||||
|
||||
if (query.Outcome.HasValue)
|
||||
{
|
||||
conditions.Add("outcome = @outcome");
|
||||
parameters.Add(("outcome", MapOutcome(query.Outcome.Value)));
|
||||
}
|
||||
|
||||
if (query.MinimumConfidence.HasValue)
|
||||
{
|
||||
conditions.Add("confidence_score >= @min_confidence");
|
||||
parameters.Add(("min_confidence", query.MinimumConfidence.Value));
|
||||
}
|
||||
|
||||
if (query.ComputedAfter.HasValue)
|
||||
{
|
||||
conditions.Add("computed_at >= @computed_after");
|
||||
parameters.Add(("computed_after", query.ComputedAfter.Value));
|
||||
}
|
||||
|
||||
if (query.ComputedBefore.HasValue)
|
||||
{
|
||||
conditions.Add("computed_at <= @computed_before");
|
||||
parameters.Add(("computed_before", query.ComputedBefore.Value));
|
||||
}
|
||||
|
||||
if (query.StatusChanged.HasValue)
|
||||
{
|
||||
conditions.Add("status_changed = @status_changed");
|
||||
parameters.Add(("status_changed", query.StatusChanged.Value));
|
||||
}
|
||||
|
||||
var whereClause = conditions.Count > 0
|
||||
? $"WHERE {string.Join(" AND ", conditions)}"
|
||||
: string.Empty;
|
||||
|
||||
var orderColumn = query.SortBy switch
|
||||
{
|
||||
ProjectionSortField.ComputedAt => "computed_at",
|
||||
ProjectionSortField.StoredAt => "stored_at",
|
||||
ProjectionSortField.VulnerabilityId => "vulnerability_id",
|
||||
ProjectionSortField.ProductKey => "product_key",
|
||||
ProjectionSortField.ConfidenceScore => "confidence_score",
|
||||
_ => "computed_at"
|
||||
};
|
||||
|
||||
var orderDirection = query.SortDescending ? "DESC" : "ASC";
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {Schema}.consensus_projections
|
||||
{whereClause}
|
||||
ORDER BY {orderColumn} {orderDirection}
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
var countSql = $"SELECT COUNT(*) FROM {Schema}.consensus_projections {whereClause}";
|
||||
|
||||
return (sql, countSql, parameters);
|
||||
}
|
||||
|
||||
private async Task EmitEventsAsync(
|
||||
ConsensusProjection projection,
|
||||
ConsensusProjection? previous,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_eventEmitter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var computedEvent = new ConsensusComputedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
Status: projection.Status,
|
||||
Justification: projection.Justification,
|
||||
ConfidenceScore: projection.ConfidenceScore,
|
||||
Outcome: projection.Outcome,
|
||||
StatementCount: projection.StatementCount,
|
||||
ComputedAt: projection.ComputedAt,
|
||||
EmittedAt: now);
|
||||
|
||||
await _eventEmitter.EmitConsensusComputedAsync(computedEvent, cancellationToken);
|
||||
|
||||
if (projection.StatusChanged && previous is not null)
|
||||
{
|
||||
var changedEvent = new ConsensusStatusChangedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
PreviousStatus: previous.Status,
|
||||
NewStatus: projection.Status,
|
||||
ChangeReason: $"Consensus status changed from {previous.Status} to {projection.Status}",
|
||||
ComputedAt: projection.ComputedAt,
|
||||
EmittedAt: now);
|
||||
|
||||
await _eventEmitter.EmitStatusChangedAsync(changedEvent, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,20 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.VexLens.Core/StellaOps.VexLens.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user