Add Canonical JSON serialization library with tests and documentation
- Implemented CanonJson class for deterministic JSON serialization and hashing. - Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters. - Created project files for the Canonical JSON library and its tests, including necessary package references. - Added README.md for library usage and API reference. - Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AssumptionPenalties.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: D-SCORE-002 - Assumption penalties in score calculation
|
||||
// Description: Penalties applied when scoring relies on assumptions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Types of assumptions that incur scoring penalties.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AssumptionType
|
||||
{
|
||||
/// <summary>Assumed vulnerable code is reachable (no reachability analysis).</summary>
|
||||
AssumedReachable,
|
||||
|
||||
/// <summary>Assumed VEX status from source without verification.</summary>
|
||||
AssumedVexStatus,
|
||||
|
||||
/// <summary>Assumed SBOM completeness (no SBOM validation).</summary>
|
||||
AssumedSbomComplete,
|
||||
|
||||
/// <summary>Assumed feed is current (stale feed data).</summary>
|
||||
AssumedFeedCurrent,
|
||||
|
||||
/// <summary>Assumed default CVSS metrics (no specific vector).</summary>
|
||||
AssumedDefaultCvss,
|
||||
|
||||
/// <summary>Assumed package version (ambiguous version).</summary>
|
||||
AssumedPackageVersion,
|
||||
|
||||
/// <summary>Assumed deployment context (no runtime info).</summary>
|
||||
AssumedDeploymentContext,
|
||||
|
||||
/// <summary>Assumed transitive dependency (unverified chain).</summary>
|
||||
AssumedTransitiveDep,
|
||||
|
||||
/// <summary>Assumed no compensating controls.</summary>
|
||||
AssumedNoControls,
|
||||
|
||||
/// <summary>Assumed exploit exists (no PoC verification).</summary>
|
||||
AssumedExploitExists
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for assumption penalties.
|
||||
/// </summary>
|
||||
public sealed record AssumptionPenaltyConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Default penalties by assumption type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("penalties")]
|
||||
public ImmutableDictionary<AssumptionType, double> Penalties { get; init; } =
|
||||
DefaultPenalties;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to compound penalties (multiply) or add them.
|
||||
/// </summary>
|
||||
[JsonPropertyName("compoundPenalties")]
|
||||
public bool CompoundPenalties { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total penalty (floor for confidence).
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxTotalPenalty")]
|
||||
public double MaxTotalPenalty { get; init; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence score after penalties.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minConfidence")]
|
||||
public double MinConfidence { get; init; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Default assumption penalties.
|
||||
/// </summary>
|
||||
public static readonly ImmutableDictionary<AssumptionType, double> DefaultPenalties =
|
||||
new Dictionary<AssumptionType, double>
|
||||
{
|
||||
[AssumptionType.AssumedReachable] = 0.15,
|
||||
[AssumptionType.AssumedVexStatus] = 0.10,
|
||||
[AssumptionType.AssumedSbomComplete] = 0.12,
|
||||
[AssumptionType.AssumedFeedCurrent] = 0.08,
|
||||
[AssumptionType.AssumedDefaultCvss] = 0.05,
|
||||
[AssumptionType.AssumedPackageVersion] = 0.10,
|
||||
[AssumptionType.AssumedDeploymentContext] = 0.07,
|
||||
[AssumptionType.AssumedTransitiveDep] = 0.05,
|
||||
[AssumptionType.AssumedNoControls] = 0.08,
|
||||
[AssumptionType.AssumedExploitExists] = 0.06
|
||||
}.ToImmutableDictionary();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An assumption made during scoring.
|
||||
/// </summary>
|
||||
public sealed record ScoringAssumption
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of assumption.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required AssumptionType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Penalty applied for this assumption.
|
||||
/// </summary>
|
||||
[JsonPropertyName("penalty")]
|
||||
public required double Penalty { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// What would remove this assumption.
|
||||
/// </summary>
|
||||
[JsonPropertyName("resolutionHint")]
|
||||
public string? ResolutionHint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related finding or component ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("relatedId")]
|
||||
public string? RelatedId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of applying assumption penalties.
|
||||
/// </summary>
|
||||
public sealed record AssumptionPenaltyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Original confidence score (before penalties).
|
||||
/// </summary>
|
||||
[JsonPropertyName("originalConfidence")]
|
||||
public required double OriginalConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Adjusted confidence score (after penalties).
|
||||
/// </summary>
|
||||
[JsonPropertyName("adjustedConfidence")]
|
||||
public required double AdjustedConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total penalty applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalPenalty")]
|
||||
public required double TotalPenalty { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assumptions that contributed to the penalty.
|
||||
/// </summary>
|
||||
[JsonPropertyName("assumptions")]
|
||||
public ImmutableArray<ScoringAssumption> Assumptions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the penalty was capped.
|
||||
/// </summary>
|
||||
[JsonPropertyName("penaltyCapped")]
|
||||
public bool PenaltyCapped { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculator for assumption-based penalties.
|
||||
/// </summary>
|
||||
public sealed class AssumptionPenaltyCalculator
|
||||
{
|
||||
private readonly AssumptionPenaltyConfig _config;
|
||||
|
||||
public AssumptionPenaltyCalculator(AssumptionPenaltyConfig? config = null)
|
||||
{
|
||||
_config = config ?? new AssumptionPenaltyConfig();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the penalty result for a set of assumptions.
|
||||
/// </summary>
|
||||
public AssumptionPenaltyResult Calculate(
|
||||
double originalConfidence,
|
||||
IEnumerable<ScoringAssumption> assumptions)
|
||||
{
|
||||
var assumptionList = assumptions.ToImmutableArray();
|
||||
|
||||
if (assumptionList.Length == 0)
|
||||
{
|
||||
return new AssumptionPenaltyResult
|
||||
{
|
||||
OriginalConfidence = originalConfidence,
|
||||
AdjustedConfidence = originalConfidence,
|
||||
TotalPenalty = 0,
|
||||
Assumptions = [],
|
||||
PenaltyCapped = false
|
||||
};
|
||||
}
|
||||
|
||||
double adjustedConfidence;
|
||||
double totalPenalty;
|
||||
bool capped = false;
|
||||
|
||||
if (_config.CompoundPenalties)
|
||||
{
|
||||
// Compound: multiply (1 - penalty) factors
|
||||
var factor = 1.0;
|
||||
foreach (var assumption in assumptionList)
|
||||
{
|
||||
factor *= (1.0 - assumption.Penalty);
|
||||
}
|
||||
adjustedConfidence = originalConfidence * factor;
|
||||
totalPenalty = 1.0 - factor;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Additive: sum penalties
|
||||
totalPenalty = assumptionList.Sum(a => a.Penalty);
|
||||
if (totalPenalty > _config.MaxTotalPenalty)
|
||||
{
|
||||
totalPenalty = _config.MaxTotalPenalty;
|
||||
capped = true;
|
||||
}
|
||||
adjustedConfidence = originalConfidence * (1.0 - totalPenalty);
|
||||
}
|
||||
|
||||
// Apply minimum confidence floor
|
||||
if (adjustedConfidence < _config.MinConfidence)
|
||||
{
|
||||
adjustedConfidence = _config.MinConfidence;
|
||||
capped = true;
|
||||
}
|
||||
|
||||
return new AssumptionPenaltyResult
|
||||
{
|
||||
OriginalConfidence = originalConfidence,
|
||||
AdjustedConfidence = adjustedConfidence,
|
||||
TotalPenalty = totalPenalty,
|
||||
Assumptions = assumptionList,
|
||||
PenaltyCapped = capped
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scoring assumption with default penalty.
|
||||
/// </summary>
|
||||
public ScoringAssumption CreateAssumption(
|
||||
AssumptionType type,
|
||||
string description,
|
||||
string? relatedId = null)
|
||||
{
|
||||
var penalty = _config.Penalties.TryGetValue(type, out var p)
|
||||
? p
|
||||
: AssumptionPenaltyConfig.DefaultPenalties.GetValueOrDefault(type, 0.05);
|
||||
|
||||
return new ScoringAssumption
|
||||
{
|
||||
Type = type,
|
||||
Description = description,
|
||||
Penalty = penalty,
|
||||
ResolutionHint = GetResolutionHint(type),
|
||||
RelatedId = relatedId
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetResolutionHint(AssumptionType type) => type switch
|
||||
{
|
||||
AssumptionType.AssumedReachable =>
|
||||
"Run reachability analysis to determine actual code path",
|
||||
AssumptionType.AssumedVexStatus =>
|
||||
"Obtain signed VEX statement from vendor",
|
||||
AssumptionType.AssumedSbomComplete =>
|
||||
"Generate verified SBOM with attestation",
|
||||
AssumptionType.AssumedFeedCurrent =>
|
||||
"Update vulnerability feeds to latest version",
|
||||
AssumptionType.AssumedDefaultCvss =>
|
||||
"Obtain environment-specific CVSS vector",
|
||||
AssumptionType.AssumedPackageVersion =>
|
||||
"Verify exact package version from lockfile",
|
||||
AssumptionType.AssumedDeploymentContext =>
|
||||
"Provide runtime environment information",
|
||||
AssumptionType.AssumedTransitiveDep =>
|
||||
"Verify dependency chain with lockfile",
|
||||
AssumptionType.AssumedNoControls =>
|
||||
"Document compensating controls in policy",
|
||||
AssumptionType.AssumedExploitExists =>
|
||||
"Check exploit databases for PoC availability",
|
||||
_ => "Provide additional context to remove assumption"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScoreAttestationStatement.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: D-SCORE-005 - DSSE-signed score attestation
|
||||
// Description: DSSE predicate for attesting to security scores.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate type for score attestation.
|
||||
/// </summary>
|
||||
public static class ScoreAttestationPredicateType
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicate type URI for score attestation.
|
||||
/// </summary>
|
||||
public const string PredicateType = "https://stellaops.io/attestation/score/v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score attestation statement (DSSE predicate payload).
|
||||
/// </summary>
|
||||
public sealed record ScoreAttestationStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Attestation version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// When the score was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scoredAt")]
|
||||
public required DateTimeOffset ScoredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject artifact digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subjectDigest")]
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject artifact name/reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subjectName")]
|
||||
public string? SubjectName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall security score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("overallScore")]
|
||||
public required int OverallScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score confidence (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score grade (A-F).
|
||||
/// </summary>
|
||||
[JsonPropertyName("grade")]
|
||||
public required string Grade { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score breakdown by category.
|
||||
/// </summary>
|
||||
[JsonPropertyName("breakdown")]
|
||||
public required ScoreBreakdown Breakdown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scoring policy used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy")]
|
||||
public required ScoringPolicyRef Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inputs used for scoring.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inputs")]
|
||||
public required ScoringInputs Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assumptions made during scoring.
|
||||
/// </summary>
|
||||
[JsonPropertyName("assumptions")]
|
||||
public ImmutableArray<AssumptionSummary> Assumptions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns that affect the score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknowns")]
|
||||
public ImmutableArray<UnknownSummary> Unknowns { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Hash of this statement for integrity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statementHash")]
|
||||
public string? StatementHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score breakdown by category.
|
||||
/// </summary>
|
||||
public sealed record ScoreBreakdown
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerability")]
|
||||
public required int Vulnerability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exploitability score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("exploitability")]
|
||||
public required int Exploitability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachability")]
|
||||
public required int Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy compliance score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("compliance")]
|
||||
public required int Compliance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Supply chain score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("supplyChain")]
|
||||
public required int SupplyChain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX/mitigation score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("mitigation")]
|
||||
public required int Mitigation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the scoring policy used.
|
||||
/// </summary>
|
||||
public sealed record ScoringPolicyRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inputs used for scoring.
|
||||
/// </summary>
|
||||
public sealed record ScoringInputs
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability feed version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feedVersion")]
|
||||
public string? FeedVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Feed fetch timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feedFetchedAt")]
|
||||
public DateTimeOffset? FeedFetchedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachabilityDigest")]
|
||||
public string? ReachabilityDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX documents used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexDocuments")]
|
||||
public ImmutableArray<VexDocRef> VexDocuments { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Total components analyzed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total vulnerabilities found.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityCount")]
|
||||
public int VulnerabilityCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total findings after filtering.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingCount")]
|
||||
public int FindingCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a VEX document.
|
||||
/// </summary>
|
||||
public sealed record VexDocRef
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX document digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decisions applied from this document.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decisionCount")]
|
||||
public int DecisionCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of an assumption made.
|
||||
/// </summary>
|
||||
public sealed record AssumptionSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Assumption type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of this assumption type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("count")]
|
||||
public required int Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total penalty from this assumption type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalPenalty")]
|
||||
public required double TotalPenalty { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of an unknown.
|
||||
/// </summary>
|
||||
public sealed record UnknownSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of this unknown type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("count")]
|
||||
public required int Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score impact from this unknown type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scoreImpact")]
|
||||
public required int ScoreImpact { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for score attestation statements.
|
||||
/// </summary>
|
||||
public sealed class ScoreAttestationBuilder
|
||||
{
|
||||
private readonly ScoreAttestationStatement _statement;
|
||||
|
||||
private ScoreAttestationBuilder(ScoreAttestationStatement statement)
|
||||
{
|
||||
_statement = statement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new builder.
|
||||
/// </summary>
|
||||
public static ScoreAttestationBuilder Create(
|
||||
string subjectDigest,
|
||||
int overallScore,
|
||||
double confidence,
|
||||
ScoreBreakdown breakdown,
|
||||
ScoringPolicyRef policy,
|
||||
ScoringInputs inputs)
|
||||
{
|
||||
return new ScoreAttestationBuilder(new ScoreAttestationStatement
|
||||
{
|
||||
ScoredAt = DateTimeOffset.UtcNow,
|
||||
SubjectDigest = subjectDigest,
|
||||
OverallScore = overallScore,
|
||||
Confidence = confidence,
|
||||
Grade = ComputeGrade(overallScore),
|
||||
Breakdown = breakdown,
|
||||
Policy = policy,
|
||||
Inputs = inputs
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the subject name.
|
||||
/// </summary>
|
||||
public ScoreAttestationBuilder WithSubjectName(string name)
|
||||
{
|
||||
return new ScoreAttestationBuilder(_statement with { SubjectName = name });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds assumptions.
|
||||
/// </summary>
|
||||
public ScoreAttestationBuilder WithAssumptions(IEnumerable<AssumptionSummary> assumptions)
|
||||
{
|
||||
return new ScoreAttestationBuilder(_statement with
|
||||
{
|
||||
Assumptions = assumptions.ToImmutableArray()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds unknowns.
|
||||
/// </summary>
|
||||
public ScoreAttestationBuilder WithUnknowns(IEnumerable<UnknownSummary> unknowns)
|
||||
{
|
||||
return new ScoreAttestationBuilder(_statement with
|
||||
{
|
||||
Unknowns = unknowns.ToImmutableArray()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the statement.
|
||||
/// </summary>
|
||||
public ScoreAttestationStatement Build()
|
||||
{
|
||||
// Compute statement hash
|
||||
var canonical = StellaOps.Canonical.Json.CanonJson.Canonicalize(_statement);
|
||||
var hash = StellaOps.Canonical.Json.CanonJson.Sha256Prefixed(canonical);
|
||||
|
||||
return _statement with { StatementHash = hash };
|
||||
}
|
||||
|
||||
private static string ComputeGrade(int score) => score switch
|
||||
{
|
||||
>= 90 => "A",
|
||||
>= 80 => "B",
|
||||
>= 70 => "C",
|
||||
>= 60 => "D",
|
||||
_ => "F"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScoringRulesSnapshot.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: E-OFF-003 - Scoring rules snapshot with digest
|
||||
// Description: Immutable snapshot of scoring rules for offline/audit use.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of scoring rules with cryptographic digest.
|
||||
/// Used for offline operation and audit trail.
|
||||
/// </summary>
|
||||
public sealed record ScoringRulesSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Snapshot identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required int Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the snapshot was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content digest of the snapshot (sha256:...).
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of this snapshot.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source policy IDs that contributed to this snapshot.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourcePolicies")]
|
||||
public ImmutableArray<string> SourcePolicies { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Scoring weights configuration.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weights")]
|
||||
public required ScoringWeights Weights { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Thresholds for grade boundaries.
|
||||
/// </summary>
|
||||
[JsonPropertyName("thresholds")]
|
||||
public required GradeThresholds Thresholds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity multipliers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severityMultipliers")]
|
||||
public required SeverityMultipliers SeverityMultipliers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assumption penalty configuration.
|
||||
/// </summary>
|
||||
[JsonPropertyName("assumptionPenalties")]
|
||||
public required AssumptionPenaltyConfig AssumptionPenalties { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust source weights.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trustSourceWeights")]
|
||||
public required TrustSourceWeightConfig TrustSourceWeights { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Freshness decay configuration.
|
||||
/// </summary>
|
||||
[JsonPropertyName("freshnessDecay")]
|
||||
public required FreshnessDecayConfig FreshnessDecay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom rules (Rego/SPL).
|
||||
/// </summary>
|
||||
[JsonPropertyName("customRules")]
|
||||
public ImmutableArray<CustomScoringRule> CustomRules { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether this snapshot is signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isSigned")]
|
||||
public bool IsSigned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature if signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signingKeyId")]
|
||||
public string? SigningKeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scoring category weights (must sum to 1.0).
|
||||
/// </summary>
|
||||
public sealed record ScoringWeights
|
||||
{
|
||||
/// <summary>
|
||||
/// Weight for vulnerability severity (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerability")]
|
||||
public double Vulnerability { get; init; } = 0.25;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for exploitability factors (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("exploitability")]
|
||||
public double Exploitability { get; init; } = 0.20;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for reachability analysis (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachability")]
|
||||
public double Reachability { get; init; } = 0.20;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for policy compliance (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("compliance")]
|
||||
public double Compliance { get; init; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for supply chain factors (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("supplyChain")]
|
||||
public double SupplyChain { get; init; } = 0.10;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for mitigation/VEX status (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("mitigation")]
|
||||
public double Mitigation { get; init; } = 0.10;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that weights sum to 1.0.
|
||||
/// </summary>
|
||||
public bool Validate()
|
||||
{
|
||||
var sum = Vulnerability + Exploitability + Reachability +
|
||||
Compliance + SupplyChain + Mitigation;
|
||||
return Math.Abs(sum - 1.0) < 0.001;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grade threshold configuration.
|
||||
/// </summary>
|
||||
public sealed record GradeThresholds
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum score for grade A.
|
||||
/// </summary>
|
||||
[JsonPropertyName("a")]
|
||||
public int A { get; init; } = 90;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum score for grade B.
|
||||
/// </summary>
|
||||
[JsonPropertyName("b")]
|
||||
public int B { get; init; } = 80;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum score for grade C.
|
||||
/// </summary>
|
||||
[JsonPropertyName("c")]
|
||||
public int C { get; init; } = 70;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum score for grade D.
|
||||
/// </summary>
|
||||
[JsonPropertyName("d")]
|
||||
public int D { get; init; } = 60;
|
||||
|
||||
// Below D threshold is grade F
|
||||
|
||||
/// <summary>
|
||||
/// Gets the grade for a score.
|
||||
/// </summary>
|
||||
public string GetGrade(int score) => score switch
|
||||
{
|
||||
_ when score >= A => "A",
|
||||
_ when score >= B => "B",
|
||||
_ when score >= C => "C",
|
||||
_ when score >= D => "D",
|
||||
_ => "F"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity multipliers for scoring.
|
||||
/// </summary>
|
||||
public sealed record SeverityMultipliers
|
||||
{
|
||||
/// <summary>
|
||||
/// Multiplier for critical severity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("critical")]
|
||||
public double Critical { get; init; } = 1.5;
|
||||
|
||||
/// <summary>
|
||||
/// Multiplier for high severity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("high")]
|
||||
public double High { get; init; } = 1.2;
|
||||
|
||||
/// <summary>
|
||||
/// Multiplier for medium severity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("medium")]
|
||||
public double Medium { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Multiplier for low severity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("low")]
|
||||
public double Low { get; init; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Multiplier for informational.
|
||||
/// </summary>
|
||||
[JsonPropertyName("informational")]
|
||||
public double Informational { get; init; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets multiplier for a severity string.
|
||||
/// </summary>
|
||||
public double GetMultiplier(string severity) => severity?.ToUpperInvariant() switch
|
||||
{
|
||||
"CRITICAL" => Critical,
|
||||
"HIGH" => High,
|
||||
"MEDIUM" => Medium,
|
||||
"LOW" => Low,
|
||||
"INFORMATIONAL" or "INFO" => Informational,
|
||||
_ => Medium
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Freshness decay configuration.
|
||||
/// </summary>
|
||||
public sealed record FreshnessDecayConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Hours after which SBOM starts to decay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomDecayStartHours")]
|
||||
public int SbomDecayStartHours { get; init; } = 168; // 7 days
|
||||
|
||||
/// <summary>
|
||||
/// Hours after which feeds start to decay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feedDecayStartHours")]
|
||||
public int FeedDecayStartHours { get; init; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Decay rate per hour after start.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decayRatePerHour")]
|
||||
public double DecayRatePerHour { get; init; } = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum freshness score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minimumFreshness")]
|
||||
public double MinimumFreshness { get; init; } = 0.5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom scoring rule.
|
||||
/// </summary>
|
||||
public sealed record CustomScoringRule
|
||||
{
|
||||
/// <summary>
|
||||
/// Rule identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule language (rego, spl).
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule priority (higher = evaluated first).
|
||||
/// </summary>
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether rule is enabled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for scoring rules snapshots.
|
||||
/// </summary>
|
||||
public sealed class ScoringRulesSnapshotBuilder
|
||||
{
|
||||
private ScoringRulesSnapshot _snapshot;
|
||||
|
||||
private ScoringRulesSnapshotBuilder(ScoringRulesSnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new builder with defaults.
|
||||
/// </summary>
|
||||
public static ScoringRulesSnapshotBuilder Create(string id, int version)
|
||||
{
|
||||
return new ScoringRulesSnapshotBuilder(new ScoringRulesSnapshot
|
||||
{
|
||||
Id = id,
|
||||
Version = version,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Digest = "", // Will be computed on build
|
||||
Weights = new ScoringWeights(),
|
||||
Thresholds = new GradeThresholds(),
|
||||
SeverityMultipliers = new SeverityMultipliers(),
|
||||
AssumptionPenalties = new AssumptionPenaltyConfig(),
|
||||
TrustSourceWeights = new TrustSourceWeightConfig(),
|
||||
FreshnessDecay = new FreshnessDecayConfig()
|
||||
});
|
||||
}
|
||||
|
||||
public ScoringRulesSnapshotBuilder WithDescription(string description)
|
||||
{
|
||||
_snapshot = _snapshot with { Description = description };
|
||||
return this;
|
||||
}
|
||||
|
||||
public ScoringRulesSnapshotBuilder WithWeights(ScoringWeights weights)
|
||||
{
|
||||
_snapshot = _snapshot with { Weights = weights };
|
||||
return this;
|
||||
}
|
||||
|
||||
public ScoringRulesSnapshotBuilder WithThresholds(GradeThresholds thresholds)
|
||||
{
|
||||
_snapshot = _snapshot with { Thresholds = thresholds };
|
||||
return this;
|
||||
}
|
||||
|
||||
public ScoringRulesSnapshotBuilder WithSeverityMultipliers(SeverityMultipliers multipliers)
|
||||
{
|
||||
_snapshot = _snapshot with { SeverityMultipliers = multipliers };
|
||||
return this;
|
||||
}
|
||||
|
||||
public ScoringRulesSnapshotBuilder WithAssumptionPenalties(AssumptionPenaltyConfig penalties)
|
||||
{
|
||||
_snapshot = _snapshot with { AssumptionPenalties = penalties };
|
||||
return this;
|
||||
}
|
||||
|
||||
public ScoringRulesSnapshotBuilder WithTrustSourceWeights(TrustSourceWeightConfig weights)
|
||||
{
|
||||
_snapshot = _snapshot with { TrustSourceWeights = weights };
|
||||
return this;
|
||||
}
|
||||
|
||||
public ScoringRulesSnapshotBuilder WithFreshnessDecay(FreshnessDecayConfig decay)
|
||||
{
|
||||
_snapshot = _snapshot with { FreshnessDecay = decay };
|
||||
return this;
|
||||
}
|
||||
|
||||
public ScoringRulesSnapshotBuilder WithCustomRules(IEnumerable<CustomScoringRule> rules)
|
||||
{
|
||||
_snapshot = _snapshot with { CustomRules = rules.ToImmutableArray() };
|
||||
return this;
|
||||
}
|
||||
|
||||
public ScoringRulesSnapshotBuilder WithSourcePolicies(IEnumerable<string> policyIds)
|
||||
{
|
||||
_snapshot = _snapshot with { SourcePolicies = policyIds.ToImmutableArray() };
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the snapshot with computed digest.
|
||||
/// </summary>
|
||||
public ScoringRulesSnapshot Build()
|
||||
{
|
||||
// Validate weights
|
||||
if (!_snapshot.Weights.Validate())
|
||||
{
|
||||
throw new InvalidOperationException("Scoring weights must sum to 1.0");
|
||||
}
|
||||
|
||||
// Compute digest
|
||||
var canonical = StellaOps.Canonical.Json.CanonJson.Canonicalize(_snapshot with { Digest = "" });
|
||||
var digest = StellaOps.Canonical.Json.CanonJson.Sha256Prefixed(canonical);
|
||||
|
||||
return _snapshot with { Digest = digest };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing scoring rules snapshots.
|
||||
/// </summary>
|
||||
public interface IScoringRulesSnapshotService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new snapshot from current rules.
|
||||
/// </summary>
|
||||
Task<ScoringRulesSnapshot> CreateSnapshotAsync(
|
||||
string description,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot by ID.
|
||||
/// </summary>
|
||||
Task<ScoringRulesSnapshot?> GetSnapshotAsync(
|
||||
string id,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest snapshot.
|
||||
/// </summary>
|
||||
Task<ScoringRulesSnapshot?> GetLatestSnapshotAsync(
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a snapshot against its digest.
|
||||
/// </summary>
|
||||
Task<bool> ValidateSnapshotAsync(
|
||||
ScoringRulesSnapshot snapshot,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all snapshots.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ScoringRulesSnapshot>> ListSnapshotsAsync(
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TrustSourceWeights.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: D-SCORE-003 - Configurable trust source weights
|
||||
// Description: Configurable weights for different vulnerability data sources.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Known vulnerability data sources.
|
||||
/// </summary>
|
||||
public static class KnownSources
|
||||
{
|
||||
public const string NvdNist = "nvd-nist";
|
||||
public const string CisaKev = "cisa-kev";
|
||||
public const string Osv = "osv";
|
||||
public const string GithubAdvisory = "github-advisory";
|
||||
public const string VendorAdvisory = "vendor";
|
||||
public const string RedHatCve = "redhat-cve";
|
||||
public const string DebianSecurity = "debian-security";
|
||||
public const string AlpineSecdb = "alpine-secdb";
|
||||
public const string UbuntuOval = "ubuntu-oval";
|
||||
public const string Epss = "epss";
|
||||
public const string ExploitDb = "exploit-db";
|
||||
public const string VulnDb = "vulndb";
|
||||
public const string Snyk = "snyk";
|
||||
public const string Internal = "internal";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for trust source weights.
|
||||
/// </summary>
|
||||
public sealed record TrustSourceWeightConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Weights by source ID (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("weights")]
|
||||
public ImmutableDictionary<string, double> Weights { get; init; } =
|
||||
DefaultWeights;
|
||||
|
||||
/// <summary>
|
||||
/// Default weight for unknown sources.
|
||||
/// </summary>
|
||||
[JsonPropertyName("defaultWeight")]
|
||||
public double DefaultWeight { get; init; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Source categories and their base weights.
|
||||
/// </summary>
|
||||
[JsonPropertyName("categoryWeights")]
|
||||
public ImmutableDictionary<SourceCategory, double> CategoryWeights { get; init; } =
|
||||
DefaultCategoryWeights;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to boost sources with corroborating data.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enableCorroborationBoost")]
|
||||
public bool EnableCorroborationBoost { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Boost multiplier when multiple sources agree.
|
||||
/// </summary>
|
||||
[JsonPropertyName("corroborationBoostFactor")]
|
||||
public double CorroborationBoostFactor { get; init; } = 1.1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of corroborating sources to count.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxCorroborationCount")]
|
||||
public int MaxCorroborationCount { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Default source weights.
|
||||
/// </summary>
|
||||
public static readonly ImmutableDictionary<string, double> DefaultWeights =
|
||||
new Dictionary<string, double>
|
||||
{
|
||||
[KnownSources.NvdNist] = 0.90,
|
||||
[KnownSources.CisaKev] = 0.98,
|
||||
[KnownSources.Osv] = 0.75,
|
||||
[KnownSources.GithubAdvisory] = 0.72,
|
||||
[KnownSources.VendorAdvisory] = 0.88,
|
||||
[KnownSources.RedHatCve] = 0.85,
|
||||
[KnownSources.DebianSecurity] = 0.82,
|
||||
[KnownSources.AlpineSecdb] = 0.80,
|
||||
[KnownSources.UbuntuOval] = 0.82,
|
||||
[KnownSources.Epss] = 0.70,
|
||||
[KnownSources.ExploitDb] = 0.65,
|
||||
[KnownSources.VulnDb] = 0.68,
|
||||
[KnownSources.Snyk] = 0.70,
|
||||
[KnownSources.Internal] = 0.60
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
/// <summary>
|
||||
/// Default category weights.
|
||||
/// </summary>
|
||||
public static readonly ImmutableDictionary<SourceCategory, double> DefaultCategoryWeights =
|
||||
new Dictionary<SourceCategory, double>
|
||||
{
|
||||
[SourceCategory.Government] = 0.95,
|
||||
[SourceCategory.Vendor] = 0.85,
|
||||
[SourceCategory.Coordinator] = 0.80,
|
||||
[SourceCategory.Distro] = 0.82,
|
||||
[SourceCategory.Community] = 0.70,
|
||||
[SourceCategory.Commercial] = 0.68,
|
||||
[SourceCategory.Internal] = 0.60
|
||||
}.ToImmutableDictionary();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source categories.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SourceCategory
|
||||
{
|
||||
/// <summary>Government agency (NIST, CISA, BSI).</summary>
|
||||
Government,
|
||||
|
||||
/// <summary>Software vendor.</summary>
|
||||
Vendor,
|
||||
|
||||
/// <summary>Vulnerability coordinator (CERT).</summary>
|
||||
Coordinator,
|
||||
|
||||
/// <summary>Linux distribution security team.</summary>
|
||||
Distro,
|
||||
|
||||
/// <summary>Open source community.</summary>
|
||||
Community,
|
||||
|
||||
/// <summary>Commercial security vendor.</summary>
|
||||
Commercial,
|
||||
|
||||
/// <summary>Internal organization sources.</summary>
|
||||
Internal
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a vulnerability source.
|
||||
/// </summary>
|
||||
public sealed record SourceMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Source identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source category.
|
||||
/// </summary>
|
||||
[JsonPropertyName("category")]
|
||||
public required SourceCategory Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When data was fetched from this source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fetchedAt")]
|
||||
public DateTimeOffset? FetchedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source data version/timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dataVersion")]
|
||||
public string? DataVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether data is signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isSigned")]
|
||||
public bool IsSigned { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding data from a source.
|
||||
/// </summary>
|
||||
public sealed record SourceFinding
|
||||
{
|
||||
/// <summary>
|
||||
/// Source metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required SourceMetadata Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity from this source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS score from this source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cvssScore")]
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status from this source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexStatus")]
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this source confirms exploitability.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confirmsExploit")]
|
||||
public bool? ConfirmsExploit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fix version from this source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixVersion")]
|
||||
public string? FixVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of merging findings from multiple sources.
|
||||
/// </summary>
|
||||
public sealed record WeightedMergeResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Merged severity (highest trust source).
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weighted average CVSS score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cvssScore")]
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status from highest trust source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexStatus")]
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fix version (earliest reported).
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixVersion")]
|
||||
public string? FixVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall confidence in the merged result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sources that contributed (ordered by weight).
|
||||
/// </summary>
|
||||
[JsonPropertyName("contributingSources")]
|
||||
public ImmutableArray<string> ContributingSources { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether sources corroborated each other.
|
||||
/// </summary>
|
||||
[JsonPropertyName("corroborated")]
|
||||
public bool Corroborated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Corroboration boost applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("corroborationBoost")]
|
||||
public double CorroborationBoost { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for weighted source merging.
|
||||
/// </summary>
|
||||
public sealed class TrustSourceWeightService
|
||||
{
|
||||
private readonly TrustSourceWeightConfig _config;
|
||||
|
||||
public TrustSourceWeightService(TrustSourceWeightConfig? config = null)
|
||||
{
|
||||
_config = config ?? new TrustSourceWeightConfig();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective weight for a source.
|
||||
/// </summary>
|
||||
public double GetSourceWeight(SourceMetadata source)
|
||||
{
|
||||
// Check for explicit weight
|
||||
if (_config.Weights.TryGetValue(source.Id, out var explicitWeight))
|
||||
{
|
||||
return ApplyModifiers(explicitWeight, source);
|
||||
}
|
||||
|
||||
// Fall back to category weight
|
||||
if (_config.CategoryWeights.TryGetValue(source.Category, out var categoryWeight))
|
||||
{
|
||||
return ApplyModifiers(categoryWeight, source);
|
||||
}
|
||||
|
||||
return ApplyModifiers(_config.DefaultWeight, source);
|
||||
}
|
||||
|
||||
private double ApplyModifiers(double baseWeight, SourceMetadata source)
|
||||
{
|
||||
var weight = baseWeight;
|
||||
|
||||
// Boost for signed data
|
||||
if (source.IsSigned)
|
||||
{
|
||||
weight *= 1.05;
|
||||
}
|
||||
|
||||
// Penalty for stale data (>7 days old)
|
||||
if (source.FetchedAt.HasValue)
|
||||
{
|
||||
var age = DateTimeOffset.UtcNow - source.FetchedAt.Value;
|
||||
if (age.TotalDays > 7)
|
||||
{
|
||||
weight *= 0.95;
|
||||
}
|
||||
if (age.TotalDays > 30)
|
||||
{
|
||||
weight *= 0.90;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.Clamp(weight, 0.0, 1.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges findings from multiple sources using weights.
|
||||
/// </summary>
|
||||
public WeightedMergeResult MergeFindings(IEnumerable<SourceFinding> findings)
|
||||
{
|
||||
var findingList = findings.ToList();
|
||||
if (findingList.Count == 0)
|
||||
{
|
||||
return new WeightedMergeResult { Confidence = 0 };
|
||||
}
|
||||
|
||||
// Sort by weight descending
|
||||
var weighted = findingList
|
||||
.Select(f => (Finding: f, Weight: GetSourceWeight(f.Source)))
|
||||
.OrderByDescending(x => x.Weight)
|
||||
.ToList();
|
||||
|
||||
var topFinding = weighted[0].Finding;
|
||||
var topWeight = weighted[0].Weight;
|
||||
|
||||
// Calculate weighted CVSS
|
||||
double? weightedCvss = null;
|
||||
var cvssFindings = weighted.Where(w => w.Finding.CvssScore.HasValue).ToList();
|
||||
if (cvssFindings.Count > 0)
|
||||
{
|
||||
var totalWeight = cvssFindings.Sum(w => w.Weight);
|
||||
weightedCvss = cvssFindings.Sum(w => w.Finding.CvssScore!.Value * w.Weight) / totalWeight;
|
||||
}
|
||||
|
||||
// Check for corroboration
|
||||
var corroborated = false;
|
||||
var corroborationBoost = 0.0;
|
||||
|
||||
if (_config.EnableCorroborationBoost && weighted.Count > 1)
|
||||
{
|
||||
// Check if multiple sources agree on severity
|
||||
var severities = weighted
|
||||
.Where(w => !string.IsNullOrEmpty(w.Finding.Severity))
|
||||
.Select(w => w.Finding.Severity)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (severities.Count == 1)
|
||||
{
|
||||
var corroboratingCount = Math.Min(
|
||||
weighted.Count(w => w.Finding.Severity == severities[0]),
|
||||
_config.MaxCorroborationCount);
|
||||
|
||||
if (corroboratingCount > 1)
|
||||
{
|
||||
corroborated = true;
|
||||
corroborationBoost = Math.Pow(
|
||||
_config.CorroborationBoostFactor,
|
||||
corroboratingCount - 1) - 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var confidence = Math.Clamp(topWeight + corroborationBoost, 0.0, 1.0);
|
||||
|
||||
return new WeightedMergeResult
|
||||
{
|
||||
Severity = topFinding.Severity,
|
||||
CvssScore = weightedCvss,
|
||||
VexStatus = topFinding.VexStatus,
|
||||
FixVersion = findingList
|
||||
.Where(f => !string.IsNullOrEmpty(f.FixVersion))
|
||||
.OrderBy(f => f.FixVersion)
|
||||
.FirstOrDefault()?.FixVersion,
|
||||
Confidence = confidence,
|
||||
ContributingSources = weighted.Select(w => w.Finding.Source.Id).ToImmutableArray(),
|
||||
Corroborated = corroborated,
|
||||
CorroborationBoost = corroborationBoost
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JurisdictionTrustRules.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: VEX-L-003 - Jurisdiction-specific trust rules (US/EU/RU/CN)
|
||||
// Description: VEX source trust rules by regulatory jurisdiction.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// Jurisdiction codes for regulatory regions.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum Jurisdiction
|
||||
{
|
||||
/// <summary>United States (FDA, NIST, CISA).</summary>
|
||||
US,
|
||||
|
||||
/// <summary>European Union (ENISA, BSI, ANSSI).</summary>
|
||||
EU,
|
||||
|
||||
/// <summary>Russian Federation (FSTEC, FSB).</summary>
|
||||
RU,
|
||||
|
||||
/// <summary>China (CNVD, CNNVD).</summary>
|
||||
CN,
|
||||
|
||||
/// <summary>Japan (JPCERT, IPA).</summary>
|
||||
JP,
|
||||
|
||||
/// <summary>Global (no specific jurisdiction).</summary>
|
||||
Global
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX source identity.
|
||||
/// </summary>
|
||||
public sealed record VexSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique source identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable source name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source type (vendor, coordinator, government, community).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required VexSourceType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Jurisdictions where this source is authoritative.
|
||||
/// </summary>
|
||||
[JsonPropertyName("jurisdictions")]
|
||||
public ImmutableArray<Jurisdiction> Jurisdictions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Base trust weight (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("baseTrustWeight")]
|
||||
public double BaseTrustWeight { get; init; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this source is a government authority.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isGovernmentAuthority")]
|
||||
public bool IsGovernmentAuthority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing key identifiers for this source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyIds")]
|
||||
public ImmutableArray<string> KeyIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX source types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum VexSourceType
|
||||
{
|
||||
/// <summary>Product vendor.</summary>
|
||||
Vendor,
|
||||
|
||||
/// <summary>Vulnerability coordinator (CERT).</summary>
|
||||
Coordinator,
|
||||
|
||||
/// <summary>Government authority.</summary>
|
||||
Government,
|
||||
|
||||
/// <summary>Community/open source.</summary>
|
||||
Community,
|
||||
|
||||
/// <summary>Commercial security vendor.</summary>
|
||||
Commercial
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Jurisdiction-specific trust configuration.
|
||||
/// </summary>
|
||||
public sealed record JurisdictionTrustConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Jurisdiction this config applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("jurisdiction")]
|
||||
public required Jurisdiction Jurisdiction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of preferred sources (highest priority first).
|
||||
/// </summary>
|
||||
[JsonPropertyName("preferredSources")]
|
||||
public ImmutableArray<string> PreferredSources { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Trust weight overrides for specific sources.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trustWeightOverrides")]
|
||||
public ImmutableDictionary<string, double> TrustWeightOverrides { get; init; } =
|
||||
ImmutableDictionary<string, double>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether government sources must be preferred.
|
||||
/// </summary>
|
||||
[JsonPropertyName("preferGovernmentSources")]
|
||||
public bool PreferGovernmentSources { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum trust weight for acceptance.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minimumTrustWeight")]
|
||||
public double MinimumTrustWeight { get; init; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// Required source types for VEX acceptance.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiredSourceTypes")]
|
||||
public ImmutableArray<VexSourceType> RequiredSourceTypes { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for jurisdiction-aware VEX trust evaluation.
|
||||
/// </summary>
|
||||
public interface IJurisdictionTrustService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the effective trust weight for a source in a jurisdiction.
|
||||
/// </summary>
|
||||
double GetEffectiveTrustWeight(VexSource source, Jurisdiction jurisdiction);
|
||||
|
||||
/// <summary>
|
||||
/// Ranks sources by trust for a jurisdiction.
|
||||
/// </summary>
|
||||
IReadOnlyList<VexSource> RankSourcesByTrust(
|
||||
IEnumerable<VexSource> sources,
|
||||
Jurisdiction jurisdiction);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a VEX decision meets jurisdiction requirements.
|
||||
/// </summary>
|
||||
JurisdictionValidationResult ValidateForJurisdiction(
|
||||
VexDecisionContext decision,
|
||||
Jurisdiction jurisdiction);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for a VEX decision being validated.
|
||||
/// </summary>
|
||||
public sealed record VexDecisionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source that provided this decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required VexSource Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification provided.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was made.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the decision is cryptographically signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isSigned")]
|
||||
public bool IsSigned { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of jurisdiction validation.
|
||||
/// </summary>
|
||||
public sealed record JurisdictionValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the decision is valid for the jurisdiction.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isValid")]
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Effective trust weight.
|
||||
/// </summary>
|
||||
[JsonPropertyName("effectiveTrustWeight")]
|
||||
public required double EffectiveTrustWeight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation issues.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issues")]
|
||||
public ImmutableArray<string> Issues { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Suggested actions to improve trust.
|
||||
/// </summary>
|
||||
[JsonPropertyName("suggestions")]
|
||||
public ImmutableArray<string> Suggestions { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of jurisdiction trust service.
|
||||
/// </summary>
|
||||
public sealed class JurisdictionTrustService : IJurisdictionTrustService
|
||||
{
|
||||
private readonly IReadOnlyDictionary<Jurisdiction, JurisdictionTrustConfig> _configs;
|
||||
|
||||
/// <summary>
|
||||
/// Default jurisdiction configurations.
|
||||
/// </summary>
|
||||
public static readonly ImmutableDictionary<Jurisdiction, JurisdictionTrustConfig> DefaultConfigs =
|
||||
new Dictionary<Jurisdiction, JurisdictionTrustConfig>
|
||||
{
|
||||
[Jurisdiction.US] = new()
|
||||
{
|
||||
Jurisdiction = Jurisdiction.US,
|
||||
PreferredSources = ["nist-nvd", "cisa-kev", "fda-medical", "vendor"],
|
||||
PreferGovernmentSources = true,
|
||||
MinimumTrustWeight = 0.4,
|
||||
TrustWeightOverrides = new Dictionary<string, double>
|
||||
{
|
||||
["nist-nvd"] = 0.95,
|
||||
["cisa-kev"] = 0.98,
|
||||
["vendor"] = 0.85
|
||||
}.ToImmutableDictionary()
|
||||
},
|
||||
[Jurisdiction.EU] = new()
|
||||
{
|
||||
Jurisdiction = Jurisdiction.EU,
|
||||
PreferredSources = ["enisa", "bsi", "anssi", "cert-eu", "vendor"],
|
||||
PreferGovernmentSources = true,
|
||||
MinimumTrustWeight = 0.4,
|
||||
TrustWeightOverrides = new Dictionary<string, double>
|
||||
{
|
||||
["enisa"] = 0.95,
|
||||
["bsi"] = 0.92,
|
||||
["anssi"] = 0.92,
|
||||
["vendor"] = 0.85
|
||||
}.ToImmutableDictionary()
|
||||
},
|
||||
[Jurisdiction.RU] = new()
|
||||
{
|
||||
Jurisdiction = Jurisdiction.RU,
|
||||
PreferredSources = ["fstec", "fsb-cert", "vendor"],
|
||||
PreferGovernmentSources = true,
|
||||
MinimumTrustWeight = 0.5,
|
||||
TrustWeightOverrides = new Dictionary<string, double>
|
||||
{
|
||||
["fstec"] = 0.98,
|
||||
["vendor"] = 0.80
|
||||
}.ToImmutableDictionary()
|
||||
},
|
||||
[Jurisdiction.CN] = new()
|
||||
{
|
||||
Jurisdiction = Jurisdiction.CN,
|
||||
PreferredSources = ["cnvd", "cnnvd", "vendor"],
|
||||
PreferGovernmentSources = true,
|
||||
MinimumTrustWeight = 0.5,
|
||||
TrustWeightOverrides = new Dictionary<string, double>
|
||||
{
|
||||
["cnvd"] = 0.95,
|
||||
["cnnvd"] = 0.95,
|
||||
["vendor"] = 0.80
|
||||
}.ToImmutableDictionary()
|
||||
},
|
||||
[Jurisdiction.Global] = new()
|
||||
{
|
||||
Jurisdiction = Jurisdiction.Global,
|
||||
PreferredSources = ["vendor", "osv", "github-advisory"],
|
||||
PreferGovernmentSources = false,
|
||||
MinimumTrustWeight = 0.3,
|
||||
TrustWeightOverrides = new Dictionary<string, double>
|
||||
{
|
||||
["vendor"] = 0.90,
|
||||
["osv"] = 0.75,
|
||||
["github-advisory"] = 0.70
|
||||
}.ToImmutableDictionary()
|
||||
}
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
public JurisdictionTrustService(
|
||||
IReadOnlyDictionary<Jurisdiction, JurisdictionTrustConfig>? configs = null)
|
||||
{
|
||||
_configs = configs ?? DefaultConfigs;
|
||||
}
|
||||
|
||||
public double GetEffectiveTrustWeight(VexSource source, Jurisdiction jurisdiction)
|
||||
{
|
||||
if (!_configs.TryGetValue(jurisdiction, out var config))
|
||||
{
|
||||
config = DefaultConfigs[Jurisdiction.Global];
|
||||
}
|
||||
|
||||
// Check for explicit override
|
||||
if (config.TrustWeightOverrides.TryGetValue(source.Id, out var overrideWeight))
|
||||
{
|
||||
return overrideWeight;
|
||||
}
|
||||
|
||||
var weight = source.BaseTrustWeight;
|
||||
|
||||
// Bonus for government sources in jurisdictions that prefer them
|
||||
if (config.PreferGovernmentSources && source.IsGovernmentAuthority)
|
||||
{
|
||||
weight *= 1.2;
|
||||
}
|
||||
|
||||
// Bonus for sources that list this jurisdiction as authoritative
|
||||
if (source.Jurisdictions.Contains(jurisdiction))
|
||||
{
|
||||
weight *= 1.1;
|
||||
}
|
||||
|
||||
// Penalty for non-preferred sources
|
||||
var preferenceIndex = config.PreferredSources
|
||||
.Select((id, i) => (id, i))
|
||||
.FirstOrDefault(x => x.id == source.Id).i;
|
||||
|
||||
if (preferenceIndex > 0)
|
||||
{
|
||||
weight *= 1.0 - (preferenceIndex * 0.05);
|
||||
}
|
||||
|
||||
return Math.Clamp(weight, 0.0, 1.0);
|
||||
}
|
||||
|
||||
public IReadOnlyList<VexSource> RankSourcesByTrust(
|
||||
IEnumerable<VexSource> sources,
|
||||
Jurisdiction jurisdiction)
|
||||
{
|
||||
return sources
|
||||
.OrderByDescending(s => GetEffectiveTrustWeight(s, jurisdiction))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public JurisdictionValidationResult ValidateForJurisdiction(
|
||||
VexDecisionContext decision,
|
||||
Jurisdiction jurisdiction)
|
||||
{
|
||||
if (!_configs.TryGetValue(jurisdiction, out var config))
|
||||
{
|
||||
config = DefaultConfigs[Jurisdiction.Global];
|
||||
}
|
||||
|
||||
var issues = new List<string>();
|
||||
var suggestions = new List<string>();
|
||||
|
||||
var effectiveWeight = GetEffectiveTrustWeight(decision.Source, jurisdiction);
|
||||
|
||||
// Check minimum trust weight
|
||||
if (effectiveWeight < config.MinimumTrustWeight)
|
||||
{
|
||||
issues.Add($"Source trust weight ({effectiveWeight:P0}) below minimum ({config.MinimumTrustWeight:P0})");
|
||||
suggestions.Add("Consider obtaining VEX from a higher-trust source");
|
||||
}
|
||||
|
||||
// Check government preference
|
||||
if (config.PreferGovernmentSources && !decision.Source.IsGovernmentAuthority)
|
||||
{
|
||||
suggestions.Add($"Jurisdiction {jurisdiction} prefers government sources");
|
||||
}
|
||||
|
||||
// Check signature requirement for high-trust decisions
|
||||
if (effectiveWeight >= 0.8 && !decision.IsSigned)
|
||||
{
|
||||
issues.Add("High-trust VEX decisions should be cryptographically signed");
|
||||
suggestions.Add("Request signed VEX statement from source");
|
||||
}
|
||||
|
||||
// Check required source types
|
||||
if (config.RequiredSourceTypes.Length > 0 &&
|
||||
!config.RequiredSourceTypes.Contains(decision.Source.Type))
|
||||
{
|
||||
issues.Add($"Source type {decision.Source.Type} not in required types");
|
||||
}
|
||||
|
||||
return new JurisdictionValidationResult
|
||||
{
|
||||
IsValid = issues.Count == 0,
|
||||
EffectiveTrustWeight = effectiveWeight,
|
||||
Issues = issues.ToImmutableArray(),
|
||||
Suggestions = suggestions.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexCustomerOverride.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: VEX-L-004 - Customer override with signed audit trail
|
||||
// Description: Customer-initiated VEX overrides with cryptographic audit trail.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// Customer-initiated VEX override with full audit trail.
|
||||
/// </summary>
|
||||
public sealed record VexCustomerOverride
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique override identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE or vulnerability ID being overridden.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product or component PURL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("productPurl")]
|
||||
public required string ProductPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original VEX status from source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("originalStatus")]
|
||||
public required string OriginalStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overridden VEX status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overrideStatus")]
|
||||
public required string OverrideStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the override.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public required VexOverrideJustification Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who created the override.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdBy")]
|
||||
public required OverrideActor CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the override was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approvers (for multi-party approval).
|
||||
/// </summary>
|
||||
[JsonPropertyName("approvers")]
|
||||
public ImmutableArray<OverrideApproval> Approvers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Expiration time for the override.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the override is currently active.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Scope of the override.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scope")]
|
||||
public required OverrideScope Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic signature of the override.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public OverrideSignature? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence references supporting the override.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public ImmutableArray<string> EvidenceRefs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Tags for categorization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public ImmutableArray<string> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Audit events for this override.
|
||||
/// </summary>
|
||||
[JsonPropertyName("auditTrail")]
|
||||
public ImmutableArray<OverrideAuditEvent> AuditTrail { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Justification for a VEX override.
|
||||
/// </summary>
|
||||
public sealed record VexOverrideJustification
|
||||
{
|
||||
/// <summary>
|
||||
/// Justification category.
|
||||
/// </summary>
|
||||
[JsonPropertyName("category")]
|
||||
public required OverrideJustificationCategory Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed explanation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("explanation")]
|
||||
public required string Explanation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Compensating controls in place.
|
||||
/// </summary>
|
||||
[JsonPropertyName("compensatingControls")]
|
||||
public ImmutableArray<string> CompensatingControls { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Risk acceptance level.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskAcceptanceLevel")]
|
||||
public RiskAcceptanceLevel? RiskAcceptanceLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Remediation plan if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("remediationPlan")]
|
||||
public RemediationPlan? RemediationPlan { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Categories for override justification.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum OverrideJustificationCategory
|
||||
{
|
||||
/// <summary>Vendor analysis incorrect.</summary>
|
||||
VendorAnalysisIncorrect,
|
||||
|
||||
/// <summary>Compensating controls in place.</summary>
|
||||
CompensatingControls,
|
||||
|
||||
/// <summary>Not applicable to deployment context.</summary>
|
||||
NotApplicableToContext,
|
||||
|
||||
/// <summary>Risk accepted per policy.</summary>
|
||||
RiskAccepted,
|
||||
|
||||
/// <summary>False positive confirmed.</summary>
|
||||
FalsePositive,
|
||||
|
||||
/// <summary>Component not in use.</summary>
|
||||
ComponentNotInUse,
|
||||
|
||||
/// <summary>Vulnerable code path not reachable.</summary>
|
||||
CodePathNotReachable,
|
||||
|
||||
/// <summary>Already mitigated by other means.</summary>
|
||||
AlreadyMitigated,
|
||||
|
||||
/// <summary>Business critical exception.</summary>
|
||||
BusinessException
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk acceptance levels.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum RiskAcceptanceLevel
|
||||
{
|
||||
/// <summary>Low risk accepted.</summary>
|
||||
Low,
|
||||
|
||||
/// <summary>Medium risk accepted.</summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>High risk accepted (requires senior approval).</summary>
|
||||
High,
|
||||
|
||||
/// <summary>Critical risk accepted (requires executive approval).</summary>
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remediation plan for accepted risk.
|
||||
/// </summary>
|
||||
public sealed record RemediationPlan
|
||||
{
|
||||
/// <summary>
|
||||
/// Target remediation date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetDate")]
|
||||
public required DateTimeOffset TargetDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Remediation steps.
|
||||
/// </summary>
|
||||
[JsonPropertyName("steps")]
|
||||
public ImmutableArray<string> Steps { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Ticket/issue reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ticketRef")]
|
||||
public string? TicketRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assigned owner.
|
||||
/// </summary>
|
||||
[JsonPropertyName("owner")]
|
||||
public string? Owner { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actor who created or modified an override.
|
||||
/// </summary>
|
||||
public sealed record OverrideActor
|
||||
{
|
||||
/// <summary>
|
||||
/// User identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("userId")]
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User display name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("displayName")]
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User email.
|
||||
/// </summary>
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User role at time of action.
|
||||
/// </summary>
|
||||
[JsonPropertyName("role")]
|
||||
public string? Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Organization/tenant.
|
||||
/// </summary>
|
||||
[JsonPropertyName("organization")]
|
||||
public string? Organization { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approval for an override.
|
||||
/// </summary>
|
||||
public sealed record OverrideApproval
|
||||
{
|
||||
/// <summary>
|
||||
/// Approver details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approver")]
|
||||
public required OverrideActor Approver { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When approved.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approvedAt")]
|
||||
public required DateTimeOffset ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approval comment.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature of approval.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public OverrideSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope of an override.
|
||||
/// </summary>
|
||||
public sealed record OverrideScope
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required OverrideScopeType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific artifact digests if scoped.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactDigests")]
|
||||
public ImmutableArray<string> ArtifactDigests { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Environment names if scoped.
|
||||
/// </summary>
|
||||
[JsonPropertyName("environments")]
|
||||
public ImmutableArray<string> Environments { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Version range if scoped.
|
||||
/// </summary>
|
||||
[JsonPropertyName("versionRange")]
|
||||
public string? VersionRange { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope types for overrides.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum OverrideScopeType
|
||||
{
|
||||
/// <summary>Applies to all versions of the product.</summary>
|
||||
AllVersions,
|
||||
|
||||
/// <summary>Applies to specific version range.</summary>
|
||||
VersionRange,
|
||||
|
||||
/// <summary>Applies to specific artifacts only.</summary>
|
||||
SpecificArtifacts,
|
||||
|
||||
/// <summary>Applies to specific environments only.</summary>
|
||||
EnvironmentScoped
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic signature for override.
|
||||
/// </summary>
|
||||
public sealed record OverrideSignature
|
||||
{
|
||||
/// <summary>
|
||||
/// Signature algorithm.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature value (base64).
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public required string Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate chain (PEM, if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("certificateChain")]
|
||||
public string? CertificateChain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit event for override lifecycle.
|
||||
/// </summary>
|
||||
public sealed record OverrideAuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Event timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("eventType")]
|
||||
public required OverrideAuditEventType EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor who caused the event.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public required OverrideActor Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("details")]
|
||||
public string? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous value (for changes).
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousValue")]
|
||||
public string? PreviousValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New value (for changes).
|
||||
/// </summary>
|
||||
[JsonPropertyName("newValue")]
|
||||
public string? NewValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// IP address of actor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ipAddress")]
|
||||
public string? IpAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event signature for tamper-evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("eventSignature")]
|
||||
public string? EventSignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit event types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum OverrideAuditEventType
|
||||
{
|
||||
/// <summary>Override created.</summary>
|
||||
Created,
|
||||
|
||||
/// <summary>Override approved.</summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>Override rejected.</summary>
|
||||
Rejected,
|
||||
|
||||
/// <summary>Override modified.</summary>
|
||||
Modified,
|
||||
|
||||
/// <summary>Override expired.</summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>Override revoked.</summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>Override renewed.</summary>
|
||||
Renewed,
|
||||
|
||||
/// <summary>Override applied to scan.</summary>
|
||||
Applied,
|
||||
|
||||
/// <summary>Override viewed.</summary>
|
||||
Viewed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing customer VEX overrides.
|
||||
/// </summary>
|
||||
public interface IVexOverrideService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new override.
|
||||
/// </summary>
|
||||
Task<VexCustomerOverride> CreateOverrideAsync(
|
||||
CreateOverrideRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Approves an override.
|
||||
/// </summary>
|
||||
Task<VexCustomerOverride> ApproveOverrideAsync(
|
||||
string overrideId,
|
||||
OverrideApproval approval,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an override.
|
||||
/// </summary>
|
||||
Task<VexCustomerOverride> RevokeOverrideAsync(
|
||||
string overrideId,
|
||||
OverrideActor actor,
|
||||
string reason,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active overrides for a vulnerability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<VexCustomerOverride>> GetActiveOverridesAsync(
|
||||
string vulnerabilityId,
|
||||
string? productPurl = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit trail for an override.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<OverrideAuditEvent>> GetAuditTrailAsync(
|
||||
string overrideId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an override.
|
||||
/// </summary>
|
||||
public sealed record CreateOverrideRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product PURL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("productPurl")]
|
||||
public required string ProductPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Override status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overrideStatus")]
|
||||
public required string OverrideStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public required VexOverrideJustification Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scope")]
|
||||
public required OverrideScope Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiration.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence references.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public ImmutableArray<string> EvidenceRefs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Tags.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public ImmutableArray<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.Scoring.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
Reference in New Issue
Block a user