Archive completed sprint documentation and deliverables: ## SPRINT_3500 - Proof of Exposure (PoE) Implementation (COMPLETE ✅) - Windows filesystem hash sanitization (colon → underscore) - Namespace conflict resolution (Subgraph → PoESubgraph) - Mock test improvements with It.IsAny<>() - Direct orchestrator unit tests - 8/8 PoE tests passing (100% success) - Archived to: docs/implplan/archived/2025-12-23-sprint-3500-poe/ ## SPRINT_7100.0001 - Proof-Driven Moats Core (COMPLETE ✅) - Four-tier backport detection system - 9 production modules (4,044 LOC) - Binary fingerprinting (TLSH + instruction hashing) - VEX integration with proof-carrying verdicts - 42+ unit tests passing (100% success) - Archived to: docs/implplan/archived/2025-12-23-sprint-7100-proof-moats/ ## SPRINT_7100.0002 - Proof Moats Storage Layer (COMPLETE ✅) - PostgreSQL repository implementations - Database migrations (4 evidence tables + audit) - Test data seed scripts (12 evidence records, 3 CVEs) - Integration tests with Testcontainers - <100ms proof generation performance - Archived to: docs/implplan/archived/2025-12-23-sprint-7100-proof-moats/ ## SPRINT_3000_0200 - Authority Admin & Branding (COMPLETE ✅) - Console admin RBAC UI components - Branding editor with tenant isolation - Authority backend endpoints - Archived to: docs/implplan/archived/ ## Additional Documentation - CLI command reference and compliance guides - Module architecture docs (26 modules documented) - Data schemas and contracts - Operations runbooks - Security risk models - Product roadmap All archived sprints achieved 100% completion of planned deliverables. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
260 lines
8.3 KiB
C#
260 lines
8.3 KiB
C#
using System.Collections.Immutable;
|
|
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using StellaOps.Canonical.Json;
|
|
using StellaOps.Policy;
|
|
using StellaOps.Policy.Engine.Materialization;
|
|
|
|
namespace StellaOps.Policy.Engine.Attestation;
|
|
|
|
/// <summary>
|
|
/// Builds DSSE verdict predicates from policy explain traces.
|
|
/// </summary>
|
|
public sealed class VerdictPredicateBuilder
|
|
{
|
|
/// <summary>
|
|
/// Initializes a new instance of VerdictPredicateBuilder.
|
|
/// </summary>
|
|
public VerdictPredicateBuilder()
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a verdict predicate from a policy explain trace.
|
|
/// </summary>
|
|
public VerdictPredicate Build(PolicyExplainTrace trace)
|
|
{
|
|
if (trace is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(trace));
|
|
}
|
|
|
|
// Map verdict
|
|
var verdict = new VerdictInfo(
|
|
status: MapVerdictStatus(trace.Verdict.Status),
|
|
severity: MapSeverity(trace.Verdict.Severity),
|
|
score: trace.Verdict.Score ?? 0.0,
|
|
rationale: trace.Verdict.Rationale
|
|
);
|
|
|
|
// Map rule chain
|
|
var ruleChain = trace.RuleChain
|
|
.Select(r => new VerdictRuleExecution(
|
|
ruleId: r.RuleId,
|
|
action: r.Action,
|
|
decision: r.Decision,
|
|
score: r.Score != 0 ? r.Score : null
|
|
))
|
|
.ToList();
|
|
|
|
// Map evidence
|
|
var evidence = trace.Evidence
|
|
.Select(e => new VerdictEvidence(
|
|
type: e.Type,
|
|
reference: e.Reference,
|
|
source: e.Source,
|
|
status: e.Status,
|
|
digest: ComputeEvidenceDigest(e),
|
|
weight: e.Weight != 0 ? e.Weight : null,
|
|
metadata: e.Metadata.Any() ? e.Metadata.ToImmutableSortedDictionary() : null
|
|
))
|
|
.ToList();
|
|
|
|
// Map VEX impacts
|
|
var vexImpacts = trace.VexImpacts
|
|
.Select(v => new VerdictVexImpact(
|
|
statementId: v.StatementId,
|
|
provider: v.Provider,
|
|
status: v.Status,
|
|
accepted: v.Accepted,
|
|
justification: v.Justification
|
|
))
|
|
.ToList();
|
|
|
|
// Extract reachability (if present in metadata)
|
|
var reachability = ExtractReachability(trace);
|
|
|
|
// Build metadata with determinism hash
|
|
var metadata = BuildMetadata(trace, evidence);
|
|
|
|
return new VerdictPredicate(
|
|
tenantId: trace.TenantId,
|
|
policyId: trace.PolicyId,
|
|
policyVersion: trace.PolicyVersion,
|
|
runId: trace.RunId,
|
|
findingId: trace.FindingId,
|
|
evaluatedAt: trace.EvaluatedAt,
|
|
verdict: verdict,
|
|
ruleChain: ruleChain,
|
|
evidence: evidence,
|
|
vexImpacts: vexImpacts,
|
|
reachability: reachability,
|
|
metadata: metadata
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serializes a verdict predicate to canonical JSON.
|
|
/// </summary>
|
|
public string Serialize(VerdictPredicate predicate)
|
|
{
|
|
if (predicate is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(predicate));
|
|
}
|
|
|
|
var canonical = CanonJson.Canonicalize(predicate);
|
|
return Encoding.UTF8.GetString(canonical);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the determinism hash for a verdict predicate.
|
|
/// </summary>
|
|
public string ComputeDeterminismHash(VerdictPredicate predicate)
|
|
{
|
|
if (predicate is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(predicate));
|
|
}
|
|
|
|
// Sort and concatenate all evidence digests
|
|
var evidenceDigests = predicate.Evidence
|
|
.Where(e => e.Digest is not null)
|
|
.Select(e => e.Digest!)
|
|
.OrderBy(d => d, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
// Add verdict status, severity, score
|
|
var components = new List<string>
|
|
{
|
|
predicate.Verdict.Status,
|
|
predicate.Verdict.Severity,
|
|
predicate.Verdict.Score.ToString("F2", CultureInfo.InvariantCulture),
|
|
};
|
|
components.AddRange(evidenceDigests);
|
|
|
|
// Compute SHA256 hash
|
|
var combined = string.Join(":", components);
|
|
var bytes = Encoding.UTF8.GetBytes(combined);
|
|
var hash = SHA256.HashData(bytes);
|
|
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
|
}
|
|
|
|
private static string MapVerdictStatus(PolicyVerdictStatus status)
|
|
{
|
|
return status switch
|
|
{
|
|
PolicyVerdictStatus.Pass => "passed",
|
|
PolicyVerdictStatus.Warned => "warned",
|
|
PolicyVerdictStatus.Blocked => "blocked",
|
|
PolicyVerdictStatus.Ignored => "ignored",
|
|
PolicyVerdictStatus.Deferred => "deferred",
|
|
PolicyVerdictStatus.Escalated => "escalated",
|
|
PolicyVerdictStatus.RequiresVex => "requires_vex",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown verdict status.")
|
|
};
|
|
}
|
|
|
|
private static string MapSeverity(SeverityRank? severity)
|
|
{
|
|
if (severity is null)
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
return severity.Value switch
|
|
{
|
|
SeverityRank.Critical => "critical",
|
|
SeverityRank.High => "high",
|
|
SeverityRank.Medium => "medium",
|
|
SeverityRank.Low => "low",
|
|
SeverityRank.Info => "info",
|
|
SeverityRank.None => "none",
|
|
_ => "none"
|
|
};
|
|
}
|
|
|
|
private static string? ComputeEvidenceDigest(PolicyExplainEvidence evidence)
|
|
{
|
|
// If evidence has a reference that looks like a digest, use it
|
|
if (evidence.Reference.StartsWith("sha256:", StringComparison.Ordinal))
|
|
{
|
|
return evidence.Reference;
|
|
}
|
|
|
|
// Otherwise, compute digest from reference + source + status
|
|
var content = $"{evidence.Type}:{evidence.Reference}:{evidence.Source}:{evidence.Status}";
|
|
var bytes = Encoding.UTF8.GetBytes(content);
|
|
var hash = SHA256.HashData(bytes);
|
|
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
|
}
|
|
|
|
private static VerdictReachability? ExtractReachability(PolicyExplainTrace trace)
|
|
{
|
|
// Check if metadata contains reachability status
|
|
if (!trace.Metadata.TryGetValue("reachabilitystatus", out var reachabilityStatus))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// TODO: Extract full reachability paths from trace or evidence
|
|
// For now, return basic reachability status
|
|
return new VerdictReachability(
|
|
status: reachabilityStatus,
|
|
paths: null
|
|
);
|
|
}
|
|
|
|
private ImmutableSortedDictionary<string, string> BuildMetadata(
|
|
PolicyExplainTrace trace,
|
|
List<VerdictEvidence> evidence)
|
|
{
|
|
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
|
|
|
// Add component PURL if present
|
|
if (trace.Metadata.TryGetValue("componentpurl", out var componentPurl))
|
|
{
|
|
builder["componentpurl"] = componentPurl;
|
|
}
|
|
|
|
// Add SBOM ID if present
|
|
if (trace.Metadata.TryGetValue("sbomid", out var sbomId))
|
|
{
|
|
builder["sbomid"] = sbomId;
|
|
}
|
|
|
|
// Add trace ID if present
|
|
if (trace.Metadata.TryGetValue("traceid", out var traceId))
|
|
{
|
|
builder["traceid"] = traceId;
|
|
}
|
|
|
|
// Compute and add determinism hash
|
|
// Temporarily create predicate to compute hash
|
|
var tempPredicate = new VerdictPredicate(
|
|
tenantId: trace.TenantId,
|
|
policyId: trace.PolicyId,
|
|
policyVersion: trace.PolicyVersion,
|
|
runId: trace.RunId,
|
|
findingId: trace.FindingId,
|
|
evaluatedAt: trace.EvaluatedAt,
|
|
verdict: new VerdictInfo(
|
|
status: MapVerdictStatus(trace.Verdict.Status),
|
|
severity: MapSeverity(trace.Verdict.Severity),
|
|
score: trace.Verdict.Score ?? 0.0
|
|
),
|
|
ruleChain: null,
|
|
evidence: evidence,
|
|
vexImpacts: null,
|
|
reachability: null,
|
|
metadata: null
|
|
);
|
|
|
|
builder["determinismhash"] = ComputeDeterminismHash(tempPredicate);
|
|
|
|
return builder.ToImmutable();
|
|
}
|
|
}
|