up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 20:55:22 +02:00
parent d040c001ac
commit 2548abc56f
231 changed files with 47468 additions and 68 deletions

View File

@@ -0,0 +1,50 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Excititor.Storage.Postgres;
/// <summary>
/// PostgreSQL data source for the Excititor (VEX) module.
/// Manages connections with tenant context for VEX statements and dependency graphs.
/// </summary>
/// <remarks>
/// The Excititor module handles high-volume graph data (nodes/edges) and requires
/// optimized queries for graph traversal and VEX consensus computation.
/// </remarks>
public sealed class ExcititorDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for Excititor/VEX tables.
/// </summary>
public const string DefaultSchemaName = "vex";
/// <summary>
/// Creates a new Excititor data source.
/// </summary>
public ExcititorDataSource(IOptions<PostgresOptions> options, ILogger<ExcititorDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "Excititor";
/// <inheritdoc />
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
// Configure for high-throughput graph operations
}
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -0,0 +1,324 @@
-- VEX Schema Migration 001: Initial Schema
-- Creates the vex schema for VEX statements and dependency graphs
-- Create schema
CREATE SCHEMA IF NOT EXISTS vex;
-- Projects table
CREATE TABLE IF NOT EXISTS vex.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
display_name TEXT,
description TEXT,
repository_url TEXT,
default_branch TEXT,
settings JSONB NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
UNIQUE(tenant_id, name)
);
CREATE INDEX idx_projects_tenant ON vex.projects(tenant_id);
-- Graph revisions table
CREATE TABLE IF NOT EXISTS vex.graph_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES vex.projects(id) ON DELETE CASCADE,
revision_id TEXT NOT NULL UNIQUE,
parent_revision_id TEXT,
sbom_digest TEXT NOT NULL,
feed_snapshot_id TEXT,
policy_version TEXT,
node_count INT NOT NULL DEFAULT 0,
edge_count INT NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT
);
CREATE INDEX idx_graph_revisions_project ON vex.graph_revisions(project_id);
CREATE INDEX idx_graph_revisions_revision ON vex.graph_revisions(revision_id);
CREATE INDEX idx_graph_revisions_created ON vex.graph_revisions(project_id, created_at DESC);
-- Graph nodes table (BIGSERIAL for high volume)
CREATE TABLE IF NOT EXISTS vex.graph_nodes (
id BIGSERIAL PRIMARY KEY,
graph_revision_id UUID NOT NULL REFERENCES vex.graph_revisions(id) ON DELETE CASCADE,
node_key TEXT NOT NULL,
node_type TEXT NOT NULL,
purl TEXT,
name TEXT,
version TEXT,
attributes JSONB NOT NULL DEFAULT '{}',
UNIQUE(graph_revision_id, node_key)
);
CREATE INDEX idx_graph_nodes_revision ON vex.graph_nodes(graph_revision_id);
CREATE INDEX idx_graph_nodes_key ON vex.graph_nodes(graph_revision_id, node_key);
CREATE INDEX idx_graph_nodes_purl ON vex.graph_nodes(purl);
CREATE INDEX idx_graph_nodes_type ON vex.graph_nodes(graph_revision_id, node_type);
-- Graph edges table (BIGSERIAL for high volume)
CREATE TABLE IF NOT EXISTS vex.graph_edges (
id BIGSERIAL PRIMARY KEY,
graph_revision_id UUID NOT NULL REFERENCES vex.graph_revisions(id) ON DELETE CASCADE,
from_node_id BIGINT NOT NULL REFERENCES vex.graph_nodes(id) ON DELETE CASCADE,
to_node_id BIGINT NOT NULL REFERENCES vex.graph_nodes(id) ON DELETE CASCADE,
edge_type TEXT NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_graph_edges_revision ON vex.graph_edges(graph_revision_id);
CREATE INDEX idx_graph_edges_from ON vex.graph_edges(from_node_id);
CREATE INDEX idx_graph_edges_to ON vex.graph_edges(to_node_id);
-- VEX statements table
CREATE TABLE IF NOT EXISTS vex.statements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
project_id UUID REFERENCES vex.projects(id),
graph_revision_id UUID REFERENCES vex.graph_revisions(id),
vulnerability_id TEXT NOT NULL,
product_id TEXT,
status TEXT NOT NULL CHECK (status IN (
'not_affected', 'affected', 'fixed', 'under_investigation'
)),
justification TEXT CHECK (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'
)),
impact_statement TEXT,
action_statement TEXT,
action_statement_timestamp TIMESTAMPTZ,
first_issued TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source TEXT,
source_url TEXT,
evidence JSONB NOT NULL DEFAULT '{}',
provenance JSONB NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
created_by TEXT
);
CREATE INDEX idx_statements_tenant ON vex.statements(tenant_id);
CREATE INDEX idx_statements_project ON vex.statements(project_id);
CREATE INDEX idx_statements_revision ON vex.statements(graph_revision_id);
CREATE INDEX idx_statements_vuln ON vex.statements(vulnerability_id);
CREATE INDEX idx_statements_status ON vex.statements(tenant_id, status);
-- VEX observations table
CREATE TABLE IF NOT EXISTS vex.observations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
statement_id UUID REFERENCES vex.statements(id) ON DELETE CASCADE,
vulnerability_id TEXT NOT NULL,
product_id TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
observer TEXT NOT NULL,
observation_type TEXT NOT NULL,
confidence NUMERIC(3,2),
details JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, vulnerability_id, product_id, observer, observation_type)
);
CREATE INDEX idx_observations_tenant ON vex.observations(tenant_id);
CREATE INDEX idx_observations_statement ON vex.observations(statement_id);
CREATE INDEX idx_observations_vuln ON vex.observations(vulnerability_id, product_id);
-- Linksets table
CREATE TABLE IF NOT EXISTS vex.linksets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
source_type TEXT NOT NULL,
source_url TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
priority INT NOT NULL DEFAULT 0,
filter JSONB NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
CREATE INDEX idx_linksets_tenant ON vex.linksets(tenant_id);
CREATE INDEX idx_linksets_enabled ON vex.linksets(tenant_id, enabled, priority DESC);
-- Linkset events table
CREATE TABLE IF NOT EXISTS vex.linkset_events (
id BIGSERIAL PRIMARY KEY,
linkset_id UUID NOT NULL REFERENCES vex.linksets(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
statement_count INT NOT NULL DEFAULT 0,
error_message TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_linkset_events_linkset ON vex.linkset_events(linkset_id);
CREATE INDEX idx_linkset_events_created ON vex.linkset_events(created_at);
-- Consensus table (VEX consensus state)
CREATE TABLE IF NOT EXISTS vex.consensus (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
vulnerability_id TEXT NOT NULL,
product_id TEXT NOT NULL,
consensus_status TEXT NOT NULL,
contributing_statements UUID[] NOT NULL DEFAULT '{}',
confidence NUMERIC(3,2),
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}',
UNIQUE(tenant_id, vulnerability_id, product_id)
);
CREATE INDEX idx_consensus_tenant ON vex.consensus(tenant_id);
CREATE INDEX idx_consensus_vuln ON vex.consensus(vulnerability_id, product_id);
-- Consensus holds table
CREATE TABLE IF NOT EXISTS vex.consensus_holds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
consensus_id UUID NOT NULL REFERENCES vex.consensus(id) ON DELETE CASCADE,
hold_type TEXT NOT NULL,
reason TEXT NOT NULL,
held_by TEXT NOT NULL,
held_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
released_at TIMESTAMPTZ,
released_by TEXT,
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_consensus_holds_consensus ON vex.consensus_holds(consensus_id);
CREATE INDEX idx_consensus_holds_active ON vex.consensus_holds(consensus_id, released_at)
WHERE released_at IS NULL;
-- Unknown snapshots table
CREATE TABLE IF NOT EXISTS vex.unknowns_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
project_id UUID REFERENCES vex.projects(id),
graph_revision_id UUID REFERENCES vex.graph_revisions(id),
snapshot_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
unknown_count INT NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_unknowns_snapshots_tenant ON vex.unknowns_snapshots(tenant_id);
CREATE INDEX idx_unknowns_snapshots_project ON vex.unknowns_snapshots(project_id);
-- Unknown items table
CREATE TABLE IF NOT EXISTS vex.unknown_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
snapshot_id UUID NOT NULL REFERENCES vex.unknowns_snapshots(id) ON DELETE CASCADE,
vulnerability_id TEXT NOT NULL,
product_id TEXT,
reason TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_unknown_items_snapshot ON vex.unknown_items(snapshot_id);
CREATE INDEX idx_unknown_items_vuln ON vex.unknown_items(vulnerability_id);
-- Evidence manifests table
CREATE TABLE IF NOT EXISTS vex.evidence_manifests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
statement_id UUID REFERENCES vex.statements(id) ON DELETE CASCADE,
manifest_type TEXT NOT NULL,
content_hash TEXT NOT NULL,
content JSONB NOT NULL,
source TEXT,
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_evidence_manifests_tenant ON vex.evidence_manifests(tenant_id);
CREATE INDEX idx_evidence_manifests_statement ON vex.evidence_manifests(statement_id);
-- CVSS receipts table
CREATE TABLE IF NOT EXISTS vex.cvss_receipts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
statement_id UUID REFERENCES vex.statements(id) ON DELETE CASCADE,
vulnerability_id TEXT NOT NULL,
cvss_version TEXT NOT NULL,
vector_string TEXT NOT NULL,
base_score NUMERIC(3,1) NOT NULL,
environmental_score NUMERIC(3,1),
temporal_score NUMERIC(3,1),
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_cvss_receipts_tenant ON vex.cvss_receipts(tenant_id);
CREATE INDEX idx_cvss_receipts_statement ON vex.cvss_receipts(statement_id);
CREATE INDEX idx_cvss_receipts_vuln ON vex.cvss_receipts(vulnerability_id);
-- Attestations table
CREATE TABLE IF NOT EXISTS vex.attestations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
statement_id UUID REFERENCES vex.statements(id),
subject_digest TEXT NOT NULL,
predicate_type TEXT NOT NULL,
predicate JSONB NOT NULL,
signature TEXT,
signature_algorithm TEXT,
signed_by TEXT,
signed_at TIMESTAMPTZ,
verified BOOLEAN NOT NULL DEFAULT FALSE,
verified_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_attestations_tenant ON vex.attestations(tenant_id);
CREATE INDEX idx_attestations_statement ON vex.attestations(statement_id);
CREATE INDEX idx_attestations_subject ON vex.attestations(subject_digest);
-- Timeline events table
CREATE TABLE IF NOT EXISTS vex.timeline_events (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
project_id UUID REFERENCES vex.projects(id),
statement_id UUID REFERENCES vex.statements(id),
event_type TEXT NOT NULL,
event_data JSONB NOT NULL DEFAULT '{}',
actor TEXT,
correlation_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_timeline_events_tenant ON vex.timeline_events(tenant_id);
CREATE INDEX idx_timeline_events_project ON vex.timeline_events(project_id);
CREATE INDEX idx_timeline_events_statement ON vex.timeline_events(statement_id);
CREATE INDEX idx_timeline_events_created ON vex.timeline_events(tenant_id, created_at);
CREATE INDEX idx_timeline_events_correlation ON vex.timeline_events(correlation_id);
-- Update timestamp function
CREATE OR REPLACE FUNCTION vex.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers
CREATE TRIGGER trg_projects_updated_at
BEFORE UPDATE ON vex.projects
FOR EACH ROW EXECUTE FUNCTION vex.update_updated_at();
CREATE TRIGGER trg_linksets_updated_at
BEFORE UPDATE ON vex.linksets
FOR EACH ROW EXECUTE FUNCTION vex.update_updated_at();
CREATE TRIGGER trg_statements_updated_at
BEFORE UPDATE ON vex.statements
FOR EACH ROW EXECUTE FUNCTION vex.update_updated_at();

View File

@@ -0,0 +1,67 @@
namespace StellaOps.Excititor.Storage.Postgres.Models;
/// <summary>
/// Represents a project entity in the vex schema.
/// </summary>
public sealed class ProjectEntity
{
/// <summary>
/// Unique project identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant this project belongs to.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Project name (unique per tenant).
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Display name.
/// </summary>
public string? DisplayName { get; init; }
/// <summary>
/// Project description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Repository URL.
/// </summary>
public string? RepositoryUrl { get; init; }
/// <summary>
/// Default branch name.
/// </summary>
public string? DefaultBranch { get; init; }
/// <summary>
/// Project settings as JSON.
/// </summary>
public string Settings { get; init; } = "{}";
/// <summary>
/// Project metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
/// <summary>
/// When the project was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the project was last updated.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// User who created the project.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,134 @@
namespace StellaOps.Excititor.Storage.Postgres.Models;
/// <summary>
/// VEX status values per OpenVEX specification.
/// </summary>
public enum VexStatus
{
/// <summary>Product is not affected by the vulnerability.</summary>
NotAffected,
/// <summary>Product is affected by the vulnerability.</summary>
Affected,
/// <summary>Vulnerability is fixed in this product version.</summary>
Fixed,
/// <summary>Vulnerability is under investigation.</summary>
UnderInvestigation
}
/// <summary>
/// VEX justification codes per OpenVEX specification.
/// </summary>
public enum VexJustification
{
/// <summary>The vulnerable component is not present.</summary>
ComponentNotPresent,
/// <summary>The vulnerable code is not present.</summary>
VulnerableCodeNotPresent,
/// <summary>The vulnerable code is not in execute path.</summary>
VulnerableCodeNotInExecutePath,
/// <summary>The vulnerable code cannot be controlled by adversary.</summary>
VulnerableCodeCannotBeControlledByAdversary,
/// <summary>Inline mitigations already exist.</summary>
InlineMitigationsAlreadyExist
}
/// <summary>
/// Represents a VEX statement entity in the vex schema.
/// </summary>
public sealed class VexStatementEntity
{
/// <summary>
/// Unique statement identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant this statement belongs to.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Project this statement applies to.
/// </summary>
public Guid? ProjectId { get; init; }
/// <summary>
/// Graph revision this statement is associated with.
/// </summary>
public Guid? GraphRevisionId { get; init; }
/// <summary>
/// Vulnerability ID (CVE, GHSA, etc.).
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Product identifier (PURL or product key).
/// </summary>
public string? ProductId { get; init; }
/// <summary>
/// VEX status.
/// </summary>
public required VexStatus Status { get; init; }
/// <summary>
/// Justification for not_affected status.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// Impact statement describing effects.
/// </summary>
public string? ImpactStatement { get; init; }
/// <summary>
/// Action statement describing remediation.
/// </summary>
public string? ActionStatement { get; init; }
/// <summary>
/// When action statement was issued.
/// </summary>
public DateTimeOffset? ActionStatementTimestamp { get; init; }
/// <summary>
/// When statement was first issued.
/// </summary>
public DateTimeOffset FirstIssued { get; init; }
/// <summary>
/// When statement was last updated.
/// </summary>
public DateTimeOffset LastUpdated { get; init; }
/// <summary>
/// Source of the statement.
/// </summary>
public string? Source { get; init; }
/// <summary>
/// URL to source document.
/// </summary>
public string? SourceUrl { get; init; }
/// <summary>
/// Evidence supporting the statement as JSON.
/// </summary>
public string Evidence { get; init; } = "{}";
/// <summary>
/// Provenance information as JSON.
/// </summary>
public string Provenance { get; init; } = "{}";
/// <summary>
/// Statement metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
/// <summary>
/// User who created the statement.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,75 @@
using StellaOps.Excititor.Storage.Postgres.Models;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for VEX statement operations.
/// </summary>
public interface IVexStatementRepository
{
/// <summary>
/// Creates a new VEX statement.
/// </summary>
Task<VexStatementEntity> CreateAsync(VexStatementEntity statement, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a VEX statement by ID.
/// </summary>
Task<VexStatementEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets VEX statements for a vulnerability.
/// </summary>
Task<IReadOnlyList<VexStatementEntity>> GetByVulnerabilityAsync(
string tenantId,
string vulnerabilityId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets VEX statements for a product.
/// </summary>
Task<IReadOnlyList<VexStatementEntity>> GetByProductAsync(
string tenantId,
string productId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets VEX statements for a project.
/// </summary>
Task<IReadOnlyList<VexStatementEntity>> GetByProjectAsync(
string tenantId,
Guid projectId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets VEX statements by status.
/// </summary>
Task<IReadOnlyList<VexStatementEntity>> GetByStatusAsync(
string tenantId,
VexStatus status,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a VEX statement.
/// </summary>
Task<bool> UpdateAsync(VexStatementEntity statement, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a VEX statement.
/// </summary>
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the effective VEX status for a vulnerability/product combination.
/// Applies lattice logic for status precedence.
/// </summary>
Task<VexStatementEntity?> GetEffectiveStatementAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,385 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Excititor.Storage.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for VEX statement operations.
/// </summary>
public sealed class VexStatementRepository : RepositoryBase<ExcititorDataSource>, IVexStatementRepository
{
/// <summary>
/// Creates a new VEX statement repository.
/// </summary>
public VexStatementRepository(ExcititorDataSource dataSource, ILogger<VexStatementRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<VexStatementEntity> CreateAsync(VexStatementEntity statement, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO vex.statements (
id, tenant_id, project_id, graph_revision_id, vulnerability_id, product_id,
status, justification, impact_statement, action_statement, action_statement_timestamp,
source, source_url, evidence, provenance, metadata, created_by
)
VALUES (
@id, @tenant_id, @project_id, @graph_revision_id, @vulnerability_id, @product_id,
@status, @justification, @impact_statement, @action_statement, @action_statement_timestamp,
@source, @source_url, @evidence::jsonb, @provenance::jsonb, @metadata::jsonb, @created_by
)
RETURNING id, tenant_id, project_id, graph_revision_id, vulnerability_id, product_id,
status, justification, impact_statement, action_statement, action_statement_timestamp,
first_issued, last_updated, source, source_url,
evidence::text, provenance::text, metadata::text, created_by
""";
await using var connection = await DataSource.OpenConnectionAsync(statement.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddStatementParameters(command, statement);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapStatement(reader);
}
/// <inheritdoc />
public async Task<VexStatementEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, project_id, graph_revision_id, vulnerability_id, product_id,
status, justification, impact_statement, action_statement, action_statement_timestamp,
first_issued, last_updated, source, source_url,
evidence::text, provenance::text, metadata::text, created_by
FROM vex.statements
WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapStatement,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexStatementEntity>> GetByVulnerabilityAsync(
string tenantId,
string vulnerabilityId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, project_id, graph_revision_id, vulnerability_id, product_id,
status, justification, impact_statement, action_statement, action_statement_timestamp,
first_issued, last_updated, source, source_url,
evidence::text, provenance::text, metadata::text, created_by
FROM vex.statements
WHERE tenant_id = @tenant_id AND vulnerability_id = @vulnerability_id
ORDER BY last_updated DESC, id
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "vulnerability_id", vulnerabilityId);
},
MapStatement,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexStatementEntity>> GetByProductAsync(
string tenantId,
string productId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, project_id, graph_revision_id, vulnerability_id, product_id,
status, justification, impact_statement, action_statement, action_statement_timestamp,
first_issued, last_updated, source, source_url,
evidence::text, provenance::text, metadata::text, created_by
FROM vex.statements
WHERE tenant_id = @tenant_id AND product_id = @product_id
ORDER BY last_updated DESC, id
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "product_id", productId);
},
MapStatement,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexStatementEntity>> GetByProjectAsync(
string tenantId,
Guid projectId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, project_id, graph_revision_id, vulnerability_id, product_id,
status, justification, impact_statement, action_statement, action_statement_timestamp,
first_issued, last_updated, source, source_url,
evidence::text, provenance::text, metadata::text, created_by
FROM vex.statements
WHERE tenant_id = @tenant_id AND project_id = @project_id
ORDER BY last_updated DESC, id
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "project_id", projectId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapStatement,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexStatementEntity>> GetByStatusAsync(
string tenantId,
VexStatus status,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, project_id, graph_revision_id, vulnerability_id, product_id,
status, justification, impact_statement, action_statement, action_statement_timestamp,
first_issued, last_updated, source, source_url,
evidence::text, provenance::text, metadata::text, created_by
FROM vex.statements
WHERE tenant_id = @tenant_id AND status = @status
ORDER BY last_updated DESC, id
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "status", StatusToString(status));
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapStatement,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(VexStatementEntity statement, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE vex.statements
SET status = @status,
justification = @justification,
impact_statement = @impact_statement,
action_statement = @action_statement,
action_statement_timestamp = @action_statement_timestamp,
source = @source,
source_url = @source_url,
evidence = @evidence::jsonb,
provenance = @provenance::jsonb,
metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
statement.TenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", statement.TenantId);
AddParameter(cmd, "id", statement.Id);
AddParameter(cmd, "status", StatusToString(statement.Status));
AddParameter(cmd, "justification", statement.Justification.HasValue
? JustificationToString(statement.Justification.Value)
: null);
AddParameter(cmd, "impact_statement", statement.ImpactStatement);
AddParameter(cmd, "action_statement", statement.ActionStatement);
AddParameter(cmd, "action_statement_timestamp", statement.ActionStatementTimestamp);
AddParameter(cmd, "source", statement.Source);
AddParameter(cmd, "source_url", statement.SourceUrl);
AddJsonbParameter(cmd, "evidence", statement.Evidence);
AddJsonbParameter(cmd, "provenance", statement.Provenance);
AddJsonbParameter(cmd, "metadata", statement.Metadata);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM vex.statements WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<VexStatementEntity?> GetEffectiveStatementAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default)
{
// VEX lattice precedence: fixed > not_affected > affected > under_investigation
const string sql = """
SELECT id, tenant_id, project_id, graph_revision_id, vulnerability_id, product_id,
status, justification, impact_statement, action_statement, action_statement_timestamp,
first_issued, last_updated, source, source_url,
evidence::text, provenance::text, metadata::text, created_by
FROM vex.statements
WHERE tenant_id = @tenant_id
AND vulnerability_id = @vulnerability_id
AND product_id = @product_id
ORDER BY
CASE status
WHEN 'fixed' THEN 1
WHEN 'not_affected' THEN 2
WHEN 'affected' THEN 3
WHEN 'under_investigation' THEN 4
END,
last_updated DESC
LIMIT 1
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "vulnerability_id", vulnerabilityId);
AddParameter(cmd, "product_id", productId);
},
MapStatement,
cancellationToken).ConfigureAwait(false);
}
private static void AddStatementParameters(NpgsqlCommand command, VexStatementEntity statement)
{
AddParameter(command, "id", statement.Id);
AddParameter(command, "tenant_id", statement.TenantId);
AddParameter(command, "project_id", statement.ProjectId);
AddParameter(command, "graph_revision_id", statement.GraphRevisionId);
AddParameter(command, "vulnerability_id", statement.VulnerabilityId);
AddParameter(command, "product_id", statement.ProductId);
AddParameter(command, "status", StatusToString(statement.Status));
AddParameter(command, "justification", statement.Justification.HasValue
? JustificationToString(statement.Justification.Value)
: null);
AddParameter(command, "impact_statement", statement.ImpactStatement);
AddParameter(command, "action_statement", statement.ActionStatement);
AddParameter(command, "action_statement_timestamp", statement.ActionStatementTimestamp);
AddParameter(command, "source", statement.Source);
AddParameter(command, "source_url", statement.SourceUrl);
AddJsonbParameter(command, "evidence", statement.Evidence);
AddJsonbParameter(command, "provenance", statement.Provenance);
AddJsonbParameter(command, "metadata", statement.Metadata);
AddParameter(command, "created_by", statement.CreatedBy);
}
private static VexStatementEntity MapStatement(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
ProjectId = GetNullableGuid(reader, 2),
GraphRevisionId = GetNullableGuid(reader, 3),
VulnerabilityId = reader.GetString(4),
ProductId = GetNullableString(reader, 5),
Status = ParseStatus(reader.GetString(6)),
Justification = ParseJustification(GetNullableString(reader, 7)),
ImpactStatement = GetNullableString(reader, 8),
ActionStatement = GetNullableString(reader, 9),
ActionStatementTimestamp = GetNullableDateTimeOffset(reader, 10),
FirstIssued = reader.GetFieldValue<DateTimeOffset>(11),
LastUpdated = reader.GetFieldValue<DateTimeOffset>(12),
Source = GetNullableString(reader, 13),
SourceUrl = GetNullableString(reader, 14),
Evidence = reader.GetString(15),
Provenance = reader.GetString(16),
Metadata = reader.GetString(17),
CreatedBy = GetNullableString(reader, 18)
};
private static string StatusToString(VexStatus status) => status switch
{
VexStatus.NotAffected => "not_affected",
VexStatus.Affected => "affected",
VexStatus.Fixed => "fixed",
VexStatus.UnderInvestigation => "under_investigation",
_ => throw new ArgumentException($"Unknown VEX status: {status}", 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 ArgumentException($"Unknown VEX status: {status}", nameof(status))
};
private static string JustificationToString(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 ArgumentException($"Unknown VEX justification: {justification}", nameof(justification))
};
private static VexJustification? ParseJustification(string? justification) => justification switch
{
null => null,
"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 ArgumentException($"Unknown VEX justification: {justification}", nameof(justification))
};
}

View File

@@ -0,0 +1,53 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Storage.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Excititor.Storage.Postgres;
/// <summary>
/// Extension methods for configuring Excititor PostgreSQL storage services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Excititor PostgreSQL storage services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddExcititorPostgresStorage(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:Excititor")
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.AddSingleton<ExcititorDataSource>();
// Register repositories
services.AddScoped<IVexStatementRepository, VexStatementRepository>();
return services;
}
/// <summary>
/// Adds Excititor PostgreSQL storage services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddExcititorPostgresStorage(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddSingleton<ExcititorDataSource>();
// Register repositories
services.AddScoped<IVexStatementRepository, VexStatementRepository>();
return services;
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Excititor.Storage.Postgres</RootNamespace>
</PropertyGroup>
<ItemGroup>
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>