sprints and audit work

This commit is contained in:
StellaOps Bot
2026-01-07 09:36:16 +02:00
parent 05833e0af2
commit ab364c6032
377 changed files with 64534 additions and 1627 deletions

View File

@@ -0,0 +1,73 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Context for determinization evaluation.
/// Contains environment, criticality, and policy settings.
/// </summary>
public sealed record DeterminizationContext
{
/// <summary>
/// Deployment environment.
/// </summary>
[JsonPropertyName("environment")]
public required DeploymentEnvironment Environment { get; init; }
/// <summary>
/// Asset criticality level.
/// </summary>
[JsonPropertyName("criticality")]
public required AssetCriticality Criticality { get; init; }
/// <summary>
/// Entropy threshold for this context.
/// Observations above this trigger guardrails.
/// </summary>
[JsonPropertyName("entropy_threshold")]
public required double EntropyThreshold { get; init; }
/// <summary>
/// Decay threshold for this context.
/// Observations below this are considered stale.
/// </summary>
[JsonPropertyName("decay_threshold")]
public required double DecayThreshold { get; init; }
/// <summary>
/// Creates context with default production settings.
/// </summary>
public static DeterminizationContext Production() => new()
{
Environment = DeploymentEnvironment.Production,
Criticality = AssetCriticality.High,
EntropyThreshold = 0.4,
DecayThreshold = 0.50
};
/// <summary>
/// Creates context with relaxed development settings.
/// </summary>
public static DeterminizationContext Development() => new()
{
Environment = DeploymentEnvironment.Development,
Criticality = AssetCriticality.Low,
EntropyThreshold = 0.6,
DecayThreshold = 0.35
};
/// <summary>
/// Creates context with custom thresholds.
/// </summary>
public static DeterminizationContext Create(
DeploymentEnvironment environment,
AssetCriticality criticality,
double entropyThreshold,
double decayThreshold) => new()
{
Environment = environment,
Criticality = criticality,
EntropyThreshold = entropyThreshold,
DecayThreshold = decayThreshold
};
}

View File

@@ -0,0 +1,126 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Result of determinization evaluation.
/// Combines observation state, uncertainty score, and guardrails.
/// </summary>
public sealed record DeterminizationResult
{
/// <summary>
/// Resulting observation state.
/// </summary>
[JsonPropertyName("state")]
public required ObservationState State { get; init; }
/// <summary>
/// Uncertainty score at evaluation time.
/// </summary>
[JsonPropertyName("uncertainty")]
public required UncertaintyScore Uncertainty { get; init; }
/// <summary>
/// Decay status at evaluation time.
/// </summary>
[JsonPropertyName("decay")]
public required ObservationDecay Decay { get; init; }
/// <summary>
/// Applied guardrails (if any).
/// </summary>
[JsonPropertyName("guardrails")]
public GuardRails? Guardrails { get; init; }
/// <summary>
/// Evaluation context.
/// </summary>
[JsonPropertyName("context")]
public required DeterminizationContext Context { get; init; }
/// <summary>
/// When this result was computed (UTC).
/// </summary>
[JsonPropertyName("evaluated_at")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Decision rationale.
/// </summary>
[JsonPropertyName("rationale")]
public string? Rationale { get; init; }
/// <summary>
/// Creates result for determined observation (low uncertainty).
/// </summary>
public static DeterminizationResult Determined(
UncertaintyScore uncertainty,
ObservationDecay decay,
DeterminizationContext context,
DateTimeOffset evaluatedAt) => new()
{
State = ObservationState.Determined,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = GuardRails.None(),
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = "Evidence sufficient for confident determination"
};
/// <summary>
/// Creates result for pending observation (high uncertainty).
/// </summary>
public static DeterminizationResult Pending(
UncertaintyScore uncertainty,
ObservationDecay decay,
GuardRails guardrails,
DeterminizationContext context,
DateTimeOffset evaluatedAt) => new()
{
State = ObservationState.PendingDeterminization,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = guardrails,
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = $"Uncertainty ({uncertainty.Entropy:F2}) above threshold ({context.EntropyThreshold:F2})"
};
/// <summary>
/// Creates result for stale observation requiring refresh.
/// </summary>
public static DeterminizationResult Stale(
UncertaintyScore uncertainty,
ObservationDecay decay,
DeterminizationContext context,
DateTimeOffset evaluatedAt) => new()
{
State = ObservationState.StaleRequiresRefresh,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = GuardRails.Strict(),
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = $"Evidence decayed below threshold ({context.DecayThreshold:F2})"
};
/// <summary>
/// Creates result for disputed observation (conflicting signals).
/// </summary>
public static DeterminizationResult Disputed(
UncertaintyScore uncertainty,
ObservationDecay decay,
DeterminizationContext context,
DateTimeOffset evaluatedAt,
string reason) => new()
{
State = ObservationState.Disputed,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = GuardRails.Strict(),
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = $"Conflicting signals detected: {reason}"
};
}

View File

@@ -0,0 +1,112 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Guardrails policy configuration for uncertain observations.
/// Defines monitoring/restrictions when evidence is incomplete.
/// </summary>
public sealed record GuardRails
{
/// <summary>
/// Enable runtime monitoring.
/// </summary>
[JsonPropertyName("enable_monitoring")]
public required bool EnableMonitoring { get; init; }
/// <summary>
/// Restrict deployment to non-production environments.
/// </summary>
[JsonPropertyName("restrict_to_non_prod")]
public required bool RestrictToNonProd { get; init; }
/// <summary>
/// Require manual approval before deployment.
/// </summary>
[JsonPropertyName("require_approval")]
public required bool RequireApproval { get; init; }
/// <summary>
/// Schedule automatic re-evaluation after this duration.
/// </summary>
[JsonPropertyName("reeval_after")]
public TimeSpan? ReevalAfter { get; init; }
/// <summary>
/// Additional notes/rationale for guardrails.
/// </summary>
[JsonPropertyName("notes")]
public string? Notes { get; init; }
/// <summary>
/// Creates GuardRails with default safe settings.
/// </summary>
public static GuardRails Default() => new()
{
EnableMonitoring = true,
RestrictToNonProd = false,
RequireApproval = false,
ReevalAfter = TimeSpan.FromDays(7),
Notes = null
};
/// <summary>
/// Creates GuardRails for high-uncertainty observations.
/// </summary>
public static GuardRails Strict() => new()
{
EnableMonitoring = true,
RestrictToNonProd = true,
RequireApproval = true,
ReevalAfter = TimeSpan.FromDays(3),
Notes = "High uncertainty - strict guardrails applied"
};
/// <summary>
/// Creates GuardRails with no restrictions (all evidence present).
/// </summary>
public static GuardRails None() => new()
{
EnableMonitoring = false,
RestrictToNonProd = false,
RequireApproval = false,
ReevalAfter = null,
Notes = null
};
}
/// <summary>
/// Deployment environment classification.
/// </summary>
public enum DeploymentEnvironment
{
/// <summary>Development environment.</summary>
Development = 0,
/// <summary>Testing environment.</summary>
Testing = 1,
/// <summary>Staging/pre-production environment.</summary>
Staging = 2,
/// <summary>Production environment.</summary>
Production = 3
}
/// <summary>
/// Asset criticality classification.
/// </summary>
public enum AssetCriticality
{
/// <summary>Low criticality - minimal impact if compromised.</summary>
Low = 0,
/// <summary>Medium criticality - moderate impact.</summary>
Medium = 1,
/// <summary>High criticality - significant impact.</summary>
High = 2,
/// <summary>Critical - severe impact if compromised.</summary>
Critical = 3
}

View File

@@ -0,0 +1,99 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Per-observation decay configuration.
/// Tracks evidence staleness with configurable half-life.
/// Formula: decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
/// </summary>
public sealed record ObservationDecay
{
/// <summary>
/// When the observation was first recorded (UTC).
/// </summary>
[JsonPropertyName("observed_at")]
public required DateTimeOffset ObservedAt { get; init; }
/// <summary>
/// When the observation was last refreshed (UTC).
/// </summary>
[JsonPropertyName("refreshed_at")]
public required DateTimeOffset RefreshedAt { get; init; }
/// <summary>
/// Half-life in days.
/// Default: 14 days.
/// </summary>
[JsonPropertyName("half_life_days")]
public required double HalfLifeDays { get; init; }
/// <summary>
/// Minimum confidence floor.
/// Default: 0.35 (consistent with FreshnessCalculator).
/// </summary>
[JsonPropertyName("floor")]
public required double Floor { get; init; }
/// <summary>
/// Staleness threshold (0.0-1.0).
/// If decay multiplier drops below this, observation becomes stale.
/// Default: 0.50
/// </summary>
[JsonPropertyName("staleness_threshold")]
public required double StalenessThreshold { get; init; }
/// <summary>
/// Calculates the current decay multiplier.
/// </summary>
public double CalculateDecay(DateTimeOffset now)
{
var ageDays = (now - RefreshedAt).TotalDays;
if (ageDays <= 0)
return 1.0;
var decay = Math.Exp(-Math.Log(2) * ageDays / HalfLifeDays);
return Math.Max(Floor, decay);
}
/// <summary>
/// Returns true if the observation is stale (decay below threshold).
/// </summary>
public bool IsStale(DateTimeOffset now) =>
CalculateDecay(now) < StalenessThreshold;
/// <summary>
/// Creates ObservationDecay with default settings.
/// </summary>
public static ObservationDecay Create(DateTimeOffset observedAt, DateTimeOffset? refreshedAt = null) => new()
{
ObservedAt = observedAt,
RefreshedAt = refreshedAt ?? observedAt,
HalfLifeDays = 14.0,
Floor = 0.35,
StalenessThreshold = 0.50
};
/// <summary>
/// Creates a fresh observation (just recorded).
/// </summary>
public static ObservationDecay Fresh(DateTimeOffset now) =>
Create(now, now);
/// <summary>
/// Creates ObservationDecay with custom settings.
/// </summary>
public static ObservationDecay WithSettings(
DateTimeOffset observedAt,
DateTimeOffset refreshedAt,
double halfLifeDays,
double floor,
double stalenessThreshold) => new()
{
ObservedAt = observedAt,
RefreshedAt = refreshedAt,
HalfLifeDays = halfLifeDays,
Floor = floor,
StalenessThreshold = stalenessThreshold
};
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Observation state for CVE tracking, independent of VEX status.
/// Allows a CVE to be "Affected" (VEX) but "PendingDeterminization" (observation).
/// </summary>
public enum ObservationState
{
/// <summary>
/// Initial state: CVE discovered but evidence incomplete.
/// Triggers guardrail-based policy evaluation.
/// </summary>
PendingDeterminization = 0,
/// <summary>
/// Evidence sufficient for confident determination.
/// Normal policy evaluation applies.
/// </summary>
Determined = 1,
/// <summary>
/// Multiple signals conflict (K4 Conflict state).
/// Requires human review regardless of confidence.
/// </summary>
Disputed = 2,
/// <summary>
/// Evidence decayed below threshold; needs refresh.
/// Auto-triggered when decay > threshold.
/// </summary>
StaleRequiresRefresh = 3,
/// <summary>
/// Manually flagged for review.
/// Bypasses automatic determinization.
/// </summary>
ManualReviewRequired = 4,
/// <summary>
/// CVE suppressed/ignored by policy exception.
/// Evidence tracking continues but decisions skip.
/// </summary>
Suppressed = 5
}

View File

@@ -0,0 +1,57 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Describes a missing signal that contributes to uncertainty.
/// </summary>
public sealed record SignalGap
{
/// <summary>
/// Signal name (e.g., "epss", "vex", "reachability").
/// </summary>
[JsonPropertyName("signal")]
public required string Signal { get; init; }
/// <summary>
/// Reason the signal is missing.
/// </summary>
[JsonPropertyName("reason")]
public required SignalGapReason Reason { get; init; }
/// <summary>
/// Prior assumption used in absence of signal.
/// </summary>
[JsonPropertyName("prior")]
public double? Prior { get; init; }
/// <summary>
/// Weight this signal contributes to total uncertainty.
/// </summary>
[JsonPropertyName("weight")]
public double Weight { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
}
/// <summary>
/// Reason a signal is missing.
/// </summary>
public enum SignalGapReason
{
/// <summary>Signal not yet queried.</summary>
NotQueried,
/// <summary>Signal legitimately does not exist (e.g., EPSS not published yet).</summary>
NotAvailable,
/// <summary>Signal query failed due to external error.</summary>
QueryFailed,
/// <summary>Signal not applicable for this artifact type.</summary>
NotApplicable
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Query status for a signal.
/// Distinguishes between "not yet queried", "queried with result", and "query failed".
/// </summary>
public enum SignalQueryStatus
{
/// <summary>
/// Signal has not been queried yet.
/// Default state before any lookup attempt.
/// </summary>
NotQueried = 0,
/// <summary>
/// Signal query succeeded.
/// Value may be present or null (signal legitimately absent).
/// </summary>
Queried = 1,
/// <summary>
/// Signal query failed due to error (network, API timeout, etc.).
/// Value is null but reason is external failure, not absence.
/// </summary>
Failed = 2
}

View File

@@ -0,0 +1,88 @@
using System.Text.Json.Serialization;
using StellaOps.Policy.Determinization.Evidence;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Point-in-time snapshot of all signals for a CVE observation.
/// Used as input to uncertainty scoring.
/// </summary>
public sealed record SignalSnapshot
{
/// <summary>
/// CVE identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// Component PURL.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// EPSS signal.
/// </summary>
[JsonPropertyName("epss")]
public required SignalState<EpssEvidence> Epss { get; init; }
/// <summary>
/// VEX signal.
/// </summary>
[JsonPropertyName("vex")]
public required SignalState<VexClaimSummary> Vex { get; init; }
/// <summary>
/// Reachability signal.
/// </summary>
[JsonPropertyName("reachability")]
public required SignalState<ReachabilityEvidence> Reachability { get; init; }
/// <summary>
/// Runtime signal.
/// </summary>
[JsonPropertyName("runtime")]
public required SignalState<RuntimeEvidence> Runtime { get; init; }
/// <summary>
/// Backport signal.
/// </summary>
[JsonPropertyName("backport")]
public required SignalState<BackportEvidence> Backport { get; init; }
/// <summary>
/// SBOM lineage signal.
/// </summary>
[JsonPropertyName("sbom")]
public required SignalState<SbomLineageEvidence> Sbom { get; init; }
/// <summary>
/// CVSS signal.
/// </summary>
[JsonPropertyName("cvss")]
public required SignalState<CvssEvidence> Cvss { get; init; }
/// <summary>
/// When this snapshot was captured (UTC).
/// </summary>
[JsonPropertyName("snapshot_at")]
public required DateTimeOffset SnapshotAt { get; init; }
/// <summary>
/// Creates an empty snapshot with all signals NotQueried.
/// </summary>
public static SignalSnapshot Empty(string cve, string purl, DateTimeOffset snapshotAt) => new()
{
Cve = cve,
Purl = purl,
Epss = SignalState<EpssEvidence>.NotQueried(),
Vex = SignalState<VexClaimSummary>.NotQueried(),
Reachability = SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = snapshotAt
};
}

View File

@@ -0,0 +1,90 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Wraps a signal value with query status metadata.
/// Distinguishes between: not queried, queried with value, queried but absent, query failed.
/// </summary>
/// <typeparam name="T">The signal value type.</typeparam>
public sealed record SignalState<T>
{
/// <summary>
/// Query status for this signal.
/// </summary>
[JsonPropertyName("status")]
public required SignalQueryStatus Status { get; init; }
/// <summary>
/// Signal value, if queried and present.
/// Null can mean: not queried, legitimately absent, or query failed.
/// Check Status to disambiguate.
/// </summary>
[JsonPropertyName("value")]
public T? Value { get; init; }
/// <summary>
/// When this signal was last queried (UTC).
/// Null if never queried.
/// </summary>
[JsonPropertyName("queried_at")]
public DateTimeOffset? QueriedAt { get; init; }
/// <summary>
/// Error message if Status == Failed.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
/// <summary>
/// Creates a SignalState in NotQueried status.
/// </summary>
public static SignalState<T> NotQueried() => new()
{
Status = SignalQueryStatus.NotQueried,
Value = default,
QueriedAt = null,
Error = null
};
/// <summary>
/// Creates a SignalState with a successful query result.
/// Value may be null if the signal legitimately does not exist.
/// </summary>
public static SignalState<T> Queried(T? value, DateTimeOffset queriedAt) => new()
{
Status = SignalQueryStatus.Queried,
Value = value,
QueriedAt = queriedAt,
Error = null
};
/// <summary>
/// Creates a SignalState representing a failed query.
/// </summary>
public static SignalState<T> Failed(string error, DateTimeOffset attemptedAt) => new()
{
Status = SignalQueryStatus.Failed,
Value = default,
QueriedAt = attemptedAt,
Error = error
};
/// <summary>
/// Returns true if the signal was queried and has a non-null value.
/// </summary>
[JsonIgnore]
public bool HasValue => Status == SignalQueryStatus.Queried && Value is not null;
/// <summary>
/// Returns true if the signal query failed.
/// </summary>
[JsonIgnore]
public bool IsFailed => Status == SignalQueryStatus.Failed;
/// <summary>
/// Returns true if the signal has not been queried yet.
/// </summary>
[JsonIgnore]
public bool IsNotQueried => Status == SignalQueryStatus.NotQueried;
}

View File

@@ -0,0 +1,123 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Uncertainty tier classification based on entropy score.
/// </summary>
public enum UncertaintyTier
{
/// <summary>
/// Very high confidence (entropy &lt; 0.2).
/// All or most key signals present and consistent.
/// </summary>
Minimal = 0,
/// <summary>
/// High confidence (entropy 0.2-0.4).
/// Most key signals present.
/// </summary>
Low = 1,
/// <summary>
/// Moderate confidence (entropy 0.4-0.6).
/// Some signals missing or conflicting.
/// </summary>
Moderate = 2,
/// <summary>
/// Low confidence (entropy 0.6-0.8).
/// Many signals missing or conflicting.
/// </summary>
High = 3,
/// <summary>
/// Very low confidence (entropy &gt;= 0.8).
/// Critical signals missing or heavily conflicting.
/// </summary>
Critical = 4
}
/// <summary>
/// Quantifies knowledge completeness (not code entropy).
/// Calculated from signal presence/absence weighted by importance.
/// Formula: entropy = 1 - (sum of weighted present signals / max possible weight)
/// </summary>
public sealed record UncertaintyScore
{
/// <summary>
/// Entropy value [0.0, 1.0].
/// 0 = complete knowledge, 1 = complete uncertainty.
/// </summary>
[JsonPropertyName("entropy")]
public required double Entropy { get; init; }
/// <summary>
/// Uncertainty tier derived from entropy.
/// </summary>
[JsonPropertyName("tier")]
public required UncertaintyTier Tier { get; init; }
/// <summary>
/// Missing signals contributing to uncertainty.
/// </summary>
[JsonPropertyName("gaps")]
public required IReadOnlyList<SignalGap> Gaps { get; init; }
/// <summary>
/// Total weight of present signals.
/// </summary>
[JsonPropertyName("present_weight")]
public required double PresentWeight { get; init; }
/// <summary>
/// Maximum possible weight (sum of all signal weights).
/// </summary>
[JsonPropertyName("max_weight")]
public required double MaxWeight { get; init; }
/// <summary>
/// When this score was calculated (UTC).
/// </summary>
[JsonPropertyName("calculated_at")]
public required DateTimeOffset CalculatedAt { get; init; }
/// <summary>
/// Creates an UncertaintyScore with calculated tier.
/// </summary>
public static UncertaintyScore Create(
double entropy,
IReadOnlyList<SignalGap> gaps,
double presentWeight,
double maxWeight,
DateTimeOffset calculatedAt)
{
if (entropy < 0.0 || entropy > 1.0)
throw new ArgumentOutOfRangeException(nameof(entropy), "Entropy must be in [0.0, 1.0]");
var tier = entropy switch
{
< 0.2 => UncertaintyTier.Minimal,
< 0.4 => UncertaintyTier.Low,
< 0.6 => UncertaintyTier.Moderate,
< 0.8 => UncertaintyTier.High,
_ => UncertaintyTier.Critical
};
return new UncertaintyScore
{
Entropy = entropy,
Tier = tier,
Gaps = gaps,
PresentWeight = presentWeight,
MaxWeight = maxWeight,
CalculatedAt = calculatedAt
};
}
/// <summary>
/// Creates a zero-entropy score (complete knowledge).
/// </summary>
public static UncertaintyScore Zero(double maxWeight, DateTimeOffset calculatedAt) =>
Create(0.0, Array.Empty<SignalGap>(), maxWeight, maxWeight, calculatedAt);
}