- 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.
395 lines
11 KiB
C#
395 lines
11 KiB
C#
/**
|
|
* 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();
|
|
}
|
|
}
|