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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user