Files
git.stella-ops.org/src/Policy/__Libraries/StellaOps.Policy/TrustLattice/ProofBundle.cs
StellaOps Bot 5fc469ad98 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.
2025-12-20 01:26:42 +02:00

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();
}
}