This commit is contained in:
StellaOps Bot
2025-12-18 20:37:27 +02:00
parent f85d53888c
commit 6410a6d082
17 changed files with 454 additions and 131 deletions

View File

@@ -124,7 +124,7 @@ public sealed class EpssPriorityCalculator
var flags = EpssChangeFlags.None;
// NEW_SCORED: first time we have EPSS data
if (!oldScore.HasValue && newScore > 0)
if (!oldScore.HasValue)
{
flags |= EpssChangeFlags.NewScored;
}
@@ -133,31 +133,37 @@ public sealed class EpssPriorityCalculator
{
var delta = newScore - oldScore.Value;
// BIG_JUMP: significant score increase
if (delta >= _options.BigJumpDelta)
{
flags |= EpssChangeFlags.BigJump;
}
// DROPPED_LOW: significant score decrease
if (delta <= -_options.DroppedLowDelta)
{
flags |= EpssChangeFlags.DroppedLow;
}
}
// CROSSED_HIGH: moved into or out of high priority
var oldBand = Calculate(oldScore, oldPercentile).Band;
var newBand = Calculate(newScore, newPercentile).Band;
if (oldBand != newBand)
{
// Crossed into critical or high
if ((newBand == EpssPriorityBand.Critical || newBand == EpssPriorityBand.High) &&
oldBand != EpssPriorityBand.Critical && oldBand != EpssPriorityBand.High)
if (oldScore.Value < _options.HighScore && newScore >= _options.HighScore)
{
flags |= EpssChangeFlags.CrossedHigh;
}
if (oldScore.Value >= _options.HighScore && newScore < _options.HighScore)
{
flags |= EpssChangeFlags.CrossedLow;
}
if (delta > _options.BigJumpDelta)
{
flags |= EpssChangeFlags.BigJumpUp;
}
if (delta < -_options.DroppedLowDelta)
{
flags |= EpssChangeFlags.BigJumpDown;
}
}
if ((oldPercentile is null || oldPercentile < _options.HighPercentile)
&& newPercentile >= _options.HighPercentile)
{
flags |= EpssChangeFlags.TopPercentile;
}
if (oldPercentile is not null && oldPercentile >= _options.HighPercentile
&& newPercentile < _options.HighPercentile)
{
flags |= EpssChangeFlags.LeftTopPercentile;
}
return flags;
@@ -174,14 +180,23 @@ public enum EpssChangeFlags
None = 0,
/// <summary>CVE was scored for the first time.</summary>
NewScored = 1 << 0,
NewScored = 1,
/// <summary>Score crossed into high priority band.</summary>
CrossedHigh = 1 << 1,
CrossedHigh = 2,
/// <summary>Score crossed below the high score threshold.</summary>
CrossedLow = 4,
/// <summary>Score increased significantly (above BigJumpDelta).</summary>
BigJump = 1 << 2,
BigJumpUp = 8,
/// <summary>Score dropped significantly (above DroppedLowDelta).</summary>
DroppedLow = 1 << 3
/// <summary>Score decreased significantly (above DroppedLowDelta).</summary>
BigJumpDown = 16,
/// <summary>Entered the top percentile band.</summary>
TopPercentile = 32,
/// <summary>Left the top percentile band.</summary>
LeftTopPercentile = 64
}

View File

@@ -8,6 +8,7 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Scanner.Core.Epss;
@@ -58,7 +59,7 @@ public sealed class CachingEpssProvider : IEpssProvider
{
var cacheResult = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value is not null)
if (cacheResult.HasValue && cacheResult.Value is not null)
{
_logger.LogDebug("Cache hit for EPSS score: {CveId}", cveId);
return MapFromCacheEntry(cacheResult.Value, fromCache: true);
@@ -119,7 +120,7 @@ public sealed class CachingEpssProvider : IEpssProvider
var cacheKey = BuildCacheKey(cveId);
var cacheResult = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value is not null)
if (cacheResult.HasValue && cacheResult.Value is not null)
{
var evidence = MapFromCacheEntry(cacheResult.Value, fromCache: true);
found.Add(evidence);
@@ -214,7 +215,7 @@ public sealed class CachingEpssProvider : IEpssProvider
{
var cacheResult = await _cache.GetAsync(ModelDateCacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value?.ModelDate is not null)
if (cacheResult.HasValue && cacheResult.Value is not null)
{
return cacheResult.Value.ModelDate;
}

View File

@@ -5,6 +5,8 @@
// Description: Deterministic EPSS delta flag computation (mirrors SQL function).
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Epss;
namespace StellaOps.Scanner.Storage.Epss;
public static class EpssChangeDetector
@@ -72,4 +74,3 @@ public readonly record struct EpssChangeThresholds(
double HighScore,
double HighPercentile,
double BigJumpDelta);

View File

@@ -1,36 +0,0 @@
// -----------------------------------------------------------------------------
// EpssChangeFlags.cs
// Sprint: SPRINT_3410_0001_0001_epss_ingestion_storage
// Task: EPSS-3410-008
// Description: Flag bitmask for EPSS change detection.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Epss;
[Flags]
public enum EpssChangeFlags
{
None = 0,
/// <summary>0x01 - CVE newly scored (not in previous snapshot).</summary>
NewScored = 1,
/// <summary>0x02 - Crossed above the high score threshold.</summary>
CrossedHigh = 2,
/// <summary>0x04 - Crossed below the high score threshold.</summary>
CrossedLow = 4,
/// <summary>0x08 - Score increased by more than the big jump delta.</summary>
BigJumpUp = 8,
/// <summary>0x10 - Score decreased by more than the big jump delta.</summary>
BigJumpDown = 16,
/// <summary>0x20 - Entered the top percentile band.</summary>
TopPercentile = 32,
/// <summary>0x40 - Left the top percentile band.</summary>
LeftTopPercentile = 64
}

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;

View File

@@ -44,7 +44,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
RETURNING raw_id, ingestion_ts
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var result = await connection.QueryFirstOrDefaultAsync<(long raw_id, DateTimeOffset ingestion_ts)?>(sql, new
{
@@ -88,7 +88,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<RawRow?>(sql, new
{
AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue)
@@ -112,7 +112,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
ORDER BY asof_date DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<RawRow>(sql, new
{
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
@@ -134,7 +134,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<RawRow?>(sql);
return row.HasValue ? MapToRaw(row.Value) : null;
@@ -149,7 +149,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new
{
AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue),
@@ -173,7 +173,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<RawRow>(sql, new
{
ModelVersion = modelVersion,
@@ -187,7 +187,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
{
var sql = $"SELECT {SchemaName}.prune_epss_raw(@RetentionDays)";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>(sql, new { RetentionDays = retentionDays });
}

View File

@@ -505,14 +505,17 @@ public sealed class PostgresEpssRepository : IEpssRepository
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<ChangeRow>(sql, new
{
ModelDate = modelDate,
Flags = flags.HasValue ? (int)flags.Value : 0,
Limit = limit
});
var rows = await connection.QueryAsync<ChangeRow>(new CommandDefinition(
sql,
new
{
ModelDate = modelDate,
Flags = flags.HasValue ? (int)flags.Value : 0,
Limit = limit
},
cancellationToken: cancellationToken)).ConfigureAwait(false);
return rows.Select(r => new EpssChangeRecord
{

View File

@@ -46,7 +46,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
RETURNING signal_id, created_at
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(signal.TenantId.ToString("D"), cancellationToken);
var result = await connection.QueryFirstOrDefaultAsync<(long signal_id, DateTimeOffset created_at)?>(sql, new
{
@@ -104,34 +104,38 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
ON CONFLICT (tenant_id, dedupe_key) DO NOTHING
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var inserted = 0;
foreach (var signal in signalList)
foreach (var tenantGroup in signalList.GroupBy(signal => signal.TenantId))
{
var affected = await connection.ExecuteAsync(sql, new
{
signal.TenantId,
ModelDate = signal.ModelDate.ToDateTime(TimeOnly.MinValue),
signal.CveId,
signal.EventType,
signal.RiskBand,
signal.EpssScore,
signal.EpssDelta,
signal.Percentile,
signal.PercentileDelta,
signal.IsModelChange,
signal.ModelVersion,
signal.DedupeKey,
signal.ExplainHash,
signal.Payload
}, transaction);
await using var connection = await _dataSource.OpenConnectionAsync(tenantGroup.Key.ToString("D"), cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
inserted += affected;
foreach (var signal in tenantGroup)
{
var affected = await connection.ExecuteAsync(sql, new
{
signal.TenantId,
ModelDate = signal.ModelDate.ToDateTime(TimeOnly.MinValue),
signal.CveId,
signal.EventType,
signal.RiskBand,
signal.EpssScore,
signal.EpssDelta,
signal.Percentile,
signal.PercentileDelta,
signal.IsModelChange,
signal.ModelVersion,
signal.DedupeKey,
signal.ExplainHash,
signal.Payload
}, transaction);
inserted += affected;
}
await transaction.CommitAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
return inserted;
}
@@ -159,7 +163,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
@@ -190,7 +194,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
@@ -222,7 +226,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
@@ -246,7 +250,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
WHERE tenant_id = @TenantId
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<ConfigRow?>(sql, new { TenantId = tenantId });
@@ -277,7 +281,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
RETURNING config_id, created_at, updated_at
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(config.TenantId.ToString("D"), cancellationToken);
var result = await connection.QueryFirstAsync<(Guid config_id, DateTimeOffset created_at, DateTimeOffset updated_at)>(sql, new
{
@@ -302,7 +306,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
{
var sql = $"SELECT {SchemaName}.prune_epss_signals(@RetentionDays)";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>(sql, new { RetentionDays = retentionDays });
}
@@ -317,7 +321,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
WHERE tenant_id = @TenantId AND dedupe_key = @DedupeKey
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<SignalRow?>(sql, new { TenantId = tenantId, DedupeKey = dedupeKey });
return row.HasValue ? MapToSignal(row.Value) : null;

View File

@@ -38,7 +38,7 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
AND cve_id LIKE 'CVE-%'
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var cves = await connection.QueryAsync<string>(sql, new { TenantId = tenantId });
return new HashSet<string>(cves, StringComparer.OrdinalIgnoreCase);
@@ -52,12 +52,12 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
var sql = $"""
SELECT EXISTS (
SELECT 1 FROM {TriageTable}
WHERE tenant_id = @TenantId
AND cve_id = @CveId
WHERE tenant_id = @TenantId
AND cve_id = @CveId
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new { TenantId = tenantId, CveId = cveId });
}
@@ -79,7 +79,7 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
AND cve_id = ANY(@CveIds)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var observed = await connection.QueryAsync<string>(sql, new
{
TenantId = tenantId,
@@ -99,7 +99,7 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
AND cve_id LIKE 'CVE-%'
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var tenants = await connection.QueryAsync<Guid>(sql);
return tenants.ToList();
@@ -122,7 +122,7 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
GROUP BY cve_id, tenant_id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<(string cve_id, Guid tenant_id)>(sql, new
{
CveIds = cveList.ToArray()