work work hard work

This commit is contained in:
StellaOps Bot
2025-12-18 00:47:24 +02:00
parent dee252940b
commit b4235c134c
189 changed files with 9627 additions and 3258 deletions

View File

@@ -74,7 +74,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<EntryTraceRepository>();
services.AddScoped<RubyPackageInventoryRepository>();
services.AddScoped<BunPackageInventoryRepository>();
services.TryAddSingleton<IClassificationHistoryRepository, ClassificationHistoryRepository>();
services.TryAddSingleton<IClassificationChangeTracker, ClassificationChangeTracker>();
services.AddScoped<IProofSpineRepository, PostgresProofSpineRepository>();
services.AddScoped<ICallGraphSnapshotRepository, PostgresCallGraphSnapshotRepository>();
services.AddScoped<IReachabilityResultRepository, PostgresReachabilityResultRepository>();
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();

View File

@@ -0,0 +1,11 @@
-- Migration: 0059_scans_table
-- Sprint: SPRINT_3500_0002_0001_score_proofs_foundations (prereq)
-- Description: Minimal `scans` table required by score replay/proof bundle tables.
CREATE TABLE IF NOT EXISTS scans (
scan_id UUID PRIMARY KEY,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ix_scans_created_at
ON scans(created_at_utc DESC);

View File

@@ -0,0 +1,20 @@
-- Migration: 0065_unknowns_table
-- Sprint: SPRINT_3600_0002_0001 (foundation prerequisite)
-- Description: Minimal `unknowns` table required for containment/ranking follow-up migrations.
CREATE TABLE IF NOT EXISTS unknowns (
unknown_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
artifact_digest TEXT NOT NULL,
vuln_id TEXT NOT NULL,
package_purl TEXT NOT NULL,
score DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ix_unknowns_tenant_artifact
ON unknowns(tenant_id, artifact_digest);
CREATE INDEX IF NOT EXISTS ix_unknowns_created_at
ON unknowns(created_at_utc DESC);

View File

@@ -0,0 +1,18 @@
-- Migration: 0075_scan_findings_table
-- Sprint: Advisory-derived (EPSS Integration prerequisite)
-- Description: Minimal `scan_findings` table required for EPSS-at-scan evidence columns.
CREATE TABLE IF NOT EXISTS scan_findings (
finding_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scan_id UUID NOT NULL,
tenant_id UUID NOT NULL,
vuln_id TEXT NOT NULL,
package_purl TEXT NOT NULL,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ix_scan_findings_scan_id
ON scan_findings(scan_id);
CREATE INDEX IF NOT EXISTS ix_scan_findings_tenant_vuln
ON scan_findings(tenant_id, vuln_id);

View File

@@ -0,0 +1,78 @@
-- Call graph snapshots + reachability analysis results
-- Sprint: SPRINT_3600_0002_0001_call_graph_infrastructure
CREATE SCHEMA IF NOT EXISTS scanner;
-- -----------------------------------------------------------------------------
-- Table: scanner.call_graph_snapshots
-- Purpose: Cache call graph snapshots per scan/language for reachability drift.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS scanner.call_graph_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
scan_id TEXT NOT NULL,
language TEXT NOT NULL,
graph_digest TEXT NOT NULL,
extracted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
node_count INT NOT NULL,
edge_count INT NOT NULL,
entrypoint_count INT NOT NULL,
sink_count INT NOT NULL,
snapshot_json JSONB NOT NULL,
CONSTRAINT call_graph_snapshot_unique_per_scan UNIQUE (tenant_id, scan_id, language, graph_digest)
);
CREATE INDEX IF NOT EXISTS idx_call_graph_snapshots_tenant_scan
ON scanner.call_graph_snapshots (tenant_id, scan_id, language);
CREATE INDEX IF NOT EXISTS idx_call_graph_snapshots_graph_digest
ON scanner.call_graph_snapshots (graph_digest);
CREATE INDEX IF NOT EXISTS idx_call_graph_snapshots_extracted_at
ON scanner.call_graph_snapshots USING BRIN (extracted_at);
ALTER TABLE scanner.call_graph_snapshots ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS call_graph_snapshots_tenant_isolation ON scanner.call_graph_snapshots;
CREATE POLICY call_graph_snapshots_tenant_isolation ON scanner.call_graph_snapshots
USING (tenant_id = scanner.current_tenant_id());
COMMENT ON TABLE scanner.call_graph_snapshots IS 'Call graph snapshots per scan/language for reachability drift detection.';
-- -----------------------------------------------------------------------------
-- Table: scanner.reachability_results
-- Purpose: Cache reachability BFS results (reachable sinks + shortest paths).
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS scanner.reachability_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
scan_id TEXT NOT NULL,
language TEXT NOT NULL,
graph_digest TEXT NOT NULL,
result_digest TEXT NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
reachable_node_count INT NOT NULL,
reachable_sink_count INT NOT NULL,
result_json JSONB NOT NULL,
CONSTRAINT reachability_result_unique_per_scan UNIQUE (tenant_id, scan_id, language, graph_digest, result_digest)
);
CREATE INDEX IF NOT EXISTS idx_reachability_results_tenant_scan
ON scanner.reachability_results (tenant_id, scan_id, language);
CREATE INDEX IF NOT EXISTS idx_reachability_results_graph_digest
ON scanner.reachability_results (graph_digest);
CREATE INDEX IF NOT EXISTS idx_reachability_results_computed_at
ON scanner.reachability_results USING BRIN (computed_at);
ALTER TABLE scanner.reachability_results ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS reachability_results_tenant_isolation ON scanner.reachability_results;
CREATE POLICY reachability_results_tenant_isolation ON scanner.reachability_results
USING (tenant_id = scanner.current_tenant_id());
COMMENT ON TABLE scanner.reachability_results IS 'Reachability analysis results per scan/language with shortest paths.';

View File

@@ -0,0 +1,322 @@
-- Migration: 009_smart_diff_tables_search_path
-- Sprint: SPRINT_3500_0003_0001_smart_diff_detection
-- Task: SDIFF-DET-016 (follow-up)
-- Description: Ensure Smart-Diff tables/types live in the active schema (search_path) and align tenant context key with DataSourceBase (`app.tenant_id`).
-- =============================================================================
-- Enums for Smart-Diff (created in the active schema)
-- =============================================================================
DO $$ BEGIN
CREATE TYPE vex_status_type AS ENUM (
'unknown',
'affected',
'not_affected',
'fixed',
'under_investigation'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE policy_decision_type AS ENUM (
'allow',
'warn',
'block'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE detection_rule AS ENUM (
'R1_ReachabilityFlip',
'R2_VexFlip',
'R3_RangeBoundary',
'R4_IntelligenceFlip'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE 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 $$;
DO $$ BEGIN
CREATE TYPE risk_direction AS ENUM (
'increased',
'decreased',
'neutral'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE 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 $$;
DO $$ BEGIN
CREATE TYPE vex_review_action AS ENUM (
'accept',
'reject',
'defer'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- =============================================================================
-- Table: risk_state_snapshots
-- =============================================================================
CREATE TABLE IF NOT EXISTS risk_state_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
vuln_id TEXT NOT NULL,
purl TEXT NOT NULL,
scan_id TEXT NOT NULL,
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
reachable BOOLEAN,
lattice_state TEXT,
vex_status vex_status_type NOT NULL DEFAULT 'unknown',
in_affected_range BOOLEAN,
kev BOOLEAN NOT NULL DEFAULT FALSE,
epss_score NUMERIC(5, 4),
policy_flags TEXT[] DEFAULT '{}',
policy_decision policy_decision_type,
state_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT risk_state_unique_per_scan UNIQUE (tenant_id, scan_id, vuln_id, purl)
);
CREATE INDEX IF NOT EXISTS idx_risk_state_tenant_finding
ON risk_state_snapshots (tenant_id, vuln_id, purl);
CREATE INDEX IF NOT EXISTS idx_risk_state_scan
ON risk_state_snapshots (scan_id);
CREATE INDEX IF NOT EXISTS idx_risk_state_captured_at
ON risk_state_snapshots USING BRIN (captured_at);
CREATE INDEX IF NOT EXISTS idx_risk_state_hash
ON risk_state_snapshots (state_hash);
-- =============================================================================
-- Table: material_risk_changes
-- =============================================================================
CREATE TABLE IF NOT EXISTS material_risk_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
vuln_id TEXT NOT NULL,
purl TEXT NOT NULL,
scan_id TEXT NOT NULL,
has_material_change BOOLEAN NOT NULL DEFAULT FALSE,
priority_score NUMERIC(6, 4) NOT NULL DEFAULT 0,
previous_state_hash TEXT NOT NULL,
current_state_hash TEXT NOT NULL,
changes JSONB NOT NULL DEFAULT '[]',
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT material_change_unique_per_scan UNIQUE (tenant_id, scan_id, vuln_id, purl)
);
CREATE INDEX IF NOT EXISTS idx_material_changes_tenant_scan
ON material_risk_changes (tenant_id, scan_id);
CREATE INDEX IF NOT EXISTS idx_material_changes_priority
ON material_risk_changes (priority_score DESC)
WHERE has_material_change = TRUE;
CREATE INDEX IF NOT EXISTS idx_material_changes_detected_at
ON material_risk_changes USING BRIN (detected_at);
CREATE INDEX IF NOT EXISTS idx_material_changes_changes_gin
ON material_risk_changes USING GIN (changes);
-- =============================================================================
-- Table: vex_candidates
-- =============================================================================
CREATE TABLE IF NOT EXISTS vex_candidates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
candidate_id TEXT NOT NULL UNIQUE,
tenant_id UUID NOT NULL,
vuln_id TEXT NOT NULL,
purl TEXT NOT NULL,
image_digest TEXT NOT NULL,
suggested_status vex_status_type NOT NULL,
justification vex_justification NOT NULL,
rationale TEXT NOT NULL,
evidence_links JSONB NOT NULL DEFAULT '[]',
confidence NUMERIC(4, 3) NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
requires_review BOOLEAN NOT NULL DEFAULT TRUE,
review_action vex_review_action,
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
review_comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vex_candidates_tenant_image
ON vex_candidates (tenant_id, image_digest);
CREATE INDEX IF NOT EXISTS idx_vex_candidates_pending_review
ON vex_candidates (tenant_id, requires_review, confidence DESC)
WHERE requires_review = TRUE;
CREATE INDEX IF NOT EXISTS idx_vex_candidates_expires
ON vex_candidates (expires_at);
CREATE INDEX IF NOT EXISTS idx_vex_candidates_candidate_id
ON vex_candidates (candidate_id);
CREATE INDEX IF NOT EXISTS idx_vex_candidates_evidence_gin
ON vex_candidates USING GIN (evidence_links);
-- =============================================================================
-- RLS Policies (tenant isolation via app.tenant_id)
-- =============================================================================
ALTER TABLE risk_state_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE material_risk_changes ENABLE ROW LEVEL SECURITY;
ALTER TABLE vex_candidates ENABLE ROW LEVEL SECURITY;
CREATE OR REPLACE FUNCTION current_tenant_id()
RETURNS UUID AS $$
BEGIN
RETURN NULLIF(current_setting('app.tenant_id', TRUE), '')::UUID;
END;
$$ LANGUAGE plpgsql STABLE;
DROP POLICY IF EXISTS risk_state_tenant_isolation ON risk_state_snapshots;
CREATE POLICY risk_state_tenant_isolation ON risk_state_snapshots
FOR ALL
USING (tenant_id = current_tenant_id())
WITH CHECK (tenant_id = current_tenant_id());
DROP POLICY IF EXISTS material_changes_tenant_isolation ON material_risk_changes;
CREATE POLICY material_changes_tenant_isolation ON material_risk_changes
FOR ALL
USING (tenant_id = current_tenant_id())
WITH CHECK (tenant_id = current_tenant_id());
DROP POLICY IF EXISTS vex_candidates_tenant_isolation ON vex_candidates;
CREATE POLICY vex_candidates_tenant_isolation ON vex_candidates
FOR ALL
USING (tenant_id = current_tenant_id())
WITH CHECK (tenant_id = current_tenant_id());
-- =============================================================================
-- Helper Functions
-- =============================================================================
CREATE OR REPLACE FUNCTION 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 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;
CREATE OR REPLACE FUNCTION 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 vex_status_type,
justification 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 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;
COMMENT ON TABLE risk_state_snapshots IS
'Point-in-time risk state snapshots for Smart-Diff change detection';
COMMENT ON TABLE material_risk_changes IS
'Detected material risk changes between scans (R1-R4 rules)';
COMMENT ON TABLE vex_candidates IS
'Auto-generated VEX candidates based on absent vulnerable APIs';
COMMENT ON COLUMN risk_state_snapshots.state_hash IS
'SHA-256 of normalized state for deterministic change detection';
COMMENT ON COLUMN material_risk_changes.changes IS
'JSONB array of DetectedChange records';
COMMENT ON COLUMN vex_candidates.evidence_links IS
'JSONB array of EvidenceLink records with type, uri, digest';

View File

@@ -10,4 +10,5 @@ internal static class MigrationIds
public const string ScoreReplayTables = "006_score_replay_tables.sql";
public const string UnknownsRankingContainment = "007_unknowns_ranking_containment.sql";
public const string EpssIntegration = "008_epss_integration.sql";
public const string CallGraphTables = "009_call_graph_tables.sql";
}

View File

@@ -0,0 +1,125 @@
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresCallGraphSnapshotRepository> _logger;
public PostgresCallGraphSnapshotRepository(
ScannerDataSource dataSource,
ILogger<PostgresCallGraphSnapshotRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreAsync(CallGraphSnapshot snapshot, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
var trimmed = snapshot.Trimmed();
const string sql = """
INSERT INTO scanner.call_graph_snapshots (
tenant_id,
scan_id,
language,
graph_digest,
extracted_at,
node_count,
edge_count,
entrypoint_count,
sink_count,
snapshot_json
) VALUES (
@TenantId,
@ScanId,
@Language,
@GraphDigest,
@ExtractedAt,
@NodeCount,
@EdgeCount,
@EntrypointCount,
@SinkCount,
@SnapshotJson::jsonb
)
ON CONFLICT (tenant_id, scan_id, language, graph_digest) DO UPDATE SET
extracted_at = EXCLUDED.extracted_at,
node_count = EXCLUDED.node_count,
edge_count = EXCLUDED.edge_count,
entrypoint_count = EXCLUDED.entrypoint_count,
sink_count = EXCLUDED.sink_count,
snapshot_json = EXCLUDED.snapshot_json
""";
var json = JsonSerializer.Serialize(trimmed, JsonOptions);
var tenantId = GetCurrentTenantId();
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = tenantId,
ScanId = trimmed.ScanId,
Language = trimmed.Language,
GraphDigest = trimmed.GraphDigest,
ExtractedAt = trimmed.ExtractedAt.UtcDateTime,
NodeCount = trimmed.Nodes.Length,
EdgeCount = trimmed.Edges.Length,
EntrypointCount = trimmed.EntrypointIds.Length,
SinkCount = trimmed.SinkIds.Length,
SnapshotJson = json
}, cancellationToken: ct)).ConfigureAwait(false);
_logger.LogDebug(
"Stored call graph snapshot scan={ScanId} lang={Language} nodes={Nodes} edges={Edges}",
trimmed.ScanId,
trimmed.Language,
trimmed.Nodes.Length,
trimmed.Edges.Length);
}
public async Task<CallGraphSnapshot?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
const string sql = """
SELECT snapshot_json
FROM scanner.call_graph_snapshots
WHERE tenant_id = @TenantId AND scan_id = @ScanId AND language = @Language
ORDER BY extracted_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
var json = await connection.ExecuteScalarAsync<string?>(new CommandDefinition(sql, new
{
TenantId = GetCurrentTenantId(),
ScanId = scanId,
Language = language
}, cancellationToken: ct)).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
return JsonSerializer.Deserialize<CallGraphSnapshot>(json, JsonOptions);
}
private static Guid GetCurrentTenantId()
{
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
}

View File

@@ -13,8 +13,15 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// </summary>
public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRepository
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private static readonly Guid TenantId = Guid.Parse(TenantContext);
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresMaterialRiskChangeRepository> _logger;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string MaterialRiskChangesTable => $"{SchemaName}.material_risk_changes";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
@@ -30,49 +37,58 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
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);
ArgumentNullException.ThrowIfNull(change);
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await InsertChangeAsync(connection, change, scanId.Trim(), ct).ConfigureAwait(false);
}
public async Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(changes);
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
if (changes.Count == 0)
return;
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
foreach (var change in changes)
{
await InsertChangeAsync(connection, change, scanId, ct, transaction);
await InsertChangeAsync(connection, change, scanId.Trim(), ct, transaction).ConfigureAwait(false);
}
await transaction.CommitAsync(ct);
await transaction.CommitAsync(ct).ConfigureAwait(false);
_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);
await transaction.RollbackAsync(ct).ConfigureAwait(false);
throw;
}
}
public async Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default)
{
const string sql = """
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
var 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
FROM {MaterialRiskChangesTable}
WHERE tenant_id = @TenantId
AND 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 });
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new { TenantId, ScanId = scanId.Trim() });
return rows.Select(r => r.ToResult()).ToList();
}
@@ -82,21 +98,27 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
int limit = 10,
CancellationToken ct = default)
{
const string sql = """
ArgumentNullException.ThrowIfNull(findingKey);
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
var 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
FROM {MaterialRiskChangesTable}
WHERE tenant_id = @TenantId
AND vuln_id = @VulnId
AND purl = @Purl
ORDER BY detected_at DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new
{
TenantId,
VulnId = findingKey.VulnId,
Purl = findingKey.Purl,
Purl = findingKey.ComponentPurl,
Limit = limit
});
@@ -107,6 +129,8 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
MaterialRiskChangeQuery query,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(query);
var conditions = new List<string> { "has_material_change = TRUE" };
var parameters = new DynamicParameters();
@@ -134,17 +158,20 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
parameters.Add("MinPriority", query.MinPriorityScore.Value);
}
conditions.Add("tenant_id = @TenantId");
parameters.Add("TenantId", TenantId);
var whereClause = string.Join(" AND ", conditions);
// Count query
var countSql = $"SELECT COUNT(*) FROM scanner.material_risk_changes WHERE {whereClause}";
var countSql = $"SELECT COUNT(*) FROM {MaterialRiskChangesTable} 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
FROM {MaterialRiskChangesTable}
WHERE {whereClause}
ORDER BY priority_score DESC
OFFSET @Offset LIMIT @Limit
@@ -153,7 +180,7 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
parameters.Add("Offset", query.Offset);
parameters.Add("Limit", query.Limit);
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var totalCount = await connection.ExecuteScalarAsync<int>(countSql, parameters);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(dataSql, parameters);
@@ -167,15 +194,19 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
Limit: query.Limit);
}
private static async Task InsertChangeAsync(
private async Task InsertChangeAsync(
NpgsqlConnection connection,
MaterialRiskChangeResult change,
string scanId,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
const string sql = """
INSERT INTO scanner.material_risk_changes (
ArgumentNullException.ThrowIfNull(connection);
ArgumentNullException.ThrowIfNull(change);
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
var sql = $"""
INSERT INTO {MaterialRiskChangesTable} (
tenant_id, vuln_id, purl, scan_id,
has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
@@ -192,14 +223,13 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
changes = EXCLUDED.changes
""";
var tenantId = GetCurrentTenantId();
var changesJson = JsonSerializer.Serialize(change.Changes, JsonOptions);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = tenantId,
TenantId,
VulnId = change.FindingKey.VulnId,
Purl = change.FindingKey.Purl,
Purl = change.FindingKey.ComponentPurl,
ScanId = scanId,
HasMaterialChange = change.HasMaterialChange,
PriorityScore = change.PriorityScore,
@@ -209,11 +239,6 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
}, transaction: transaction, cancellationToken: ct));
}
private static Guid GetCurrentTenantId()
{
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
/// <summary>
/// Row mapping class for Dapper.
/// </summary>
@@ -236,7 +261,7 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
FindingKey: new FindingKey(vuln_id, purl),
HasMaterialChange: has_material_change,
Changes: [.. detectedChanges],
PriorityScore: (int)priority_score,
PriorityScore: (double)priority_score,
PreviousStateHash: previous_state_hash,
CurrentStateHash: current_state_hash);
}

View File

@@ -0,0 +1,119 @@
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
public sealed class PostgresReachabilityResultRepository : IReachabilityResultRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresReachabilityResultRepository> _logger;
public PostgresReachabilityResultRepository(
ScannerDataSource dataSource,
ILogger<PostgresReachabilityResultRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreAsync(ReachabilityAnalysisResult result, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(result);
var trimmed = result.Trimmed();
const string sql = """
INSERT INTO scanner.reachability_results (
tenant_id,
scan_id,
language,
graph_digest,
result_digest,
computed_at,
reachable_node_count,
reachable_sink_count,
result_json
) VALUES (
@TenantId,
@ScanId,
@Language,
@GraphDigest,
@ResultDigest,
@ComputedAt,
@ReachableNodeCount,
@ReachableSinkCount,
@ResultJson::jsonb
)
ON CONFLICT (tenant_id, scan_id, language, graph_digest, result_digest) DO UPDATE SET
computed_at = EXCLUDED.computed_at,
reachable_node_count = EXCLUDED.reachable_node_count,
reachable_sink_count = EXCLUDED.reachable_sink_count,
result_json = EXCLUDED.result_json
""";
var json = JsonSerializer.Serialize(trimmed, JsonOptions);
var tenantId = GetCurrentTenantId();
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = tenantId,
ScanId = trimmed.ScanId,
Language = trimmed.Language,
GraphDigest = trimmed.GraphDigest,
ResultDigest = trimmed.ResultDigest,
ComputedAt = trimmed.ComputedAt.UtcDateTime,
ReachableNodeCount = trimmed.ReachableNodeIds.Length,
ReachableSinkCount = trimmed.ReachableSinkIds.Length,
ResultJson = json
}, cancellationToken: ct)).ConfigureAwait(false);
_logger.LogDebug(
"Stored reachability result scan={ScanId} lang={Language} sinks={Sinks}",
trimmed.ScanId,
trimmed.Language,
trimmed.ReachableSinkIds.Length);
}
public async Task<ReachabilityAnalysisResult?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
const string sql = """
SELECT result_json
FROM scanner.reachability_results
WHERE tenant_id = @TenantId AND scan_id = @ScanId AND language = @Language
ORDER BY computed_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
var json = await connection.ExecuteScalarAsync<string?>(new CommandDefinition(sql, new
{
TenantId = GetCurrentTenantId(),
ScanId = scanId,
Language = language
}, cancellationToken: ct)).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
return JsonSerializer.Deserialize<ReachabilityAnalysisResult>(json, JsonOptions);
}
private static Guid GetCurrentTenantId()
{
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
}

View File

@@ -1,6 +1,4 @@
using System.Collections.Immutable;
using System.Data;
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using Npgsql;
@@ -9,14 +7,20 @@ using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of IRiskStateRepository.
/// PostgreSQL implementation of <see cref="IRiskStateRepository"/>.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public sealed class PostgresRiskStateRepository : IRiskStateRepository
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private static readonly Guid TenantId = Guid.Parse(TenantContext);
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresRiskStateRepository> _logger;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string RiskStateSnapshotsTable => $"{SchemaName}.risk_state_snapshots";
public PostgresRiskStateRepository(
ScannerDataSource dataSource,
ILogger<PostgresRiskStateRepository> logger)
@@ -27,52 +31,63 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
public async Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await InsertSnapshotAsync(connection, snapshot, ct);
ArgumentNullException.ThrowIfNull(snapshot);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await InsertSnapshotAsync(connection, snapshot, ct).ConfigureAwait(false);
}
public async Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default)
{
if (snapshots.Count == 0)
return;
ArgumentNullException.ThrowIfNull(snapshots);
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
if (snapshots.Count == 0)
{
return;
}
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
foreach (var snapshot in snapshots)
{
await InsertSnapshotAsync(connection, snapshot, ct, transaction);
await InsertSnapshotAsync(connection, snapshot, ct, transaction).ConfigureAwait(false);
}
await transaction.CommitAsync(ct);
await transaction.CommitAsync(ct).ConfigureAwait(false);
}
catch
{
await transaction.RollbackAsync(ct);
await transaction.RollbackAsync(ct).ConfigureAwait(false);
throw;
}
}
public async Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default)
{
const string sql = """
SELECT
ArgumentNullException.ThrowIfNull(findingKey);
var 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
FROM {RiskStateSnapshotsTable}
WHERE tenant_id = @TenantId
AND vuln_id = @VulnId
AND purl = @Purl
ORDER BY captured_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync<RiskStateRow>(sql, new
{
TenantId,
VulnId = findingKey.VulnId,
Purl = findingKey.Purl
Purl = findingKey.ComponentPurl
});
return row?.ToSnapshot();
@@ -80,18 +95,21 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default)
{
const string sql = """
SELECT
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
var 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
FROM {RiskStateSnapshotsTable}
WHERE tenant_id = @TenantId
AND 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 });
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { TenantId, ScanId = scanId.Trim() });
return rows.Select(r => r.ToSnapshot()).ToList();
}
@@ -101,22 +119,28 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
int limit = 10,
CancellationToken ct = default)
{
const string sql = """
SELECT
ArgumentNullException.ThrowIfNull(findingKey);
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
var 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
FROM {RiskStateSnapshotsTable}
WHERE tenant_id = @TenantId
AND vuln_id = @VulnId
AND purl = @Purl
ORDER BY captured_at DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new
{
TenantId,
VulnId = findingKey.VulnId,
Purl = findingKey.Purl,
Purl = findingKey.ComponentPurl,
Limit = limit
});
@@ -125,37 +149,42 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default)
{
const string sql = """
SELECT
ArgumentException.ThrowIfNullOrWhiteSpace(stateHash);
var 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
FROM {RiskStateSnapshotsTable}
WHERE tenant_id = @TenantId
AND 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 });
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { TenantId, StateHash = stateHash.Trim() });
return rows.Select(r => r.ToSnapshot()).ToList();
}
private static async Task InsertSnapshotAsync(
private async Task InsertSnapshotAsync(
NpgsqlConnection connection,
RiskStateSnapshot snapshot,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
const string sql = """
INSERT INTO scanner.risk_state_snapshots (
ArgumentNullException.ThrowIfNull(snapshot);
var sql = $"""
INSERT INTO {RiskStateSnapshotsTable} (
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
@Reachable, @LatticeState, @VexStatus::vex_status_type, @InAffectedRange,
@Kev, @EpssScore, @PolicyFlags, @PolicyDecision::policy_decision_type, @StateHash
)
ON CONFLICT (tenant_id, scan_id, vuln_id, purl) DO UPDATE SET
reachable = EXCLUDED.reachable,
@@ -169,32 +198,27 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
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");
await connection.ExecuteAsync(new CommandDefinition(
sql,
new
{
TenantId,
VulnId = snapshot.FindingKey.VulnId,
Purl = snapshot.FindingKey.ComponentPurl,
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)).ConfigureAwait(false);
}
/// <summary>
@@ -214,7 +238,6 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
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()
{
@@ -247,7 +270,9 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
private static PolicyDecisionType? ParsePolicyDecision(string? value)
{
if (string.IsNullOrEmpty(value))
{
return null;
}
return value.ToLowerInvariant() switch
{

View File

@@ -13,8 +13,15 @@ namespace StellaOps.Scanner.Storage.Postgres;
/// </summary>
public sealed class PostgresVexCandidateStore : IVexCandidateStore
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private static readonly Guid TenantId = Guid.Parse(TenantContext);
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresVexCandidateStore> _logger;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string VexCandidatesTable => $"{SchemaName}.vex_candidates";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
@@ -30,83 +37,96 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
public async Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(candidates);
if (candidates.Count == 0)
return;
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
foreach (var candidate in candidates)
{
await InsertCandidateAsync(connection, candidate, ct, transaction);
await InsertCandidateAsync(connection, candidate, ct, transaction).ConfigureAwait(false);
}
await transaction.CommitAsync(ct);
await transaction.CommitAsync(ct).ConfigureAwait(false);
_logger.LogDebug("Stored {Count} VEX candidates", candidates.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store VEX candidates");
await transaction.RollbackAsync(ct);
await transaction.RollbackAsync(ct).ConfigureAwait(false);
throw;
}
}
public async Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default)
{
const string sql = """
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
var 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
FROM {VexCandidatesTable}
WHERE tenant_id = @TenantId
AND 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 });
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var rows = await connection.QueryAsync<VexCandidateRow>(sql, new { TenantId, ImageDigest = imageDigest.Trim() });
return rows.Select(r => r.ToCandidate()).ToList();
}
public async Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default)
{
const string sql = """
ArgumentException.ThrowIfNullOrWhiteSpace(candidateId);
var 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
FROM {VexCandidatesTable}
WHERE tenant_id = @TenantId
AND candidate_id = @CandidateId
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<VexCandidateRow>(sql, new { CandidateId = candidateId });
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync<VexCandidateRow>(sql, new { TenantId, CandidateId = candidateId.Trim() });
return row?.ToCandidate();
}
public async Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default)
{
const string sql = """
UPDATE scanner.vex_candidates SET
ArgumentException.ThrowIfNullOrWhiteSpace(candidateId);
ArgumentNullException.ThrowIfNull(review);
var sql = $"""
UPDATE {VexCandidatesTable} SET
requires_review = FALSE,
review_action = @ReviewAction::scanner.vex_review_action,
review_action = @ReviewAction::vex_review_action,
reviewed_by = @ReviewedBy,
reviewed_at = @ReviewedAt,
review_comment = @ReviewComment
WHERE candidate_id = @CandidateId
WHERE tenant_id = @TenantId
AND candidate_id = @CandidateId
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
var affected = await connection.ExecuteAsync(sql, new
{
CandidateId = candidateId,
TenantId,
CandidateId = candidateId.Trim(),
ReviewAction = review.Action.ToString().ToLowerInvariant(),
ReviewedBy = review.Reviewer,
ReviewedAt = review.ReviewedAt,
@@ -122,20 +142,23 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
return affected > 0;
}
private static async Task InsertCandidateAsync(
private async Task InsertCandidateAsync(
NpgsqlConnection connection,
VexCandidate candidate,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
const string sql = """
INSERT INTO scanner.vex_candidates (
ArgumentNullException.ThrowIfNull(connection);
ArgumentNullException.ThrowIfNull(candidate);
var sql = $"""
INSERT INTO {VexCandidatesTable} (
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,
@SuggestedStatus::vex_status_type, @Justification::vex_justification, @Rationale,
@EvidenceLinks::jsonb, @Confidence, @GeneratedAt, @ExpiresAt, @RequiresReview
)
ON CONFLICT (candidate_id) DO UPDATE SET
@@ -147,7 +170,7 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
expires_at = EXCLUDED.expires_at
""";
var tenantId = GetCurrentTenantId();
var tenantId = TenantId;
var evidenceLinksJson = JsonSerializer.Serialize(candidate.EvidenceLinks, JsonOptions);
await connection.ExecuteAsync(new CommandDefinition(sql, new
@@ -155,7 +178,7 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
TenantId = tenantId,
CandidateId = candidate.CandidateId,
VulnId = candidate.FindingKey.VulnId,
Purl = candidate.FindingKey.Purl,
Purl = candidate.FindingKey.ComponentPurl,
ImageDigest = candidate.ImageDigest,
SuggestedStatus = MapVexStatus(candidate.SuggestedStatus),
Justification = MapJustification(candidate.Justification),
@@ -193,12 +216,6 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
};
}
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>

View File

@@ -150,21 +150,34 @@ public sealed class ClassificationHistoryRepository : RepositoryBase<ScannerData
DateOnly toDate,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT day_bucket, tenant_id, cause, total_reclassified, fn_count, fn_drift_percent,
feed_delta_count, rule_delta_count, lattice_delta_count, reachability_delta_count,
engine_count, other_count
FROM {DriftStatsView}
WHERE tenant_id = @tenant_id AND day_bucket >= @from_date AND day_bucket <= @to_date
ORDER BY day_bucket DESC
""";
var sql = tenantId == Guid.Empty
? $"""
SELECT day_bucket, tenant_id, cause, total_reclassified, fn_count, fn_drift_percent,
feed_delta_count, rule_delta_count, lattice_delta_count, reachability_delta_count,
engine_count, other_count
FROM {DriftStatsView}
WHERE day_bucket >= @from_date AND day_bucket <= @to_date
ORDER BY day_bucket DESC
"""
: $"""
SELECT day_bucket, tenant_id, cause, total_reclassified, fn_count, fn_drift_percent,
feed_delta_count, rule_delta_count, lattice_delta_count, reachability_delta_count,
engine_count, other_count
FROM {DriftStatsView}
WHERE tenant_id = @tenant_id AND day_bucket >= @from_date AND day_bucket <= @to_date
ORDER BY day_bucket DESC
""";
return QueryAsync(
Tenant,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (tenantId != Guid.Empty)
{
AddParameter(cmd, "tenant_id", tenantId);
}
AddParameter(cmd, "from_date", fromDate);
AddParameter(cmd, "to_date", toDate);
},

View File

@@ -0,0 +1,11 @@
using StellaOps.Scanner.CallGraph;
namespace StellaOps.Scanner.Storage.Repositories;
public interface ICallGraphSnapshotRepository
{
Task StoreAsync(CallGraphSnapshot snapshot, CancellationToken ct = default);
Task<CallGraphSnapshot?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default);
}

View File

@@ -0,0 +1,11 @@
using StellaOps.Scanner.CallGraph;
namespace StellaOps.Scanner.Storage.Repositories;
public interface IReachabilityResultRepository
{
Task StoreAsync(ReachabilityAnalysisResult result, CancellationToken ct = default);
Task<ReachabilityAnalysisResult?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default);
}

View File

@@ -0,0 +1,174 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Storage.Models;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Services;
/// <summary>
/// Calculates FN-Drift rate with stratification.
/// </summary>
public sealed class FnDriftCalculator
{
private readonly IClassificationHistoryRepository _repository;
private readonly ILogger<FnDriftCalculator> _logger;
public FnDriftCalculator(
IClassificationHistoryRepository repository,
ILogger<FnDriftCalculator> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Computes FN-Drift for a tenant over a rolling window.
/// </summary>
/// <param name="tenantId">Tenant to calculate for</param>
/// <param name="windowDays">Rolling window in days (default: 30)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>FN-Drift summary with stratification</returns>
public async Task<FnDrift30dSummary> CalculateAsync(
Guid tenantId,
int windowDays = 30,
CancellationToken cancellationToken = default)
{
var since = DateTimeOffset.UtcNow.AddDays(-windowDays);
var changes = await _repository.GetChangesAsync(tenantId, since, cancellationToken);
var fnTransitions = changes.Where(c => c.IsFnTransition).ToList();
var totalEvaluated = changes.Count;
var summary = new FnDrift30dSummary
{
TenantId = tenantId,
TotalFnTransitions = fnTransitions.Count,
TotalEvaluated = totalEvaluated,
FnDriftPercent = totalEvaluated > 0
? Math.Round((decimal)fnTransitions.Count / totalEvaluated * 100, 4)
: 0,
FeedCaused = fnTransitions.Count(c => c.Cause == DriftCause.FeedDelta),
RuleCaused = fnTransitions.Count(c => c.Cause == DriftCause.RuleDelta),
LatticeCaused = fnTransitions.Count(c => c.Cause == DriftCause.LatticeDelta),
ReachabilityCaused = fnTransitions.Count(c => c.Cause == DriftCause.ReachabilityDelta),
EngineCaused = fnTransitions.Count(c => c.Cause == DriftCause.Engine)
};
_logger.LogInformation(
"FN-Drift for tenant {TenantId}: {Percent}% ({FnCount}/{Total}), " +
"Feed={Feed}, Rule={Rule}, Lattice={Lattice}, Reach={Reach}, Engine={Engine}",
tenantId, summary.FnDriftPercent, summary.TotalFnTransitions, summary.TotalEvaluated,
summary.FeedCaused, summary.RuleCaused, summary.LatticeCaused,
summary.ReachabilityCaused, summary.EngineCaused);
return summary;
}
/// <summary>
/// Determines the drift cause for a classification change.
/// </summary>
public DriftCause DetermineCause(
string? previousFeedVersion,
string? currentFeedVersion,
string? previousRuleHash,
string? currentRuleHash,
string? previousLatticeHash,
string? currentLatticeHash,
bool? previousReachable,
bool? currentReachable)
{
// Priority order: feed > rule > lattice > reachability > engine > other
// Check feed delta
if (!string.Equals(previousFeedVersion, currentFeedVersion, StringComparison.Ordinal))
{
_logger.LogDebug(
"Drift cause: feed_delta (prev={PrevFeed}, curr={CurrFeed})",
previousFeedVersion, currentFeedVersion);
return DriftCause.FeedDelta;
}
// Check rule delta
if (!string.Equals(previousRuleHash, currentRuleHash, StringComparison.Ordinal))
{
_logger.LogDebug(
"Drift cause: rule_delta (prev={PrevRule}, curr={CurrRule})",
previousRuleHash, currentRuleHash);
return DriftCause.RuleDelta;
}
// Check lattice delta
if (!string.Equals(previousLatticeHash, currentLatticeHash, StringComparison.Ordinal))
{
_logger.LogDebug(
"Drift cause: lattice_delta (prev={PrevLattice}, curr={CurrLattice})",
previousLatticeHash, currentLatticeHash);
return DriftCause.LatticeDelta;
}
// Check reachability delta
if (previousReachable != currentReachable)
{
_logger.LogDebug(
"Drift cause: reachability_delta (prev={PrevReach}, curr={CurrReach})",
previousReachable, currentReachable);
return DriftCause.ReachabilityDelta;
}
// If nothing external changed, it's an engine change or unknown
_logger.LogDebug("Drift cause: other (no external cause identified)");
return DriftCause.Other;
}
/// <summary>
/// Creates a ClassificationChange record for a status transition.
/// </summary>
public ClassificationChange CreateChange(
string artifactDigest,
string vulnId,
string packagePurl,
Guid tenantId,
Guid manifestId,
Guid executionId,
ClassificationStatus previousStatus,
ClassificationStatus newStatus,
DriftCause cause,
IReadOnlyDictionary<string, string>? causeDetail = null)
{
return new ClassificationChange
{
ArtifactDigest = artifactDigest,
VulnId = vulnId,
PackagePurl = packagePurl,
TenantId = tenantId,
ManifestId = manifestId,
ExecutionId = executionId,
PreviousStatus = previousStatus,
NewStatus = newStatus,
Cause = cause,
CauseDetail = causeDetail,
ChangedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Checks if the FN-Drift rate exceeds the threshold.
/// </summary>
/// <param name="summary">The drift summary to check</param>
/// <param name="thresholdPercent">Maximum acceptable FN-Drift rate (default: 5%)</param>
/// <returns>True if drift rate exceeds threshold</returns>
public bool ExceedsThreshold(FnDrift30dSummary summary, decimal thresholdPercent = 5.0m)
{
ArgumentNullException.ThrowIfNull(summary);
var exceeds = summary.FnDriftPercent > thresholdPercent;
if (exceeds)
{
_logger.LogWarning(
"FN-Drift for tenant {TenantId} exceeds threshold: {Percent}% > {Threshold}%",
summary.TenantId, summary.FnDriftPercent, thresholdPercent);
}
return exceeds;
}
}

View File

@@ -142,6 +142,8 @@ public sealed class FnDriftMetricsExporter : BackgroundService
private async Task RefreshMetricsAsync(CancellationToken cancellationToken)
{
await _repository.RefreshDriftStatsAsync(cancellationToken);
// Get 30-day summary for all tenants (aggregated)
// In production, this would iterate over active tenants
var now = _timeProvider.GetUtcNow();

View File

@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.305.6" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
@@ -20,8 +21,10 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Scanner.EntryTrace\\StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.Core\\StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.ProofSpine\\StellaOps.Scanner.ProofSpine.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.SmartDiff\\StellaOps.Scanner.SmartDiff.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,237 +0,0 @@
using StellaOps.Scanner.Storage.Models;
using StellaOps.Scanner.Storage.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
/// <summary>
/// Unit tests for ClassificationChangeTracker.
/// SPRINT_3404_0001_0001 - Task #11, #12
/// </summary>
public sealed class ClassificationChangeTrackerTests
{
private readonly Mock<IClassificationHistoryRepository> _repositoryMock;
private readonly ClassificationChangeTracker _tracker;
private readonly FakeTimeProvider _timeProvider;
public ClassificationChangeTrackerTests()
{
_repositoryMock = new Mock<IClassificationHistoryRepository>();
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
_tracker = new ClassificationChangeTracker(
_repositoryMock.Object,
NullLogger<ClassificationChangeTracker>.Instance,
_timeProvider);
}
[Fact]
public async Task TrackChangeAsync_ActualChange_InsertsToRepository()
{
// Arrange
var change = CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected);
// Act
await _tracker.TrackChangeAsync(change);
// Assert
_repositoryMock.Verify(r => r.InsertAsync(change, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task TrackChangeAsync_NoOpChange_SkipsInsert()
{
// Arrange - same status
var change = CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected);
// Act
await _tracker.TrackChangeAsync(change);
// Assert
_repositoryMock.Verify(r => r.InsertAsync(It.IsAny<ClassificationChange>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task TrackChangesAsync_FiltersNoOpChanges()
{
// Arrange
var changes = new[]
{
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected),
CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected), // No-op
CreateChange(ClassificationStatus.Affected, ClassificationStatus.Fixed),
};
// Act
await _tracker.TrackChangesAsync(changes);
// Assert
_repositoryMock.Verify(r => r.InsertBatchAsync(
It.Is<IEnumerable<ClassificationChange>>(c => c.Count() == 2),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task TrackChangesAsync_EmptyAfterFilter_DoesNotInsert()
{
// Arrange - all no-ops
var changes = new[]
{
CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected),
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Unknown),
};
// Act
await _tracker.TrackChangesAsync(changes);
// Assert
_repositoryMock.Verify(r => r.InsertBatchAsync(It.IsAny<IEnumerable<ClassificationChange>>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public void IsFnTransition_UnknownToAffected_ReturnsTrue()
{
// Arrange
var change = CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected);
// Assert
Assert.True(change.IsFnTransition);
}
[Fact]
public void IsFnTransition_UnaffectedToAffected_ReturnsTrue()
{
// Arrange
var change = CreateChange(ClassificationStatus.Unaffected, ClassificationStatus.Affected);
// Assert
Assert.True(change.IsFnTransition);
}
[Fact]
public void IsFnTransition_AffectedToFixed_ReturnsFalse()
{
// Arrange
var change = CreateChange(ClassificationStatus.Affected, ClassificationStatus.Fixed);
// Assert
Assert.False(change.IsFnTransition);
}
[Fact]
public void IsFnTransition_NewToAffected_ReturnsFalse()
{
// Arrange - new finding, not a reclassification
var change = CreateChange(ClassificationStatus.New, ClassificationStatus.Affected);
// Assert
Assert.False(change.IsFnTransition);
}
[Fact]
public async Task ComputeDeltaAsync_NewFinding_RecordsAsNewStatus()
{
// Arrange
var tenantId = Guid.NewGuid();
var artifact = "sha256:abc123";
var prevExecId = Guid.NewGuid();
var currExecId = Guid.NewGuid();
_repositoryMock
.Setup(r => r.GetByExecutionAsync(tenantId, prevExecId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<ClassificationChange>());
_repositoryMock
.Setup(r => r.GetByExecutionAsync(tenantId, currExecId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
CreateChange(ClassificationStatus.New, ClassificationStatus.Affected, artifact, "CVE-2024-0001"),
});
// Act
var delta = await _tracker.ComputeDeltaAsync(tenantId, artifact, prevExecId, currExecId);
// Assert
Assert.Single(delta);
Assert.Equal(ClassificationStatus.New, delta[0].PreviousStatus);
Assert.Equal(ClassificationStatus.Affected, delta[0].NewStatus);
}
[Fact]
public async Task ComputeDeltaAsync_StatusChange_RecordsDelta()
{
// Arrange
var tenantId = Guid.NewGuid();
var artifact = "sha256:abc123";
var prevExecId = Guid.NewGuid();
var currExecId = Guid.NewGuid();
_repositoryMock
.Setup(r => r.GetByExecutionAsync(tenantId, prevExecId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
CreateChange(ClassificationStatus.New, ClassificationStatus.Unknown, artifact, "CVE-2024-0001"),
});
_repositoryMock
.Setup(r => r.GetByExecutionAsync(tenantId, currExecId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected, artifact, "CVE-2024-0001"),
});
// Act
var delta = await _tracker.ComputeDeltaAsync(tenantId, artifact, prevExecId, currExecId);
// Assert
Assert.Single(delta);
Assert.Equal(ClassificationStatus.Unknown, delta[0].PreviousStatus);
Assert.Equal(ClassificationStatus.Affected, delta[0].NewStatus);
}
private static ClassificationChange CreateChange(
ClassificationStatus previous,
ClassificationStatus next,
string artifact = "sha256:test",
string vulnId = "CVE-2024-0001")
{
return new ClassificationChange
{
ArtifactDigest = artifact,
VulnId = vulnId,
PackagePurl = "pkg:npm/test@1.0.0",
TenantId = Guid.NewGuid(),
ManifestId = Guid.NewGuid(),
ExecutionId = Guid.NewGuid(),
PreviousStatus = previous,
NewStatus = next,
Cause = DriftCause.FeedDelta,
};
}
}
/// <summary>
/// Fake time provider for testing.
/// </summary>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
/// <summary>
/// Mock interface for testing.
/// </summary>
public interface IClassificationHistoryRepository
{
Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default);
Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ClassificationChange>> GetByExecutionAsync(Guid tenantId, Guid executionId, CancellationToken cancellationToken = default);
}