Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -0,0 +1,40 @@
# AGENTS.md - Policy Unknowns Library
## Purpose
- Provide deterministic ranking for unknown findings using uncertainty, exploit pressure, decay, and containment signals.
- Maintain stable, reproducible scoring and band assignment.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/policy/architecture.md
- docs/product-advisories/archived/2025-12-21-moat-gap-closure/14-Dec-2025 - Triage and Unknowns Technical Reference.md
## Working Directory
- src/Policy/__Libraries/StellaOps.Policy.Unknowns/
## Signal Sources
### BlastRadius
- Source: Scanner/Signals module call graph analysis.
- Dependents: count of packages in dependency tree.
- NetFacing: reachability from network entrypoints (HTTP controllers, gRPC, etc).
- Privilege: extracted from container config or runtime probes.
### ContainmentSignals
- Source: runtime probes (eBPF, Seccomp profiles, container inspection).
- Seccomp: profile enforcement status.
- FileSystem: mount mode from container spec or /proc/mounts.
- NetworkPolicy: Kubernetes NetworkPolicy or firewall rules.
### Data Flow
1. Scanner generates BlastRadius during SBOM or call graph analysis.
2. Runtime probes collect ContainmentSignals.
3. Signals are stored in policy.unknowns columns.
4. UnknownRanker reads signals for scoring and explainability.
## Engineering Rules
- Target net10.0 with preview features already enabled in repo.
- Determinism: stable ordering, UTC timestamps, and decimal math for scoring.
- No network dependencies inside ranking logic.

View File

@@ -0,0 +1,22 @@
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Configuration;
/// <summary>
/// Configuration options for unknown budgets.
/// </summary>
public sealed class UnknownBudgetOptions
{
public const string SectionName = "UnknownBudgets";
/// <summary>
/// Budget configurations keyed by environment name.
/// </summary>
public Dictionary<string, UnknownBudget> Budgets { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Whether to enforce budgets (false = warn only).
/// </summary>
public bool EnforceBudgets { get; set; } = true;
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Represents the dependency graph impact of an unknown package.
/// Data sourced from scanner call graph analysis.
/// </summary>
public sealed record BlastRadius
{
/// <summary>
/// Number of packages that directly or transitively depend on this package.
/// 0 indicates isolation.
/// </summary>
public int Dependents { get; init; }
/// <summary>
/// Whether this package is reachable from network-facing entrypoints.
/// True indicates higher risk.
/// </summary>
public bool NetFacing { get; init; }
/// <summary>
/// Privilege level under which this package typically runs.
/// Expected values: root, user, none.
/// </summary>
public string? Privilege { get; init; }
}

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Represents runtime isolation and containment posture signals.
/// Data sourced from runtime probes.
/// </summary>
public sealed record ContainmentSignals
{
/// <summary>
/// Seccomp profile status: enforced, permissive, disabled, or null if unknown.
/// </summary>
public string? Seccomp { get; init; }
/// <summary>
/// Filesystem mount mode: ro, rw, or null if unknown.
/// </summary>
public string? FileSystem { get; init; }
/// <summary>
/// Network policy status: isolated, restricted, open, or null if unknown.
/// </summary>
public string? NetworkPolicy { get; init; }
}

View File

@@ -56,6 +56,24 @@ public sealed record Unknown
/// <summary>Exploit pressure from KEV/EPSS/CVSS (0.0000 - 1.0000).</summary>
public required decimal ExploitPressure { get; init; }
/// <summary>Reason code explaining why this entry is unknown.</summary>
public required UnknownReasonCode ReasonCode { get; init; }
/// <summary>Human-readable remediation guidance for this unknown.</summary>
public string? RemediationHint { get; init; }
/// <summary>References to evidence supporting the unknown classification.</summary>
public IReadOnlyList<EvidenceRef> EvidenceRefs { get; init; } = [];
/// <summary>Assumptions applied during analysis.</summary>
public IReadOnlyList<string> Assumptions { get; init; } = [];
/// <summary>Dependency impact signals for containment reduction.</summary>
public BlastRadius? BlastRadius { get; init; }
/// <summary>Runtime containment posture signals.</summary>
public ContainmentSignals? Containment { get; init; }
/// <summary>When this unknown was first detected.</summary>
public required DateTimeOffset FirstSeenAt { get; init; }
@@ -75,6 +93,14 @@ public sealed record Unknown
public required DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Reference to evidence supporting unknown classification.
/// </summary>
public sealed record EvidenceRef(
string Type,
string Uri,
string? Digest);
/// <summary>
/// Summary counts of unknowns by band for dashboard display.
/// </summary>

View File

@@ -0,0 +1,92 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Represents an unknown budget for a specific environment.
/// Budgets define maximum acceptable unknown counts by reason code.
/// </summary>
public sealed record UnknownBudget
{
/// <summary>
/// Environment name: "prod", "stage", "dev", or custom.
/// </summary>
public required string Environment { get; init; }
/// <summary>
/// Maximum total unknowns allowed across all reason codes.
/// </summary>
public int? TotalLimit { get; init; }
/// <summary>
/// Per-reason-code limits. Missing codes inherit from TotalLimit.
/// </summary>
public IReadOnlyDictionary<UnknownReasonCode, int> ReasonLimits { get; init; }
= new Dictionary<UnknownReasonCode, int>();
/// <summary>
/// Action when budget is exceeded.
/// </summary>
public BudgetAction Action { get; init; } = BudgetAction.Warn;
/// <summary>
/// Custom message to display when budget is exceeded.
/// </summary>
public string? ExceededMessage { get; init; }
}
/// <summary>
/// Action to take when unknown budget is exceeded.
/// </summary>
public enum BudgetAction
{
/// <summary>
/// Log warning only, do not block.
/// </summary>
Warn,
/// <summary>
/// Block the operation (fail policy evaluation).
/// </summary>
Block,
/// <summary>
/// Warn but allow if exception is applied.
/// </summary>
WarnUnlessException
}
/// <summary>
/// Result of checking unknowns against a budget.
/// </summary>
public sealed record BudgetCheckResult
{
public required bool IsWithinBudget { get; init; }
public required BudgetAction RecommendedAction { get; init; }
public required int TotalUnknowns { get; init; }
public int? TotalLimit { get; init; }
public IReadOnlyDictionary<UnknownReasonCode, BudgetViolation> Violations { get; init; }
= new Dictionary<UnknownReasonCode, BudgetViolation>();
public string? Message { get; init; }
}
/// <summary>
/// Details of a specific budget violation.
/// </summary>
public sealed record BudgetViolation(
UnknownReasonCode ReasonCode,
int Count,
int Limit);
/// <summary>
/// Summary of budget status for reporting and dashboards.
/// </summary>
public sealed record BudgetStatusSummary
{
public required string Environment { get; init; }
public required int TotalUnknowns { get; init; }
public int? TotalLimit { get; init; }
public decimal PercentageUsed { get; init; }
public bool IsExceeded { get; init; }
public int ViolationCount { get; init; }
public IReadOnlyDictionary<UnknownReasonCode, int> ByReasonCode { get; init; }
= new Dictionary<UnknownReasonCode, int>();
}

View File

@@ -0,0 +1,50 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Canonical reason codes explaining why a component is marked as unknown.
/// Each code maps to a specific remediation action.
/// </summary>
public enum UnknownReasonCode
{
/// <summary>
/// U-RCH: Call path analysis is indeterminate.
/// The reachability analyzer cannot confirm or deny exploitability.
/// </summary>
Reachability,
/// <summary>
/// U-ID: Ambiguous package identity or missing digest.
/// Cannot uniquely identify the component (e.g., missing PURL, no checksum).
/// </summary>
Identity,
/// <summary>
/// U-PROV: Cannot map binary artifact to source repository.
/// Provenance chain is broken or unavailable.
/// </summary>
Provenance,
/// <summary>
/// U-VEX: VEX statements conflict or missing applicability data.
/// Multiple VEX sources disagree or no VEX coverage exists.
/// </summary>
VexConflict,
/// <summary>
/// U-FEED: Required knowledge source is missing or stale.
/// Advisory feed gap (e.g., no NVD/OSV data for this package).
/// </summary>
FeedGap,
/// <summary>
/// U-CONFIG: Feature flag or configuration not observable.
/// Cannot determine if vulnerable code path is enabled at runtime.
/// </summary>
ConfigUnknown,
/// <summary>
/// U-ANALYZER: Language or framework not supported by analyzer.
/// Static analysis tools do not cover this ecosystem.
/// </summary>
AnalyzerLimit
}

View File

@@ -1,4 +1,5 @@
using System.Data;
using System.Text.Json;
using Dapper;
using StellaOps.Policy.Unknowns.Models;
@@ -24,8 +25,13 @@ public sealed class UnknownsRepository : IUnknownsRepository
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,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE id = @Id;
@@ -50,8 +56,13 @@ public sealed class UnknownsRepository : IUnknownsRepository
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,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
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;
@@ -76,8 +87,13 @@ public sealed class UnknownsRepository : IUnknownsRepository
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,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE band = @Band
@@ -122,18 +138,31 @@ public sealed class UnknownsRepository : IUnknownsRepository
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,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs, assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
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,
@UncertaintyFactor, @ExploitPressure,
@ReasonCode, @RemediationHint,
@EvidenceRefs::jsonb, @Assumptions::jsonb,
@BlastRadiusDependents, @BlastRadiusNetFacing, @BlastRadiusPrivilege,
@ContainmentSeccomp, @ContainmentFsMode, @ContainmentNetworkPolicy,
@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,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at;
""";
@@ -147,6 +176,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
ReasonCode = unknown.ReasonCode.ToString(),
unknown.RemediationHint,
EvidenceRefs = SerializeEvidenceRefs(unknown.EvidenceRefs),
Assumptions = SerializeAssumptions(unknown.Assumptions),
BlastRadiusDependents = unknown.BlastRadius?.Dependents,
BlastRadiusNetFacing = unknown.BlastRadius?.NetFacing,
BlastRadiusPrivilege = unknown.BlastRadius?.Privilege,
ContainmentSeccomp = unknown.Containment?.Seccomp,
ContainmentFsMode = unknown.Containment?.FileSystem,
ContainmentNetworkPolicy = unknown.Containment?.NetworkPolicy,
FirstSeenAt = unknown.FirstSeenAt == default ? now : unknown.FirstSeenAt,
LastEvaluatedAt = unknown.LastEvaluatedAt == default ? now : unknown.LastEvaluatedAt,
unknown.ResolutionReason,
@@ -171,6 +210,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
score = @Score,
uncertainty_factor = @UncertaintyFactor,
exploit_pressure = @ExploitPressure,
reason_code = @ReasonCode,
remediation_hint = @RemediationHint,
evidence_refs = @EvidenceRefs::jsonb,
assumptions = @Assumptions::jsonb,
blast_radius_dependents = COALESCE(@BlastRadiusDependents, blast_radius_dependents),
blast_radius_net_facing = COALESCE(@BlastRadiusNetFacing, blast_radius_net_facing),
blast_radius_privilege = COALESCE(@BlastRadiusPrivilege, blast_radius_privilege),
containment_seccomp = COALESCE(@ContainmentSeccomp, containment_seccomp),
containment_fs_mode = COALESCE(@ContainmentFsMode, containment_fs_mode),
containment_network_policy = COALESCE(@ContainmentNetworkPolicy, containment_network_policy),
last_evaluated_at = @LastEvaluatedAt,
resolution_reason = @ResolutionReason,
resolved_at = @ResolvedAt,
@@ -178,6 +227,7 @@ public sealed class UnknownsRepository : IUnknownsRepository
WHERE id = @Id;
""";
var evaluatedAt = DateTimeOffset.UtcNow;
var param = new
{
unknown.TenantId,
@@ -186,10 +236,20 @@ public sealed class UnknownsRepository : IUnknownsRepository
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
unknown.LastEvaluatedAt,
ReasonCode = unknown.ReasonCode.ToString(),
unknown.RemediationHint,
EvidenceRefs = SerializeEvidenceRefs(unknown.EvidenceRefs),
Assumptions = SerializeAssumptions(unknown.Assumptions),
BlastRadiusDependents = unknown.BlastRadius?.Dependents,
BlastRadiusNetFacing = unknown.BlastRadius?.NetFacing,
BlastRadiusPrivilege = unknown.BlastRadius?.Privilege,
ContainmentSeccomp = unknown.Containment?.Seccomp,
ContainmentFsMode = unknown.Containment?.FileSystem,
ContainmentNetworkPolicy = unknown.Containment?.NetworkPolicy,
LastEvaluatedAt = evaluatedAt,
unknown.ResolutionReason,
unknown.ResolvedAt,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = evaluatedAt
};
var affected = await _connection.ExecuteAsync(sql, param);
@@ -240,13 +300,21 @@ public sealed class UnknownsRepository : IUnknownsRepository
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,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs, assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
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,
@UncertaintyFactor, @ExploitPressure,
@ReasonCode, @RemediationHint,
@EvidenceRefs::jsonb, @Assumptions::jsonb,
@BlastRadiusDependents, @BlastRadiusNetFacing, @BlastRadiusPrivilege,
@ContainmentSeccomp, @ContainmentFsMode, @ContainmentNetworkPolicy,
@FirstSeenAt, @LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@CreatedAt, @UpdatedAt
)
ON CONFLICT (tenant_id, package_id, package_version)
@@ -255,6 +323,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
score = EXCLUDED.score,
uncertainty_factor = EXCLUDED.uncertainty_factor,
exploit_pressure = EXCLUDED.exploit_pressure,
reason_code = EXCLUDED.reason_code,
remediation_hint = EXCLUDED.remediation_hint,
evidence_refs = EXCLUDED.evidence_refs,
assumptions = EXCLUDED.assumptions,
blast_radius_dependents = COALESCE(EXCLUDED.blast_radius_dependents, blast_radius_dependents),
blast_radius_net_facing = COALESCE(EXCLUDED.blast_radius_net_facing, blast_radius_net_facing),
blast_radius_privilege = COALESCE(EXCLUDED.blast_radius_privilege, blast_radius_privilege),
containment_seccomp = COALESCE(EXCLUDED.containment_seccomp, containment_seccomp),
containment_fs_mode = COALESCE(EXCLUDED.containment_fs_mode, containment_fs_mode),
containment_network_policy = COALESCE(EXCLUDED.containment_network_policy, containment_network_policy),
last_evaluated_at = EXCLUDED.last_evaluated_at,
updated_at = EXCLUDED.updated_at;
""";
@@ -272,6 +350,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
ReasonCode = unknown.ReasonCode.ToString(),
unknown.RemediationHint,
EvidenceRefs = SerializeEvidenceRefs(unknown.EvidenceRefs),
Assumptions = SerializeAssumptions(unknown.Assumptions),
BlastRadiusDependents = unknown.BlastRadius?.Dependents,
BlastRadiusNetFacing = unknown.BlastRadius?.NetFacing,
BlastRadiusPrivilege = unknown.BlastRadius?.Privilege,
ContainmentSeccomp = unknown.Containment?.Seccomp,
ContainmentFsMode = unknown.Containment?.FileSystem,
ContainmentNetworkPolicy = unknown.Containment?.NetworkPolicy,
FirstSeenAt = unknown.FirstSeenAt == default ? now : unknown.FirstSeenAt,
LastEvaluatedAt = now,
unknown.ResolutionReason,
@@ -298,6 +386,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
decimal score,
decimal uncertainty_factor,
decimal exploit_pressure,
string? reason_code,
string? remediation_hint,
string? evidence_refs,
string? assumptions,
int? blast_radius_dependents,
bool? blast_radius_net_facing,
string? blast_radius_privilege,
string? containment_seccomp,
string? containment_fs_mode,
string? containment_network_policy,
DateTimeOffset first_seen_at,
DateTimeOffset last_evaluated_at,
string? resolution_reason,
@@ -315,6 +413,30 @@ public sealed class UnknownsRepository : IUnknownsRepository
Score = score,
UncertaintyFactor = uncertainty_factor,
ExploitPressure = exploit_pressure,
ReasonCode = ParseReasonCode(reason_code),
RemediationHint = remediation_hint,
EvidenceRefs = ParseEvidenceRefs(evidence_refs),
Assumptions = ParseAssumptions(assumptions),
BlastRadius = blast_radius_dependents.HasValue ||
blast_radius_net_facing.HasValue ||
!string.IsNullOrEmpty(blast_radius_privilege)
? new BlastRadius
{
Dependents = blast_radius_dependents ?? 0,
NetFacing = blast_radius_net_facing ?? false,
Privilege = blast_radius_privilege
}
: null,
Containment = !string.IsNullOrEmpty(containment_seccomp) ||
!string.IsNullOrEmpty(containment_fs_mode) ||
!string.IsNullOrEmpty(containment_network_policy)
? new ContainmentSignals
{
Seccomp = containment_seccomp,
FileSystem = containment_fs_mode,
NetworkPolicy = containment_network_policy
}
: null,
FirstSeenAt = first_seen_at,
LastEvaluatedAt = last_evaluated_at,
ResolutionReason = resolution_reason,
@@ -326,5 +448,54 @@ public sealed class UnknownsRepository : IUnknownsRepository
private sealed record SummaryRow(int hot_count, int warm_count, int cold_count, int resolved_count);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static IReadOnlyList<EvidenceRef> ParseEvidenceRefs(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<EvidenceRef>();
try
{
return JsonSerializer.Deserialize<IReadOnlyList<EvidenceRef>>(json, JsonOptions)
?? Array.Empty<EvidenceRef>();
}
catch (JsonException)
{
return Array.Empty<EvidenceRef>();
}
}
private static IReadOnlyList<string> ParseAssumptions(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<string>();
try
{
return JsonSerializer.Deserialize<IReadOnlyList<string>>(json, JsonOptions)
?? Array.Empty<string>();
}
catch (JsonException)
{
return Array.Empty<string>();
}
}
private static string SerializeEvidenceRefs(IReadOnlyList<EvidenceRef>? refs) =>
JsonSerializer.Serialize(refs ?? Array.Empty<EvidenceRef>(), JsonOptions);
private static string SerializeAssumptions(IReadOnlyList<string>? assumptions) =>
JsonSerializer.Serialize(assumptions ?? Array.Empty<string>(), JsonOptions);
private static UnknownReasonCode ParseReasonCode(string? value) =>
Enum.TryParse<UnknownReasonCode>(value, ignoreCase: true, out var parsed)
? parsed
: UnknownReasonCode.Reachability;
#endregion
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Policy.Unknowns.Configuration;
using StellaOps.Policy.Unknowns.Repositories;
using StellaOps.Policy.Unknowns.Services;
@@ -17,13 +18,18 @@ public static class ServiceCollectionExtensions
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddUnknownsRegistry(
this IServiceCollection services,
Action<UnknownRankerOptions>? configureOptions = null)
Action<UnknownRankerOptions>? configureOptions = null,
Action<UnknownBudgetOptions>? configureBudgetOptions = null)
{
// Configure options
if (configureOptions is not null)
services.Configure(configureOptions);
if (configureBudgetOptions is not null)
services.Configure(configureBudgetOptions);
// Register services
services.AddSingleton<IUnknownBudgetService, UnknownBudgetService>();
services.AddSingleton<IRemediationHintsRegistry, RemediationHintsRegistry>();
services.AddSingleton<IUnknownRanker, UnknownRanker>();
services.AddScoped<IUnknownsRepository, UnknownsRepository>();

View File

@@ -0,0 +1,70 @@
using System.Linq;
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Services;
/// <summary>
/// Registry of remediation hints for each unknown reason code.
/// Provides actionable guidance for resolving unknowns.
/// </summary>
public sealed class RemediationHintsRegistry : IRemediationHintsRegistry
{
private static readonly IReadOnlyDictionary<UnknownReasonCode, RemediationHint> Hints =
new Dictionary<UnknownReasonCode, RemediationHint>
{
[UnknownReasonCode.Reachability] = new(
ShortHint: "Run reachability analysis",
DetailedHint: "Execute call-graph analysis to determine if vulnerable code paths are reachable from application entrypoints.",
AutomationRef: "stella analyze --reachability"),
[UnknownReasonCode.Identity] = new(
ShortHint: "Add package digest",
DetailedHint: "Ensure SBOM includes package checksums (SHA-256) and valid PURL coordinates.",
AutomationRef: "stella sbom --include-digests"),
[UnknownReasonCode.Provenance] = new(
ShortHint: "Add provenance attestation",
DetailedHint: "Generate SLSA provenance linking binary artifact to source repository and build.",
AutomationRef: "stella attest --provenance"),
[UnknownReasonCode.VexConflict] = new(
ShortHint: "Publish authoritative VEX",
DetailedHint: "Create or update VEX document with applicability assessment for your deployment context.",
AutomationRef: "stella vex create"),
[UnknownReasonCode.FeedGap] = new(
ShortHint: "Add advisory source",
DetailedHint: "Configure additional advisory feeds (OSV, vendor-specific) or request coverage from upstream.",
AutomationRef: "stella feed add"),
[UnknownReasonCode.ConfigUnknown] = new(
ShortHint: "Document feature flags",
DetailedHint: "Export runtime configuration showing which features are enabled/disabled in this deployment.",
AutomationRef: "stella config export"),
[UnknownReasonCode.AnalyzerLimit] = new(
ShortHint: "Request analyzer support",
DetailedHint: "This language/framework is not yet supported. File an issue or use manual assessment.",
AutomationRef: null)
};
public RemediationHint GetHint(UnknownReasonCode code) =>
Hints.TryGetValue(code, out var hint) ? hint : RemediationHint.Empty;
public IEnumerable<(UnknownReasonCode Code, RemediationHint Hint)> GetAllHints() =>
Hints.Select(kv => (kv.Key, kv.Value));
}
public sealed record RemediationHint(
string ShortHint,
string DetailedHint,
string? AutomationRef)
{
public static RemediationHint Empty { get; } = new("No remediation available", string.Empty, null);
}
public interface IRemediationHintsRegistry
{
RemediationHint GetHint(UnknownReasonCode code);
IEnumerable<(UnknownReasonCode Code, RemediationHint Hint)> GetAllHints();
}

View File

@@ -0,0 +1,322 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Unknowns.Configuration;
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Services;
/// <summary>
/// Service for managing and checking unknown budgets.
/// </summary>
public sealed class UnknownBudgetService : IUnknownBudgetService
{
private static readonly string[] ReasonCodeMetadataKeys =
[
"unknownReasonCodes",
"unknown_reason_codes",
"unknown-reason-codes"
];
private static readonly IReadOnlyDictionary<string, UnknownReasonCode> ShortCodeMap =
new Dictionary<string, UnknownReasonCode>(StringComparer.OrdinalIgnoreCase)
{
["U-RCH"] = UnknownReasonCode.Reachability,
["U-ID"] = UnknownReasonCode.Identity,
["U-PROV"] = UnknownReasonCode.Provenance,
["U-VEX"] = UnknownReasonCode.VexConflict,
["U-FEED"] = UnknownReasonCode.FeedGap,
["U-CONFIG"] = UnknownReasonCode.ConfigUnknown,
["U-ANALYZER"] = UnknownReasonCode.AnalyzerLimit
};
private readonly IOptionsMonitor<UnknownBudgetOptions> _options;
private readonly ILogger<UnknownBudgetService> _logger;
public UnknownBudgetService(
IOptionsMonitor<UnknownBudgetOptions> options,
ILogger<UnknownBudgetService> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public UnknownBudget GetBudgetForEnvironment(string environment)
{
var normalized = NormalizeEnvironment(environment);
var budgets = _options.CurrentValue.Budgets
?? new Dictionary<string, UnknownBudget>(StringComparer.OrdinalIgnoreCase);
if (budgets.TryGetValue(normalized, out var budget))
{
return NormalizeBudget(budget, normalized);
}
if (budgets.TryGetValue("default", out var defaultBudget))
{
return NormalizeBudget(defaultBudget, normalized);
}
return new UnknownBudget
{
Environment = normalized,
TotalLimit = null,
Action = BudgetAction.Warn
};
}
/// <inheritdoc />
public BudgetCheckResult CheckBudget(
string environment,
IReadOnlyList<Unknown> unknowns)
{
var normalized = NormalizeEnvironment(environment);
var budget = GetBudgetForEnvironment(normalized);
var safeUnknowns = unknowns ?? Array.Empty<Unknown>();
var byReason = CountByReason(safeUnknowns);
var violations = BuildViolations(budget, byReason);
var total = safeUnknowns.Count;
var totalExceeded = budget.TotalLimit.HasValue && total > budget.TotalLimit.Value;
var isWithinBudget = violations.Count == 0 && !totalExceeded;
var action = isWithinBudget ? BudgetAction.Warn : budget.Action;
if (!isWithinBudget && !_options.CurrentValue.EnforceBudgets)
{
action = BudgetAction.Warn;
}
var message = isWithinBudget
? null
: budget.ExceededMessage ?? $"Unknown budget exceeded: {total} unknowns in {normalized}";
return new BudgetCheckResult
{
IsWithinBudget = isWithinBudget,
RecommendedAction = action,
TotalUnknowns = total,
TotalLimit = budget.TotalLimit,
Violations = violations,
Message = message
};
}
/// <inheritdoc />
public BudgetCheckResult CheckBudgetWithEscalation(
string environment,
IReadOnlyList<Unknown> unknowns,
IReadOnlyList<ExceptionObject>? exceptions = null)
{
var normalized = NormalizeEnvironment(environment);
var baseResult = CheckBudget(normalized, unknowns);
if (baseResult.IsWithinBudget || exceptions is null || exceptions.Count == 0)
{
return baseResult;
}
var coveredReasons = CollectCoveredReasons(exceptions, normalized);
if (coveredReasons.Count == 0)
{
LogViolation(normalized, baseResult);
return baseResult;
}
var byReason = CountByReason(unknowns ?? Array.Empty<Unknown>());
var totalExceeded = baseResult.TotalLimit.HasValue && baseResult.TotalUnknowns > baseResult.TotalLimit.Value;
var violationsCovered = baseResult.Violations.Keys.All(coveredReasons.Contains);
var totalCovered = !totalExceeded || byReason.Keys.All(coveredReasons.Contains);
if (violationsCovered && totalCovered)
{
return baseResult with
{
IsWithinBudget = true,
RecommendedAction = BudgetAction.Warn,
Message = "Budget exceeded but covered by approved exceptions"
};
}
LogViolation(normalized, baseResult);
return baseResult;
}
/// <inheritdoc />
public BudgetStatusSummary GetBudgetStatus(
string environment,
IReadOnlyList<Unknown> unknowns)
{
var normalized = NormalizeEnvironment(environment);
var budget = GetBudgetForEnvironment(normalized);
var safeUnknowns = unknowns ?? Array.Empty<Unknown>();
var byReason = CountByReason(safeUnknowns);
var result = CheckBudget(normalized, safeUnknowns);
var percentage = budget.TotalLimit.HasValue && budget.TotalLimit.Value > 0
? (decimal)safeUnknowns.Count / budget.TotalLimit.Value * 100m
: 0m;
return new BudgetStatusSummary
{
Environment = normalized,
TotalUnknowns = safeUnknowns.Count,
TotalLimit = budget.TotalLimit,
PercentageUsed = percentage,
IsExceeded = !result.IsWithinBudget,
ViolationCount = result.Violations.Count,
ByReasonCode = byReason
};
}
/// <inheritdoc />
public bool ShouldBlock(BudgetCheckResult result) =>
!result.IsWithinBudget && result.RecommendedAction == BudgetAction.Block;
private static string NormalizeEnvironment(string environment) =>
string.IsNullOrWhiteSpace(environment) ? "default" : environment.Trim();
private static UnknownBudget NormalizeBudget(UnknownBudget budget, string environment)
{
var reasonLimits = budget.ReasonLimits ?? new Dictionary<UnknownReasonCode, int>();
return budget with
{
Environment = environment,
ReasonLimits = reasonLimits
};
}
private static IReadOnlyDictionary<UnknownReasonCode, int> CountByReason(IReadOnlyList<Unknown> unknowns) =>
unknowns
.GroupBy(u => u.ReasonCode)
.OrderBy(g => g.Key)
.ToDictionary(g => g.Key, g => g.Count());
private static IReadOnlyDictionary<UnknownReasonCode, BudgetViolation> BuildViolations(
UnknownBudget budget,
IReadOnlyDictionary<UnknownReasonCode, int> byReason)
{
var violations = new Dictionary<UnknownReasonCode, BudgetViolation>();
foreach (var entry in budget.ReasonLimits.OrderBy(r => r.Key))
{
if (byReason.TryGetValue(entry.Key, out var count) && count > entry.Value)
{
violations[entry.Key] = new BudgetViolation(entry.Key, count, entry.Value);
}
}
return violations;
}
private static HashSet<UnknownReasonCode> CollectCoveredReasons(
IReadOnlyList<ExceptionObject> exceptions,
string environment)
{
var covered = new HashSet<UnknownReasonCode>();
foreach (var exception in exceptions)
{
if (exception.Type != ExceptionType.Unknown)
{
continue;
}
if (exception.Status is not (ExceptionStatus.Approved or ExceptionStatus.Active))
{
continue;
}
if (exception.Scope.Environments.Length > 0
&& !exception.Scope.Environments.Any(env => env.Equals(environment, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
var reasons = ParseCoveredReasonCodes(exception);
if (reasons.Count == 0)
{
foreach (var code in Enum.GetValues<UnknownReasonCode>())
{
covered.Add(code);
}
}
else
{
foreach (var code in reasons)
{
covered.Add(code);
}
}
}
return covered;
}
private static HashSet<UnknownReasonCode> ParseCoveredReasonCodes(ExceptionObject exception)
{
foreach (var key in ReasonCodeMetadataKeys)
{
if (exception.Metadata.TryGetValue(key, out var value))
{
return ParseReasonCodes(value);
}
}
return [];
}
private static HashSet<UnknownReasonCode> ParseReasonCodes(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return [];
}
var tokens = raw.Split([',', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var codes = new HashSet<UnknownReasonCode>();
foreach (var token in tokens)
{
if (ShortCodeMap.TryGetValue(token, out var shortCode))
{
codes.Add(shortCode);
continue;
}
var cleaned = token.Replace("U-", "", StringComparison.OrdinalIgnoreCase).Trim();
if (Enum.TryParse(cleaned, ignoreCase: true, out UnknownReasonCode parsed))
{
codes.Add(parsed);
}
}
return codes;
}
private void LogViolation(string environment, BudgetCheckResult result)
{
if (result.IsWithinBudget)
{
return;
}
_logger.LogWarning(
"Unknown budget exceeded for environment {Environment}: {Total}/{Limit}",
environment,
result.TotalUnknowns,
result.TotalLimit?.ToString(CultureInfo.InvariantCulture) ?? "none");
}
}
public interface IUnknownBudgetService
{
UnknownBudget GetBudgetForEnvironment(string environment);
BudgetCheckResult CheckBudget(string environment, IReadOnlyList<Unknown> unknowns);
BudgetCheckResult CheckBudgetWithEscalation(
string environment,
IReadOnlyList<Unknown> unknowns,
IReadOnlyList<ExceptionObject>? exceptions = null);
BudgetStatusSummary GetBudgetStatus(string environment, IReadOnlyList<Unknown> unknowns);
bool ShouldBlock(BudgetCheckResult result);
}

View File

@@ -13,6 +13,17 @@ namespace StellaOps.Policy.Unknowns.Services;
/// <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>
/// <param name="FirstSeenAt">When the unknown was first observed.</param>
/// <param name="LastEvaluatedAt">When the unknown was last re-ranked.</param>
/// <param name="AsOfDateTime">Reference time for decay calculations.</param>
/// <param name="BlastRadius">Dependency impact signals for containment reduction.</param>
/// <param name="Containment">Runtime containment posture signals.</param>
/// <param name="HasPackageDigest">Whether a package digest is available.</param>
/// <param name="HasProvenanceAttestation">Whether provenance attestation exists.</param>
/// <param name="HasVexConflicts">Whether VEX statements conflict.</param>
/// <param name="HasFeedCoverage">Whether advisory feeds cover this package.</param>
/// <param name="HasConfigVisibility">Whether configuration visibility is available.</param>
/// <param name="IsAnalyzerSupported">Whether analyzer supports this ecosystem.</param>
public sealed record UnknownRankInput(
bool HasVexStatement,
bool HasReachabilityData,
@@ -20,7 +31,18 @@ public sealed record UnknownRankInput(
bool IsStaleAdvisory,
bool IsInKev,
decimal EpssScore,
decimal CvssScore);
decimal CvssScore,
DateTimeOffset? FirstSeenAt,
DateTimeOffset? LastEvaluatedAt,
DateTimeOffset AsOfDateTime,
BlastRadius? BlastRadius,
ContainmentSignals? Containment,
bool HasPackageDigest,
bool HasProvenanceAttestation,
bool HasVexConflicts,
bool HasFeedCoverage,
bool HasConfigVisibility,
bool IsAnalyzerSupported);
/// <summary>
/// Result of unknown ranking calculation.
@@ -29,11 +51,19 @@ public sealed record UnknownRankInput(
/// <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>
/// <param name="DecayFactor">Applied time-based decay multiplier.</param>
/// <param name="ContainmentReduction">Applied containment reduction factor.</param>
/// <param name="ReasonCode">Primary reason code for the unknown classification.</param>
/// <param name="RemediationHint">Short remediation hint for the reason code.</param>
public sealed record UnknownRankResult(
decimal Score,
decimal UncertaintyFactor,
decimal ExploitPressure,
UnknownBand Band);
UnknownBand Band,
decimal DecayFactor = 1.0m,
decimal ContainmentReduction = 0m,
UnknownReasonCode ReasonCode = UnknownReasonCode.Reachability,
string? RemediationHint = null);
/// <summary>
/// Service for computing deterministic unknown rankings.
@@ -74,9 +104,13 @@ public interface IUnknownRanker
public sealed class UnknownRanker : IUnknownRanker
{
private readonly UnknownRankerOptions _options;
private readonly IRemediationHintsRegistry _hintsRegistry;
public UnknownRanker(IOptions<UnknownRankerOptions> options)
=> _options = options.Value;
public UnknownRanker(IOptions<UnknownRankerOptions> options, IRemediationHintsRegistry? hintsRegistry = null)
{
_options = options.Value;
_hintsRegistry = hintsRegistry ?? new RemediationHintsRegistry();
}
/// <summary>
/// Default constructor for simple usage without DI.
@@ -88,10 +122,29 @@ public sealed class UnknownRanker : IUnknownRanker
{
var uncertainty = ComputeUncertainty(input);
var pressure = ComputeExploitPressure(input);
var score = Math.Round((uncertainty * 50m) + (pressure * 50m), 2);
var band = AssignBand(score);
var rawScore = Math.Round((uncertainty * 50m) + (pressure * 50m), 2);
return new UnknownRankResult(score, uncertainty, pressure, band);
var decayFactor = _options.EnableDecay ? ComputeDecayFactor(input) : 1.0m;
var decayedScore = Math.Round(rawScore * decayFactor, 2);
var containmentReduction = _options.EnableContainmentReduction
? ComputeContainmentReduction(input)
: 0m;
var finalScore = Math.Round(Math.Max(0m, decayedScore * (1m - containmentReduction)), 2);
var band = AssignBand(finalScore);
var reasonCode = DetermineReasonCode(input);
var hint = _hintsRegistry.GetHint(reasonCode);
return new UnknownRankResult(
finalScore,
uncertainty,
pressure,
band,
decayFactor,
containmentReduction,
reasonCode,
hint.ShortHint);
}
/// <summary>
@@ -144,16 +197,113 @@ public sealed class UnknownRanker : IUnknownRanker
return Math.Min(pressure, 1.0m);
}
/// <summary>
/// Computes time-based decay factor for stale unknowns.
/// </summary>
private decimal ComputeDecayFactor(UnknownRankInput input)
{
if (input.LastEvaluatedAt is null)
return 1.0m;
if (_options.DecayBuckets is null || _options.DecayBuckets.Count == 0)
return 1.0m;
var ageDays = (int)Math.Max(0, (input.AsOfDateTime - input.LastEvaluatedAt.Value).TotalDays);
DecayBucket? selected = null;
foreach (var bucket in _options.DecayBuckets)
{
if (bucket.MaxAgeDays >= ageDays &&
(selected is null || bucket.MaxAgeDays < selected.MaxAgeDays))
{
selected = bucket;
}
}
if (selected is null)
return 1.0m;
var clamped = Math.Clamp(selected.MultiplierBps, 0, 10000);
return clamped / 10000m;
}
/// <summary>
/// Computes containment-based reduction factor.
/// </summary>
private decimal ComputeContainmentReduction(UnknownRankInput input)
{
decimal reduction = 0m;
if (input.BlastRadius is { } blast)
{
if (blast.Dependents == 0)
reduction += _options.IsolatedReduction;
if (!blast.NetFacing)
reduction += _options.NotNetFacingReduction;
if (blast.Privilege is "user" or "none")
reduction += _options.NonRootReduction;
}
if (input.Containment is { } containment)
{
if (containment.Seccomp == "enforced")
reduction += _options.SeccompEnforcedReduction;
if (containment.FileSystem == "ro")
reduction += _options.FsReadOnlyReduction;
if (containment.NetworkPolicy == "isolated")
reduction += _options.NetworkIsolatedReduction;
}
return Math.Min(reduction, _options.MaxContainmentReduction);
}
/// <summary>
/// Assigns band based on score thresholds.
/// </summary>
private UnknownBand AssignBand(decimal score) => score switch
private UnknownBand AssignBand(decimal score)
{
>= 75m => UnknownBand.Hot, // Hot threshold (configurable)
>= 50m => UnknownBand.Warm, // Warm threshold
>= 25m => UnknownBand.Cold, // Cold threshold
_ => UnknownBand.Resolved // Below cold = resolved
};
if (score >= _options.HotThreshold)
return UnknownBand.Hot;
if (score >= _options.WarmThreshold)
return UnknownBand.Warm;
if (score >= _options.ColdThreshold)
return UnknownBand.Cold;
return UnknownBand.Resolved;
}
/// <summary>
/// Determines the primary reason code for unknown classification.
/// Returns the most actionable/resolvable reason.
/// </summary>
private static UnknownReasonCode DetermineReasonCode(UnknownRankInput input)
{
if (!input.IsAnalyzerSupported)
return UnknownReasonCode.AnalyzerLimit;
if (!input.HasReachabilityData)
return UnknownReasonCode.Reachability;
if (!input.HasPackageDigest)
return UnknownReasonCode.Identity;
if (!input.HasProvenanceAttestation)
return UnknownReasonCode.Provenance;
if (input.HasVexConflicts || !input.HasVexStatement)
return UnknownReasonCode.VexConflict;
if (!input.HasFeedCoverage)
return UnknownReasonCode.FeedGap;
if (!input.HasConfigVisibility)
return UnknownReasonCode.ConfigUnknown;
return UnknownReasonCode.Reachability;
}
}
/// <summary>
@@ -169,4 +319,50 @@ public sealed class UnknownRankerOptions
/// <summary>Score threshold for COLD band (default: 25).</summary>
public decimal ColdThreshold { get; set; } = 25m;
/// <summary>Enable time-based score decay.</summary>
public bool EnableDecay { get; set; } = true;
/// <summary>Decay buckets ordered by maximum age in days.</summary>
public IReadOnlyList<DecayBucket> DecayBuckets { get; set; } = DefaultDecayBuckets;
/// <summary>Default decay buckets using basis points.</summary>
public static IReadOnlyList<DecayBucket> DefaultDecayBuckets { get; } =
[
new DecayBucket(7, 10000),
new DecayBucket(30, 9000),
new DecayBucket(90, 7500),
new DecayBucket(180, 6000),
new DecayBucket(365, 4000),
new DecayBucket(int.MaxValue, 2000)
];
/// <summary>Enable containment-based reduction.</summary>
public bool EnableContainmentReduction { get; set; } = true;
/// <summary>Reduction for isolated package (dependents=0).</summary>
public decimal IsolatedReduction { get; set; } = 0.15m;
/// <summary>Reduction for not network-facing packages.</summary>
public decimal NotNetFacingReduction { get; set; } = 0.05m;
/// <summary>Reduction for non-root privilege.</summary>
public decimal NonRootReduction { get; set; } = 0.05m;
/// <summary>Reduction for enforced Seccomp.</summary>
public decimal SeccompEnforcedReduction { get; set; } = 0.10m;
/// <summary>Reduction for read-only filesystem.</summary>
public decimal FsReadOnlyReduction { get; set; } = 0.10m;
/// <summary>Reduction for isolated network policy.</summary>
public decimal NetworkIsolatedReduction { get; set; } = 0.05m;
/// <summary>Maximum reduction allowed from containment signals.</summary>
public decimal MaxContainmentReduction { get; set; } = 0.40m;
}
/// <summary>
/// Represents a decay bucket using basis points.
/// </summary>
public sealed record DecayBucket(int MaxAgeDays, int MultiplierBps);

View File

@@ -10,10 +10,12 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Npgsql" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Npgsql" Version="9.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="..\StellaOps.Policy\StellaOps.Policy.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,215 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Unknowns;
/// <summary>
/// Unknowns budget configuration for policy evaluation.
/// </summary>
public sealed record UnknownsBudgetConfig
{
/// <summary>
/// Maximum allowed critical severity unknowns.
/// </summary>
public int MaxCriticalUnknowns { get; init; } = 0;
/// <summary>
/// Maximum allowed high severity unknowns.
/// </summary>
public int MaxHighUnknowns { get; init; } = 5;
/// <summary>
/// Maximum allowed medium severity unknowns.
/// </summary>
public int MaxMediumUnknowns { get; init; } = 20;
/// <summary>
/// Maximum allowed low severity unknowns.
/// </summary>
public int MaxLowUnknowns { get; init; } = 50;
/// <summary>
/// Maximum total unknowns across all severities.
/// </summary>
public int? MaxTotalUnknowns { get; init; }
/// <summary>
/// Action to take when budget is exceeded.
/// </summary>
public UnknownsBudgetAction Action { get; init; } = UnknownsBudgetAction.Block;
/// <summary>
/// Environment-specific overrides.
/// </summary>
public Dictionary<string, UnknownsBudgetConfig>? EnvironmentOverrides { get; init; }
}
/// <summary>
/// Action to take when unknowns budget is exceeded.
/// </summary>
public enum UnknownsBudgetAction
{
/// <summary>
/// Block deployment/approval.
/// </summary>
Block,
/// <summary>
/// Warn but allow deployment.
/// </summary>
Warn,
/// <summary>
/// Log only, no enforcement.
/// </summary>
Log
}
/// <summary>
/// Counts of unknowns by severity.
/// </summary>
public sealed record UnknownsCounts
{
public int Critical { get; init; }
public int High { get; init; }
public int Medium { get; init; }
public int Low { get; init; }
public int Total => Critical + High + Medium + Low;
}
/// <summary>
/// Result of unknowns budget enforcement.
/// </summary>
public sealed record UnknownsBudgetResult
{
public required bool WithinBudget { get; init; }
public required UnknownsCounts Counts { get; init; }
public required UnknownsBudgetConfig Budget { get; init; }
public required UnknownsBudgetAction Action { get; init; }
public IReadOnlyList<string>? Violations { get; init; }
}
/// <summary>
/// Enforces unknowns budget for policy decisions.
/// </summary>
public sealed class UnknownsBudgetEnforcer
{
private readonly ILogger<UnknownsBudgetEnforcer> _logger;
public UnknownsBudgetEnforcer(ILogger<UnknownsBudgetEnforcer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Evaluate unknowns counts against budget.
/// </summary>
public UnknownsBudgetResult Evaluate(
UnknownsCounts counts,
UnknownsBudgetConfig budget,
string? environment = null)
{
ArgumentNullException.ThrowIfNull(counts);
ArgumentNullException.ThrowIfNull(budget);
var effectiveBudget = GetEffectiveBudget(budget, environment);
var violations = new List<string>();
if (counts.Critical > effectiveBudget.MaxCriticalUnknowns)
{
violations.Add($"Critical unknowns ({counts.Critical}) exceeds budget ({effectiveBudget.MaxCriticalUnknowns})");
}
if (counts.High > effectiveBudget.MaxHighUnknowns)
{
violations.Add($"High unknowns ({counts.High}) exceeds budget ({effectiveBudget.MaxHighUnknowns})");
}
if (counts.Medium > effectiveBudget.MaxMediumUnknowns)
{
violations.Add($"Medium unknowns ({counts.Medium}) exceeds budget ({effectiveBudget.MaxMediumUnknowns})");
}
if (counts.Low > effectiveBudget.MaxLowUnknowns)
{
violations.Add($"Low unknowns ({counts.Low}) exceeds budget ({effectiveBudget.MaxLowUnknowns})");
}
if (effectiveBudget.MaxTotalUnknowns.HasValue &&
counts.Total > effectiveBudget.MaxTotalUnknowns.Value)
{
violations.Add($"Total unknowns ({counts.Total}) exceeds budget ({effectiveBudget.MaxTotalUnknowns.Value})");
}
var withinBudget = violations.Count == 0;
if (!withinBudget)
{
LogViolations(violations, effectiveBudget.Action, environment);
}
return new UnknownsBudgetResult
{
WithinBudget = withinBudget,
Counts = counts,
Budget = effectiveBudget,
Action = effectiveBudget.Action,
Violations = violations
};
}
/// <summary>
/// Check if deployment should be blocked based on budget result.
/// </summary>
public bool ShouldBlock(UnknownsBudgetResult result)
{
ArgumentNullException.ThrowIfNull(result);
return !result.WithinBudget && result.Action == UnknownsBudgetAction.Block;
}
private static UnknownsBudgetConfig GetEffectiveBudget(
UnknownsBudgetConfig budget,
string? environment)
{
if (string.IsNullOrWhiteSpace(environment) ||
budget.EnvironmentOverrides is null ||
!budget.EnvironmentOverrides.TryGetValue(environment, out var override_))
{
return budget;
}
return override_;
}
private void LogViolations(
List<string> violations,
UnknownsBudgetAction action,
string? environment)
{
var envStr = string.IsNullOrWhiteSpace(environment) ? "" : $" (env: {environment})";
switch (action)
{
case UnknownsBudgetAction.Block:
_logger.LogError(
"Unknowns budget exceeded{Env}. Blocking deployment. Violations: {Violations}",
envStr,
string.Join("; ", violations));
break;
case UnknownsBudgetAction.Warn:
_logger.LogWarning(
"Unknowns budget exceeded{Env}. Allowing deployment with warning. Violations: {Violations}",
envStr,
string.Join("; ", violations));
break;
case UnknownsBudgetAction.Log:
_logger.LogInformation(
"Unknowns budget exceeded{Env}. Logging only. Violations: {Violations}",
envStr,
string.Join("; ", violations));
break;
}
}
}