Add unit and integration tests for VexCandidateEmitter and SmartDiff repositories

- Implemented comprehensive unit tests for VexCandidateEmitter to validate candidate emission logic based on various scenarios including absent and present APIs, confidence thresholds, and rate limiting.
- Added integration tests for SmartDiff PostgreSQL repositories, covering snapshot storage and retrieval, candidate storage, and material risk change handling.
- Ensured tests validate correct behavior for storing, retrieving, and querying snapshots and candidates, including edge cases and expected outcomes.
This commit is contained in:
master
2025-12-16 18:44:25 +02:00
parent 2170a58734
commit 3a2100aa78
126 changed files with 15776 additions and 542 deletions

View File

@@ -0,0 +1,370 @@
-- Migration: 005_smart_diff_tables
-- Sprint: SPRINT_3500_0003_0001_smart_diff_detection
-- Task: SDIFF-DET-016
-- Description: Smart-Diff risk state snapshots, material changes, and VEX candidates
-- Ensure scanner schema exists
CREATE SCHEMA IF NOT EXISTS scanner;
-- =============================================================================
-- Enums for Smart-Diff
-- =============================================================================
-- VEX status types
DO $$ BEGIN
CREATE TYPE scanner.vex_status_type AS ENUM (
'unknown',
'affected',
'not_affected',
'fixed',
'under_investigation'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Policy decision types
DO $$ BEGIN
CREATE TYPE scanner.policy_decision_type AS ENUM (
'allow',
'warn',
'block'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Detection rule types
DO $$ BEGIN
CREATE TYPE scanner.detection_rule AS ENUM (
'R1_ReachabilityFlip',
'R2_VexFlip',
'R3_RangeBoundary',
'R4_IntelligenceFlip'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Material change types
DO $$ BEGIN
CREATE TYPE scanner.material_change_type AS ENUM (
'reachability_flip',
'vex_flip',
'range_boundary',
'kev_added',
'kev_removed',
'epss_threshold',
'policy_flip'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Risk direction
DO $$ BEGIN
CREATE TYPE scanner.risk_direction AS ENUM (
'increased',
'decreased',
'neutral'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- VEX justification codes
DO $$ BEGIN
CREATE TYPE scanner.vex_justification AS ENUM (
'component_not_present',
'vulnerable_code_not_present',
'vulnerable_code_not_in_execute_path',
'vulnerable_code_cannot_be_controlled_by_adversary',
'inline_mitigations_already_exist'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- VEX review actions
DO $$ BEGIN
CREATE TYPE scanner.vex_review_action AS ENUM (
'accept',
'reject',
'defer'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- =============================================================================
-- Table: scanner.risk_state_snapshots
-- Purpose: Store point-in-time risk state for findings
-- =============================================================================
CREATE TABLE IF NOT EXISTS scanner.risk_state_snapshots (
-- Identity
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Finding identification (composite key)
vuln_id TEXT NOT NULL,
purl TEXT NOT NULL,
-- Scan context
scan_id TEXT NOT NULL,
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Risk state dimensions
reachable BOOLEAN,
lattice_state TEXT,
vex_status scanner.vex_status_type NOT NULL DEFAULT 'unknown',
in_affected_range BOOLEAN,
-- Intelligence signals
kev BOOLEAN NOT NULL DEFAULT FALSE,
epss_score NUMERIC(5, 4),
-- Policy state
policy_flags TEXT[] DEFAULT '{}',
policy_decision scanner.policy_decision_type,
-- State hash for change detection (deterministic)
state_hash TEXT NOT NULL,
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT risk_state_unique_per_scan UNIQUE (tenant_id, scan_id, vuln_id, purl)
);
-- Indexes for risk_state_snapshots
CREATE INDEX IF NOT EXISTS idx_risk_state_tenant_finding
ON scanner.risk_state_snapshots (tenant_id, vuln_id, purl);
CREATE INDEX IF NOT EXISTS idx_risk_state_scan
ON scanner.risk_state_snapshots (scan_id);
CREATE INDEX IF NOT EXISTS idx_risk_state_captured_at
ON scanner.risk_state_snapshots USING BRIN (captured_at);
CREATE INDEX IF NOT EXISTS idx_risk_state_hash
ON scanner.risk_state_snapshots (state_hash);
-- =============================================================================
-- Table: scanner.material_risk_changes
-- Purpose: Store detected material risk changes between scans
-- =============================================================================
CREATE TABLE IF NOT EXISTS scanner.material_risk_changes (
-- Identity
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Finding identification
vuln_id TEXT NOT NULL,
purl TEXT NOT NULL,
-- Scan context
scan_id TEXT NOT NULL,
-- Change summary
has_material_change BOOLEAN NOT NULL DEFAULT FALSE,
priority_score NUMERIC(6, 4) NOT NULL DEFAULT 0,
-- State hashes
previous_state_hash TEXT NOT NULL,
current_state_hash TEXT NOT NULL,
-- Detected changes (JSONB array)
changes JSONB NOT NULL DEFAULT '[]',
-- Audit
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT material_change_unique_per_scan UNIQUE (tenant_id, scan_id, vuln_id, purl)
);
-- Indexes for material_risk_changes
CREATE INDEX IF NOT EXISTS idx_material_changes_tenant_scan
ON scanner.material_risk_changes (tenant_id, scan_id);
CREATE INDEX IF NOT EXISTS idx_material_changes_priority
ON scanner.material_risk_changes (priority_score DESC)
WHERE has_material_change = TRUE;
CREATE INDEX IF NOT EXISTS idx_material_changes_detected_at
ON scanner.material_risk_changes USING BRIN (detected_at);
-- GIN index for JSON querying
CREATE INDEX IF NOT EXISTS idx_material_changes_changes_gin
ON scanner.material_risk_changes USING GIN (changes);
-- =============================================================================
-- Table: scanner.vex_candidates
-- Purpose: Store auto-generated VEX candidates for review
-- =============================================================================
CREATE TABLE IF NOT EXISTS scanner.vex_candidates (
-- Identity
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
candidate_id TEXT NOT NULL UNIQUE,
tenant_id UUID NOT NULL,
-- Finding identification
vuln_id TEXT NOT NULL,
purl TEXT NOT NULL,
-- Image context
image_digest TEXT NOT NULL,
-- Suggested VEX assertion
suggested_status scanner.vex_status_type NOT NULL,
justification scanner.vex_justification NOT NULL,
rationale TEXT NOT NULL,
-- Evidence links (JSONB array)
evidence_links JSONB NOT NULL DEFAULT '[]',
-- Confidence and validity
confidence NUMERIC(4, 3) NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
-- Review state
requires_review BOOLEAN NOT NULL DEFAULT TRUE,
review_action scanner.vex_review_action,
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
review_comment TEXT,
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes for vex_candidates
CREATE INDEX IF NOT EXISTS idx_vex_candidates_tenant_image
ON scanner.vex_candidates (tenant_id, image_digest);
CREATE INDEX IF NOT EXISTS idx_vex_candidates_pending_review
ON scanner.vex_candidates (tenant_id, requires_review, confidence DESC)
WHERE requires_review = TRUE;
CREATE INDEX IF NOT EXISTS idx_vex_candidates_expires
ON scanner.vex_candidates (expires_at);
CREATE INDEX IF NOT EXISTS idx_vex_candidates_candidate_id
ON scanner.vex_candidates (candidate_id);
-- GIN index for evidence links
CREATE INDEX IF NOT EXISTS idx_vex_candidates_evidence_gin
ON scanner.vex_candidates USING GIN (evidence_links);
-- =============================================================================
-- RLS Policies (for multi-tenant isolation)
-- =============================================================================
-- Enable RLS
ALTER TABLE scanner.risk_state_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE scanner.material_risk_changes ENABLE ROW LEVEL SECURITY;
ALTER TABLE scanner.vex_candidates ENABLE ROW LEVEL SECURITY;
-- RLS function for tenant isolation
CREATE OR REPLACE FUNCTION scanner.current_tenant_id()
RETURNS UUID AS $$
BEGIN
RETURN NULLIF(current_setting('app.current_tenant_id', TRUE), '')::UUID;
END;
$$ LANGUAGE plpgsql STABLE;
-- Policies for risk_state_snapshots
DROP POLICY IF EXISTS risk_state_tenant_isolation ON scanner.risk_state_snapshots;
CREATE POLICY risk_state_tenant_isolation ON scanner.risk_state_snapshots
USING (tenant_id = scanner.current_tenant_id());
-- Policies for material_risk_changes
DROP POLICY IF EXISTS material_changes_tenant_isolation ON scanner.material_risk_changes;
CREATE POLICY material_changes_tenant_isolation ON scanner.material_risk_changes
USING (tenant_id = scanner.current_tenant_id());
-- Policies for vex_candidates
DROP POLICY IF EXISTS vex_candidates_tenant_isolation ON scanner.vex_candidates;
CREATE POLICY vex_candidates_tenant_isolation ON scanner.vex_candidates
USING (tenant_id = scanner.current_tenant_id());
-- =============================================================================
-- Helper Functions
-- =============================================================================
-- Function to get material changes for a scan
CREATE OR REPLACE FUNCTION scanner.get_material_changes_for_scan(
p_scan_id TEXT,
p_min_priority NUMERIC DEFAULT NULL
)
RETURNS TABLE (
vuln_id TEXT,
purl TEXT,
priority_score NUMERIC,
changes JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
mc.vuln_id,
mc.purl,
mc.priority_score,
mc.changes
FROM scanner.material_risk_changes mc
WHERE mc.scan_id = p_scan_id
AND mc.has_material_change = TRUE
AND (p_min_priority IS NULL OR mc.priority_score >= p_min_priority)
ORDER BY mc.priority_score DESC;
END;
$$ LANGUAGE plpgsql STABLE;
-- Function to get pending VEX candidates for review
CREATE OR REPLACE FUNCTION scanner.get_pending_vex_candidates(
p_image_digest TEXT DEFAULT NULL,
p_min_confidence NUMERIC DEFAULT 0.7,
p_limit INT DEFAULT 50
)
RETURNS TABLE (
candidate_id TEXT,
vuln_id TEXT,
purl TEXT,
image_digest TEXT,
suggested_status scanner.vex_status_type,
justification scanner.vex_justification,
rationale TEXT,
confidence NUMERIC,
evidence_links JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
vc.candidate_id,
vc.vuln_id,
vc.purl,
vc.image_digest,
vc.suggested_status,
vc.justification,
vc.rationale,
vc.confidence,
vc.evidence_links
FROM scanner.vex_candidates vc
WHERE vc.requires_review = TRUE
AND vc.expires_at > NOW()
AND vc.confidence >= p_min_confidence
AND (p_image_digest IS NULL OR vc.image_digest = p_image_digest)
ORDER BY vc.confidence DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql STABLE;
-- =============================================================================
-- Comments
-- =============================================================================
COMMENT ON TABLE scanner.risk_state_snapshots IS
'Point-in-time risk state snapshots for Smart-Diff change detection';
COMMENT ON TABLE scanner.material_risk_changes IS
'Detected material risk changes between scans (R1-R4 rules)';
COMMENT ON TABLE scanner.vex_candidates IS
'Auto-generated VEX candidates based on absent vulnerable APIs';
COMMENT ON COLUMN scanner.risk_state_snapshots.state_hash IS
'SHA-256 of normalized state for deterministic change detection';
COMMENT ON COLUMN scanner.material_risk_changes.changes IS
'JSONB array of DetectedChange records';
COMMENT ON COLUMN scanner.vex_candidates.evidence_links IS
'JSONB array of EvidenceLink records with type, uri, digest';

View File

@@ -0,0 +1,244 @@
using System.Collections.Immutable;
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of IMaterialRiskChangeRepository.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRepository
{
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresMaterialRiskChangeRepository> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public PostgresMaterialRiskChangeRepository(
ScannerDataSource dataSource,
ILogger<PostgresMaterialRiskChangeRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await InsertChangeAsync(connection, change, scanId, ct);
}
public async Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default)
{
if (changes.Count == 0)
return;
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
foreach (var change in changes)
{
await InsertChangeAsync(connection, change, scanId, ct, transaction);
}
await transaction.CommitAsync(ct);
_logger.LogDebug("Stored {Count} material risk changes for scan {ScanId}", changes.Count, scanId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store material risk changes for scan {ScanId}", scanId);
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
FROM scanner.material_risk_changes
WHERE scan_id = @ScanId
ORDER BY priority_score DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new { ScanId = scanId });
return rows.Select(r => r.ToResult()).ToList();
}
public async Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
FROM scanner.material_risk_changes
WHERE vuln_id = @VulnId AND purl = @Purl
ORDER BY detected_at DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new
{
VulnId = findingKey.VulnId,
Purl = findingKey.Purl,
Limit = limit
});
return rows.Select(r => r.ToResult()).ToList();
}
public async Task<MaterialRiskChangeQueryResult> QueryChangesAsync(
MaterialRiskChangeQuery query,
CancellationToken ct = default)
{
var conditions = new List<string> { "has_material_change = TRUE" };
var parameters = new DynamicParameters();
if (!string.IsNullOrEmpty(query.ImageDigest))
{
// Would need a join with scan metadata for image filtering
// For now, skip this filter
}
if (query.Since.HasValue)
{
conditions.Add("detected_at >= @Since");
parameters.Add("Since", query.Since.Value);
}
if (query.Until.HasValue)
{
conditions.Add("detected_at <= @Until");
parameters.Add("Until", query.Until.Value);
}
if (query.MinPriorityScore.HasValue)
{
conditions.Add("priority_score >= @MinPriority");
parameters.Add("MinPriority", query.MinPriorityScore.Value);
}
var whereClause = string.Join(" AND ", conditions);
// Count query
var countSql = $"SELECT COUNT(*) FROM scanner.material_risk_changes WHERE {whereClause}";
// Data query
var dataSql = $"""
SELECT
vuln_id, purl, has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
FROM scanner.material_risk_changes
WHERE {whereClause}
ORDER BY priority_score DESC
OFFSET @Offset LIMIT @Limit
""";
parameters.Add("Offset", query.Offset);
parameters.Add("Limit", query.Limit);
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var totalCount = await connection.ExecuteScalarAsync<int>(countSql, parameters);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(dataSql, parameters);
var changes = rows.Select(r => r.ToResult()).ToImmutableArray();
return new MaterialRiskChangeQueryResult(
Changes: changes,
TotalCount: totalCount,
Offset: query.Offset,
Limit: query.Limit);
}
private static async Task InsertChangeAsync(
NpgsqlConnection connection,
MaterialRiskChangeResult change,
string scanId,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
const string sql = """
INSERT INTO scanner.material_risk_changes (
tenant_id, vuln_id, purl, scan_id,
has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
) VALUES (
@TenantId, @VulnId, @Purl, @ScanId,
@HasMaterialChange, @PriorityScore,
@PreviousStateHash, @CurrentStateHash, @Changes::jsonb
)
ON CONFLICT (tenant_id, scan_id, vuln_id, purl) DO UPDATE SET
has_material_change = EXCLUDED.has_material_change,
priority_score = EXCLUDED.priority_score,
previous_state_hash = EXCLUDED.previous_state_hash,
current_state_hash = EXCLUDED.current_state_hash,
changes = EXCLUDED.changes
""";
var tenantId = GetCurrentTenantId();
var changesJson = JsonSerializer.Serialize(change.Changes, JsonOptions);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = tenantId,
VulnId = change.FindingKey.VulnId,
Purl = change.FindingKey.Purl,
ScanId = scanId,
HasMaterialChange = change.HasMaterialChange,
PriorityScore = change.PriorityScore,
PreviousStateHash = change.PreviousStateHash,
CurrentStateHash = change.CurrentStateHash,
Changes = changesJson
}, transaction: transaction, cancellationToken: ct));
}
private static Guid GetCurrentTenantId()
{
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
/// <summary>
/// Row mapping class for Dapper.
/// </summary>
private sealed class MaterialRiskChangeRow
{
public string vuln_id { get; set; } = "";
public string purl { get; set; } = "";
public bool has_material_change { get; set; }
public decimal priority_score { get; set; }
public string previous_state_hash { get; set; } = "";
public string current_state_hash { get; set; } = "";
public string changes { get; set; } = "[]";
public MaterialRiskChangeResult ToResult()
{
var detectedChanges = JsonSerializer.Deserialize<List<DetectedChange>>(changes, JsonOptions)
?? [];
return new MaterialRiskChangeResult(
FindingKey: new FindingKey(vuln_id, purl),
HasMaterialChange: has_material_change,
Changes: [.. detectedChanges],
PriorityScore: (int)priority_score,
PreviousStateHash: previous_state_hash,
CurrentStateHash: current_state_hash);
}
}
}

View File

@@ -0,0 +1,261 @@
using System.Collections.Immutable;
using System.Data;
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of IRiskStateRepository.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public sealed class PostgresRiskStateRepository : IRiskStateRepository
{
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresRiskStateRepository> _logger;
public PostgresRiskStateRepository(
ScannerDataSource dataSource,
ILogger<PostgresRiskStateRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await InsertSnapshotAsync(connection, snapshot, ct);
}
public async Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default)
{
if (snapshots.Count == 0)
return;
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
foreach (var snapshot in snapshots)
{
await InsertSnapshotAsync(connection, snapshot, ct, transaction);
}
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
FROM scanner.risk_state_snapshots
WHERE vuln_id = @VulnId AND purl = @Purl
ORDER BY captured_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<RiskStateRow>(sql, new
{
VulnId = findingKey.VulnId,
Purl = findingKey.Purl
});
return row?.ToSnapshot();
}
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
FROM scanner.risk_state_snapshots
WHERE scan_id = @ScanId
ORDER BY vuln_id, purl
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { ScanId = scanId });
return rows.Select(r => r.ToSnapshot()).ToList();
}
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotHistoryAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
FROM scanner.risk_state_snapshots
WHERE vuln_id = @VulnId AND purl = @Purl
ORDER BY captured_at DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new
{
VulnId = findingKey.VulnId,
Purl = findingKey.Purl,
Limit = limit
});
return rows.Select(r => r.ToSnapshot()).ToList();
}
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
FROM scanner.risk_state_snapshots
WHERE state_hash = @StateHash
ORDER BY captured_at DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { StateHash = stateHash });
return rows.Select(r => r.ToSnapshot()).ToList();
}
private static async Task InsertSnapshotAsync(
NpgsqlConnection connection,
RiskStateSnapshot snapshot,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
const string sql = """
INSERT INTO scanner.risk_state_snapshots (
tenant_id, vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status, in_affected_range,
kev, epss_score, policy_flags, policy_decision, state_hash
) VALUES (
@TenantId, @VulnId, @Purl, @ScanId, @CapturedAt,
@Reachable, @LatticeState, @VexStatus::scanner.vex_status_type, @InAffectedRange,
@Kev, @EpssScore, @PolicyFlags, @PolicyDecision::scanner.policy_decision_type, @StateHash
)
ON CONFLICT (tenant_id, scan_id, vuln_id, purl) DO UPDATE SET
reachable = EXCLUDED.reachable,
lattice_state = EXCLUDED.lattice_state,
vex_status = EXCLUDED.vex_status,
in_affected_range = EXCLUDED.in_affected_range,
kev = EXCLUDED.kev,
epss_score = EXCLUDED.epss_score,
policy_flags = EXCLUDED.policy_flags,
policy_decision = EXCLUDED.policy_decision,
state_hash = EXCLUDED.state_hash
""";
var tenantId = GetCurrentTenantId();
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = tenantId,
VulnId = snapshot.FindingKey.VulnId,
Purl = snapshot.FindingKey.Purl,
ScanId = snapshot.ScanId,
CapturedAt = snapshot.CapturedAt,
Reachable = snapshot.Reachable,
LatticeState = snapshot.LatticeState,
VexStatus = snapshot.VexStatus.ToString().ToLowerInvariant(),
InAffectedRange = snapshot.InAffectedRange,
Kev = snapshot.Kev,
EpssScore = snapshot.EpssScore,
PolicyFlags = snapshot.PolicyFlags.ToArray(),
PolicyDecision = snapshot.PolicyDecision?.ToString().ToLowerInvariant(),
StateHash = snapshot.ComputeStateHash()
}, transaction: transaction, cancellationToken: ct));
}
private static Guid GetCurrentTenantId()
{
// In production, this would come from the current context
// For now, return a default tenant ID
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
/// <summary>
/// Row mapping class for Dapper.
/// </summary>
private sealed class RiskStateRow
{
public string vuln_id { get; set; } = "";
public string purl { get; set; } = "";
public string scan_id { get; set; } = "";
public DateTimeOffset captured_at { get; set; }
public bool? reachable { get; set; }
public string? lattice_state { get; set; }
public string vex_status { get; set; } = "unknown";
public bool? in_affected_range { get; set; }
public bool kev { get; set; }
public decimal? epss_score { get; set; }
public string[]? policy_flags { get; set; }
public string? policy_decision { get; set; }
public string state_hash { get; set; } = "";
public RiskStateSnapshot ToSnapshot()
{
return new RiskStateSnapshot(
FindingKey: new FindingKey(vuln_id, purl),
ScanId: scan_id,
CapturedAt: captured_at,
Reachable: reachable,
LatticeState: lattice_state,
VexStatus: ParseVexStatus(vex_status),
InAffectedRange: in_affected_range,
Kev: kev,
EpssScore: epss_score.HasValue ? (double)epss_score.Value : null,
PolicyFlags: policy_flags?.ToImmutableArray() ?? [],
PolicyDecision: ParsePolicyDecision(policy_decision));
}
private static VexStatusType ParseVexStatus(string value)
{
return value.ToLowerInvariant() switch
{
"affected" => VexStatusType.Affected,
"not_affected" => VexStatusType.NotAffected,
"fixed" => VexStatusType.Fixed,
"under_investigation" => VexStatusType.UnderInvestigation,
_ => VexStatusType.Unknown
};
}
private static PolicyDecisionType? ParsePolicyDecision(string? value)
{
if (string.IsNullOrEmpty(value))
return null;
return value.ToLowerInvariant() switch
{
"allow" => PolicyDecisionType.Allow,
"warn" => PolicyDecisionType.Warn,
"block" => PolicyDecisionType.Block,
_ => null
};
}
}
}

View File

@@ -0,0 +1,268 @@
using System.Collections.Immutable;
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of IVexCandidateStore.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public sealed class PostgresVexCandidateStore : IVexCandidateStore
{
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresVexCandidateStore> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public PostgresVexCandidateStore(
ScannerDataSource dataSource,
ILogger<PostgresVexCandidateStore> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default)
{
if (candidates.Count == 0)
return;
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
foreach (var candidate in candidates)
{
await InsertCandidateAsync(connection, candidate, ct, transaction);
}
await transaction.CommitAsync(ct);
_logger.LogDebug("Stored {Count} VEX candidates", candidates.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store VEX candidates");
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default)
{
const string sql = """
SELECT
candidate_id, vuln_id, purl, image_digest,
suggested_status::TEXT, justification::TEXT, rationale,
evidence_links, confidence, generated_at, expires_at,
requires_review, review_action::TEXT, reviewed_by, reviewed_at, review_comment
FROM scanner.vex_candidates
WHERE image_digest = @ImageDigest
ORDER BY confidence DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<VexCandidateRow>(sql, new { ImageDigest = imageDigest });
return rows.Select(r => r.ToCandidate()).ToList();
}
public async Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default)
{
const string sql = """
SELECT
candidate_id, vuln_id, purl, image_digest,
suggested_status::TEXT, justification::TEXT, rationale,
evidence_links, confidence, generated_at, expires_at,
requires_review, review_action::TEXT, reviewed_by, reviewed_at, review_comment
FROM scanner.vex_candidates
WHERE candidate_id = @CandidateId
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<VexCandidateRow>(sql, new { CandidateId = candidateId });
return row?.ToCandidate();
}
public async Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default)
{
const string sql = """
UPDATE scanner.vex_candidates SET
requires_review = FALSE,
review_action = @ReviewAction::scanner.vex_review_action,
reviewed_by = @ReviewedBy,
reviewed_at = @ReviewedAt,
review_comment = @ReviewComment
WHERE candidate_id = @CandidateId
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var affected = await connection.ExecuteAsync(sql, new
{
CandidateId = candidateId,
ReviewAction = review.Action.ToString().ToLowerInvariant(),
ReviewedBy = review.Reviewer,
ReviewedAt = review.ReviewedAt,
ReviewComment = review.Comment
});
if (affected > 0)
{
_logger.LogInformation("Reviewed VEX candidate {CandidateId} with action {Action}",
candidateId, review.Action);
}
return affected > 0;
}
private static async Task InsertCandidateAsync(
NpgsqlConnection connection,
VexCandidate candidate,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
const string sql = """
INSERT INTO scanner.vex_candidates (
tenant_id, candidate_id, vuln_id, purl, image_digest,
suggested_status, justification, rationale,
evidence_links, confidence, generated_at, expires_at, requires_review
) VALUES (
@TenantId, @CandidateId, @VulnId, @Purl, @ImageDigest,
@SuggestedStatus::scanner.vex_status_type, @Justification::scanner.vex_justification, @Rationale,
@EvidenceLinks::jsonb, @Confidence, @GeneratedAt, @ExpiresAt, @RequiresReview
)
ON CONFLICT (candidate_id) DO UPDATE SET
suggested_status = EXCLUDED.suggested_status,
justification = EXCLUDED.justification,
rationale = EXCLUDED.rationale,
evidence_links = EXCLUDED.evidence_links,
confidence = EXCLUDED.confidence,
expires_at = EXCLUDED.expires_at
""";
var tenantId = GetCurrentTenantId();
var evidenceLinksJson = JsonSerializer.Serialize(candidate.EvidenceLinks, JsonOptions);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = tenantId,
CandidateId = candidate.CandidateId,
VulnId = candidate.FindingKey.VulnId,
Purl = candidate.FindingKey.Purl,
ImageDigest = candidate.ImageDigest,
SuggestedStatus = MapVexStatus(candidate.SuggestedStatus),
Justification = MapJustification(candidate.Justification),
Rationale = candidate.Rationale,
EvidenceLinks = evidenceLinksJson,
Confidence = candidate.Confidence,
GeneratedAt = candidate.GeneratedAt,
ExpiresAt = candidate.ExpiresAt,
RequiresReview = candidate.RequiresReview
}, transaction: transaction, cancellationToken: ct));
}
private static string MapVexStatus(VexStatusType status)
{
return status switch
{
VexStatusType.Affected => "affected",
VexStatusType.NotAffected => "not_affected",
VexStatusType.Fixed => "fixed",
VexStatusType.UnderInvestigation => "under_investigation",
_ => "unknown"
};
}
private static string MapJustification(VexJustification justification)
{
return 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",
_ => "vulnerable_code_not_present"
};
}
private static Guid GetCurrentTenantId()
{
// In production, this would come from the current context
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
/// <summary>
/// Row mapping class for Dapper.
/// </summary>
private sealed class VexCandidateRow
{
public string candidate_id { get; set; } = "";
public string vuln_id { get; set; } = "";
public string purl { get; set; } = "";
public string image_digest { get; set; } = "";
public string suggested_status { get; set; } = "not_affected";
public string justification { get; set; } = "vulnerable_code_not_present";
public string rationale { get; set; } = "";
public string evidence_links { get; set; } = "[]";
public decimal confidence { get; set; }
public DateTimeOffset generated_at { get; set; }
public DateTimeOffset expires_at { get; set; }
public bool requires_review { get; set; }
public string? review_action { get; set; }
public string? reviewed_by { get; set; }
public DateTimeOffset? reviewed_at { get; set; }
public string? review_comment { get; set; }
public VexCandidate ToCandidate()
{
var links = JsonSerializer.Deserialize<List<EvidenceLink>>(evidence_links, JsonOptions)
?? [];
return new VexCandidate(
CandidateId: candidate_id,
FindingKey: new FindingKey(vuln_id, purl),
SuggestedStatus: ParseVexStatus(suggested_status),
Justification: ParseJustification(justification),
Rationale: rationale,
EvidenceLinks: [.. links],
Confidence: (double)confidence,
ImageDigest: image_digest,
GeneratedAt: generated_at,
ExpiresAt: expires_at,
RequiresReview: requires_review);
}
private static VexStatusType ParseVexStatus(string value)
{
return value.ToLowerInvariant() switch
{
"affected" => VexStatusType.Affected,
"not_affected" => VexStatusType.NotAffected,
"fixed" => VexStatusType.Fixed,
"under_investigation" => VexStatusType.UnderInvestigation,
_ => VexStatusType.Unknown
};
}
private static VexJustification ParseJustification(string value)
{
return value.ToLowerInvariant() 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,
_ => VexJustification.VulnerableCodeNotPresent
};
}
}
}