feat: Add VEX Status Chip component and integration tests for reachability drift detection
- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips. - Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling. - Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation. - Updated project references to include the new Reachability Drift library.
This commit is contained in:
265
src/Policy/__Libraries/StellaOps.Policy/TrustLattice/Claim.cs
Normal file
265
src/Policy/__Libraries/StellaOps.Policy/TrustLattice/Claim.cs
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Claim and Evidence - Core assertion models.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Tasks: TRUST-007, TRUST-008
|
||||
*
|
||||
* A Claim is a signed or unsigned assertion about a Subject.
|
||||
* Evidence is a typed object that supports replay and audit.
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// An atomic assertion about a security proposition.
|
||||
/// </summary>
|
||||
public sealed record AtomAssertion
|
||||
{
|
||||
/// <summary>
|
||||
/// The security atom being asserted.
|
||||
/// </summary>
|
||||
public required SecurityAtom Atom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The asserted value (true or false).
|
||||
/// </summary>
|
||||
public required bool Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional condition under which this assertion holds.
|
||||
/// E.g., "under current config snapshot", "unless dependency present".
|
||||
/// </summary>
|
||||
public string? Condition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable justification for the assertion.
|
||||
/// </summary>
|
||||
public string? Justification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time fields for claim validity.
|
||||
/// </summary>
|
||||
public sealed record ClaimTimeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// When the claim was issued.
|
||||
/// </summary>
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the claim becomes valid (optional).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the claim expires (optional).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidUntil { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A claim is a signed or unsigned assertion about a Subject.
|
||||
/// </summary>
|
||||
public sealed record Claim
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressable digest of canonical claim JSON.
|
||||
/// Computed from claim contents, not supplied externally.
|
||||
/// </summary>
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject of this claim.
|
||||
/// </summary>
|
||||
public required Subject Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The principal making this claim.
|
||||
/// </summary>
|
||||
public Principal Principal { get; init; } = Principal.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Time information for the claim.
|
||||
/// </summary>
|
||||
public ClaimTimeInfo? TimeInfo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of atomic assertions in this claim.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AtomAssertion> Assertions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// References to supporting evidence objects.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? EvidenceRefs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to DSSE/signature wrapper (optional).
|
||||
/// </summary>
|
||||
public string? SignatureRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust label computed for this claim (set during evaluation).
|
||||
/// </summary>
|
||||
public TrustLabel? TrustLabel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source format (e.g., "cyclonedx", "openvex", "csaf", "internal").
|
||||
/// </summary>
|
||||
public string? SourceFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes the content-addressable ID for this claim.
|
||||
/// </summary>
|
||||
public string ComputeId()
|
||||
{
|
||||
// Create a canonical representation excluding the Id field
|
||||
var forHashing = new
|
||||
{
|
||||
subject = Subject,
|
||||
principal = new { id = Principal.Id },
|
||||
time = TimeInfo,
|
||||
assertions = Assertions,
|
||||
evidence_refs = EvidenceRefs,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(forHashing, CanonicalJsonOptions.Default);
|
||||
var hash = SHA256.HashData(json);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new claim with computed ID.
|
||||
/// </summary>
|
||||
public Claim WithComputedId() => this with { Id = ComputeId() };
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the claim is currently valid based on time fields.
|
||||
/// </summary>
|
||||
public bool IsValidAt(DateTimeOffset asOf)
|
||||
{
|
||||
if (TimeInfo?.ValidFrom.HasValue == true && asOf < TimeInfo.ValidFrom.Value)
|
||||
return false;
|
||||
if (TimeInfo?.ValidUntil.HasValue == true && asOf > TimeInfo.ValidUntil.Value)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of evidence supporting a claim.
|
||||
/// </summary>
|
||||
public enum EvidenceType
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM node linkage evidence.
|
||||
/// </summary>
|
||||
SbomNode,
|
||||
|
||||
/// <summary>
|
||||
/// Call graph path showing reachability.
|
||||
/// </summary>
|
||||
CallGraphPath,
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic loader resolution evidence.
|
||||
/// </summary>
|
||||
LoaderResolution,
|
||||
|
||||
/// <summary>
|
||||
/// Configuration snapshot evidence.
|
||||
/// </summary>
|
||||
ConfigSnapshot,
|
||||
|
||||
/// <summary>
|
||||
/// Patch diff evidence.
|
||||
/// </summary>
|
||||
PatchDiff,
|
||||
|
||||
/// <summary>
|
||||
/// Pedigree/commit chain evidence.
|
||||
/// </summary>
|
||||
PedigreeCommitChain,
|
||||
|
||||
/// <summary>
|
||||
/// Runtime behavior observation.
|
||||
/// </summary>
|
||||
RuntimeObservation,
|
||||
|
||||
/// <summary>
|
||||
/// Mitigation control evidence.
|
||||
/// </summary>
|
||||
MitigationControl,
|
||||
|
||||
/// <summary>
|
||||
/// Scanner detection output.
|
||||
/// </summary>
|
||||
ScannerDetection,
|
||||
|
||||
/// <summary>
|
||||
/// Vendor advisory statement.
|
||||
/// </summary>
|
||||
VendorAdvisory,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence is a typed object that supports replay and audit.
|
||||
/// </summary>
|
||||
public sealed record Evidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of evidence.
|
||||
/// </summary>
|
||||
public required EvidenceType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable digest of canonical evidence bytes.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool/system that produced this evidence.
|
||||
/// </summary>
|
||||
public required string Producer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the producer tool.
|
||||
/// </summary>
|
||||
public string? ProducerVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the evidence was collected.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CollectedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the payload in content-addressable storage.
|
||||
/// </summary>
|
||||
public string? PayloadRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to signature/attestation for this evidence (optional).
|
||||
/// </summary>
|
||||
public string? SignatureRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determines the evidence class based on type.
|
||||
/// </summary>
|
||||
public EvidenceClass GetEvidenceClass() => Type switch
|
||||
{
|
||||
EvidenceType.SbomNode => EvidenceClass.E1_SbomLinkage,
|
||||
EvidenceType.CallGraphPath => EvidenceClass.E2_ReachabilityMitigation,
|
||||
EvidenceType.LoaderResolution => EvidenceClass.E2_ReachabilityMitigation,
|
||||
EvidenceType.ConfigSnapshot => EvidenceClass.E2_ReachabilityMitigation,
|
||||
EvidenceType.RuntimeObservation => EvidenceClass.E2_ReachabilityMitigation,
|
||||
EvidenceType.MitigationControl => EvidenceClass.E2_ReachabilityMitigation,
|
||||
EvidenceType.PatchDiff => EvidenceClass.E3_Remediation,
|
||||
EvidenceType.PedigreeCommitChain => EvidenceClass.E3_Remediation,
|
||||
EvidenceType.ScannerDetection => EvidenceClass.E1_SbomLinkage,
|
||||
EvidenceType.VendorAdvisory => EvidenceClass.E0_StatementOnly,
|
||||
_ => EvidenceClass.E0_StatementOnly,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* CSAF VEX Normalizer - Convert CSAF VEX documents to canonical claims.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-012
|
||||
*
|
||||
* CSAF (Common Security Advisory Framework) VEX follows OASIS standard.
|
||||
* See: https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html
|
||||
*/
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// CSAF product status values.
|
||||
/// </summary>
|
||||
public enum CsafProductStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Known affected products.
|
||||
/// </summary>
|
||||
KnownAffected,
|
||||
|
||||
/// <summary>
|
||||
/// Known not affected products.
|
||||
/// </summary>
|
||||
KnownNotAffected,
|
||||
|
||||
/// <summary>
|
||||
/// First affected version.
|
||||
/// </summary>
|
||||
FirstAffected,
|
||||
|
||||
/// <summary>
|
||||
/// First fixed version.
|
||||
/// </summary>
|
||||
FirstFixed,
|
||||
|
||||
/// <summary>
|
||||
/// Fixed versions.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>
|
||||
/// Last affected version.
|
||||
/// </summary>
|
||||
LastAffected,
|
||||
|
||||
/// <summary>
|
||||
/// Recommended versions.
|
||||
/// </summary>
|
||||
Recommended,
|
||||
|
||||
/// <summary>
|
||||
/// Under investigation.
|
||||
/// </summary>
|
||||
UnderInvestigation,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CSAF flag label values.
|
||||
/// </summary>
|
||||
public enum CsafFlagLabel
|
||||
{
|
||||
/// <summary>
|
||||
/// No flag specified.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Component is not present.
|
||||
/// </summary>
|
||||
ComponentNotPresent,
|
||||
|
||||
/// <summary>
|
||||
/// Inline mitigations exist.
|
||||
/// </summary>
|
||||
InlineMitigationsAlreadyExist,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable code cannot be controlled by adversary.
|
||||
/// </summary>
|
||||
VulnerableCodeCannotBeControlledByAdversary,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable code not in execute path.
|
||||
/// </summary>
|
||||
VulnerableCodeNotInExecutePath,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable code not present.
|
||||
/// </summary>
|
||||
VulnerableCodeNotPresent,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes CSAF VEX documents to canonical claims.
|
||||
/// </summary>
|
||||
public sealed class CsafVexNormalizer : IVexNormalizer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Format => "CSAF";
|
||||
|
||||
/// <summary>
|
||||
/// Mapping from CSAF product status to atom assertions.
|
||||
/// Per specification Table 3.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<CsafProductStatus, List<AtomAssertion>> StatusToAtoms = new()
|
||||
{
|
||||
[CsafProductStatus.KnownAffected] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = true, Justification = "known_affected status" },
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = true, Justification = "known_affected status" },
|
||||
],
|
||||
[CsafProductStatus.KnownNotAffected] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = false, Justification = "known_not_affected status" },
|
||||
],
|
||||
[CsafProductStatus.FirstAffected] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = true, Justification = "first_affected status" },
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = true, Justification = "first_affected status" },
|
||||
],
|
||||
[CsafProductStatus.FirstFixed] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Fixed, Value = true, Justification = "first_fixed status" },
|
||||
],
|
||||
[CsafProductStatus.Fixed] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Fixed, Value = true, Justification = "fixed status" },
|
||||
],
|
||||
[CsafProductStatus.LastAffected] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = true, Justification = "last_affected status" },
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = true, Justification = "last_affected status" },
|
||||
],
|
||||
[CsafProductStatus.Recommended] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Fixed, Value = true, Justification = "recommended status" },
|
||||
],
|
||||
[CsafProductStatus.UnderInvestigation] =
|
||||
[
|
||||
// under_investigation: no definite assertions
|
||||
],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Mapping from CSAF flag label to atom assertions.
|
||||
/// Per specification Table 3.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<CsafFlagLabel, List<AtomAssertion>> FlagToAtoms = new()
|
||||
{
|
||||
[CsafFlagLabel.ComponentNotPresent] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = false, Justification = "component_not_present flag" },
|
||||
],
|
||||
[CsafFlagLabel.InlineMitigationsAlreadyExist] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Mitigated, Value = true, Justification = "inline_mitigations_already_exist flag" },
|
||||
],
|
||||
[CsafFlagLabel.VulnerableCodeCannotBeControlledByAdversary] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Mitigated, Value = true, Justification = "vulnerable_code_cannot_be_controlled_by_adversary flag" },
|
||||
],
|
||||
[CsafFlagLabel.VulnerableCodeNotInExecutePath] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Reachable, Value = false, Justification = "vulnerable_code_not_in_execute_path flag" },
|
||||
],
|
||||
[CsafFlagLabel.VulnerableCodeNotPresent] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = false, Justification = "vulnerable_code_not_present flag" },
|
||||
],
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Claim> Normalize(string document, Principal principal, TrustLabel? trustLabel = null)
|
||||
{
|
||||
// Placeholder for JSON parsing implementation
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a pre-parsed CSAF VEX statement.
|
||||
/// </summary>
|
||||
public Claim NormalizeStatement(
|
||||
Subject subject,
|
||||
CsafProductStatus status,
|
||||
CsafFlagLabel flag = CsafFlagLabel.None,
|
||||
string? remediation = null,
|
||||
Principal? principal = null,
|
||||
TrustLabel? trustLabel = null)
|
||||
{
|
||||
var assertions = new List<AtomAssertion>();
|
||||
|
||||
// Add status-based assertions
|
||||
if (StatusToAtoms.TryGetValue(status, out var statusAtoms))
|
||||
{
|
||||
assertions.AddRange(statusAtoms);
|
||||
}
|
||||
|
||||
// Add flag-based assertions
|
||||
if (flag != CsafFlagLabel.None && FlagToAtoms.TryGetValue(flag, out var flagAtoms))
|
||||
{
|
||||
assertions.AddRange(flagAtoms);
|
||||
}
|
||||
|
||||
// Add remediation as justification if provided
|
||||
if (!string.IsNullOrWhiteSpace(remediation))
|
||||
{
|
||||
for (int i = 0; i < assertions.Count; i++)
|
||||
{
|
||||
assertions[i] = assertions[i] with
|
||||
{
|
||||
Justification = $"{assertions[i].Justification} (remediation: {remediation})"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new Claim
|
||||
{
|
||||
Subject = subject,
|
||||
Issuer = principal ?? Principal.Unknown,
|
||||
Assertions = assertions,
|
||||
TrustLabel = trustLabel,
|
||||
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* DispositionSelector - Maps atom values to ECMA-424 dispositions.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-013
|
||||
*
|
||||
* Implements the decision rules from Table 4 of the specification.
|
||||
* Produces deterministic, explainable disposition decisions with full audit trail.
|
||||
*/
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// ECMA-424 disposition values.
|
||||
/// </summary>
|
||||
public enum Disposition
|
||||
{
|
||||
/// <summary>
|
||||
/// Full provenance chain verified.
|
||||
/// </summary>
|
||||
ResolvedWithPedigree,
|
||||
|
||||
/// <summary>
|
||||
/// Resolved but without full pedigree.
|
||||
/// </summary>
|
||||
Resolved,
|
||||
|
||||
/// <summary>
|
||||
/// Misattributed or not applicable.
|
||||
/// </summary>
|
||||
FalsePositive,
|
||||
|
||||
/// <summary>
|
||||
/// Not affected due to context/configuration.
|
||||
/// </summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed exploitable.
|
||||
/// </summary>
|
||||
Exploitable,
|
||||
|
||||
/// <summary>
|
||||
/// Analysis incomplete.
|
||||
/// </summary>
|
||||
InTriage,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A decision trace step for audit/explainability.
|
||||
/// </summary>
|
||||
public sealed record DecisionStep
|
||||
{
|
||||
/// <summary>
|
||||
/// The rule that was evaluated.
|
||||
/// </summary>
|
||||
public required string RuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the rule matched.
|
||||
/// </summary>
|
||||
public required bool Matched { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The condition that was evaluated.
|
||||
/// </summary>
|
||||
public required string Condition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Atom values used in evaluation.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<SecurityAtom, K4Value> AtomValues { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust considerations if any.
|
||||
/// </summary>
|
||||
public string? TrustNote { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The result of disposition selection.
|
||||
/// </summary>
|
||||
public sealed record DispositionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The selected disposition.
|
||||
/// </summary>
|
||||
public required Disposition Disposition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable explanation.
|
||||
/// </summary>
|
||||
public required string Explanation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The rule that determined the disposition.
|
||||
/// </summary>
|
||||
public required string MatchedRule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full decision trace for audit.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DecisionStep> Trace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Any conflicts detected.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SecurityAtom> Conflicts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Any unknowns detected.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SecurityAtom> Unknowns { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The atom snapshot at decision time.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<SecurityAtom, K4Value>? AtomSnapshot { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A disposition selection rule.
|
||||
/// </summary>
|
||||
public sealed record SelectionRule
|
||||
{
|
||||
/// <summary>
|
||||
/// Rule identifier.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule priority (lower = higher priority).
|
||||
/// </summary>
|
||||
public required int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The disposition this rule produces.
|
||||
/// </summary>
|
||||
public required Disposition Disposition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable condition description.
|
||||
/// </summary>
|
||||
public required string ConditionDescription { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The condition predicate.
|
||||
/// </summary>
|
||||
public required Func<IReadOnlyDictionary<SecurityAtom, K4Value>, bool> Condition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Explanation template.
|
||||
/// </summary>
|
||||
public required string ExplanationTemplate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects dispositions based on atom values using policy-driven rules.
|
||||
/// </summary>
|
||||
public sealed class DispositionSelector
|
||||
{
|
||||
private readonly List<SelectionRule> _rules;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new disposition selector with default baseline rules.
|
||||
/// </summary>
|
||||
public DispositionSelector()
|
||||
: this(GetBaselineRules())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new disposition selector with custom rules.
|
||||
/// </summary>
|
||||
public DispositionSelector(IEnumerable<SelectionRule> rules)
|
||||
{
|
||||
_rules = rules.OrderBy(r => r.Priority).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a disposition for the given subject state.
|
||||
/// </summary>
|
||||
public DispositionResult Select(SubjectState state)
|
||||
{
|
||||
var atomValues = SecurityAtomExtensions.All()
|
||||
.ToDictionary(a => a, a => state.GetValue(a));
|
||||
|
||||
return Select(atomValues);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a disposition for the given atom values.
|
||||
/// </summary>
|
||||
public DispositionResult Select(IReadOnlyDictionary<SecurityAtom, K4Value> atomValues)
|
||||
{
|
||||
var trace = new List<DecisionStep>();
|
||||
|
||||
// Detect conflicts and unknowns
|
||||
var conflicts = atomValues
|
||||
.Where(kvp => kvp.Value == K4Value.Conflict)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
var unknowns = atomValues
|
||||
.Where(kvp => kvp.Value == K4Value.Unknown)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
// Evaluate rules in priority order
|
||||
foreach (var rule in _rules)
|
||||
{
|
||||
var matched = rule.Condition(atomValues);
|
||||
trace.Add(new DecisionStep
|
||||
{
|
||||
RuleName = rule.Name,
|
||||
Matched = matched,
|
||||
Condition = rule.ConditionDescription,
|
||||
AtomValues = atomValues,
|
||||
});
|
||||
|
||||
if (matched)
|
||||
{
|
||||
return new DispositionResult
|
||||
{
|
||||
Disposition = rule.Disposition,
|
||||
Explanation = FormatExplanation(rule.ExplanationTemplate, atomValues),
|
||||
MatchedRule = rule.Name,
|
||||
Trace = trace,
|
||||
Conflicts = conflicts,
|
||||
Unknowns = unknowns,
|
||||
AtomSnapshot = atomValues,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to in_triage if no rule matched
|
||||
return new DispositionResult
|
||||
{
|
||||
Disposition = Disposition.InTriage,
|
||||
Explanation = "No disposition rule matched; defaulting to in_triage.",
|
||||
MatchedRule = "fallback",
|
||||
Trace = trace,
|
||||
Conflicts = conflicts,
|
||||
Unknowns = unknowns,
|
||||
AtomSnapshot = atomValues,
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatExplanation(
|
||||
string template,
|
||||
IReadOnlyDictionary<SecurityAtom, K4Value> atomValues)
|
||||
{
|
||||
var result = template;
|
||||
foreach (var (atom, value) in atomValues)
|
||||
{
|
||||
result = result.Replace($"{{{atom}}}", value.ToString());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the baseline selection rules per Table 4 of the specification.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<SelectionRule> GetBaselineRules() =>
|
||||
[
|
||||
// Rule 1: MISATTRIBUTED = T → false_positive
|
||||
new SelectionRule
|
||||
{
|
||||
Name = "misattributed",
|
||||
Priority = 10,
|
||||
Disposition = Disposition.FalsePositive,
|
||||
ConditionDescription = "MISATTRIBUTED = T",
|
||||
Condition = atoms => atoms[SecurityAtom.Misattributed] == K4Value.True,
|
||||
ExplanationTemplate = "Vulnerability is misattributed (MISATTRIBUTED = {Misattributed}).",
|
||||
},
|
||||
|
||||
// Rule 2: FIXED = T → resolved_with_pedigree (if full chain) or resolved
|
||||
new SelectionRule
|
||||
{
|
||||
Name = "fixed_resolved",
|
||||
Priority = 20,
|
||||
Disposition = Disposition.ResolvedWithPedigree,
|
||||
ConditionDescription = "FIXED = T",
|
||||
Condition = atoms => atoms[SecurityAtom.Fixed] == K4Value.True,
|
||||
ExplanationTemplate = "Vulnerability has been fixed (FIXED = {Fixed}).",
|
||||
},
|
||||
|
||||
// Rule 3: PRESENT = F → false_positive
|
||||
new SelectionRule
|
||||
{
|
||||
Name = "not_present",
|
||||
Priority = 30,
|
||||
Disposition = Disposition.FalsePositive,
|
||||
ConditionDescription = "PRESENT = F",
|
||||
Condition = atoms => atoms[SecurityAtom.Present] == K4Value.False,
|
||||
ExplanationTemplate = "Vulnerable component is not present (PRESENT = {Present}).",
|
||||
},
|
||||
|
||||
// Rule 4: APPLIES = F → not_affected
|
||||
new SelectionRule
|
||||
{
|
||||
Name = "not_applicable",
|
||||
Priority = 40,
|
||||
Disposition = Disposition.NotAffected,
|
||||
ConditionDescription = "APPLIES = F",
|
||||
Condition = atoms => atoms[SecurityAtom.Applies] == K4Value.False,
|
||||
ExplanationTemplate = "Vulnerability does not apply to this context (APPLIES = {Applies}).",
|
||||
},
|
||||
|
||||
// Rule 5: REACHABLE = F → not_affected
|
||||
new SelectionRule
|
||||
{
|
||||
Name = "not_reachable",
|
||||
Priority = 50,
|
||||
Disposition = Disposition.NotAffected,
|
||||
ConditionDescription = "REACHABLE = F",
|
||||
Condition = atoms => atoms[SecurityAtom.Reachable] == K4Value.False,
|
||||
ExplanationTemplate = "Vulnerable code is not reachable (REACHABLE = {Reachable}).",
|
||||
},
|
||||
|
||||
// Rule 6: MITIGATED = T → not_affected
|
||||
new SelectionRule
|
||||
{
|
||||
Name = "mitigated",
|
||||
Priority = 60,
|
||||
Disposition = Disposition.NotAffected,
|
||||
ConditionDescription = "MITIGATED = T",
|
||||
Condition = atoms => atoms[SecurityAtom.Mitigated] == K4Value.True,
|
||||
ExplanationTemplate = "Vulnerability is mitigated (MITIGATED = {Mitigated}).",
|
||||
},
|
||||
|
||||
// Rule 7: PRESENT = T ∧ APPLIES = T ∧ REACHABLE = T → exploitable
|
||||
new SelectionRule
|
||||
{
|
||||
Name = "exploitable",
|
||||
Priority = 70,
|
||||
Disposition = Disposition.Exploitable,
|
||||
ConditionDescription = "PRESENT = T ∧ APPLIES = T ∧ REACHABLE = T",
|
||||
Condition = atoms =>
|
||||
atoms[SecurityAtom.Present] == K4Value.True &&
|
||||
atoms[SecurityAtom.Applies] == K4Value.True &&
|
||||
atoms[SecurityAtom.Reachable] == K4Value.True,
|
||||
ExplanationTemplate = "Vulnerability is present, applicable, and reachable (PRESENT = {Present}, APPLIES = {Applies}, REACHABLE = {Reachable}).",
|
||||
},
|
||||
|
||||
// Rule 8: PRESENT = T ∧ APPLIES = T ∧ REACHABLE = ⊥ → exploitable (conservative)
|
||||
new SelectionRule
|
||||
{
|
||||
Name = "exploitable_unknown_reachability",
|
||||
Priority = 75,
|
||||
Disposition = Disposition.Exploitable,
|
||||
ConditionDescription = "PRESENT = T ∧ APPLIES = T ∧ REACHABLE = ⊥",
|
||||
Condition = atoms =>
|
||||
atoms[SecurityAtom.Present] == K4Value.True &&
|
||||
atoms[SecurityAtom.Applies] == K4Value.True &&
|
||||
atoms[SecurityAtom.Reachable] == K4Value.Unknown,
|
||||
ExplanationTemplate = "Vulnerability is present and applicable; reachability unknown, assuming exploitable (PRESENT = {Present}, APPLIES = {Applies}, REACHABLE = {Reachable}).",
|
||||
},
|
||||
|
||||
// Rule 9: Any conflict → in_triage (requires human review)
|
||||
new SelectionRule
|
||||
{
|
||||
Name = "conflict_detected",
|
||||
Priority = 80,
|
||||
Disposition = Disposition.InTriage,
|
||||
ConditionDescription = "Any atom = ⊤ (conflict)",
|
||||
Condition = atoms => atoms.Values.Any(v => v == K4Value.Conflict),
|
||||
ExplanationTemplate = "Conflicting evidence detected; requires human review.",
|
||||
},
|
||||
|
||||
// Rule 10: Insufficient data → in_triage
|
||||
new SelectionRule
|
||||
{
|
||||
Name = "insufficient_data",
|
||||
Priority = 100,
|
||||
Disposition = Disposition.InTriage,
|
||||
ConditionDescription = "PRESENT = ⊥ ∨ APPLIES = ⊥",
|
||||
Condition = atoms =>
|
||||
atoms[SecurityAtom.Present] == K4Value.Unknown ||
|
||||
atoms[SecurityAtom.Applies] == K4Value.Unknown,
|
||||
ExplanationTemplate = "Insufficient data for disposition (PRESENT = {Present}, APPLIES = {Applies}).",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* K4 Four-Valued Logic (Belnap-style) for Trust Lattice Engine.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-001
|
||||
*
|
||||
* Implements the knowledge lattice for representing truth values that can be:
|
||||
* - Unknown (no evidence)
|
||||
* - True (supported true)
|
||||
* - False (supported false)
|
||||
* - Conflict (credible evidence for both)
|
||||
*
|
||||
* This four-valued logic enables deterministic aggregation of heterogeneous
|
||||
* security assertions while preserving unknowns and contradictions.
|
||||
*/
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// Belnap four-valued logic (K4) for representing knowledge states.
|
||||
/// Enables monotone, conflict-preserving, order-independent aggregation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The knowledge ordering is:
|
||||
/// <code>
|
||||
/// ⊤ (Conflict)
|
||||
/// / \
|
||||
/// T F
|
||||
/// \ /
|
||||
/// ⊥ (Unknown)
|
||||
/// </code>
|
||||
/// T and F are incomparable; both are above ⊥ and below ⊤.
|
||||
/// </remarks>
|
||||
public enum K4Value
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown (⊥) - No evidence supports this proposition.
|
||||
/// Bottom of the knowledge lattice.
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// True (T) - Evidence supports the proposition being true.
|
||||
/// </summary>
|
||||
True = 1,
|
||||
|
||||
/// <summary>
|
||||
/// False (F) - Evidence supports the proposition being false.
|
||||
/// </summary>
|
||||
False = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Conflict (⊤) - Credible evidence exists for both true and false.
|
||||
/// Top of the knowledge lattice; represents contradiction.
|
||||
/// </summary>
|
||||
Conflict = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lattice operations for K4 four-valued logic.
|
||||
/// All operations are deterministic and order-independent.
|
||||
/// </summary>
|
||||
public static class K4Lattice
|
||||
{
|
||||
/// <summary>
|
||||
/// Knowledge join (⊔k): union of support.
|
||||
/// Aggregates information from multiple sources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Truth table:
|
||||
/// <code>
|
||||
/// ⊔k | ⊥ | T | F | ⊤
|
||||
/// ----+----+----+----+----
|
||||
/// ⊥ | ⊥ | T | F | ⊤
|
||||
/// T | T | T | ⊤ | ⊤
|
||||
/// F | F | ⊤ | F | ⊤
|
||||
/// ⊤ | ⊤ | ⊤ | ⊤ | ⊤
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public static K4Value Join(K4Value a, K4Value b)
|
||||
{
|
||||
// Fast paths
|
||||
if (a == b) return a;
|
||||
if (a == K4Value.Conflict || b == K4Value.Conflict) return K4Value.Conflict;
|
||||
if (a == K4Value.Unknown) return b;
|
||||
if (b == K4Value.Unknown) return a;
|
||||
|
||||
// T ⊔ F = ⊤ (conflict)
|
||||
return K4Value.Conflict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Knowledge join over a sequence of values.
|
||||
/// Order-independent aggregation.
|
||||
/// </summary>
|
||||
public static K4Value JoinAll(IEnumerable<K4Value> values)
|
||||
{
|
||||
var result = K4Value.Unknown;
|
||||
foreach (var v in values)
|
||||
{
|
||||
result = Join(result, v);
|
||||
// Short-circuit: conflict is maximal
|
||||
if (result == K4Value.Conflict)
|
||||
return K4Value.Conflict;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Knowledge meet (⊓k): intersection of support.
|
||||
/// Used for composed claims along dependency chains.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Truth table:
|
||||
/// <code>
|
||||
/// ⊓k | ⊥ | T | F | ⊤
|
||||
/// ----+----+----+----+----
|
||||
/// ⊥ | ⊥ | ⊥ | ⊥ | ⊥
|
||||
/// T | ⊥ | T | ⊥ | T
|
||||
/// F | ⊥ | ⊥ | F | F
|
||||
/// ⊤ | ⊥ | T | F | ⊤
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public static K4Value Meet(K4Value a, K4Value b)
|
||||
{
|
||||
// Fast paths
|
||||
if (a == b) return a;
|
||||
if (a == K4Value.Unknown || b == K4Value.Unknown) return K4Value.Unknown;
|
||||
if (a == K4Value.Conflict) return b;
|
||||
if (b == K4Value.Conflict) return a;
|
||||
|
||||
// T ⊓ F = ⊥ (no agreement)
|
||||
return K4Value.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Knowledge ordering: a ≤k b means b has at least as much information as a.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// true if a is below or equal to b in the knowledge ordering.
|
||||
/// </returns>
|
||||
public static bool LessOrEqual(K4Value a, K4Value b)
|
||||
{
|
||||
// ⊥ ≤ everything
|
||||
if (a == K4Value.Unknown) return true;
|
||||
// nothing ≤ ⊥ except ⊥
|
||||
if (b == K4Value.Unknown) return false;
|
||||
// everything ≤ ⊤
|
||||
if (b == K4Value.Conflict) return true;
|
||||
// ⊤ ≤ only ⊤
|
||||
if (a == K4Value.Conflict) return false;
|
||||
// T ≤ T, F ≤ F; T and F are incomparable
|
||||
return a == b;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two values are comparable in the knowledge ordering.
|
||||
/// T and F are incomparable.
|
||||
/// </summary>
|
||||
public static bool AreComparable(K4Value a, K4Value b)
|
||||
{
|
||||
return LessOrEqual(a, b) || LessOrEqual(b, a);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Negation of a K4 value.
|
||||
/// Swaps True ↔ False; Unknown and Conflict are self-negating.
|
||||
/// </summary>
|
||||
public static K4Value Negate(K4Value v) => v switch
|
||||
{
|
||||
K4Value.True => K4Value.False,
|
||||
K4Value.False => K4Value.True,
|
||||
_ => v, // Unknown and Conflict are unchanged
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the value has any true support (T or ⊤).
|
||||
/// </summary>
|
||||
public static bool HasTrueSupport(K4Value v)
|
||||
=> v == K4Value.True || v == K4Value.Conflict;
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the value has any false support (F or ⊤).
|
||||
/// </summary>
|
||||
public static bool HasFalseSupport(K4Value v)
|
||||
=> v == K4Value.False || v == K4Value.Conflict;
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the value is definite (T or F, not ⊥ or ⊤).
|
||||
/// </summary>
|
||||
public static bool IsDefinite(K4Value v)
|
||||
=> v == K4Value.True || v == K4Value.False;
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the value represents lack of information (⊥ or ⊤).
|
||||
/// </summary>
|
||||
public static bool IsIndeterminate(K4Value v)
|
||||
=> v == K4Value.Unknown || v == K4Value.Conflict;
|
||||
|
||||
/// <summary>
|
||||
/// Computes K4 value from support set presence.
|
||||
/// </summary>
|
||||
/// <param name="hasTrueSupport">True if any claims support the proposition.</param>
|
||||
/// <param name="hasFalseSupport">True if any claims refute the proposition.</param>
|
||||
public static K4Value FromSupport(bool hasTrueSupport, bool hasFalseSupport)
|
||||
{
|
||||
return (hasTrueSupport, hasFalseSupport) switch
|
||||
{
|
||||
(false, false) => K4Value.Unknown,
|
||||
(true, false) => K4Value.True,
|
||||
(false, true) => K4Value.False,
|
||||
(true, true) => K4Value.Conflict,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* AtomValue and LatticeStore - Aggregation infrastructure.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Tasks: TRUST-003, TRUST-009
|
||||
*
|
||||
* AtomValue tracks the K4 truth value for a single atom with support sets.
|
||||
* LatticeStore maintains the complete aggregation state for all subjects.
|
||||
*/
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the K4 truth value for a single security atom with support sets.
|
||||
/// </summary>
|
||||
public sealed class AtomValue
|
||||
{
|
||||
private readonly HashSet<string> _supportTrue = [];
|
||||
private readonly HashSet<string> _supportFalse = [];
|
||||
private TrustLabel? _trustTrue;
|
||||
private TrustLabel? _trustFalse;
|
||||
|
||||
/// <summary>
|
||||
/// The security atom this value tracks.
|
||||
/// </summary>
|
||||
public SecurityAtom Atom { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new atom value tracker.
|
||||
/// </summary>
|
||||
public AtomValue(SecurityAtom atom)
|
||||
{
|
||||
Atom = atom;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current K4 value based on support sets.
|
||||
/// </summary>
|
||||
public K4Value Value => K4Lattice.FromSupport(
|
||||
hasTrueSupport: _supportTrue.Count > 0,
|
||||
hasFalseSupport: _supportFalse.Count > 0);
|
||||
|
||||
/// <summary>
|
||||
/// Claim IDs supporting the proposition as true.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> SupportTrue => _supportTrue;
|
||||
|
||||
/// <summary>
|
||||
/// Claim IDs supporting the proposition as false.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> SupportFalse => _supportFalse;
|
||||
|
||||
/// <summary>
|
||||
/// Highest trust label among true supporters.
|
||||
/// </summary>
|
||||
public TrustLabel? TrustTrue => _trustTrue;
|
||||
|
||||
/// <summary>
|
||||
/// Highest trust label among false supporters.
|
||||
/// </summary>
|
||||
public TrustLabel? TrustFalse => _trustFalse;
|
||||
|
||||
/// <summary>
|
||||
/// Adds support from a claim.
|
||||
/// </summary>
|
||||
/// <param name="claimId">The claim identifier.</param>
|
||||
/// <param name="value">The asserted value (true or false).</param>
|
||||
/// <param name="trust">The trust label for this claim.</param>
|
||||
public void AddSupport(string claimId, bool value, TrustLabel? trust)
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
_supportTrue.Add(claimId);
|
||||
if (trust is not null && (_trustTrue is null || trust.CompareTo(_trustTrue) > 0))
|
||||
_trustTrue = trust;
|
||||
}
|
||||
else
|
||||
{
|
||||
_supportFalse.Add(claimId);
|
||||
if (trust is not null && (_trustFalse is null || trust.CompareTo(_trustFalse) > 0))
|
||||
_trustFalse = trust;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes support from a claim (for retraction/expiry).
|
||||
/// </summary>
|
||||
public void RemoveSupport(string claimId)
|
||||
{
|
||||
_supportTrue.Remove(claimId);
|
||||
_supportFalse.Remove(claimId);
|
||||
// Note: Trust labels are not recalculated on removal for simplicity
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot for proof bundles.
|
||||
/// </summary>
|
||||
public AtomValueSnapshot ToSnapshot() => new()
|
||||
{
|
||||
Atom = Atom,
|
||||
Value = Value,
|
||||
SupportTrueCount = _supportTrue.Count,
|
||||
SupportFalseCount = _supportFalse.Count,
|
||||
SupportTrueIds = [.. _supportTrue],
|
||||
SupportFalseIds = [.. _supportFalse],
|
||||
TrustTrue = _trustTrue,
|
||||
TrustFalse = _trustFalse,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of an atom value for proof bundles.
|
||||
/// </summary>
|
||||
public sealed record AtomValueSnapshot
|
||||
{
|
||||
public required SecurityAtom Atom { get; init; }
|
||||
public required K4Value Value { get; init; }
|
||||
public required int SupportTrueCount { get; init; }
|
||||
public required int SupportFalseCount { get; init; }
|
||||
public required IReadOnlyList<string> SupportTrueIds { get; init; }
|
||||
public required IReadOnlyList<string> SupportFalseIds { get; init; }
|
||||
public TrustLabel? TrustTrue { get; init; }
|
||||
public TrustLabel? TrustFalse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key for indexing atom values by subject and atom.
|
||||
/// </summary>
|
||||
public readonly record struct AtomKey(string SubjectDigest, SecurityAtom Atom);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregation state for a single subject.
|
||||
/// </summary>
|
||||
public sealed class SubjectState
|
||||
{
|
||||
private readonly Dictionary<SecurityAtom, AtomValue> _atoms = [];
|
||||
private readonly List<string> _claimIds = [];
|
||||
|
||||
/// <summary>
|
||||
/// The subject this state tracks.
|
||||
/// </summary>
|
||||
public Subject Subject { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the subject.
|
||||
/// </summary>
|
||||
public string SubjectDigest { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new subject state.
|
||||
/// </summary>
|
||||
public SubjectState(Subject subject)
|
||||
{
|
||||
Subject = subject;
|
||||
SubjectDigest = subject.ComputeDigest();
|
||||
|
||||
// Initialize all atoms to unknown
|
||||
foreach (var atom in SecurityAtomExtensions.All())
|
||||
{
|
||||
_atoms[atom] = new AtomValue(atom);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the K4 value for a specific atom.
|
||||
/// </summary>
|
||||
public K4Value GetValue(SecurityAtom atom)
|
||||
=> _atoms.TryGetValue(atom, out var av) ? av.Value : K4Value.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full atom value tracker for a specific atom.
|
||||
/// </summary>
|
||||
public AtomValue GetAtomValue(SecurityAtom atom)
|
||||
=> _atoms.GetValueOrDefault(atom) ?? new AtomValue(atom);
|
||||
|
||||
/// <summary>
|
||||
/// All claim IDs that have contributed to this subject.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ClaimIds => _claimIds;
|
||||
|
||||
/// <summary>
|
||||
/// Ingests a claim, updating atom values.
|
||||
/// </summary>
|
||||
public void IngestClaim(Claim claim)
|
||||
{
|
||||
var claimId = claim.Id ?? claim.ComputeId();
|
||||
_claimIds.Add(claimId);
|
||||
|
||||
foreach (var assertion in claim.Assertions)
|
||||
{
|
||||
if (_atoms.TryGetValue(assertion.Atom, out var atomValue))
|
||||
{
|
||||
atomValue.AddSupport(claimId, assertion.Value, claim.TrustLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot of all atom values for proof bundles.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<SecurityAtom, AtomValueSnapshot> ToSnapshot()
|
||||
{
|
||||
return _atoms.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value.ToSnapshot());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The lattice store maintains aggregation state for all subjects.
|
||||
/// Thread-safe for concurrent ingestion.
|
||||
/// </summary>
|
||||
public sealed class LatticeStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SubjectState> _subjects = new();
|
||||
private readonly ConcurrentDictionary<string, Claim> _claims = new();
|
||||
private readonly ConcurrentDictionary<string, Evidence> _evidence = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates the state for a subject.
|
||||
/// </summary>
|
||||
public SubjectState GetOrCreateSubject(Subject subject)
|
||||
{
|
||||
var digest = subject.ComputeDigest();
|
||||
return _subjects.GetOrAdd(digest, _ => new SubjectState(subject));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests a claim into the store.
|
||||
/// </summary>
|
||||
/// <param name="claim">The claim to ingest.</param>
|
||||
/// <returns>The claim with computed ID.</returns>
|
||||
public Claim IngestClaim(Claim claim)
|
||||
{
|
||||
var withId = claim.Id is not null ? claim : claim.WithComputedId();
|
||||
_claims[withId.Id!] = withId;
|
||||
|
||||
var subjectState = GetOrCreateSubject(claim.Subject);
|
||||
subjectState.IngestClaim(withId);
|
||||
|
||||
return withId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers evidence in the store.
|
||||
/// </summary>
|
||||
public void RegisterEvidence(Evidence evidence)
|
||||
{
|
||||
_evidence[evidence.Digest] = evidence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a claim by ID.
|
||||
/// </summary>
|
||||
public Claim? GetClaim(string claimId)
|
||||
=> _claims.GetValueOrDefault(claimId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence by digest.
|
||||
/// </summary>
|
||||
public Evidence? GetEvidence(string digest)
|
||||
=> _evidence.GetValueOrDefault(digest);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the subject state if it exists.
|
||||
/// </summary>
|
||||
public SubjectState? GetSubjectState(string subjectDigest)
|
||||
=> _subjects.GetValueOrDefault(subjectDigest);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the K4 value for a specific subject and atom.
|
||||
/// </summary>
|
||||
public K4Value GetValue(Subject subject, SecurityAtom atom)
|
||||
{
|
||||
var digest = subject.ComputeDigest();
|
||||
if (_subjects.TryGetValue(digest, out var state))
|
||||
return state.GetValue(atom);
|
||||
return K4Value.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all subjects in the store.
|
||||
/// </summary>
|
||||
public IEnumerable<SubjectState> GetAllSubjects()
|
||||
=> _subjects.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all claims in the store.
|
||||
/// </summary>
|
||||
public IEnumerable<Claim> GetAllClaims()
|
||||
=> _claims.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Gets subjects with conflicts (any atom = ⊤).
|
||||
/// </summary>
|
||||
public IEnumerable<SubjectState> GetConflictingSubjects()
|
||||
{
|
||||
return _subjects.Values.Where(s =>
|
||||
SecurityAtomExtensions.All().Any(a => s.GetValue(a) == K4Value.Conflict));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets subjects with unknowns (any required atom = ⊥).
|
||||
/// </summary>
|
||||
public IEnumerable<SubjectState> GetIncompleteSubjects()
|
||||
{
|
||||
// Required atoms for disposition: PRESENT, APPLIES, REACHABLE
|
||||
var requiredAtoms = new[] { SecurityAtom.Present, SecurityAtom.Applies, SecurityAtom.Reachable };
|
||||
|
||||
return _subjects.Values.Where(s =>
|
||||
requiredAtoms.Any(a => s.GetValue(a) == K4Value.Unknown));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the store.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_subjects.Clear();
|
||||
_claims.Clear();
|
||||
_evidence.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about the store.
|
||||
/// </summary>
|
||||
public LatticeStoreStats GetStats() => new()
|
||||
{
|
||||
SubjectCount = _subjects.Count,
|
||||
ClaimCount = _claims.Count,
|
||||
EvidenceCount = _evidence.Count,
|
||||
ConflictCount = GetConflictingSubjects().Count(),
|
||||
IncompleteCount = GetIncompleteSubjects().Count(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the lattice store.
|
||||
/// </summary>
|
||||
public sealed record LatticeStoreStats
|
||||
{
|
||||
public int SubjectCount { get; init; }
|
||||
public int ClaimCount { get; init; }
|
||||
public int EvidenceCount { get; init; }
|
||||
public int ConflictCount { get; init; }
|
||||
public int IncompleteCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* OpenVEX Normalizer - Convert OpenVEX documents to canonical claims.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-011
|
||||
*
|
||||
* OpenVEX follows the VEX minimal elements specification.
|
||||
* See: https://github.com/openvex/spec
|
||||
*/
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX status values.
|
||||
/// </summary>
|
||||
public enum OpenVexStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Not yet determined if affected.
|
||||
/// </summary>
|
||||
UnderInvestigation,
|
||||
|
||||
/// <summary>
|
||||
/// Product is not affected.
|
||||
/// </summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>
|
||||
/// Product is affected.
|
||||
/// </summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability has been fixed.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX justification values (for not_affected status).
|
||||
/// </summary>
|
||||
public enum OpenVexJustification
|
||||
{
|
||||
/// <summary>
|
||||
/// No justification provided.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable component not included.
|
||||
/// </summary>
|
||||
ComponentNotPresent,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable code not present.
|
||||
/// </summary>
|
||||
VulnerableCodeNotPresent,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable code not in execute path.
|
||||
/// </summary>
|
||||
VulnerableCodeNotInExecutePath,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable code cannot be controlled by adversary.
|
||||
/// </summary>
|
||||
VulnerableCodeCannotBeControlledByAdversary,
|
||||
|
||||
/// <summary>
|
||||
/// Inline mitigations already exist.
|
||||
/// </summary>
|
||||
InlineMitigationsAlreadyExist,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes OpenVEX documents to canonical claims.
|
||||
/// </summary>
|
||||
public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Format => "OpenVEX";
|
||||
|
||||
/// <summary>
|
||||
/// Mapping from OpenVEX status to atom assertions.
|
||||
/// Per specification Table 2.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<OpenVexStatus, List<AtomAssertion>> StatusToAtoms = new()
|
||||
{
|
||||
[OpenVexStatus.UnderInvestigation] =
|
||||
[
|
||||
// under_investigation: no definite assertions
|
||||
],
|
||||
[OpenVexStatus.NotAffected] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = false, Justification = "not_affected status" },
|
||||
],
|
||||
[OpenVexStatus.Affected] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = true, Justification = "affected status" },
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = true, Justification = "affected status" },
|
||||
],
|
||||
[OpenVexStatus.Fixed] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Fixed, Value = true, Justification = "fixed status" },
|
||||
],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Mapping from OpenVEX justification to atom assertions.
|
||||
/// Per specification Table 2.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<OpenVexJustification, List<AtomAssertion>> JustificationToAtoms = new()
|
||||
{
|
||||
[OpenVexJustification.ComponentNotPresent] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = false, Justification = "component_not_present" },
|
||||
],
|
||||
[OpenVexJustification.VulnerableCodeNotPresent] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = false, Justification = "vulnerable_code_not_present" },
|
||||
],
|
||||
[OpenVexJustification.VulnerableCodeNotInExecutePath] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Reachable, Value = false, Justification = "vulnerable_code_not_in_execute_path" },
|
||||
],
|
||||
[OpenVexJustification.VulnerableCodeCannotBeControlledByAdversary] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Mitigated, Value = true, Justification = "vulnerable_code_cannot_be_controlled" },
|
||||
],
|
||||
[OpenVexJustification.InlineMitigationsAlreadyExist] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Mitigated, Value = true, Justification = "inline_mitigations_exist" },
|
||||
],
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Claim> Normalize(string document, Principal principal, TrustLabel? trustLabel = null)
|
||||
{
|
||||
// Placeholder for JSON parsing implementation
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a pre-parsed OpenVEX statement.
|
||||
/// </summary>
|
||||
public Claim NormalizeStatement(
|
||||
Subject subject,
|
||||
OpenVexStatus status,
|
||||
OpenVexJustification justification = OpenVexJustification.None,
|
||||
string? actionStatement = null,
|
||||
string? impactStatement = null,
|
||||
Principal? principal = null,
|
||||
TrustLabel? trustLabel = null)
|
||||
{
|
||||
var assertions = new List<AtomAssertion>();
|
||||
|
||||
// Add status-based assertions
|
||||
if (StatusToAtoms.TryGetValue(status, out var statusAtoms))
|
||||
{
|
||||
assertions.AddRange(statusAtoms);
|
||||
}
|
||||
|
||||
// Add justification-based assertions
|
||||
if (justification != OpenVexJustification.None &&
|
||||
JustificationToAtoms.TryGetValue(justification, out var justAtoms))
|
||||
{
|
||||
assertions.AddRange(justAtoms);
|
||||
}
|
||||
|
||||
// Build detail from action/impact statements
|
||||
var details = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(actionStatement))
|
||||
details.Add($"action: {actionStatement}");
|
||||
if (!string.IsNullOrWhiteSpace(impactStatement))
|
||||
details.Add($"impact: {impactStatement}");
|
||||
|
||||
if (details.Count > 0)
|
||||
{
|
||||
var detail = string.Join("; ", details);
|
||||
for (int i = 0; i < assertions.Count; i++)
|
||||
{
|
||||
assertions[i] = assertions[i] with
|
||||
{
|
||||
Justification = $"{assertions[i].Justification} ({detail})"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new Claim
|
||||
{
|
||||
Subject = subject,
|
||||
Issuer = principal ?? Principal.Unknown,
|
||||
Assertions = assertions,
|
||||
TrustLabel = trustLabel,
|
||||
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* PolicyBundle - Policy configuration for trust evaluation.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-014
|
||||
*
|
||||
* Defines trust roots, trust requirements, and selection rule overrides.
|
||||
*/
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// A trust root defines a trusted principal and its authority scope.
|
||||
/// </summary>
|
||||
public sealed record TrustRoot
|
||||
{
|
||||
/// <summary>
|
||||
/// The trusted principal.
|
||||
/// </summary>
|
||||
public required Principal Principal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The authority scope for this principal.
|
||||
/// </summary>
|
||||
public required AuthorityScope Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum assurance level granted to this principal.
|
||||
/// </summary>
|
||||
public AssuranceLevel MaxAssurance { get; init; } = AssuranceLevel.A3_ProvenanceBound;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this root is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Expiration time for this trust root.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust requirements for disposition decisions.
|
||||
/// </summary>
|
||||
public sealed record TrustRequirements
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum assurance level required for "resolved" dispositions.
|
||||
/// </summary>
|
||||
public AssuranceLevel MinResolvedAssurance { get; init; } = AssuranceLevel.A2_VerifiedIdentity;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum assurance level required for "resolved_with_pedigree".
|
||||
/// </summary>
|
||||
public AssuranceLevel MinPedigreeAssurance { get; init; } = AssuranceLevel.A3_ProvenanceBound;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum evidence class for certain atom types.
|
||||
/// </summary>
|
||||
public EvidenceClass MinEvidenceClass { get; init; } = EvidenceClass.E1_SbomLinkage;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age for fresh claims (null = no limit).
|
||||
/// </summary>
|
||||
public TimeSpan? MaxClaimAge { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require signature verification for all claims.
|
||||
/// </summary>
|
||||
public bool RequireSignatures { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict resolution strategy.
|
||||
/// </summary>
|
||||
public enum ConflictResolution
|
||||
{
|
||||
/// <summary>
|
||||
/// Report conflict, let human decide (default).
|
||||
/// </summary>
|
||||
ReportConflict,
|
||||
|
||||
/// <summary>
|
||||
/// Use highest trust value.
|
||||
/// </summary>
|
||||
PreferHigherTrust,
|
||||
|
||||
/// <summary>
|
||||
/// Use most recent claim.
|
||||
/// </summary>
|
||||
PreferMostRecent,
|
||||
|
||||
/// <summary>
|
||||
/// Conservative: assume worst case.
|
||||
/// </summary>
|
||||
PreferConservative,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle configuration for the trust lattice engine.
|
||||
/// </summary>
|
||||
public sealed record PolicyBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy bundle identifier.
|
||||
/// </summary>
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle version.
|
||||
/// </summary>
|
||||
public string Version { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Trusted principals (trust roots).
|
||||
/// </summary>
|
||||
public IReadOnlyList<TrustRoot> TrustRoots { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Trust requirements for dispositions.
|
||||
/// </summary>
|
||||
public TrustRequirements TrustRequirements { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Custom selection rules (merged with baseline).
|
||||
/// </summary>
|
||||
public IReadOnlyList<SelectionRule> CustomRules { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Conflict resolution strategy.
|
||||
/// </summary>
|
||||
public ConflictResolution ConflictResolution { get; init; } = ConflictResolution.ReportConflict;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to assume reachability when unknown.
|
||||
/// </summary>
|
||||
public bool AssumeReachableWhenUnknown { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// VEX formats to accept.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AcceptedVexFormats { get; init; } =
|
||||
["CycloneDX/ECMA-424", "OpenVEX", "CSAF"];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the merged selection rules (custom + baseline).
|
||||
/// </summary>
|
||||
public IReadOnlyList<SelectionRule> GetEffectiveRules()
|
||||
{
|
||||
var baseline = DispositionSelector.GetBaselineRules().ToList();
|
||||
|
||||
// Custom rules override baseline rules with same name
|
||||
var customByName = CustomRules.ToDictionary(r => r.Name);
|
||||
for (int i = 0; i < baseline.Count; i++)
|
||||
{
|
||||
if (customByName.TryGetValue(baseline[i].Name, out var custom))
|
||||
{
|
||||
baseline[i] = custom;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new custom rules
|
||||
var baselineNames = baseline.Select(r => r.Name).ToHashSet();
|
||||
baseline.AddRange(CustomRules.Where(r => !baselineNames.Contains(r.Name)));
|
||||
|
||||
return baseline.OrderBy(r => r.Priority).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a principal is trusted for a given scope.
|
||||
/// </summary>
|
||||
public bool IsTrusted(Principal principal, AuthorityScope? requiredScope = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var root in TrustRoots)
|
||||
{
|
||||
if (!root.IsActive) continue;
|
||||
if (root.ExpiresAt.HasValue && root.ExpiresAt.Value < now) continue;
|
||||
if (root.Principal.Id != principal.Id) continue;
|
||||
|
||||
if (requiredScope is null || root.Scope.Covers(requiredScope))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum assurance level for a principal.
|
||||
/// </summary>
|
||||
public AssuranceLevel? GetMaxAssurance(Principal principal)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var root in TrustRoots)
|
||||
{
|
||||
if (!root.IsActive) continue;
|
||||
if (root.ExpiresAt.HasValue && root.ExpiresAt.Value < now) continue;
|
||||
if (root.Principal.Id != principal.Id) continue;
|
||||
|
||||
return root.MaxAssurance;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default policy bundle with no trust roots.
|
||||
/// </summary>
|
||||
public static PolicyBundle Default => new()
|
||||
{
|
||||
Id = "default",
|
||||
Name = "Default Policy",
|
||||
Version = "1.0.0",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* ProofBundle - Content-addressable audit trail for disposition decisions.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-015
|
||||
*
|
||||
* The proof bundle captures all inputs, normalization, atom evaluation,
|
||||
* and decision trace for deterministic replay and audit.
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// Input evidence that was ingested.
|
||||
/// </summary>
|
||||
public sealed record ProofInput
|
||||
{
|
||||
/// <summary>
|
||||
/// The content-addressable digest of the input.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of input (e.g., "sbom", "vex", "scan", "attestation").
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The format of the input (e.g., "CycloneDX", "SPDX", "OpenVEX").
|
||||
/// </summary>
|
||||
public string? Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI/path to the original input.
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the input was ingested.
|
||||
/// </summary>
|
||||
public DateTimeOffset IngestedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalization trace for a VEX statement.
|
||||
/// </summary>
|
||||
public sealed record NormalizationTrace
|
||||
{
|
||||
/// <summary>
|
||||
/// The original statement ID.
|
||||
/// </summary>
|
||||
public string? OriginalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The VEX format.
|
||||
/// </summary>
|
||||
public required string SourceFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The original status/state value.
|
||||
/// </summary>
|
||||
public string? OriginalStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The original justification value.
|
||||
/// </summary>
|
||||
public string? OriginalJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated claim ID.
|
||||
/// </summary>
|
||||
public required string ClaimId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The atoms that were asserted.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<AtomAssertion> GeneratedAssertions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The atom table showing final values for a subject.
|
||||
/// </summary>
|
||||
public sealed record AtomTable
|
||||
{
|
||||
/// <summary>
|
||||
/// The subject digest.
|
||||
/// </summary>
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject details.
|
||||
/// </summary>
|
||||
public required Subject Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Atom values with support sets.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<SecurityAtom, AtomValueSnapshot> Atoms { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The decision result for a subject.
|
||||
/// </summary>
|
||||
public sealed record DecisionRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// The subject digest.
|
||||
/// </summary>
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The selected disposition.
|
||||
/// </summary>
|
||||
public required Disposition Disposition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The rule that matched.
|
||||
/// </summary>
|
||||
public required string MatchedRule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable explanation.
|
||||
/// </summary>
|
||||
public required string Explanation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full decision trace.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DecisionStep> Trace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected conflicts.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SecurityAtom> Conflicts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Detected unknowns.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SecurityAtom> Unknowns { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable proof bundle for audit and replay.
|
||||
/// </summary>
|
||||
public sealed record ProofBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// The proof bundle ID (content-addressable).
|
||||
/// </summary>
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Proof bundle version for schema evolution.
|
||||
/// </summary>
|
||||
public string Version { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the proof bundle was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// The policy bundle used for evaluation.
|
||||
/// </summary>
|
||||
public required string PolicyBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle version.
|
||||
/// </summary>
|
||||
public string? PolicyBundleVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All inputs that were ingested.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ProofInput> Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalization traces for VEX statements.
|
||||
/// </summary>
|
||||
public IReadOnlyList<NormalizationTrace> Normalization { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Claims that were generated/ingested.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<Claim> Claims { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Atom tables for all subjects.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<AtomTable> AtomTables { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decision records for all subjects.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DecisionRecord> Decisions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics.
|
||||
/// </summary>
|
||||
public ProofBundleStats? Stats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content-addressable ID for the proof bundle.
|
||||
/// </summary>
|
||||
public string ComputeId()
|
||||
{
|
||||
// Canonicalize and hash
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
// Create a canonical form without the Id field
|
||||
var canonical = new
|
||||
{
|
||||
version = Version,
|
||||
created_at = CreatedAt.ToUnixTimeSeconds(),
|
||||
policy_bundle_id = PolicyBundleId,
|
||||
policy_bundle_version = PolicyBundleVersion,
|
||||
input_digests = Inputs.Select(i => i.Digest).Order().ToList(),
|
||||
claim_ids = Claims.Select(c => c.Id ?? c.ComputeId()).Order().ToList(),
|
||||
subject_digests = AtomTables.Select(a => a.SubjectDigest).Order().ToList(),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(canonical, options);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a proof bundle with computed ID.
|
||||
/// </summary>
|
||||
public ProofBundle WithComputedId() => this with { Id = ComputeId() };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for a proof bundle.
|
||||
/// </summary>
|
||||
public sealed record ProofBundleStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of inputs.
|
||||
/// </summary>
|
||||
public int InputCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of claims.
|
||||
/// </summary>
|
||||
public int ClaimCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of subjects.
|
||||
/// </summary>
|
||||
public int SubjectCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of subjects with conflicts.
|
||||
/// </summary>
|
||||
public int ConflictCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of subjects with incomplete data.
|
||||
/// </summary>
|
||||
public int IncompleteCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Disposition counts.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<Disposition, int> DispositionCounts { get; init; } =
|
||||
new Dictionary<Disposition, int>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating proof bundles.
|
||||
/// </summary>
|
||||
public sealed class ProofBundleBuilder
|
||||
{
|
||||
private readonly List<ProofInput> _inputs = [];
|
||||
private readonly List<NormalizationTrace> _normalization = [];
|
||||
private readonly List<Claim> _claims = [];
|
||||
private readonly List<AtomTable> _atomTables = [];
|
||||
private readonly List<DecisionRecord> _decisions = [];
|
||||
private string _policyBundleId = "unknown";
|
||||
private string? _policyBundleVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the policy bundle.
|
||||
/// </summary>
|
||||
public ProofBundleBuilder WithPolicyBundle(PolicyBundle policy)
|
||||
{
|
||||
_policyBundleId = policy.Id ?? "unknown";
|
||||
_policyBundleVersion = policy.Version;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an input.
|
||||
/// </summary>
|
||||
public ProofBundleBuilder AddInput(ProofInput input)
|
||||
{
|
||||
_inputs.Add(input);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a normalization trace.
|
||||
/// </summary>
|
||||
public ProofBundleBuilder AddNormalization(NormalizationTrace trace)
|
||||
{
|
||||
_normalization.Add(trace);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a claim.
|
||||
/// </summary>
|
||||
public ProofBundleBuilder AddClaim(Claim claim)
|
||||
{
|
||||
_claims.Add(claim);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an atom table from subject state.
|
||||
/// </summary>
|
||||
public ProofBundleBuilder AddAtomTable(SubjectState state)
|
||||
{
|
||||
_atomTables.Add(new AtomTable
|
||||
{
|
||||
SubjectDigest = state.SubjectDigest,
|
||||
Subject = state.Subject,
|
||||
Atoms = state.ToSnapshot(),
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a decision record.
|
||||
/// </summary>
|
||||
public ProofBundleBuilder AddDecision(string subjectDigest, DispositionResult result)
|
||||
{
|
||||
_decisions.Add(new DecisionRecord
|
||||
{
|
||||
SubjectDigest = subjectDigest,
|
||||
Disposition = result.Disposition,
|
||||
MatchedRule = result.MatchedRule,
|
||||
Explanation = result.Explanation,
|
||||
Trace = result.Trace,
|
||||
Conflicts = result.Conflicts,
|
||||
Unknowns = result.Unknowns,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the proof bundle.
|
||||
/// </summary>
|
||||
public ProofBundle Build()
|
||||
{
|
||||
var dispositionCounts = _decisions
|
||||
.GroupBy(d => d.Disposition)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var stats = new ProofBundleStats
|
||||
{
|
||||
InputCount = _inputs.Count,
|
||||
ClaimCount = _claims.Count,
|
||||
SubjectCount = _atomTables.Count,
|
||||
ConflictCount = _decisions.Count(d => d.Conflicts.Count > 0),
|
||||
IncompleteCount = _decisions.Count(d => d.Unknowns.Count > 0),
|
||||
DispositionCounts = dispositionCounts,
|
||||
};
|
||||
|
||||
var bundle = new ProofBundle
|
||||
{
|
||||
PolicyBundleId = _policyBundleId,
|
||||
PolicyBundleVersion = _policyBundleVersion,
|
||||
Inputs = _inputs,
|
||||
Normalization = _normalization,
|
||||
Claims = _claims,
|
||||
AtomTables = _atomTables,
|
||||
Decisions = _decisions,
|
||||
Stats = stats,
|
||||
};
|
||||
|
||||
return bundle.WithComputedId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Security Atoms - Canonical propositions for vulnerability disposition.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-002
|
||||
*
|
||||
* Defines the orthogonal atomic propositions used to represent security
|
||||
* knowledge about a Subject (artifact + component + vulnerability).
|
||||
*
|
||||
* External VEX formats (CycloneDX, OpenVEX, CSAF) are normalized into
|
||||
* these canonical atoms for uniform aggregation and decision making.
|
||||
*/
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical security propositions for vulnerability disposition.
|
||||
/// Each atom is a boolean proposition that can have a K4 truth value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These atoms are intentionally orthogonal; external VEX formats
|
||||
/// are normalized into combinations of these atoms.
|
||||
/// </remarks>
|
||||
public enum SecurityAtom
|
||||
{
|
||||
/// <summary>
|
||||
/// PRESENT: The component instance exists in the artifact/context.
|
||||
/// False when component is not actually in the artifact despite declaration.
|
||||
/// </summary>
|
||||
Present = 1,
|
||||
|
||||
/// <summary>
|
||||
/// APPLIES: The vulnerability applies to this component (version/range/CPE match).
|
||||
/// False when version is outside affected range.
|
||||
/// </summary>
|
||||
Applies = 2,
|
||||
|
||||
/// <summary>
|
||||
/// REACHABLE: The vulnerable code is reachable in the given execution context.
|
||||
/// False when code paths to vulnerability are not exercised.
|
||||
/// </summary>
|
||||
Reachable = 3,
|
||||
|
||||
/// <summary>
|
||||
/// MITIGATED: Controls exist that prevent exploitation.
|
||||
/// True when compiler protections, runtime guards, WAF rules, etc. are active.
|
||||
/// </summary>
|
||||
Mitigated = 4,
|
||||
|
||||
/// <summary>
|
||||
/// FIXED: Remediation has been applied to the artifact.
|
||||
/// True when patches, upgrades, or other fixes are in place.
|
||||
/// </summary>
|
||||
Fixed = 5,
|
||||
|
||||
/// <summary>
|
||||
/// MISATTRIBUTED: The finding is a false association (false positive).
|
||||
/// True when the vulnerability was incorrectly linked to this component.
|
||||
/// </summary>
|
||||
Misattributed = 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for SecurityAtom.
|
||||
/// </summary>
|
||||
public static class SecurityAtomExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a human-readable display name for the atom.
|
||||
/// </summary>
|
||||
public static string ToDisplayName(this SecurityAtom atom) => atom switch
|
||||
{
|
||||
SecurityAtom.Present => "Component Present",
|
||||
SecurityAtom.Applies => "Vulnerability Applies",
|
||||
SecurityAtom.Reachable => "Code Reachable",
|
||||
SecurityAtom.Mitigated => "Mitigations Active",
|
||||
SecurityAtom.Fixed => "Remediation Applied",
|
||||
SecurityAtom.Misattributed => "False Association",
|
||||
_ => atom.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the canonical string representation for serialization.
|
||||
/// </summary>
|
||||
public static string ToCanonicalName(this SecurityAtom atom) => atom switch
|
||||
{
|
||||
SecurityAtom.Present => "PRESENT",
|
||||
SecurityAtom.Applies => "APPLIES",
|
||||
SecurityAtom.Reachable => "REACHABLE",
|
||||
SecurityAtom.Mitigated => "MITIGATED",
|
||||
SecurityAtom.Fixed => "FIXED",
|
||||
SecurityAtom.Misattributed => "MISATTRIBUTED",
|
||||
_ => atom.ToString().ToUpperInvariant(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses a canonical name to SecurityAtom.
|
||||
/// </summary>
|
||||
public static SecurityAtom? FromCanonicalName(string name)
|
||||
{
|
||||
return name?.ToUpperInvariant() switch
|
||||
{
|
||||
"PRESENT" => SecurityAtom.Present,
|
||||
"APPLIES" => SecurityAtom.Applies,
|
||||
"REACHABLE" => SecurityAtom.Reachable,
|
||||
"MITIGATED" => SecurityAtom.Mitigated,
|
||||
"FIXED" => SecurityAtom.Fixed,
|
||||
"MISATTRIBUTED" => SecurityAtom.Misattributed,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all defined security atoms.
|
||||
/// </summary>
|
||||
public static IEnumerable<SecurityAtom> All()
|
||||
{
|
||||
yield return SecurityAtom.Present;
|
||||
yield return SecurityAtom.Applies;
|
||||
yield return SecurityAtom.Reachable;
|
||||
yield return SecurityAtom.Mitigated;
|
||||
yield return SecurityAtom.Fixed;
|
||||
yield return SecurityAtom.Misattributed;
|
||||
}
|
||||
}
|
||||
187
src/Policy/__Libraries/StellaOps.Policy/TrustLattice/Subject.cs
Normal file
187
src/Policy/__Libraries/StellaOps.Policy/TrustLattice/Subject.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Subject - The target of security assertions.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-004
|
||||
*
|
||||
* A Subject is the entity we are making a security determination about.
|
||||
* It uniquely identifies the combination of:
|
||||
* - Artifact (container image, binary, etc.)
|
||||
* - Component (library, package)
|
||||
* - Vulnerability (CVE, OSV, etc.)
|
||||
* - Optional context (environment, config)
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an artifact being analyzed.
|
||||
/// </summary>
|
||||
public sealed record ArtifactRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressable digest (e.g., "sha256:abc123...").
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional name/tag for human readability.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type (e.g., "oci-image", "binary", "archive").
|
||||
/// </summary>
|
||||
public string? Type { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a component within an artifact.
|
||||
/// </summary>
|
||||
public sealed record ComponentRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Package URL (PURL) - preferred identifier.
|
||||
/// Example: "pkg:npm/lodash@4.17.21"
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPE (Common Platform Enumeration) - fallback identifier.
|
||||
/// </summary>
|
||||
public string? Cpe { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BOM reference ID - last resort identifier.
|
||||
/// </summary>
|
||||
public string? BomRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the best available identifier.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string Id => Purl ?? Cpe ?? BomRef ?? "unknown";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a vulnerability.
|
||||
/// </summary>
|
||||
public sealed record VulnerabilityRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier (e.g., "CVE-2024-12345", "GHSA-xxxx-xxxx-xxxx").
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source database (e.g., "nvd", "osv", "github").
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional context for environment-sensitive assertions.
|
||||
/// </summary>
|
||||
public sealed record ContextRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Build configuration flags.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? BuildFlags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime configuration profile.
|
||||
/// </summary>
|
||||
public string? ConfigProfile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deployment mode (e.g., "production", "staging").
|
||||
/// </summary>
|
||||
public string? DeploymentMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Operating system / libc family.
|
||||
/// </summary>
|
||||
public string? OsFamily { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether FIPS mode is enabled.
|
||||
/// </summary>
|
||||
public bool? FipsMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Security posture (e.g., "selinux:enforcing", "apparmor:enabled").
|
||||
/// </summary>
|
||||
public string? SecurityPosture { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content-addressable digest for this context.
|
||||
/// </summary>
|
||||
public string ComputeDigest()
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(this, CanonicalJsonOptions.Default);
|
||||
var hash = SHA256.HashData(json);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The subject of a security assertion.
|
||||
/// Uniquely identifies what we are making a determination about.
|
||||
/// </summary>
|
||||
public sealed record Subject
|
||||
{
|
||||
/// <summary>
|
||||
/// Reference to the artifact containing the component.
|
||||
/// </summary>
|
||||
public required ArtifactRef Artifact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the component within the artifact.
|
||||
/// </summary>
|
||||
public required ComponentRef Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the vulnerability being assessed.
|
||||
/// </summary>
|
||||
public required VulnerabilityRef Vulnerability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional context for environment-sensitive assertions.
|
||||
/// </summary>
|
||||
public ContextRef? Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content-addressable digest for this subject.
|
||||
/// Used as a stable key for aggregation.
|
||||
/// </summary>
|
||||
public string ComputeDigest()
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(this, CanonicalJsonOptions.Default);
|
||||
var hash = SHA256.HashData(json);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable string representation.
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
=> $"{Vulnerability.Id}@{Component.Id} in {Artifact.Digest[..19]}...";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical JSON serialization options for deterministic hashing.
|
||||
/// </summary>
|
||||
internal static class CanonicalJsonOptions
|
||||
{
|
||||
public static readonly JsonSerializerOptions Default = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Trust Label and Principal - Trust algebra components.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Tasks: TRUST-005, TRUST-006
|
||||
*
|
||||
* Trust is not a single number; it must represent:
|
||||
* - Cryptographic verification
|
||||
* - Identity assurance
|
||||
* - Authority scope
|
||||
* - Freshness/revocation
|
||||
* - Evidence strength
|
||||
*
|
||||
* These models enable policy-driven trust evaluation that is
|
||||
* deterministic and explainable.
|
||||
*/
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// Assurance level for cryptographic and identity verification.
|
||||
/// Increasing levels from A0 (weakest) to A4 (strongest).
|
||||
/// </summary>
|
||||
public enum AssuranceLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// A0: Unsigned or unverifiable assertion.
|
||||
/// No cryptographic backing.
|
||||
/// </summary>
|
||||
A0_Unsigned = 0,
|
||||
|
||||
/// <summary>
|
||||
/// A1: Signed, but weak identity binding.
|
||||
/// Key is known but identity not strongly verified.
|
||||
/// </summary>
|
||||
A1_WeakIdentity = 1,
|
||||
|
||||
/// <summary>
|
||||
/// A2: Signed with verified identity.
|
||||
/// Certificate chain or keyless identity (OIDC) verified.
|
||||
/// </summary>
|
||||
A2_VerifiedIdentity = 2,
|
||||
|
||||
/// <summary>
|
||||
/// A3: Signed with provenance binding.
|
||||
/// Signature bound to artifact digest via attestation.
|
||||
/// </summary>
|
||||
A3_ProvenanceBound = 3,
|
||||
|
||||
/// <summary>
|
||||
/// A4: Full transparency log inclusion.
|
||||
/// Signed + provenance + Rekor/transparency log entry.
|
||||
/// </summary>
|
||||
A4_TransparencyLog = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Freshness class for temporal validity of assertions.
|
||||
/// </summary>
|
||||
public enum FreshnessClass
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown or missing timestamp.
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Expired assertion (past valid_until).
|
||||
/// </summary>
|
||||
Expired = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Stale assertion (older than freshness threshold).
|
||||
/// </summary>
|
||||
Stale = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Fresh assertion (within freshness threshold).
|
||||
/// </summary>
|
||||
Fresh = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Live assertion (just issued or real-time).
|
||||
/// </summary>
|
||||
Live = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence class describing the strength of supporting evidence.
|
||||
/// </summary>
|
||||
public enum EvidenceClass
|
||||
{
|
||||
/// <summary>
|
||||
/// E0: Statement only (no supporting evidence refs).
|
||||
/// </summary>
|
||||
E0_StatementOnly = 0,
|
||||
|
||||
/// <summary>
|
||||
/// E1: SBOM linkage evidence.
|
||||
/// Component present + version evidence.
|
||||
/// </summary>
|
||||
E1_SbomLinkage = 1,
|
||||
|
||||
/// <summary>
|
||||
/// E2: Reachability/mitigation evidence.
|
||||
/// Call paths, config snapshots, runtime proofs.
|
||||
/// </summary>
|
||||
E2_ReachabilityMitigation = 2,
|
||||
|
||||
/// <summary>
|
||||
/// E3: Remediation evidence.
|
||||
/// Patch diffs, pedigree/commit chain, fix verification.
|
||||
/// </summary>
|
||||
E3_Remediation = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Role that a principal can play in the trust model.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum PrincipalRole
|
||||
{
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Vendor: Original software vendor.
|
||||
/// Authoritative for their own products.
|
||||
/// </summary>
|
||||
Vendor = 1 << 0,
|
||||
|
||||
/// <summary>
|
||||
/// Distributor: OS/distro package maintainer.
|
||||
/// Authoritative for packages in their repositories.
|
||||
/// </summary>
|
||||
Distributor = 1 << 1,
|
||||
|
||||
/// <summary>
|
||||
/// Scanner: Automated vulnerability scanner.
|
||||
/// Provides detection evidence.
|
||||
/// </summary>
|
||||
Scanner = 1 << 2,
|
||||
|
||||
/// <summary>
|
||||
/// Auditor: Security auditor or penetration tester.
|
||||
/// Provides expert assessment evidence.
|
||||
/// </summary>
|
||||
Auditor = 1 << 3,
|
||||
|
||||
/// <summary>
|
||||
/// InternalSecurity: Internal security team.
|
||||
/// Authoritative for internal artifact reachability/mitigation.
|
||||
/// </summary>
|
||||
InternalSecurity = 1 << 4,
|
||||
|
||||
/// <summary>
|
||||
/// BuildSystem: CI/CD build system.
|
||||
/// Provides provenance and build evidence.
|
||||
/// </summary>
|
||||
BuildSystem = 1 << 5,
|
||||
|
||||
/// <summary>
|
||||
/// RuntimeMonitor: Runtime observability system.
|
||||
/// Provides runtime behavior evidence.
|
||||
/// </summary>
|
||||
RuntimeMonitor = 1 << 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authority scope defining what subjects a principal is authoritative for.
|
||||
/// </summary>
|
||||
public sealed record AuthorityScope
|
||||
{
|
||||
/// <summary>
|
||||
/// Product namespace patterns (e.g., "vendor.example/*").
|
||||
/// Principal is authoritative for these products.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Products { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package namespace patterns (e.g., "pkg:npm/*", "pkg:maven/org.example/*").
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Packages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact digest patterns (e.g., "sha256:*" for internal artifacts).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Artifacts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability source patterns (e.g., "nvd", "osv").
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? VulnerabilitySources { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this scope covers a given subject.
|
||||
/// </summary>
|
||||
public bool Covers(Subject subject)
|
||||
{
|
||||
// Check artifacts
|
||||
if (Artifacts is { Count: > 0 })
|
||||
{
|
||||
if (!MatchesAny(subject.Artifact.Digest, Artifacts))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check packages
|
||||
if (Packages is { Count: > 0 })
|
||||
{
|
||||
var componentId = subject.Component.Purl ?? subject.Component.Id;
|
||||
if (!MatchesAny(componentId, Packages))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check vulnerability sources
|
||||
if (VulnerabilitySources is { Count: > 0 })
|
||||
{
|
||||
var source = subject.Vulnerability.Source ?? "";
|
||||
if (!MatchesAny(source, VulnerabilitySources))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this scope covers (is a superset of) another scope.
|
||||
/// </summary>
|
||||
public bool Covers(AuthorityScope other)
|
||||
{
|
||||
// A scope covers another if all patterns in other are covered by patterns in this scope
|
||||
// Universal scope (*) covers everything
|
||||
if (Artifacts is { Count: > 0 } && Artifacts.Contains("*"))
|
||||
return true;
|
||||
|
||||
// Check that we cover all artifact patterns from the other scope
|
||||
if (other.Artifacts is { Count: > 0 })
|
||||
{
|
||||
if (Artifacts is null || Artifacts.Count == 0)
|
||||
return false;
|
||||
foreach (var pattern in other.Artifacts)
|
||||
{
|
||||
if (!Artifacts.Any(a => PatternCovers(a, pattern)))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check that we cover all package patterns from the other scope
|
||||
if (other.Packages is { Count: > 0 })
|
||||
{
|
||||
if (Packages is null || Packages.Count == 0)
|
||||
return false;
|
||||
foreach (var pattern in other.Packages)
|
||||
{
|
||||
if (!Packages.Any(p => PatternCovers(p, pattern)))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check vulnerability sources
|
||||
if (other.VulnerabilitySources is { Count: > 0 })
|
||||
{
|
||||
if (VulnerabilitySources is null || VulnerabilitySources.Count == 0)
|
||||
return false;
|
||||
foreach (var source in other.VulnerabilitySources)
|
||||
{
|
||||
if (!VulnerabilitySources.Any(s => PatternCovers(s, source)))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool PatternCovers(string coveringPattern, string coveredPattern)
|
||||
{
|
||||
// Universal pattern covers everything
|
||||
if (coveringPattern == "*") return true;
|
||||
|
||||
// Exact match
|
||||
if (coveringPattern.Equals(coveredPattern, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Prefix pattern (e.g., "pkg:npm/*" covers "pkg:npm/express")
|
||||
if (coveringPattern.EndsWith("/*"))
|
||||
{
|
||||
var prefix = coveringPattern[..^1];
|
||||
if (coveredPattern.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
// Also check if covered pattern is a more specific prefix pattern
|
||||
if (coveredPattern.EndsWith("/*"))
|
||||
{
|
||||
var otherPrefix = coveredPattern[..^1];
|
||||
if (otherPrefix.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesAny(string value, IReadOnlyList<string> patterns)
|
||||
{
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
if (pattern == "*") return true;
|
||||
if (pattern.EndsWith("/*"))
|
||||
{
|
||||
var prefix = pattern[..^1];
|
||||
if (value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
else if (pattern.Equals(value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Universal scope that covers all subjects.
|
||||
/// </summary>
|
||||
public static AuthorityScope Universal { get; } = new()
|
||||
{
|
||||
Artifacts = ["*"],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A principal is an issuer identity with verifiable keys.
|
||||
/// </summary>
|
||||
public sealed record Principal
|
||||
{
|
||||
/// <summary>
|
||||
/// Principal identifier (URI-like, e.g., "did:web:vendor.example").
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifiers for verification.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? KeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity claims (e.g., cert SANs, OIDC subject, org, repo).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? IdentityClaims { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Roles this principal can play.
|
||||
/// </summary>
|
||||
public PrincipalRole Roles { get; init; } = PrincipalRole.None;
|
||||
|
||||
/// <summary>
|
||||
/// Display name for human readability.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// An unknown principal used as a fallback when no issuer is specified.
|
||||
/// </summary>
|
||||
public static Principal Unknown { get; } = new Principal
|
||||
{
|
||||
Id = "urn:stellaops:principal:unknown",
|
||||
DisplayName = "Unknown"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust label computed from policy and verification.
|
||||
/// Affects decision selection without destroying underlying knowledge.
|
||||
/// </summary>
|
||||
public sealed record TrustLabel : IComparable<TrustLabel>
|
||||
{
|
||||
/// <summary>
|
||||
/// Cryptographic and identity verification strength.
|
||||
/// </summary>
|
||||
public required AssuranceLevel AssuranceLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope of subjects this trust applies to.
|
||||
/// </summary>
|
||||
public required AuthorityScope AuthorityScope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Temporal validity of the assertion.
|
||||
/// </summary>
|
||||
public required FreshnessClass Freshness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Strength of attached evidence.
|
||||
/// </summary>
|
||||
public required EvidenceClass EvidenceClass { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The principal providing this trust.
|
||||
/// </summary>
|
||||
public Principal? Principal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes an overall trust score for ordering.
|
||||
/// Higher is more trustworthy.
|
||||
/// </summary>
|
||||
public int ComputeScore()
|
||||
{
|
||||
// Weighted combination (can be policy-configurable)
|
||||
return (int)AssuranceLevel * 100
|
||||
+ (int)EvidenceClass * 10
|
||||
+ (int)Freshness;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares trust labels by overall score.
|
||||
/// </summary>
|
||||
public int CompareTo(TrustLabel? other)
|
||||
{
|
||||
if (other is null) return 1;
|
||||
return ComputeScore().CompareTo(other.ComputeScore());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the higher trust label (join operation).
|
||||
/// </summary>
|
||||
public static TrustLabel Max(TrustLabel a, TrustLabel b)
|
||||
=> a.CompareTo(b) >= 0 ? a : b;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the lower trust label (meet operation).
|
||||
/// </summary>
|
||||
public static TrustLabel Min(TrustLabel a, TrustLabel b)
|
||||
=> a.CompareTo(b) <= 0 ? a : b;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal trust label (unsigned, no evidence).
|
||||
/// </summary>
|
||||
public static TrustLabel Minimal { get; } = new()
|
||||
{
|
||||
AssuranceLevel = AssuranceLevel.A0_Unsigned,
|
||||
AuthorityScope = new AuthorityScope(),
|
||||
Freshness = FreshnessClass.Unknown,
|
||||
EvidenceClass = EvidenceClass.E0_StatementOnly,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* TrustLatticeEngine - Orchestrates the complete trust evaluation pipeline.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-016
|
||||
*
|
||||
* The engine coordinates:
|
||||
* 1. VEX normalization from multiple formats
|
||||
* 2. Claim ingestion and aggregation
|
||||
* 3. K4 lattice evaluation
|
||||
* 4. Disposition selection
|
||||
* 5. Proof bundle generation
|
||||
*/
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// Result of processing a batch of inputs.
|
||||
/// </summary>
|
||||
public sealed record EvaluationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the evaluation completed successfully.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The proof bundle containing all evidence.
|
||||
/// </summary>
|
||||
public ProofBundle? ProofBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Quick access to disposition results by subject.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, DispositionResult> Dispositions { get; init; } =
|
||||
new Dictionary<string, DispositionResult>();
|
||||
|
||||
/// <summary>
|
||||
/// Warnings generated during evaluation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for trust lattice evaluation.
|
||||
/// </summary>
|
||||
public sealed record EvaluationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to generate a proof bundle.
|
||||
/// </summary>
|
||||
public bool GenerateProofBundle { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include full decision traces in the proof bundle.
|
||||
/// </summary>
|
||||
public bool IncludeDecisionTraces { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate claim signatures.
|
||||
/// </summary>
|
||||
public bool ValidateSignatures { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp for claim validity evaluation (null = now).
|
||||
/// </summary>
|
||||
public DateTimeOffset? EvaluationTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter to specific subjects (null = all).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? SubjectFilter { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The trust lattice engine orchestrates the complete evaluation pipeline.
|
||||
/// </summary>
|
||||
public sealed class TrustLatticeEngine
|
||||
{
|
||||
private readonly PolicyBundle _policy;
|
||||
private readonly LatticeStore _store;
|
||||
private readonly DispositionSelector _selector;
|
||||
private readonly Dictionary<string, IVexNormalizer> _normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new trust lattice engine.
|
||||
/// </summary>
|
||||
/// <param name="policy">The policy bundle to use.</param>
|
||||
public TrustLatticeEngine(PolicyBundle? policy = null)
|
||||
{
|
||||
_policy = policy ?? PolicyBundle.Default;
|
||||
_store = new LatticeStore();
|
||||
_selector = new DispositionSelector(_policy.GetEffectiveRules());
|
||||
|
||||
// Register default normalizers
|
||||
_normalizers = new Dictionary<string, IVexNormalizer>(StringComparer.OrdinalIgnoreCase);
|
||||
RegisterNormalizer(new CycloneDxVexNormalizer());
|
||||
RegisterNormalizer(new OpenVexNormalizer());
|
||||
RegisterNormalizer(new CsafVexNormalizer());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy bundle.
|
||||
/// </summary>
|
||||
public PolicyBundle Policy => _policy;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the lattice store.
|
||||
/// </summary>
|
||||
public LatticeStore Store => _store;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a VEX normalizer.
|
||||
/// </summary>
|
||||
public void RegisterNormalizer(IVexNormalizer normalizer)
|
||||
{
|
||||
_normalizers[normalizer.Format] = normalizer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests a claim directly.
|
||||
/// </summary>
|
||||
public Claim IngestClaim(Claim claim)
|
||||
{
|
||||
return _store.IngestClaim(claim);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests multiple claims.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Claim> IngestClaims(IEnumerable<Claim> claims)
|
||||
{
|
||||
return claims.Select(c => _store.IngestClaim(c)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests a VEX document.
|
||||
/// </summary>
|
||||
/// <param name="document">The VEX document content.</param>
|
||||
/// <param name="format">The VEX format (CycloneDX/ECMA-424, OpenVEX, CSAF).</param>
|
||||
/// <param name="principal">The principal making the assertions.</param>
|
||||
/// <param name="trustLabel">Default trust label for generated claims.</param>
|
||||
public IReadOnlyList<Claim> IngestVex(
|
||||
string document,
|
||||
string format,
|
||||
Principal principal,
|
||||
TrustLabel? trustLabel = null)
|
||||
{
|
||||
if (!_normalizers.TryGetValue(format, out var normalizer))
|
||||
{
|
||||
throw new ArgumentException($"Unknown VEX format: {format}", nameof(format));
|
||||
}
|
||||
|
||||
var claims = normalizer.Normalize(document, principal, trustLabel).ToList();
|
||||
return IngestClaims(claims);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the disposition for a subject.
|
||||
/// </summary>
|
||||
public DispositionResult GetDisposition(Subject subject)
|
||||
{
|
||||
var state = _store.GetOrCreateSubject(subject);
|
||||
return _selector.Select(state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the disposition for a subject by digest.
|
||||
/// </summary>
|
||||
public DispositionResult? GetDisposition(string subjectDigest)
|
||||
{
|
||||
var state = _store.GetSubjectState(subjectDigest);
|
||||
if (state is null) return null;
|
||||
return _selector.Select(state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates all subjects and produces dispositions.
|
||||
/// </summary>
|
||||
public EvaluationResult Evaluate(EvaluationOptions? options = null)
|
||||
{
|
||||
options ??= new EvaluationOptions();
|
||||
var warnings = new List<string>();
|
||||
var dispositions = new Dictionary<string, DispositionResult>();
|
||||
|
||||
try
|
||||
{
|
||||
var subjects = _store.GetAllSubjects();
|
||||
|
||||
// Apply subject filter if specified
|
||||
if (options.SubjectFilter is not null)
|
||||
{
|
||||
subjects = subjects.Where(s => options.SubjectFilter.Contains(s.SubjectDigest));
|
||||
}
|
||||
|
||||
// Evaluate each subject
|
||||
foreach (var state in subjects)
|
||||
{
|
||||
var result = _selector.Select(state);
|
||||
dispositions[state.SubjectDigest] = result;
|
||||
}
|
||||
|
||||
// Generate proof bundle if requested
|
||||
ProofBundle? proofBundle = null;
|
||||
if (options.GenerateProofBundle)
|
||||
{
|
||||
proofBundle = GenerateProofBundle(dispositions, options);
|
||||
}
|
||||
|
||||
return new EvaluationResult
|
||||
{
|
||||
Success = true,
|
||||
ProofBundle = proofBundle,
|
||||
Dispositions = dispositions,
|
||||
Warnings = warnings,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new EvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
Dispositions = dispositions,
|
||||
Warnings = warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a proof bundle for the current evaluation state.
|
||||
/// </summary>
|
||||
private ProofBundle GenerateProofBundle(
|
||||
Dictionary<string, DispositionResult> dispositions,
|
||||
EvaluationOptions options)
|
||||
{
|
||||
var builder = new ProofBundleBuilder()
|
||||
.WithPolicyBundle(_policy);
|
||||
|
||||
// Add all claims
|
||||
foreach (var claim in _store.GetAllClaims())
|
||||
{
|
||||
builder.AddClaim(claim);
|
||||
}
|
||||
|
||||
// Add atom tables and decisions for each subject
|
||||
foreach (var state in _store.GetAllSubjects())
|
||||
{
|
||||
builder.AddAtomTable(state);
|
||||
|
||||
if (dispositions.TryGetValue(state.SubjectDigest, out var result))
|
||||
{
|
||||
builder.AddDecision(state.SubjectDigest, result);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all state from the engine.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_store.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about the current state.
|
||||
/// </summary>
|
||||
public LatticeStoreStats GetStats() => _store.GetStats();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a builder for claims.
|
||||
/// </summary>
|
||||
public ClaimBuilder CreateClaim() => new(this);
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for creating and ingesting claims.
|
||||
/// </summary>
|
||||
public sealed class ClaimBuilder
|
||||
{
|
||||
private readonly TrustLatticeEngine _engine;
|
||||
private Subject? _subject;
|
||||
private Principal _principal = Principal.Unknown;
|
||||
private TrustLabel? _trustLabel;
|
||||
private readonly List<AtomAssertion> _assertions = [];
|
||||
private readonly List<string> _evidenceRefs = [];
|
||||
|
||||
internal ClaimBuilder(TrustLatticeEngine engine)
|
||||
{
|
||||
_engine = engine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the subject.
|
||||
/// </summary>
|
||||
public ClaimBuilder ForSubject(Subject subject)
|
||||
{
|
||||
_subject = subject;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the principal.
|
||||
/// </summary>
|
||||
public ClaimBuilder FromPrincipal(Principal principal)
|
||||
{
|
||||
_principal = principal;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the trust label.
|
||||
/// </summary>
|
||||
public ClaimBuilder WithTrust(TrustLabel label)
|
||||
{
|
||||
_trustLabel = label;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts an atom value.
|
||||
/// </summary>
|
||||
public ClaimBuilder Assert(SecurityAtom atom, bool value, string? justification = null)
|
||||
{
|
||||
_assertions.Add(new AtomAssertion
|
||||
{
|
||||
Atom = atom,
|
||||
Value = value,
|
||||
Justification = justification,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts PRESENT = true.
|
||||
/// </summary>
|
||||
public ClaimBuilder Present(bool value = true, string? justification = null)
|
||||
=> Assert(SecurityAtom.Present, value, justification);
|
||||
|
||||
/// <summary>
|
||||
/// Asserts APPLIES = true.
|
||||
/// </summary>
|
||||
public ClaimBuilder Applies(bool value = true, string? justification = null)
|
||||
=> Assert(SecurityAtom.Applies, value, justification);
|
||||
|
||||
/// <summary>
|
||||
/// Asserts REACHABLE = true.
|
||||
/// </summary>
|
||||
public ClaimBuilder Reachable(bool value = true, string? justification = null)
|
||||
=> Assert(SecurityAtom.Reachable, value, justification);
|
||||
|
||||
/// <summary>
|
||||
/// Asserts MITIGATED = true.
|
||||
/// </summary>
|
||||
public ClaimBuilder Mitigated(bool value = true, string? justification = null)
|
||||
=> Assert(SecurityAtom.Mitigated, value, justification);
|
||||
|
||||
/// <summary>
|
||||
/// Asserts FIXED = true.
|
||||
/// </summary>
|
||||
public ClaimBuilder Fixed(bool value = true, string? justification = null)
|
||||
=> Assert(SecurityAtom.Fixed, value, justification);
|
||||
|
||||
/// <summary>
|
||||
/// Asserts MISATTRIBUTED = true.
|
||||
/// </summary>
|
||||
public ClaimBuilder Misattributed(bool value = true, string? justification = null)
|
||||
=> Assert(SecurityAtom.Misattributed, value, justification);
|
||||
|
||||
/// <summary>
|
||||
/// References evidence by digest.
|
||||
/// </summary>
|
||||
public ClaimBuilder WithEvidence(string digest)
|
||||
{
|
||||
_evidenceRefs.Add(digest);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and ingests the claim.
|
||||
/// </summary>
|
||||
public Claim Build()
|
||||
{
|
||||
if (_subject is null)
|
||||
throw new InvalidOperationException("Subject is required.");
|
||||
|
||||
var claim = new Claim
|
||||
{
|
||||
Subject = _subject,
|
||||
Principal = _principal,
|
||||
TrustLabel = _trustLabel,
|
||||
Assertions = _assertions,
|
||||
EvidenceRefs = _evidenceRefs,
|
||||
TimeInfo = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
|
||||
};
|
||||
|
||||
return _engine.IngestClaim(claim);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* VEX Normalizers - Convert vendor-specific VEX to canonical claims.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Tasks: TRUST-010, TRUST-011, TRUST-012
|
||||
*
|
||||
* Normalizers translate CycloneDX/ECMA-424, OpenVEX, and CSAF VEX statements
|
||||
* into canonical security atom assertions.
|
||||
*/
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX format normalizers.
|
||||
/// </summary>
|
||||
public interface IVexNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// The VEX format this normalizer handles.
|
||||
/// </summary>
|
||||
string Format { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a VEX document into canonical claims.
|
||||
/// </summary>
|
||||
/// <param name="document">The raw VEX document (JSON or other format).</param>
|
||||
/// <param name="principal">The principal making the assertions.</param>
|
||||
/// <param name="trustLabel">Default trust label for generated claims.</param>
|
||||
/// <returns>A sequence of normalized claims.</returns>
|
||||
IEnumerable<Claim> Normalize(string document, Principal principal, TrustLabel? trustLabel = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of normalizing a single VEX statement.
|
||||
/// </summary>
|
||||
public sealed record NormalizedStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// The generated claim.
|
||||
/// </summary>
|
||||
public required Claim Claim { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original statement identifier from the VEX document.
|
||||
/// </summary>
|
||||
public string? OriginalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The VEX format the statement came from.
|
||||
/// </summary>
|
||||
public required string SourceFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Any warnings generated during normalization.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CycloneDX/ECMA-424 VEX status values.
|
||||
/// Per ECMA-424 section 7.
|
||||
/// </summary>
|
||||
public enum CycloneDxVexStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Status not specified.
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// Analysis not yet complete.
|
||||
/// </summary>
|
||||
InTriage,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability does not affect this component.
|
||||
/// </summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability affects this component.
|
||||
/// </summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>
|
||||
/// A fix is available but not yet applied.
|
||||
/// </summary>
|
||||
FixAvailable,
|
||||
|
||||
/// <summary>
|
||||
/// Component has been fixed.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CycloneDX/ECMA-424 justification values.
|
||||
/// Per ECMA-424 section 7.2.
|
||||
/// </summary>
|
||||
public enum CycloneDxJustification
|
||||
{
|
||||
/// <summary>
|
||||
/// No justification specified.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Code not present.
|
||||
/// </summary>
|
||||
CodeNotPresent,
|
||||
|
||||
/// <summary>
|
||||
/// Code not reachable.
|
||||
/// </summary>
|
||||
CodeNotReachable,
|
||||
|
||||
/// <summary>
|
||||
/// Requires configuration not in default/deployed config.
|
||||
/// </summary>
|
||||
RequiresConfiguration,
|
||||
|
||||
/// <summary>
|
||||
/// Requires dependency not in environment.
|
||||
/// </summary>
|
||||
RequiresDependency,
|
||||
|
||||
/// <summary>
|
||||
/// Requires specific environment conditions.
|
||||
/// </summary>
|
||||
RequiresEnvironment,
|
||||
|
||||
/// <summary>
|
||||
/// Protected by inline mitigation.
|
||||
/// </summary>
|
||||
ProtectedByMitigatingControl,
|
||||
|
||||
/// <summary>
|
||||
/// Protected at perimeter.
|
||||
/// </summary>
|
||||
ProtectedAtPerimeter,
|
||||
|
||||
/// <summary>
|
||||
/// Protected at runtime.
|
||||
/// </summary>
|
||||
ProtectedAtRuntime,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability was inaccurate (misattributed).
|
||||
/// </summary>
|
||||
VulnerableCodeCannotBeControlledByAdversary,
|
||||
|
||||
/// <summary>
|
||||
/// Inline mitigations exist.
|
||||
/// </summary>
|
||||
InlineMitigationsAlreadyExist,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes CycloneDX/ECMA-424 VEX documents to canonical claims.
|
||||
/// </summary>
|
||||
public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Format => "CycloneDX/ECMA-424";
|
||||
|
||||
/// <summary>
|
||||
/// Mapping from CycloneDX status to atom assertions.
|
||||
/// Per specification Table 1.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<CycloneDxVexStatus, List<AtomAssertion>> StatusToAtoms = new()
|
||||
{
|
||||
[CycloneDxVexStatus.InTriage] =
|
||||
[
|
||||
// in_triage: no definite assertions, only that analysis is incomplete
|
||||
],
|
||||
[CycloneDxVexStatus.NotAffected] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = false, Justification = "not_affected status" },
|
||||
],
|
||||
[CycloneDxVexStatus.Affected] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = true, Justification = "affected status" },
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = true, Justification = "affected status" },
|
||||
],
|
||||
[CycloneDxVexStatus.FixAvailable] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = true, Justification = "fix_available status" },
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = true, Justification = "fix_available status" },
|
||||
// Fixed = false (fix is available but not applied)
|
||||
new AtomAssertion { Atom = SecurityAtom.Fixed, Value = false, Justification = "fix available but not applied" },
|
||||
],
|
||||
[CycloneDxVexStatus.Fixed] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Fixed, Value = true, Justification = "fixed status" },
|
||||
],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Mapping from justification to additional atom assertions.
|
||||
/// Per specification Table 1.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<CycloneDxJustification, List<AtomAssertion>> JustificationToAtoms = new()
|
||||
{
|
||||
[CycloneDxJustification.CodeNotPresent] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Present, Value = false, Justification = "code_not_present" },
|
||||
],
|
||||
[CycloneDxJustification.CodeNotReachable] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Reachable, Value = false, Justification = "code_not_reachable" },
|
||||
],
|
||||
[CycloneDxJustification.RequiresConfiguration] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = false, Condition = "default_config", Justification = "requires_configuration" },
|
||||
],
|
||||
[CycloneDxJustification.RequiresDependency] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = false, Condition = "current_deps", Justification = "requires_dependency" },
|
||||
],
|
||||
[CycloneDxJustification.RequiresEnvironment] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Applies, Value = false, Condition = "deployed_env", Justification = "requires_environment" },
|
||||
],
|
||||
[CycloneDxJustification.ProtectedByMitigatingControl] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Mitigated, Value = true, Justification = "protected_by_mitigating_control" },
|
||||
],
|
||||
[CycloneDxJustification.ProtectedAtPerimeter] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Mitigated, Value = true, Justification = "protected_at_perimeter" },
|
||||
],
|
||||
[CycloneDxJustification.ProtectedAtRuntime] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Mitigated, Value = true, Justification = "protected_at_runtime" },
|
||||
],
|
||||
[CycloneDxJustification.VulnerableCodeCannotBeControlledByAdversary] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Mitigated, Value = true, Justification = "vulnerable_code_cannot_be_controlled" },
|
||||
],
|
||||
[CycloneDxJustification.InlineMitigationsAlreadyExist] =
|
||||
[
|
||||
new AtomAssertion { Atom = SecurityAtom.Mitigated, Value = true, Justification = "inline_mitigations_exist" },
|
||||
],
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Claim> Normalize(string document, Principal principal, TrustLabel? trustLabel = null)
|
||||
{
|
||||
// For now, this is a simplified implementation.
|
||||
// Full implementation would parse the CycloneDX JSON and extract VEX data.
|
||||
// The real implementation should use System.Text.Json to parse the document.
|
||||
|
||||
// Placeholder: return empty for now
|
||||
// Real implementation would:
|
||||
// 1. Parse JSON document
|
||||
// 2. Extract vulnerabilities[] array
|
||||
// 3. For each vulnerability, extract analysis.state, analysis.justification
|
||||
// 4. Map to atoms using the tables above
|
||||
// 5. Build Subject from bom-ref, vulnerability ID, etc.
|
||||
// 6. Create Claim with assertions
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a pre-parsed CycloneDX VEX statement.
|
||||
/// </summary>
|
||||
/// <param name="subject">The subject of the VEX statement.</param>
|
||||
/// <param name="status">The CycloneDX status.</param>
|
||||
/// <param name="justification">Optional justification.</param>
|
||||
/// <param name="detail">Optional detail text.</param>
|
||||
/// <param name="principal">The principal making the assertion.</param>
|
||||
/// <param name="trustLabel">Optional trust label.</param>
|
||||
/// <returns>A normalized claim.</returns>
|
||||
public Claim NormalizeStatement(
|
||||
Subject subject,
|
||||
CycloneDxVexStatus status,
|
||||
CycloneDxJustification justification = CycloneDxJustification.None,
|
||||
string? detail = null,
|
||||
Principal? principal = null,
|
||||
TrustLabel? trustLabel = null)
|
||||
{
|
||||
var assertions = new List<AtomAssertion>();
|
||||
|
||||
// Add status-based assertions
|
||||
if (StatusToAtoms.TryGetValue(status, out var statusAtoms))
|
||||
{
|
||||
assertions.AddRange(statusAtoms);
|
||||
}
|
||||
|
||||
// Add justification-based assertions
|
||||
if (justification != CycloneDxJustification.None &&
|
||||
JustificationToAtoms.TryGetValue(justification, out var justAtoms))
|
||||
{
|
||||
assertions.AddRange(justAtoms);
|
||||
}
|
||||
|
||||
// Add detail as justification if provided
|
||||
if (!string.IsNullOrWhiteSpace(detail))
|
||||
{
|
||||
for (int i = 0; i < assertions.Count; i++)
|
||||
{
|
||||
assertions[i] = assertions[i] with
|
||||
{
|
||||
Justification = $"{assertions[i].Justification}: {detail}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new Claim
|
||||
{
|
||||
Subject = subject,
|
||||
Principal = principal ?? Principal.Unknown,
|
||||
Assertions = assertions,
|
||||
TrustLabel = trustLabel,
|
||||
TimeInfo = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user