save progress
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
namespace StellaOps.Policy.Unknowns.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Band classification for unknowns triage priority.
|
||||
/// </summary>
|
||||
public enum UnknownBand
|
||||
{
|
||||
/// <summary>Requires immediate attention (score 75-100). SLA: 24h.</summary>
|
||||
Hot,
|
||||
|
||||
/// <summary>Elevated priority (score 50-74). SLA: 7d.</summary>
|
||||
Warm,
|
||||
|
||||
/// <summary>Low priority (score 25-49). SLA: 30d.</summary>
|
||||
Cold,
|
||||
|
||||
/// <summary>Resolved or score below threshold.</summary>
|
||||
Resolved
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an ambiguous or incomplete finding requiring triage.
|
||||
/// Tracks packages with missing VEX statements, incomplete reachability data,
|
||||
/// or conflicting information sources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The unknowns queue enables systematic tracking and prioritization
|
||||
/// of ambiguous findings using a two-factor ranking model:
|
||||
/// - Uncertainty Factor: measures data completeness (0.0 - 1.0)
|
||||
/// - Exploit Pressure: measures risk urgency (0.0 - 1.0)
|
||||
/// Score = (Uncertainty × 50) + (ExploitPressure × 50)
|
||||
/// </remarks>
|
||||
public sealed record Unknown
|
||||
{
|
||||
/// <summary>Unique identifier for this unknown entry.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>Tenant that owns this unknown entry (RLS key).</summary>
|
||||
public required Guid TenantId { get; init; }
|
||||
|
||||
/// <summary>Package identifier (PURL base without version).</summary>
|
||||
public required string PackageId { get; init; }
|
||||
|
||||
/// <summary>Specific package version.</summary>
|
||||
public required string PackageVersion { get; init; }
|
||||
|
||||
/// <summary>Current band classification based on score.</summary>
|
||||
public required UnknownBand Band { get; init; }
|
||||
|
||||
/// <summary>Computed ranking score (0.00 - 100.00).</summary>
|
||||
public required decimal Score { get; init; }
|
||||
|
||||
/// <summary>Uncertainty factor from missing data (0.0000 - 1.0000).</summary>
|
||||
public required decimal UncertaintyFactor { get; init; }
|
||||
|
||||
/// <summary>Exploit pressure from KEV/EPSS/CVSS (0.0000 - 1.0000).</summary>
|
||||
public required decimal ExploitPressure { get; init; }
|
||||
|
||||
/// <summary>When this unknown was first detected.</summary>
|
||||
public required DateTimeOffset FirstSeenAt { get; init; }
|
||||
|
||||
/// <summary>Last time the ranking was re-evaluated.</summary>
|
||||
public required DateTimeOffset LastEvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>Reason for resolution (null until resolved).</summary>
|
||||
public string? ResolutionReason { get; init; }
|
||||
|
||||
/// <summary>When the unknown was resolved (null until resolved).</summary>
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
|
||||
/// <summary>Record creation timestamp.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Last update timestamp.</summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts of unknowns by band for dashboard display.
|
||||
/// </summary>
|
||||
public sealed record UnknownsSummary(
|
||||
int Hot,
|
||||
int Warm,
|
||||
int Cold,
|
||||
int Resolved);
|
||||
@@ -0,0 +1,98 @@
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
|
||||
namespace StellaOps.Policy.Unknowns.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for unknown tracking operations.
|
||||
/// </summary>
|
||||
public interface IUnknownsRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an unknown by its unique identifier.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier for RLS.</param>
|
||||
/// <param name="id">Unknown identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The unknown if found; otherwise, null.</returns>
|
||||
Task<Unknown?> GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an unknown by package coordinates.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier for RLS.</param>
|
||||
/// <param name="packageId">Package identifier (PURL or NEVRA).</param>
|
||||
/// <param name="packageVersion">Package version.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The unknown if found; otherwise, null.</returns>
|
||||
Task<Unknown?> GetByPackageAsync(
|
||||
Guid tenantId,
|
||||
string packageId,
|
||||
string packageVersion,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unknowns for a tenant in a specific band.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier for RLS.</param>
|
||||
/// <param name="band">Band to filter by.</param>
|
||||
/// <param name="limit">Maximum number of results.</param>
|
||||
/// <param name="offset">Number of results to skip.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Ordered list of unknowns in the band (by score descending).</returns>
|
||||
Task<IReadOnlyList<Unknown>> GetByBandAsync(
|
||||
Guid tenantId,
|
||||
UnknownBand band,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a summary of unknowns by band for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier for RLS.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Summary counts by band.</returns>
|
||||
Task<UnknownsSummary> GetSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new unknown.
|
||||
/// </summary>
|
||||
/// <param name="unknown">Unknown to create.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created unknown with generated ID.</returns>
|
||||
Task<Unknown> CreateAsync(Unknown unknown, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing unknown.
|
||||
/// </summary>
|
||||
/// <param name="unknown">Unknown to update.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if updated; false if not found.</returns>
|
||||
Task<bool> UpdateAsync(Unknown unknown, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks an unknown as resolved.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier for RLS.</param>
|
||||
/// <param name="id">Unknown identifier.</param>
|
||||
/// <param name="resolutionReason">Reason for resolution.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if resolved; false if not found.</returns>
|
||||
Task<bool> ResolveAsync(
|
||||
Guid tenantId,
|
||||
Guid id,
|
||||
string resolutionReason,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch upserts unknowns from a re-evaluation pass.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier for RLS.</param>
|
||||
/// <param name="unknowns">Unknowns to upsert.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of rows affected.</returns>
|
||||
Task<int> UpsertBatchAsync(
|
||||
Guid tenantId,
|
||||
IEnumerable<Unknown> unknowns,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
|
||||
namespace StellaOps.Policy.Unknowns.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based PostgreSQL implementation of <see cref="IUnknownsRepository"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>This implementation relies on PostgreSQL Row-Level Security (RLS) for tenant isolation.</para>
|
||||
/// <para>All queries set <c>app.current_tenant</c> before execution.</para>
|
||||
/// </remarks>
|
||||
public sealed class UnknownsRepository : IUnknownsRepository
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
public UnknownsRepository(IDbConnection connection)
|
||||
=> _connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Unknown?> GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT set_config('app.current_tenant', @TenantId::text, true);
|
||||
SELECT id, tenant_id, package_id, package_version, band, score,
|
||||
uncertainty_factor, exploit_pressure, first_seen_at,
|
||||
last_evaluated_at, resolution_reason, resolved_at,
|
||||
created_at, updated_at
|
||||
FROM policy.unknowns
|
||||
WHERE id = @Id;
|
||||
""";
|
||||
|
||||
var param = new { TenantId = tenantId, Id = id };
|
||||
using var reader = await _connection.QueryMultipleAsync(sql, param);
|
||||
|
||||
// Skip set_config result
|
||||
await reader.ReadAsync();
|
||||
var row = await reader.ReadFirstOrDefaultAsync<UnknownRow>();
|
||||
return row?.ToModel();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Unknown?> GetByPackageAsync(
|
||||
Guid tenantId,
|
||||
string packageId,
|
||||
string packageVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT set_config('app.current_tenant', @TenantId::text, true);
|
||||
SELECT id, tenant_id, package_id, package_version, band, score,
|
||||
uncertainty_factor, exploit_pressure, first_seen_at,
|
||||
last_evaluated_at, resolution_reason, resolved_at,
|
||||
created_at, updated_at
|
||||
FROM policy.unknowns
|
||||
WHERE package_id = @PackageId AND package_version = @PackageVersion;
|
||||
""";
|
||||
|
||||
var param = new { TenantId = tenantId, PackageId = packageId, PackageVersion = packageVersion };
|
||||
using var reader = await _connection.QueryMultipleAsync(sql, param);
|
||||
|
||||
await reader.ReadAsync();
|
||||
var row = await reader.ReadFirstOrDefaultAsync<UnknownRow>();
|
||||
return row?.ToModel();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Unknown>> GetByBandAsync(
|
||||
Guid tenantId,
|
||||
UnknownBand band,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT set_config('app.current_tenant', @TenantId::text, true);
|
||||
SELECT id, tenant_id, package_id, package_version, band, score,
|
||||
uncertainty_factor, exploit_pressure, first_seen_at,
|
||||
last_evaluated_at, resolution_reason, resolved_at,
|
||||
created_at, updated_at
|
||||
FROM policy.unknowns
|
||||
WHERE band = @Band
|
||||
ORDER BY score DESC, package_id ASC
|
||||
LIMIT @Limit OFFSET @Offset;
|
||||
""";
|
||||
|
||||
var param = new { TenantId = tenantId, Band = band.ToString().ToLowerInvariant(), Limit = limit, Offset = offset };
|
||||
using var reader = await _connection.QueryMultipleAsync(sql, param);
|
||||
|
||||
await reader.ReadAsync();
|
||||
var rows = await reader.ReadAsync<UnknownRow>();
|
||||
return rows.Select(r => r.ToModel()).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnknownsSummary> GetSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT set_config('app.current_tenant', @TenantId::text, true);
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE band = 'hot') as hot_count,
|
||||
COUNT(*) FILTER (WHERE band = 'warm') as warm_count,
|
||||
COUNT(*) FILTER (WHERE band = 'cold') as cold_count,
|
||||
COUNT(*) FILTER (WHERE band = 'resolved') as resolved_count
|
||||
FROM policy.unknowns;
|
||||
""";
|
||||
|
||||
using var reader = await _connection.QueryMultipleAsync(sql, new { TenantId = tenantId });
|
||||
await reader.ReadAsync();
|
||||
var row = await reader.ReadSingleAsync<SummaryRow>();
|
||||
return new UnknownsSummary(row.hot_count, row.warm_count, row.cold_count, row.resolved_count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Unknown> CreateAsync(Unknown unknown, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = unknown.Id == Guid.Empty ? Guid.NewGuid() : unknown.Id;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
const string sql = """
|
||||
SELECT set_config('app.current_tenant', @TenantId::text, true);
|
||||
INSERT INTO policy.unknowns (
|
||||
id, tenant_id, package_id, package_version, band, score,
|
||||
uncertainty_factor, exploit_pressure, first_seen_at,
|
||||
last_evaluated_at, resolution_reason, resolved_at,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
@Id, @TenantId, @PackageId, @PackageVersion, @Band, @Score,
|
||||
@UncertaintyFactor, @ExploitPressure, @FirstSeenAt,
|
||||
@LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
|
||||
@CreatedAt, @UpdatedAt
|
||||
)
|
||||
RETURNING id, tenant_id, package_id, package_version, band, score,
|
||||
uncertainty_factor, exploit_pressure, first_seen_at,
|
||||
last_evaluated_at, resolution_reason, resolved_at,
|
||||
created_at, updated_at;
|
||||
""";
|
||||
|
||||
var param = new
|
||||
{
|
||||
Id = id,
|
||||
unknown.TenantId,
|
||||
unknown.PackageId,
|
||||
unknown.PackageVersion,
|
||||
Band = unknown.Band.ToString().ToLowerInvariant(),
|
||||
unknown.Score,
|
||||
unknown.UncertaintyFactor,
|
||||
unknown.ExploitPressure,
|
||||
FirstSeenAt = unknown.FirstSeenAt == default ? now : unknown.FirstSeenAt,
|
||||
LastEvaluatedAt = unknown.LastEvaluatedAt == default ? now : unknown.LastEvaluatedAt,
|
||||
unknown.ResolutionReason,
|
||||
unknown.ResolvedAt,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
using var reader = await _connection.QueryMultipleAsync(sql, param);
|
||||
await reader.ReadAsync();
|
||||
var row = await reader.ReadSingleAsync<UnknownRow>();
|
||||
return row.ToModel();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateAsync(Unknown unknown, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT set_config('app.current_tenant', @TenantId::text, true);
|
||||
UPDATE policy.unknowns
|
||||
SET band = @Band,
|
||||
score = @Score,
|
||||
uncertainty_factor = @UncertaintyFactor,
|
||||
exploit_pressure = @ExploitPressure,
|
||||
last_evaluated_at = @LastEvaluatedAt,
|
||||
resolution_reason = @ResolutionReason,
|
||||
resolved_at = @ResolvedAt,
|
||||
updated_at = @UpdatedAt
|
||||
WHERE id = @Id;
|
||||
""";
|
||||
|
||||
var param = new
|
||||
{
|
||||
unknown.TenantId,
|
||||
unknown.Id,
|
||||
Band = unknown.Band.ToString().ToLowerInvariant(),
|
||||
unknown.Score,
|
||||
unknown.UncertaintyFactor,
|
||||
unknown.ExploitPressure,
|
||||
unknown.LastEvaluatedAt,
|
||||
unknown.ResolutionReason,
|
||||
unknown.ResolvedAt,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var affected = await _connection.ExecuteAsync(sql, param);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ResolveAsync(
|
||||
Guid tenantId,
|
||||
Guid id,
|
||||
string resolutionReason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT set_config('app.current_tenant', @TenantId::text, true);
|
||||
UPDATE policy.unknowns
|
||||
SET band = 'resolved',
|
||||
resolution_reason = @ResolutionReason,
|
||||
resolved_at = @ResolvedAt,
|
||||
updated_at = @UpdatedAt
|
||||
WHERE id = @Id;
|
||||
""";
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var param = new
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Id = id,
|
||||
ResolutionReason = resolutionReason,
|
||||
ResolvedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
var affected = await _connection.ExecuteAsync(sql, param);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> UpsertBatchAsync(
|
||||
Guid tenantId,
|
||||
IEnumerable<Unknown> unknowns,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var total = 0;
|
||||
|
||||
const string sql = """
|
||||
SELECT set_config('app.current_tenant', @TenantId::text, true);
|
||||
INSERT INTO policy.unknowns (
|
||||
id, tenant_id, package_id, package_version, band, score,
|
||||
uncertainty_factor, exploit_pressure, first_seen_at,
|
||||
last_evaluated_at, resolution_reason, resolved_at,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
@Id, @TenantId, @PackageId, @PackageVersion, @Band, @Score,
|
||||
@UncertaintyFactor, @ExploitPressure, @FirstSeenAt,
|
||||
@LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
|
||||
@CreatedAt, @UpdatedAt
|
||||
)
|
||||
ON CONFLICT (tenant_id, package_id, package_version)
|
||||
DO UPDATE SET
|
||||
band = EXCLUDED.band,
|
||||
score = EXCLUDED.score,
|
||||
uncertainty_factor = EXCLUDED.uncertainty_factor,
|
||||
exploit_pressure = EXCLUDED.exploit_pressure,
|
||||
last_evaluated_at = EXCLUDED.last_evaluated_at,
|
||||
updated_at = EXCLUDED.updated_at;
|
||||
""";
|
||||
|
||||
foreach (var unknown in unknowns)
|
||||
{
|
||||
var id = unknown.Id == Guid.Empty ? Guid.NewGuid() : unknown.Id;
|
||||
var param = new
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
unknown.PackageId,
|
||||
unknown.PackageVersion,
|
||||
Band = unknown.Band.ToString().ToLowerInvariant(),
|
||||
unknown.Score,
|
||||
unknown.UncertaintyFactor,
|
||||
unknown.ExploitPressure,
|
||||
FirstSeenAt = unknown.FirstSeenAt == default ? now : unknown.FirstSeenAt,
|
||||
LastEvaluatedAt = now,
|
||||
unknown.ResolutionReason,
|
||||
unknown.ResolvedAt,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
var affected = await _connection.ExecuteAsync(sql, param);
|
||||
total += affected > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
#region Row Mapping
|
||||
|
||||
private sealed record UnknownRow(
|
||||
Guid id,
|
||||
Guid tenant_id,
|
||||
string package_id,
|
||||
string package_version,
|
||||
string band,
|
||||
decimal score,
|
||||
decimal uncertainty_factor,
|
||||
decimal exploit_pressure,
|
||||
DateTimeOffset first_seen_at,
|
||||
DateTimeOffset last_evaluated_at,
|
||||
string? resolution_reason,
|
||||
DateTimeOffset? resolved_at,
|
||||
DateTimeOffset created_at,
|
||||
DateTimeOffset updated_at)
|
||||
{
|
||||
public Unknown ToModel() => new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenant_id,
|
||||
PackageId = package_id,
|
||||
PackageVersion = package_version,
|
||||
Band = Enum.Parse<UnknownBand>(band, ignoreCase: true),
|
||||
Score = score,
|
||||
UncertaintyFactor = uncertainty_factor,
|
||||
ExploitPressure = exploit_pressure,
|
||||
FirstSeenAt = first_seen_at,
|
||||
LastEvaluatedAt = last_evaluated_at,
|
||||
ResolutionReason = resolution_reason,
|
||||
ResolvedAt = resolved_at,
|
||||
CreatedAt = created_at,
|
||||
UpdatedAt = updated_at
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record SummaryRow(int hot_count, int warm_count, int cold_count, int resolved_count);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Policy.Unknowns.Repositories;
|
||||
using StellaOps.Policy.Unknowns.Services;
|
||||
|
||||
namespace StellaOps.Policy.Unknowns;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Unknowns services in DI.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Unknowns Registry services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Optional action to configure ranker options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddUnknownsRegistry(
|
||||
this IServiceCollection services,
|
||||
Action<UnknownRankerOptions>? configureOptions = null)
|
||||
{
|
||||
// Configure options
|
||||
if (configureOptions is not null)
|
||||
services.Configure(configureOptions);
|
||||
|
||||
// Register services
|
||||
services.AddSingleton<IUnknownRanker, UnknownRanker>();
|
||||
services.AddScoped<IUnknownsRepository, UnknownsRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
|
||||
namespace StellaOps.Policy.Unknowns.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Input data for unknown ranking calculation.
|
||||
/// </summary>
|
||||
/// <param name="HasVexStatement">Whether a VEX statement exists for this package/CVE.</param>
|
||||
/// <param name="HasReachabilityData">Whether reachability analysis has been performed.</param>
|
||||
/// <param name="HasConflictingSources">Whether multiple sources provide conflicting information.</param>
|
||||
/// <param name="IsStaleAdvisory">Whether the advisory is older than 90 days without update.</param>
|
||||
/// <param name="IsInKev">Whether the CVE is in the CISA KEV list.</param>
|
||||
/// <param name="EpssScore">EPSS score (0.0 - 1.0).</param>
|
||||
/// <param name="CvssScore">CVSS base score (0.0 - 10.0).</param>
|
||||
public sealed record UnknownRankInput(
|
||||
bool HasVexStatement,
|
||||
bool HasReachabilityData,
|
||||
bool HasConflictingSources,
|
||||
bool IsStaleAdvisory,
|
||||
bool IsInKev,
|
||||
decimal EpssScore,
|
||||
decimal CvssScore);
|
||||
|
||||
/// <summary>
|
||||
/// Result of unknown ranking calculation.
|
||||
/// </summary>
|
||||
/// <param name="Score">Computed score (0.00 - 100.00).</param>
|
||||
/// <param name="UncertaintyFactor">Uncertainty component (0.0000 - 1.0000).</param>
|
||||
/// <param name="ExploitPressure">Exploit pressure component (0.0000 - 1.0000).</param>
|
||||
/// <param name="Band">Assigned band based on score thresholds.</param>
|
||||
public sealed record UnknownRankResult(
|
||||
decimal Score,
|
||||
decimal UncertaintyFactor,
|
||||
decimal ExploitPressure,
|
||||
UnknownBand Band);
|
||||
|
||||
/// <summary>
|
||||
/// Service for computing deterministic unknown rankings.
|
||||
/// </summary>
|
||||
public interface IUnknownRanker
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a deterministic ranking for an unknown based on input factors.
|
||||
/// </summary>
|
||||
/// <param name="input">Ranking input data.</param>
|
||||
/// <returns>Ranking result with score, factors, and band assignment.</returns>
|
||||
UnknownRankResult Rank(UnknownRankInput input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of the two-factor unknown ranking algorithm.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Ranking formula:</para>
|
||||
/// <code>Score = (Uncertainty × 50) + (ExploitPressure × 50)</code>
|
||||
///
|
||||
/// <para>Uncertainty factors:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Missing VEX statement: +0.40</item>
|
||||
/// <item>Missing reachability: +0.30</item>
|
||||
/// <item>Conflicting sources: +0.20</item>
|
||||
/// <item>Stale advisory (>90d): +0.10</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Exploit pressure factors:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>In KEV list: +0.50</item>
|
||||
/// <item>EPSS ≥ 0.90: +0.30</item>
|
||||
/// <item>EPSS ≥ 0.50: +0.15</item>
|
||||
/// <item>CVSS ≥ 9.0: +0.05</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class UnknownRanker : IUnknownRanker
|
||||
{
|
||||
private readonly UnknownRankerOptions _options;
|
||||
|
||||
public UnknownRanker(IOptions<UnknownRankerOptions> options)
|
||||
=> _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Default constructor for simple usage without DI.
|
||||
/// </summary>
|
||||
public UnknownRanker() : this(Options.Create(new UnknownRankerOptions())) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public UnknownRankResult Rank(UnknownRankInput input)
|
||||
{
|
||||
var uncertainty = ComputeUncertainty(input);
|
||||
var pressure = ComputeExploitPressure(input);
|
||||
var score = Math.Round((uncertainty * 50m) + (pressure * 50m), 2);
|
||||
var band = AssignBand(score);
|
||||
|
||||
return new UnknownRankResult(score, uncertainty, pressure, band);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes uncertainty factor from missing data signals.
|
||||
/// </summary>
|
||||
private static decimal ComputeUncertainty(UnknownRankInput input)
|
||||
{
|
||||
decimal factor = 0m;
|
||||
|
||||
// Missing VEX statement is the highest uncertainty signal
|
||||
if (!input.HasVexStatement)
|
||||
factor += 0.40m;
|
||||
|
||||
// Missing reachability analysis
|
||||
if (!input.HasReachabilityData)
|
||||
factor += 0.30m;
|
||||
|
||||
// Conflicting information from multiple sources
|
||||
if (input.HasConflictingSources)
|
||||
factor += 0.20m;
|
||||
|
||||
// Stale advisory without recent updates
|
||||
if (input.IsStaleAdvisory)
|
||||
factor += 0.10m;
|
||||
|
||||
return Math.Min(factor, 1.0m);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes exploit pressure from KEV/EPSS/CVSS signals.
|
||||
/// </summary>
|
||||
private static decimal ComputeExploitPressure(UnknownRankInput input)
|
||||
{
|
||||
decimal pressure = 0m;
|
||||
|
||||
// KEV is the highest pressure signal (known active exploitation)
|
||||
if (input.IsInKev)
|
||||
pressure += 0.50m;
|
||||
|
||||
// EPSS thresholds (mutually exclusive)
|
||||
if (input.EpssScore >= 0.90m)
|
||||
pressure += 0.30m;
|
||||
else if (input.EpssScore >= 0.50m)
|
||||
pressure += 0.15m;
|
||||
|
||||
// Critical CVSS adds small additional pressure
|
||||
if (input.CvssScore >= 9.0m)
|
||||
pressure += 0.05m;
|
||||
|
||||
return Math.Min(pressure, 1.0m);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns band based on score thresholds.
|
||||
/// </summary>
|
||||
private UnknownBand AssignBand(decimal score) => score switch
|
||||
{
|
||||
>= 75m => UnknownBand.Hot, // Hot threshold (configurable)
|
||||
>= 50m => UnknownBand.Warm, // Warm threshold
|
||||
>= 25m => UnknownBand.Cold, // Cold threshold
|
||||
_ => UnknownBand.Resolved // Below cold = resolved
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the unknown ranker.
|
||||
/// </summary>
|
||||
public sealed class UnknownRankerOptions
|
||||
{
|
||||
/// <summary>Score threshold for HOT band (default: 75).</summary>
|
||||
public decimal HotThreshold { get; set; } = 75m;
|
||||
|
||||
/// <summary>Score threshold for WARM band (default: 50).</summary>
|
||||
public decimal WarmThreshold { get; set; } = 50m;
|
||||
|
||||
/// <summary>Score threshold for COLD band (default: 25).</summary>
|
||||
public decimal ColdThreshold { get; set; } = 25m;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Policy.Unknowns</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Policy\StellaOps.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user