// // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // using Microsoft.Extensions.Logging; using StellaOps.Evidence.Pack.Models; using System.Collections.Immutable; using System.Globalization; using System.Net; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using static StellaOps.Localization.T; namespace StellaOps.Evidence.Pack; /// /// Implementation of . /// Sprint: SPRINT_20260109_011_005 Task: EVPK-003 /// internal sealed class EvidencePackService : IEvidencePackService { private readonly IEvidencePackStore _store; private readonly IEvidenceResolver _resolver; private readonly IEvidencePackSigner _signer; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; /// /// Creates a new EvidencePackService. /// public EvidencePackService( IEvidencePackStore store, IEvidenceResolver resolver, IEvidencePackSigner signer, TimeProvider timeProvider, ILogger logger) { _store = store ?? throw new ArgumentNullException(nameof(store)); _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _signer = signer ?? throw new ArgumentNullException(nameof(signer)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public async Task CreateAsync( IEnumerable claims, IEnumerable evidence, EvidenceSubject subject, EvidencePackContext? context, CancellationToken cancellationToken) { var packId = $"pack-{Guid.NewGuid():N}"; var tenantId = context?.TenantId ?? "unknown"; _logger.LogDebug("Creating evidence pack {PackId} for tenant {TenantId}", packId, tenantId); var pack = new EvidencePack { PackId = packId, Version = "1.0", CreatedAt = _timeProvider.GetUtcNow(), TenantId = tenantId, Subject = subject, Claims = claims.ToImmutableArray(), Evidence = evidence.ToImmutableArray(), Context = context }; await _store.SaveAsync(pack, cancellationToken).ConfigureAwait(false); // Link to run if specified if (!string.IsNullOrEmpty(context?.RunId)) { await _store.LinkToRunAsync(packId, context.RunId, cancellationToken).ConfigureAwait(false); } _logger.LogInformation( "Created evidence pack {PackId} with {ClaimCount} claims and {EvidenceCount} evidence items", packId, pack.Claims.Length, pack.Evidence.Length); return pack; } /// public async Task CreateFromRunAsync( string runId, EvidenceSubject subject, CancellationToken cancellationToken) { _logger.LogDebug("Creating evidence pack from run {RunId}", runId); // Get existing packs for this run to build upon var existingPacks = await _store.GetByRunIdAsync(runId, cancellationToken).ConfigureAwait(false); // Aggregate all claims and evidence from existing packs var allClaims = new List(); var allEvidence = new List(); foreach (var existingPack in existingPacks) { allClaims.AddRange(existingPack.Claims); allEvidence.AddRange(existingPack.Evidence); } // Deduplicate evidence by ID var uniqueEvidence = allEvidence .GroupBy(e => e.EvidenceId) .Select(g => g.First()) .ToList(); var context = new EvidencePackContext { RunId = runId, GeneratedBy = "EvidencePackService" }; return await CreateAsync(allClaims, uniqueEvidence, subject, context, cancellationToken) .ConfigureAwait(false); } /// public async Task AddEvidenceAsync( string packId, IEnumerable items, CancellationToken cancellationToken) { _logger.LogDebug("Adding evidence to pack {PackId}", packId); // Get existing pack (we need tenant ID, so we search across all) var existingPack = await GetPackByIdAcrossTenants(packId, cancellationToken).ConfigureAwait(false); if (existingPack is null) { throw new EvidencePackNotFoundException(packId); } // Create new version with additional evidence var newPackId = $"pack-{Guid.NewGuid():N}"; var combinedEvidence = existingPack.Evidence.AddRange(items); var newPack = existingPack with { PackId = newPackId, Version = IncrementVersion(existingPack.Version), Evidence = combinedEvidence, CreatedAt = _timeProvider.GetUtcNow() }; await _store.SaveAsync(newPack, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Created new version {Version} of evidence pack with {EvidenceCount} evidence items", newPack.Version, newPack.Evidence.Length); return newPack; } /// public async Task SignAsync( EvidencePack pack, CancellationToken cancellationToken) { _logger.LogDebug("Signing evidence pack {PackId}", pack.PackId); // Create DSSE envelope via signer var envelope = await _signer.SignAsync(pack, cancellationToken) .ConfigureAwait(false); var signedPack = new SignedEvidencePack { Pack = pack, Envelope = envelope, SignedAt = _timeProvider.GetUtcNow() }; await _store.SaveSignedAsync(signedPack, cancellationToken).ConfigureAwait(false); _logger.LogInformation("Signed evidence pack {PackId}", pack.PackId); return signedPack; } /// public async Task VerifyAsync( SignedEvidencePack signedPack, CancellationToken cancellationToken) { _logger.LogDebug("Verifying evidence pack {PackId}", signedPack.Pack.PackId); var issues = new List(); // 1. Verify DSSE signature var signatureValid = await _signer.VerifyAsync( signedPack.Envelope, cancellationToken).ConfigureAwait(false); if (!signatureValid.Valid) { issues.Add($"Signature verification failed: {signatureValid.FailureReason}"); } // 2. Verify content digest var computedDigest = signedPack.Pack.ComputeContentDigest(); var digestMatches = string.Equals( signedPack.Envelope.PayloadDigest, computedDigest, StringComparison.OrdinalIgnoreCase); if (!digestMatches) { issues.Add("Content digest mismatch"); } // 3. Verify each evidence item var evidenceResults = new List(); foreach (var evidence in signedPack.Pack.Evidence) { var resolution = await _resolver.VerifyEvidenceAsync(evidence, cancellationToken) .ConfigureAwait(false); evidenceResults.Add(resolution); if (!resolution.Resolved) { issues.Add($"Evidence {evidence.EvidenceId} could not be resolved: {resolution.Error}"); } else if (!resolution.DigestMatches) { issues.Add($"Evidence {evidence.EvidenceId} digest mismatch"); } } var allValid = signatureValid.Valid && digestMatches && evidenceResults.All(r => r.Resolved && r.DigestMatches); _logger.LogInformation( "Evidence pack {PackId} verification {Result}: {IssueCount} issues", signedPack.Pack.PackId, allValid ? "passed" : "failed", issues.Count); return new EvidencePackVerificationResult { Valid = allValid, PackDigest = computedDigest, SignatureKeyId = signedPack.Envelope.Signatures.FirstOrDefault()?.KeyId ?? "unknown", Issues = issues.ToImmutableArray(), EvidenceResolutions = evidenceResults.ToImmutableArray() }; } /// public async Task ExportAsync( string packId, EvidencePackExportFormat format, CancellationToken cancellationToken) { _logger.LogDebug("Exporting evidence pack {PackId} as {Format}", packId, format); var pack = await GetPackByIdAcrossTenants(packId, cancellationToken).ConfigureAwait(false); if (pack is null) { throw new EvidencePackNotFoundException(packId); } return format switch { EvidencePackExportFormat.Json => ExportAsJson(pack), EvidencePackExportFormat.SignedJson => await ExportAsSignedJson(pack, cancellationToken).ConfigureAwait(false), EvidencePackExportFormat.Markdown => ExportAsMarkdown(pack), EvidencePackExportFormat.Html => ExportAsHtml(pack), EvidencePackExportFormat.Pdf => throw new NotSupportedException(_t("common.evidence.pdf_export_requires_config")), // Sprint: SPRINT_20260112_005_BE_evidence_card_api (EVPCARD-BE-001) EvidencePackExportFormat.EvidenceCard => await ExportAsEvidenceCard(pack, compact: false, cancellationToken).ConfigureAwait(false), EvidencePackExportFormat.EvidenceCardCompact => await ExportAsEvidenceCard(pack, compact: true, cancellationToken).ConfigureAwait(false), _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported export format") }; } /// public Task GetAsync( string tenantId, string packId, CancellationToken cancellationToken) { return _store.GetByIdAsync(tenantId, packId, cancellationToken); } /// public Task> ListAsync( string tenantId, EvidencePackQuery? query, CancellationToken cancellationToken) { return _store.ListAsync(tenantId, query, cancellationToken); } private async Task GetPackByIdAcrossTenants(string packId, CancellationToken ct) { // In a real implementation, this would need proper tenant resolution // For now, we search with a wildcard tenant return await _store.GetByIdAsync("*", packId, ct).ConfigureAwait(false); } private static string IncrementVersion(string version) { if (Version.TryParse(version, out var parsed)) { return new Version(parsed.Major, parsed.Minor + 1).ToString(); } return "1.1"; } private static EvidencePackExport ExportAsJson(EvidencePack pack) { var json = System.Text.Json.JsonSerializer.Serialize(pack, new System.Text.Json.JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); return new EvidencePackExport { PackId = pack.PackId, Format = EvidencePackExportFormat.Json, Content = Encoding.UTF8.GetBytes(json), ContentType = "application/json", FileName = $"evidence-pack-{pack.PackId}.json" }; } private async Task ExportAsSignedJson(EvidencePack pack, CancellationToken ct) { var signed = await _store.GetSignedByIdAsync(pack.TenantId, pack.PackId, ct).ConfigureAwait(false); if (signed is null) { // Sign it now signed = await SignAsync(pack, ct).ConfigureAwait(false); } var json = System.Text.Json.JsonSerializer.Serialize(signed, new System.Text.Json.JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); return new EvidencePackExport { PackId = pack.PackId, Format = EvidencePackExportFormat.SignedJson, Content = Encoding.UTF8.GetBytes(json), ContentType = "application/json", FileName = $"evidence-pack-{pack.PackId}.signed.json" }; } private static EvidencePackExport ExportAsMarkdown(EvidencePack pack) { var sb = new StringBuilder(); sb.AppendLine(CultureInfo.InvariantCulture, $"# Evidence Pack: {pack.PackId}"); sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, $"**Created:** {pack.CreatedAt:O}"); sb.AppendLine(CultureInfo.InvariantCulture, $"**Subject:** {pack.Subject.Type} - {pack.Subject.CveId ?? pack.Subject.FindingId ?? "N/A"}"); sb.AppendLine(CultureInfo.InvariantCulture, $"**Digest:** `{pack.ComputeContentDigest()}`"); sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, $"## Claims ({pack.Claims.Length})"); sb.AppendLine(); foreach (var claim in pack.Claims) { sb.AppendLine(CultureInfo.InvariantCulture, $"### {claim.ClaimId}: {claim.Text}"); sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, $"- **Type:** {claim.Type}"); sb.AppendLine(CultureInfo.InvariantCulture, $"- **Status:** {claim.Status}"); sb.AppendLine(CultureInfo.InvariantCulture, $"- **Confidence:** {claim.Confidence:P0}"); sb.AppendLine(CultureInfo.InvariantCulture, $"- **Evidence:** {string.Join(", ", claim.EvidenceIds)}"); sb.AppendLine(); } sb.AppendLine(CultureInfo.InvariantCulture, $"## Evidence ({pack.Evidence.Length})"); sb.AppendLine(); foreach (var evidence in pack.Evidence) { sb.AppendLine(CultureInfo.InvariantCulture, $"### {evidence.EvidenceId}: {evidence.Type}"); sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, $"- **URI:** `{evidence.Uri}`"); sb.AppendLine(CultureInfo.InvariantCulture, $"- **Digest:** `{evidence.Digest}`"); sb.AppendLine(CultureInfo.InvariantCulture, $"- **Collected:** {evidence.CollectedAt:O}"); sb.AppendLine(); } return new EvidencePackExport { PackId = pack.PackId, Format = EvidencePackExportFormat.Markdown, Content = Encoding.UTF8.GetBytes(sb.ToString()), ContentType = "text/markdown", FileName = $"evidence-pack-{pack.PackId}.md" }; } private static EvidencePackExport ExportAsHtml(EvidencePack pack) { var markdown = ExportAsMarkdown(pack); var markdownContent = Encoding.UTF8.GetString(markdown.Content); // Simple HTML wrapper (in production, use a proper Markdown-to-HTML converter) var html = string.Format( CultureInfo.InvariantCulture, HtmlTemplate, pack.PackId, WebUtility.HtmlEncode(markdownContent)); return new EvidencePackExport { PackId = pack.PackId, Format = EvidencePackExportFormat.Html, Content = Encoding.UTF8.GetBytes(html), ContentType = "text/html", FileName = $"evidence-pack-{pack.PackId}.html" }; } // Sprint: SPRINT_20260112_005_BE_evidence_card_api (EVPCARD-BE-001) private async Task ExportAsEvidenceCard( EvidencePack pack, bool compact, CancellationToken cancellationToken) { // Get signed pack if available var signedPack = await _store.GetSignedByIdAsync(pack.TenantId, pack.PackId, cancellationToken) .ConfigureAwait(false); // Compute content digest for this pack var contentDigest = pack.ComputeContentDigest(); // Build evidence card structure using simple object var card = new { schema_version = "1.0.0", pack_id = pack.PackId, created_at = pack.CreatedAt, finding_id = pack.Subject.FindingId, cve_id = pack.Subject.CveId, component = pack.Subject.Component, claims = pack.Claims.Select(c => new { claim_type = c.Type.ToString(), text = c.Text, status = c.Status, confidence = c.Confidence }).ToList(), sbom_excerpt = compact ? null : BuildSbomExcerptFromEvidence(pack), dsse_envelope = signedPack is not null ? new { payload_type = signedPack.Envelope.PayloadType, payload_digest = signedPack.Envelope.PayloadDigest, signatures = signedPack.Envelope.Signatures.Select(s => new { key_id = s.KeyId, sig = s.Sig }).ToList() } : null, signed_at = signedPack?.SignedAt, content_digest = contentDigest }; var json = JsonSerializer.Serialize(card, EvidenceCardJsonOptions); var format = compact ? EvidencePackExportFormat.EvidenceCardCompact : EvidencePackExportFormat.EvidenceCard; return new EvidencePackExport { PackId = pack.PackId, Format = format, Content = Encoding.UTF8.GetBytes(json), ContentType = "application/vnd.stellaops.evidence-card+json", FileName = $"evidence-card-{pack.PackId}.json" }; } private static object? BuildSbomExcerptFromEvidence(EvidencePack pack) { // Extract components from evidence items for determinism var components = pack.Evidence .Where(e => e.Type == EvidenceType.Sbom && !string.IsNullOrEmpty(e.Uri)) .OrderBy(e => e.Uri, StringComparer.Ordinal) .Take(50) .Select(e => new { uri = e.Uri, digest = e.Digest }) .ToList(); if (components.Count == 0) { return null; } return new { total_evidence_count = pack.Evidence.Length, excerpt_count = components.Count, components }; } private static readonly JsonSerializerOptions EvidenceCardJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private const string HtmlTemplate = """ Evidence Pack: {0}
{1}
"""; } /// /// Exception thrown when an evidence pack is not found. /// public sealed class EvidencePackNotFoundException : Exception { /// /// Creates a new EvidencePackNotFoundException. /// public EvidencePackNotFoundException(string packId) : base($"Evidence pack not found: {packId}") { PackId = packId; } /// Gets the pack identifier. public string PackId { get; } }