Add call graph fixtures for various languages and scenarios
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET. - Added `all-visibility-levels.json` to validate method visibility levels in .NET. - Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application. - Included `go-gin-api.json` for a Go Gin API application structure. - Added `java-spring-boot.json` for the Spring PetClinic application in Java. - Introduced `legacy-no-schema.json` for legacy application structure without schema. - Created `node-express-api.json` for an Express.js API application structure.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
namespace StellaOps.Scanner.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a classification status change for FN-Drift tracking.
|
||||
/// </summary>
|
||||
public sealed record ClassificationChange
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
// Artifact identification
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string VulnId { get; init; }
|
||||
public required string PackagePurl { get; init; }
|
||||
|
||||
// Scan context
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid ManifestId { get; init; }
|
||||
public required Guid ExecutionId { get; init; }
|
||||
|
||||
// Status transition
|
||||
public required ClassificationStatus PreviousStatus { get; init; }
|
||||
public required ClassificationStatus NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if this was a false-negative transition (unaffected/unknown -> affected)
|
||||
/// </summary>
|
||||
public bool IsFnTransition =>
|
||||
PreviousStatus is ClassificationStatus.Unaffected or ClassificationStatus.Unknown
|
||||
&& NewStatus == ClassificationStatus.Affected;
|
||||
|
||||
// Drift cause
|
||||
public required DriftCause Cause { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? CauseDetail { get; init; }
|
||||
|
||||
// Timestamp
|
||||
public DateTimeOffset ChangedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classification status values.
|
||||
/// </summary>
|
||||
public enum ClassificationStatus
|
||||
{
|
||||
/// <summary>First scan, no previous status</summary>
|
||||
New,
|
||||
|
||||
/// <summary>Confirmed not affected</summary>
|
||||
Unaffected,
|
||||
|
||||
/// <summary>Status unknown/uncertain</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Confirmed affected</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>Previously affected, now fixed</summary>
|
||||
Fixed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stratification causes for FN-Drift analysis.
|
||||
/// </summary>
|
||||
public enum DriftCause
|
||||
{
|
||||
/// <summary>Vulnerability feed updated (NVD, GHSA, OVAL)</summary>
|
||||
FeedDelta,
|
||||
|
||||
/// <summary>Policy rules changed</summary>
|
||||
RuleDelta,
|
||||
|
||||
/// <summary>VEX lattice state changed</summary>
|
||||
LatticeDelta,
|
||||
|
||||
/// <summary>Reachability analysis changed</summary>
|
||||
ReachabilityDelta,
|
||||
|
||||
/// <summary>Scanner engine change (should be ~0)</summary>
|
||||
Engine,
|
||||
|
||||
/// <summary>Other/unknown cause</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FN-Drift statistics for a time period.
|
||||
/// </summary>
|
||||
public sealed record FnDriftStats
|
||||
{
|
||||
public required DateOnly DayBucket { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required DriftCause Cause { get; init; }
|
||||
|
||||
public required int TotalReclassified { get; init; }
|
||||
public required int FnCount { get; init; }
|
||||
public required decimal FnDriftPercent { get; init; }
|
||||
|
||||
// Stratification counts
|
||||
public required int FeedDeltaCount { get; init; }
|
||||
public required int RuleDeltaCount { get; init; }
|
||||
public required int LatticeDeltaCount { get; init; }
|
||||
public required int ReachabilityDeltaCount { get; init; }
|
||||
public required int EngineCount { get; init; }
|
||||
public required int OtherCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 30-day rolling FN-Drift summary.
|
||||
/// </summary>
|
||||
public sealed record FnDrift30dSummary
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required int TotalFnTransitions { get; init; }
|
||||
public required int TotalEvaluated { get; init; }
|
||||
public required decimal FnDriftPercent { get; init; }
|
||||
|
||||
// Stratification breakdown
|
||||
public required int FeedCaused { get; init; }
|
||||
public required int RuleCaused { get; init; }
|
||||
public required int LatticeCaused { get; init; }
|
||||
public required int ReachabilityCaused { get; init; }
|
||||
public required int EngineCaused { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Classification history for FN-Drift tracking
|
||||
-- Per advisory section 13.2
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- Artifact identification
|
||||
artifact_digest TEXT NOT NULL,
|
||||
vuln_id TEXT NOT NULL,
|
||||
package_purl TEXT NOT NULL,
|
||||
|
||||
-- Scan context
|
||||
tenant_id UUID NOT NULL,
|
||||
manifest_id UUID NOT NULL,
|
||||
execution_id UUID NOT NULL,
|
||||
|
||||
-- Status transition
|
||||
previous_status TEXT NOT NULL, -- 'new', 'unaffected', 'unknown', 'affected', 'fixed'
|
||||
new_status TEXT NOT NULL,
|
||||
is_fn_transition BOOLEAN NOT NULL GENERATED ALWAYS AS (
|
||||
previous_status IN ('unaffected', 'unknown') AND new_status = 'affected'
|
||||
) STORED,
|
||||
|
||||
-- Drift cause classification
|
||||
cause TEXT NOT NULL, -- 'feed_delta', 'rule_delta', 'lattice_delta', 'reachability_delta', 'engine', 'other'
|
||||
cause_detail JSONB, -- Additional context (e.g., feed version, rule hash)
|
||||
|
||||
-- Timestamps
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT valid_previous_status CHECK (previous_status IN ('new', 'unaffected', 'unknown', 'affected', 'fixed')),
|
||||
CONSTRAINT valid_new_status CHECK (new_status IN ('unaffected', 'unknown', 'affected', 'fixed')),
|
||||
CONSTRAINT valid_cause CHECK (cause IN ('feed_delta', 'rule_delta', 'lattice_delta', 'reachability_delta', 'engine', 'other'))
|
||||
);
|
||||
|
||||
-- Indexes for common query patterns
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_artifact ON classification_history(artifact_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_tenant ON classification_history(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_changed_at ON classification_history(changed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_fn_transition ON classification_history(is_fn_transition) WHERE is_fn_transition = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_cause ON classification_history(cause);
|
||||
CREATE INDEX IF NOT EXISTS idx_classification_history_vuln ON classification_history(vuln_id);
|
||||
|
||||
COMMENT ON TABLE classification_history IS 'Tracks vulnerability classification changes for FN-Drift analysis';
|
||||
COMMENT ON COLUMN classification_history.is_fn_transition IS 'True if this was a false-negative transition (unaffected/unknown -> affected)';
|
||||
COMMENT ON COLUMN classification_history.cause IS 'Stratification cause: feed_delta, rule_delta, lattice_delta, reachability_delta, engine, other';
|
||||
|
||||
-- Materialized view for FN-Drift statistics
|
||||
-- Aggregates classification_history for dashboard queries
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS fn_drift_stats AS
|
||||
SELECT
|
||||
date_trunc('day', changed_at)::date AS day_bucket,
|
||||
tenant_id,
|
||||
cause,
|
||||
|
||||
-- Total reclassifications
|
||||
COUNT(*) AS total_reclassified,
|
||||
|
||||
-- FN transitions (unaffected/unknown -> affected)
|
||||
COUNT(*) FILTER (WHERE is_fn_transition) AS fn_count,
|
||||
|
||||
-- FN-Drift rate
|
||||
ROUND(
|
||||
(COUNT(*) FILTER (WHERE is_fn_transition)::numeric /
|
||||
NULLIF(COUNT(*), 0)) * 100, 4
|
||||
) AS fn_drift_percent,
|
||||
|
||||
-- Stratification counts
|
||||
COUNT(*) FILTER (WHERE cause = 'feed_delta') AS feed_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'rule_delta') AS rule_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'lattice_delta') AS lattice_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'reachability_delta') AS reachability_delta_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'engine') AS engine_count,
|
||||
COUNT(*) FILTER (WHERE cause = 'other') AS other_count
|
||||
|
||||
FROM classification_history
|
||||
GROUP BY date_trunc('day', changed_at)::date, tenant_id, cause;
|
||||
|
||||
-- Index for efficient queries
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_fn_drift_stats_pk ON fn_drift_stats(day_bucket, tenant_id, cause);
|
||||
CREATE INDEX IF NOT EXISTS idx_fn_drift_stats_tenant ON fn_drift_stats(tenant_id);
|
||||
|
||||
-- View for 30-day rolling FN-Drift (per advisory definition)
|
||||
CREATE OR REPLACE VIEW fn_drift_30d AS
|
||||
SELECT
|
||||
tenant_id,
|
||||
SUM(fn_count)::int AS total_fn_transitions,
|
||||
SUM(total_reclassified)::int AS total_evaluated,
|
||||
ROUND(
|
||||
(SUM(fn_count)::numeric / NULLIF(SUM(total_reclassified), 0)) * 100, 4
|
||||
) AS fn_drift_percent,
|
||||
|
||||
-- Stratification breakdown
|
||||
SUM(feed_delta_count)::int AS feed_caused,
|
||||
SUM(rule_delta_count)::int AS rule_caused,
|
||||
SUM(lattice_delta_count)::int AS lattice_caused,
|
||||
SUM(reachability_delta_count)::int AS reachability_caused,
|
||||
SUM(engine_count)::int AS engine_caused
|
||||
|
||||
FROM fn_drift_stats
|
||||
WHERE day_bucket >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY tenant_id;
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW fn_drift_stats IS 'Daily FN-Drift statistics, refresh periodically';
|
||||
COMMENT ON VIEW fn_drift_30d IS 'Rolling 30-day FN-Drift rate per tenant';
|
||||
@@ -4,4 +4,5 @@ internal static class MigrationIds
|
||||
{
|
||||
public const string CreateTables = "001_create_tables.sql";
|
||||
public const string ProofSpineTables = "002_proof_spine_tables.sql";
|
||||
public const string ClassificationHistory = "003_classification_history.sql";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of classification history repository.
|
||||
/// </summary>
|
||||
public sealed class ClassificationHistoryRepository : RepositoryBase<ScannerDataSource>, IClassificationHistoryRepository
|
||||
{
|
||||
private const string Tenant = "";
|
||||
private string Table => $"{SchemaName}.classification_history";
|
||||
private string DriftStatsView => $"{SchemaName}.fn_drift_stats";
|
||||
private string Drift30dView => $"{SchemaName}.fn_drift_30d";
|
||||
private string SchemaName => DataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public ClassificationHistoryRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<ClassificationHistoryRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {Table}
|
||||
(artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, cause, cause_detail, changed_at)
|
||||
VALUES
|
||||
(@artifact_digest, @vuln_id, @package_purl, @tenant_id, @manifest_id, @execution_id,
|
||||
@previous_status, @new_status, @cause, @cause_detail::jsonb, @changed_at)
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddChangeParameters(cmd, change),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(changes);
|
||||
|
||||
var changeList = changes.ToList();
|
||||
if (changeList.Count == 0) return;
|
||||
|
||||
// Use batch insert for better performance
|
||||
foreach (var change in changeList)
|
||||
{
|
||||
await InsertAsync(change, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetChangesAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE tenant_id = @tenant_id AND changed_at >= @since
|
||||
ORDER BY changed_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "since", since);
|
||||
},
|
||||
MapChange,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByArtifactAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE artifact_digest = @artifact_digest
|
||||
ORDER BY changed_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "artifact_digest", artifactDigest),
|
||||
MapChange,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByVulnIdAsync(
|
||||
string vulnId,
|
||||
Guid? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnId);
|
||||
|
||||
var sql = tenantId.HasValue
|
||||
? $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE vuln_id = @vuln_id AND tenant_id = @tenant_id
|
||||
ORDER BY changed_at DESC
|
||||
"""
|
||||
: $"""
|
||||
SELECT id, artifact_digest, vuln_id, package_purl, tenant_id, manifest_id, execution_id,
|
||||
previous_status, new_status, is_fn_transition, cause, cause_detail, changed_at
|
||||
FROM {Table}
|
||||
WHERE vuln_id = @vuln_id
|
||||
ORDER BY changed_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "vuln_id", vulnId);
|
||||
if (tenantId.HasValue)
|
||||
AddParameter(cmd, "tenant_id", tenantId.Value);
|
||||
},
|
||||
MapChange,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FnDriftStats>> GetDriftStatsAsync(
|
||||
Guid tenantId,
|
||||
DateOnly fromDate,
|
||||
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
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "from_date", fromDate);
|
||||
AddParameter(cmd, "to_date", toDate);
|
||||
},
|
||||
MapDriftStats,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<FnDrift30dSummary?> GetDrift30dSummaryAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT tenant_id, total_fn_transitions, total_evaluated, fn_drift_percent,
|
||||
feed_caused, rule_caused, lattice_caused, reachability_caused, engine_caused
|
||||
FROM {Drift30dView}
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapDrift30dSummary,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"REFRESH MATERIALIZED VIEW CONCURRENTLY {DriftStatsView}";
|
||||
|
||||
await ExecuteAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
static _ => { },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private void AddChangeParameters(NpgsqlCommand cmd, ClassificationChange change)
|
||||
{
|
||||
AddParameter(cmd, "artifact_digest", change.ArtifactDigest);
|
||||
AddParameter(cmd, "vuln_id", change.VulnId);
|
||||
AddParameter(cmd, "package_purl", change.PackagePurl);
|
||||
AddParameter(cmd, "tenant_id", change.TenantId);
|
||||
AddParameter(cmd, "manifest_id", change.ManifestId);
|
||||
AddParameter(cmd, "execution_id", change.ExecutionId);
|
||||
AddParameter(cmd, "previous_status", MapStatusToString(change.PreviousStatus));
|
||||
AddParameter(cmd, "new_status", MapStatusToString(change.NewStatus));
|
||||
AddParameter(cmd, "cause", MapCauseToString(change.Cause));
|
||||
AddParameter(cmd, "cause_detail", change.CauseDetail != null
|
||||
? JsonSerializer.Serialize(change.CauseDetail, JsonOptions)
|
||||
: null);
|
||||
AddParameter(cmd, "changed_at", change.ChangedAt);
|
||||
}
|
||||
|
||||
private static ClassificationChange MapChange(NpgsqlDataReader reader)
|
||||
{
|
||||
var causeDetailJson = reader.IsDBNull(11) ? null : reader.GetString(11);
|
||||
var causeDetail = causeDetailJson != null
|
||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(causeDetailJson, JsonOptions)
|
||||
: null;
|
||||
|
||||
return new ClassificationChange
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
ArtifactDigest = reader.GetString(1),
|
||||
VulnId = reader.GetString(2),
|
||||
PackagePurl = reader.GetString(3),
|
||||
TenantId = reader.GetGuid(4),
|
||||
ManifestId = reader.GetGuid(5),
|
||||
ExecutionId = reader.GetGuid(6),
|
||||
PreviousStatus = MapStringToStatus(reader.GetString(7)),
|
||||
NewStatus = MapStringToStatus(reader.GetString(8)),
|
||||
// is_fn_transition is at index 9, but we compute it from PreviousStatus/NewStatus
|
||||
Cause = MapStringToCause(reader.GetString(10)),
|
||||
CauseDetail = causeDetail,
|
||||
ChangedAt = reader.GetDateTime(12)
|
||||
};
|
||||
}
|
||||
|
||||
private static FnDriftStats MapDriftStats(NpgsqlDataReader reader)
|
||||
{
|
||||
return new FnDriftStats
|
||||
{
|
||||
DayBucket = DateOnly.FromDateTime(reader.GetDateTime(0)),
|
||||
TenantId = reader.GetGuid(1),
|
||||
Cause = MapStringToCause(reader.GetString(2)),
|
||||
TotalReclassified = reader.GetInt32(3),
|
||||
FnCount = reader.GetInt32(4),
|
||||
FnDriftPercent = reader.GetDecimal(5),
|
||||
FeedDeltaCount = reader.GetInt32(6),
|
||||
RuleDeltaCount = reader.GetInt32(7),
|
||||
LatticeDeltaCount = reader.GetInt32(8),
|
||||
ReachabilityDeltaCount = reader.GetInt32(9),
|
||||
EngineCount = reader.GetInt32(10),
|
||||
OtherCount = reader.GetInt32(11)
|
||||
};
|
||||
}
|
||||
|
||||
private static FnDrift30dSummary MapDrift30dSummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new FnDrift30dSummary
|
||||
{
|
||||
TenantId = reader.GetGuid(0),
|
||||
TotalFnTransitions = reader.GetInt32(1),
|
||||
TotalEvaluated = reader.GetInt32(2),
|
||||
FnDriftPercent = reader.IsDBNull(3) ? 0 : reader.GetDecimal(3),
|
||||
FeedCaused = reader.GetInt32(4),
|
||||
RuleCaused = reader.GetInt32(5),
|
||||
LatticeCaused = reader.GetInt32(6),
|
||||
ReachabilityCaused = reader.GetInt32(7),
|
||||
EngineCaused = reader.GetInt32(8)
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapStatusToString(ClassificationStatus status) => status switch
|
||||
{
|
||||
ClassificationStatus.New => "new",
|
||||
ClassificationStatus.Unaffected => "unaffected",
|
||||
ClassificationStatus.Unknown => "unknown",
|
||||
ClassificationStatus.Affected => "affected",
|
||||
ClassificationStatus.Fixed => "fixed",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status))
|
||||
};
|
||||
|
||||
private static ClassificationStatus MapStringToStatus(string status) => status switch
|
||||
{
|
||||
"new" => ClassificationStatus.New,
|
||||
"unaffected" => ClassificationStatus.Unaffected,
|
||||
"unknown" => ClassificationStatus.Unknown,
|
||||
"affected" => ClassificationStatus.Affected,
|
||||
"fixed" => ClassificationStatus.Fixed,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status))
|
||||
};
|
||||
|
||||
private static string MapCauseToString(DriftCause cause) => cause switch
|
||||
{
|
||||
DriftCause.FeedDelta => "feed_delta",
|
||||
DriftCause.RuleDelta => "rule_delta",
|
||||
DriftCause.LatticeDelta => "lattice_delta",
|
||||
DriftCause.ReachabilityDelta => "reachability_delta",
|
||||
DriftCause.Engine => "engine",
|
||||
DriftCause.Other => "other",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(cause))
|
||||
};
|
||||
|
||||
private static DriftCause MapStringToCause(string cause) => cause switch
|
||||
{
|
||||
"feed_delta" => DriftCause.FeedDelta,
|
||||
"rule_delta" => DriftCause.RuleDelta,
|
||||
"lattice_delta" => DriftCause.LatticeDelta,
|
||||
"reachability_delta" => DriftCause.ReachabilityDelta,
|
||||
"engine" => DriftCause.Engine,
|
||||
"other" => DriftCause.Other,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(cause))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for classification history operations.
|
||||
/// </summary>
|
||||
public interface IClassificationHistoryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a classification status change.
|
||||
/// </summary>
|
||||
Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records multiple classification changes in a batch.
|
||||
/// </summary>
|
||||
Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets classification changes for a tenant since a given date.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ClassificationChange>> GetChangesAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets classification changes for a specific artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ClassificationChange>> GetByArtifactAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets classification changes for a specific vulnerability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ClassificationChange>> GetByVulnIdAsync(
|
||||
string vulnId,
|
||||
Guid? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets FN-Drift statistics from the materialized view.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FnDriftStats>> GetDriftStatsAsync(
|
||||
Guid tenantId,
|
||||
DateOnly fromDate,
|
||||
DateOnly toDate,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets 30-day rolling FN-Drift summary for a tenant.
|
||||
/// </summary>
|
||||
Task<FnDrift30dSummary?> GetDrift30dSummaryAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the FN-Drift statistics materialized view.
|
||||
/// </summary>
|
||||
Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user