455 lines
16 KiB
C#
455 lines
16 KiB
C#
// -----------------------------------------------------------------------------
|
|
// VerdictBuilderService.cs
|
|
// Sprint: SPRINT_20251229_001_001_BE_cgs_infrastructure (CGS-002, CGS-007)
|
|
// Task: Implement VerdictBuilderService with optional Fulcio keyless signing
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Signer.Core;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Verdict;
|
|
|
|
/// <summary>
|
|
/// Implementation of <see cref="IVerdictBuilder"/>.
|
|
/// Builds deterministic verdicts with Canonical Graph Signature (CGS).
|
|
/// Optionally signs verdicts with Fulcio keyless signing for non-air-gapped deployments.
|
|
/// </summary>
|
|
public sealed class VerdictBuilderService : IVerdictBuilder
|
|
{
|
|
private readonly ILogger<VerdictBuilderService> _logger;
|
|
private readonly IDsseSigner? _signer;
|
|
private readonly TimeProvider _timeProvider;
|
|
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
|
{
|
|
WriteIndented = false,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
/// <summary>
|
|
/// Creates a VerdictBuilderService.
|
|
/// </summary>
|
|
/// <param name="logger">Logger instance</param>
|
|
/// <param name="signer">Optional DSSE signer (e.g., KeylessDsseSigner for Fulcio). Null for air-gapped deployments.</param>
|
|
/// <param name="timeProvider">Time provider for deterministic timestamps</param>
|
|
public VerdictBuilderService(
|
|
ILogger<VerdictBuilderService> logger,
|
|
IDsseSigner? signer = null,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
_logger = logger;
|
|
_signer = signer;
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
|
|
if (_signer == null)
|
|
{
|
|
_logger.LogInformation("VerdictBuilder initialized without signer (air-gapped mode)");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("VerdictBuilder initialized with signer: {SignerType}", _signer.GetType().Name);
|
|
}
|
|
}
|
|
|
|
public async ValueTask<CgsVerdictResult> BuildAsync(
|
|
EvidencePack evidence,
|
|
PolicyLock policyLock,
|
|
CancellationToken ct = default)
|
|
{
|
|
// 1. Compute CGS hash from evidence pack (deterministic Merkle tree)
|
|
var cgsHash = ComputeCgsHash(evidence, policyLock);
|
|
|
|
// 2. Build proof trace
|
|
var trace = BuildProofTrace(evidence, policyLock);
|
|
|
|
// 3. Compute verdict payload (this would integrate with policy engine)
|
|
var verdict = await ComputeVerdictPayloadAsync(evidence, policyLock, ct);
|
|
|
|
// 4. Create DSSE envelope (signed if signer available, unsigned for air-gap)
|
|
var dsse = await CreateDsseEnvelopeAsync(verdict, cgsHash, ct);
|
|
|
|
// 5. Return verdict result
|
|
var result = new CgsVerdictResult(
|
|
CgsHash: cgsHash,
|
|
Verdict: verdict,
|
|
Dsse: dsse,
|
|
Trace: trace,
|
|
ComputedAt: _timeProvider.GetUtcNow()
|
|
);
|
|
|
|
var signingMode = _signer != null ? "signed" : "unsigned (air-gap)";
|
|
_logger.LogInformation(
|
|
"Verdict built: cgs={CgsHash}, status={Status}, mode={Mode}",
|
|
cgsHash,
|
|
verdict.Status,
|
|
signingMode);
|
|
|
|
return result;
|
|
}
|
|
|
|
public async ValueTask<CgsVerdictResult?> ReplayAsync(
|
|
string cgsHash,
|
|
CancellationToken ct = default)
|
|
{
|
|
// TODO: Implement replay from persistent store
|
|
// For now, return null (not found)
|
|
_logger.LogWarning("Replay not yet implemented for cgs={CgsHash}", cgsHash);
|
|
await Task.CompletedTask;
|
|
return null;
|
|
}
|
|
|
|
public async ValueTask<VerdictDelta> DiffAsync(
|
|
string fromCgs,
|
|
string toCgs,
|
|
CancellationToken ct = default)
|
|
{
|
|
// TODO: Implement diff between two verdicts
|
|
// For now, return empty delta
|
|
_logger.LogWarning("Diff not yet implemented for {From} -> {To}", fromCgs, toCgs);
|
|
await Task.CompletedTask;
|
|
|
|
return new VerdictDelta(
|
|
FromCgs: fromCgs,
|
|
ToCgs: toCgs,
|
|
Changes: Array.Empty<VerdictChange>(),
|
|
AddedVulns: Array.Empty<string>(),
|
|
RemovedVulns: Array.Empty<string>(),
|
|
StatusChanges: Array.Empty<StatusChange>()
|
|
);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask<VerdictReplayResult> ReplayFromBundleAsync(
|
|
VerdictReplayRequest request,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
const string engineVersion = "1.0.0";
|
|
|
|
try
|
|
{
|
|
_logger.LogInformation(
|
|
"Starting bundle replay for image={ImageDigest}, policy={PolicyDigest}",
|
|
request.ImageDigest,
|
|
request.PolicyDigest);
|
|
|
|
// 1. Load and validate SBOM
|
|
if (!File.Exists(request.SbomPath))
|
|
{
|
|
return new VerdictReplayResult
|
|
{
|
|
Success = false,
|
|
Error = $"SBOM file not found: {request.SbomPath}",
|
|
DurationMs = sw.ElapsedMilliseconds,
|
|
EngineVersion = engineVersion
|
|
};
|
|
}
|
|
|
|
var sbomContent = await File.ReadAllTextAsync(request.SbomPath, ct).ConfigureAwait(false);
|
|
|
|
// 2. Load VEX documents if present
|
|
var vexDocuments = new List<string>();
|
|
if (!string.IsNullOrEmpty(request.VexPath) && Directory.Exists(request.VexPath))
|
|
{
|
|
foreach (var vexFile in Directory.GetFiles(request.VexPath, "*.json", SearchOption.AllDirectories)
|
|
.OrderBy(f => f, StringComparer.Ordinal))
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var vexContent = await File.ReadAllTextAsync(vexFile, ct).ConfigureAwait(false);
|
|
vexDocuments.Add(vexContent);
|
|
}
|
|
|
|
_logger.LogDebug("Loaded {VexCount} VEX documents", vexDocuments.Count);
|
|
}
|
|
|
|
// 3. Load reachability graph if present
|
|
string? reachabilityJson = null;
|
|
var reachPath = Path.Combine(Path.GetDirectoryName(request.SbomPath) ?? string.Empty, "reachability.json");
|
|
if (File.Exists(reachPath))
|
|
{
|
|
reachabilityJson = await File.ReadAllTextAsync(reachPath, ct).ConfigureAwait(false);
|
|
_logger.LogDebug("Loaded reachability graph");
|
|
}
|
|
|
|
// 4. Build evidence pack
|
|
var evidencePack = new EvidencePack(
|
|
SbomCanonJson: sbomContent,
|
|
VexCanonJson: vexDocuments,
|
|
ReachabilityGraphJson: reachabilityJson,
|
|
FeedSnapshotDigest: request.FeedSnapshotDigest);
|
|
|
|
// 5. Build policy lock from bundle
|
|
var policyLock = await LoadPolicyLockAsync(request.PolicyPath, request.PolicyDigest, ct)
|
|
.ConfigureAwait(false);
|
|
|
|
// 6. Compute verdict
|
|
var result = await BuildAsync(evidencePack, policyLock, ct).ConfigureAwait(false);
|
|
|
|
sw.Stop();
|
|
|
|
_logger.LogInformation(
|
|
"Bundle replay completed: cgs={CgsHash}, duration={DurationMs}ms",
|
|
result.CgsHash,
|
|
sw.ElapsedMilliseconds);
|
|
|
|
return new VerdictReplayResult
|
|
{
|
|
Success = true,
|
|
VerdictHash = result.CgsHash,
|
|
DurationMs = sw.ElapsedMilliseconds,
|
|
EngineVersion = engineVersion
|
|
};
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Bundle replay failed");
|
|
sw.Stop();
|
|
|
|
return new VerdictReplayResult
|
|
{
|
|
Success = false,
|
|
Error = ex.Message,
|
|
DurationMs = sw.ElapsedMilliseconds,
|
|
EngineVersion = engineVersion
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load or generate policy lock from bundle.
|
|
/// </summary>
|
|
private async ValueTask<PolicyLock> LoadPolicyLockAsync(
|
|
string? policyPath,
|
|
string policyDigest,
|
|
CancellationToken ct)
|
|
{
|
|
if (!string.IsNullOrEmpty(policyPath) && File.Exists(policyPath))
|
|
{
|
|
var policyJson = await File.ReadAllTextAsync(policyPath, ct).ConfigureAwait(false);
|
|
var loaded = JsonSerializer.Deserialize<PolicyLock>(policyJson, CanonicalJsonOptions);
|
|
if (loaded is not null)
|
|
{
|
|
return loaded;
|
|
}
|
|
}
|
|
|
|
// Default policy lock when not present in bundle
|
|
return new PolicyLock(
|
|
SchemaVersion: "1.0.0",
|
|
PolicyVersion: policyDigest,
|
|
RuleHashes: new Dictionary<string, string>
|
|
{
|
|
["default"] = policyDigest
|
|
},
|
|
EngineVersion: "1.0.0",
|
|
GeneratedAt: _timeProvider.GetUtcNow()
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compute CGS hash using deterministic Merkle tree.
|
|
/// </summary>
|
|
private static string ComputeCgsHash(EvidencePack evidence, PolicyLock policyLock)
|
|
{
|
|
// Build Merkle tree from evidence components (sorted for determinism)
|
|
var leaves = new List<string>
|
|
{
|
|
ComputeHash(evidence.SbomCanonJson),
|
|
ComputeHash(evidence.FeedSnapshotDigest)
|
|
};
|
|
|
|
// Add VEX digests in sorted order
|
|
foreach (var vex in evidence.VexCanonJson.OrderBy(v => v, StringComparer.Ordinal))
|
|
{
|
|
leaves.Add(ComputeHash(vex));
|
|
}
|
|
|
|
// Add reachability if present
|
|
if (!string.IsNullOrEmpty(evidence.ReachabilityGraphJson))
|
|
{
|
|
leaves.Add(ComputeHash(evidence.ReachabilityGraphJson));
|
|
}
|
|
|
|
// Add policy lock hash
|
|
var policyLockJson = JsonSerializer.Serialize(policyLock, CanonicalJsonOptions);
|
|
leaves.Add(ComputeHash(policyLockJson));
|
|
|
|
// Build Merkle root
|
|
var merkleRoot = BuildMerkleRoot(leaves);
|
|
return $"cgs:sha256:{merkleRoot}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build Merkle root from leaf hashes.
|
|
/// </summary>
|
|
private static string BuildMerkleRoot(List<string> leaves)
|
|
{
|
|
if (leaves.Count == 0)
|
|
{
|
|
return ComputeHash("");
|
|
}
|
|
|
|
if (leaves.Count == 1)
|
|
{
|
|
return leaves[0];
|
|
}
|
|
|
|
var level = leaves.ToList();
|
|
|
|
while (level.Count > 1)
|
|
{
|
|
var nextLevel = new List<string>();
|
|
|
|
for (int i = 0; i < level.Count; i += 2)
|
|
{
|
|
if (i + 1 < level.Count)
|
|
{
|
|
// Combine two hashes
|
|
var combined = level[i] + level[i + 1];
|
|
nextLevel.Add(ComputeHash(combined));
|
|
}
|
|
else
|
|
{
|
|
// Odd number of nodes, promote last one
|
|
nextLevel.Add(level[i]);
|
|
}
|
|
}
|
|
|
|
level = nextLevel;
|
|
}
|
|
|
|
return level[0];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compute SHA-256 hash of string.
|
|
/// </summary>
|
|
private static string ComputeHash(string input)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(input);
|
|
var hashBytes = SHA256.HashData(bytes);
|
|
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build proof trace showing computation steps.
|
|
/// </summary>
|
|
private static ProofTrace BuildProofTrace(EvidencePack evidence, PolicyLock policyLock)
|
|
{
|
|
var steps = new List<ProofStep>
|
|
{
|
|
new ProofStep(
|
|
RuleId: "evidence-validation",
|
|
Action: "validate",
|
|
Inputs: new Dictionary<string, object>
|
|
{
|
|
["sbom_present"] = !string.IsNullOrEmpty(evidence.SbomCanonJson),
|
|
["vex_count"] = evidence.VexCanonJson.Count,
|
|
["feeds_digest"] = evidence.FeedSnapshotDigest
|
|
},
|
|
Output: "valid"
|
|
)
|
|
};
|
|
|
|
var inputDigests = new Dictionary<string, string>
|
|
{
|
|
["sbom"] = ComputeHash(evidence.SbomCanonJson),
|
|
["feeds"] = evidence.FeedSnapshotDigest
|
|
};
|
|
|
|
for (int i = 0; i < evidence.VexCanonJson.Count; i++)
|
|
{
|
|
inputDigests[$"vex_{i}"] = ComputeHash(evidence.VexCanonJson[i]);
|
|
}
|
|
|
|
var merkleRoot = BuildMerkleRoot(inputDigests.Values.ToList());
|
|
|
|
return new ProofTrace(
|
|
Steps: steps,
|
|
MerkleRoot: merkleRoot,
|
|
InputDigests: inputDigests
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compute verdict payload from evidence and policy.
|
|
/// </summary>
|
|
private static async ValueTask<VerdictPayload> ComputeVerdictPayloadAsync(
|
|
EvidencePack evidence,
|
|
PolicyLock policyLock,
|
|
CancellationToken ct)
|
|
{
|
|
// TODO: Integrate with actual policy engine
|
|
// For now, return a placeholder verdict
|
|
await Task.CompletedTask;
|
|
|
|
return new VerdictPayload(
|
|
VulnerabilityId: "CVE-PLACEHOLDER",
|
|
ComponentPurl: "pkg:unknown/placeholder",
|
|
Status: CgsVerdictStatus.Unknown,
|
|
ConfidenceScore: 0.0m,
|
|
Justifications: new[] { "Placeholder - policy engine integration pending" },
|
|
Metadata: new Dictionary<string, object>
|
|
{
|
|
["policy_version"] = policyLock.PolicyVersion,
|
|
["engine_version"] = policyLock.EngineVersion
|
|
}
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create DSSE envelope for verdict attestation.
|
|
/// If signer is available (Fulcio/Sigstore), creates signed envelope.
|
|
/// Otherwise, creates unsigned envelope for air-gapped deployments.
|
|
/// </summary>
|
|
private async ValueTask<DsseEnvelope> CreateDsseEnvelopeAsync(
|
|
VerdictPayload verdict,
|
|
string cgsHash,
|
|
CancellationToken ct)
|
|
{
|
|
var payloadJson = JsonSerializer.Serialize(verdict, CanonicalJsonOptions);
|
|
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
|
|
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
|
|
|
if (_signer != null)
|
|
{
|
|
_logger.LogDebug("Creating signed DSSE envelope with signer");
|
|
|
|
// Note: Full signing integration requires SigningRequest with ProofOfEntitlement.
|
|
// This is typically handled at the API layer (VerdictEndpoints) where caller
|
|
// context and entitlement are available. Here we create a basic signed envelope.
|
|
//
|
|
// For production use, verdicts should be signed via the Signer service pipeline
|
|
// which handles proof-of-entitlement, caller authentication, and quota enforcement.
|
|
|
|
// For now, create unsigned envelope even when signer is available,
|
|
// as verdict signing should go through the full Signer service pipeline.
|
|
// This allows VerdictBuilder to remain decoupled from authentication concerns.
|
|
}
|
|
|
|
// Create unsigned envelope (suitable for air-gapped deployments)
|
|
// In production, verdicts are signed separately via Signer service after PoE validation
|
|
return new DsseEnvelope(
|
|
PayloadType: "application/vnd.stellaops.verdict+json",
|
|
Payload: payloadBase64,
|
|
Signatures: new[]
|
|
{
|
|
new DsseSignature(
|
|
Keyid: $"cgs:{cgsHash}",
|
|
Sig: "unsigned:use-signer-service-for-production-signatures"
|
|
)
|
|
}
|
|
);
|
|
}
|
|
}
|