Files
git.stella-ops.org/src/__Libraries/StellaOps.Evidence.Pack/EvidencePackService.cs

554 lines
20 KiB
C#

// <copyright file="EvidencePackService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
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;
/// <summary>
/// Implementation of <see cref="IEvidencePackService"/>.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-003
/// </summary>
internal sealed class EvidencePackService : IEvidencePackService
{
private readonly IEvidencePackStore _store;
private readonly IEvidenceResolver _resolver;
private readonly IEvidencePackSigner _signer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EvidencePackService> _logger;
/// <summary>
/// Creates a new EvidencePackService.
/// </summary>
public EvidencePackService(
IEvidencePackStore store,
IEvidenceResolver resolver,
IEvidencePackSigner signer,
TimeProvider timeProvider,
ILogger<EvidencePackService> 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));
}
/// <inheritdoc/>
public async Task<EvidencePack> CreateAsync(
IEnumerable<EvidenceClaim> claims,
IEnumerable<EvidenceItem> 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;
}
/// <inheritdoc/>
public async Task<EvidencePack> 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<EvidenceClaim>();
var allEvidence = new List<EvidenceItem>();
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);
}
/// <inheritdoc/>
public async Task<EvidencePack> AddEvidenceAsync(
string packId,
IEnumerable<EvidenceItem> 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;
}
/// <inheritdoc/>
public async Task<SignedEvidencePack> 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;
}
/// <inheritdoc/>
public async Task<EvidencePackVerificationResult> VerifyAsync(
SignedEvidencePack signedPack,
CancellationToken cancellationToken)
{
_logger.LogDebug("Verifying evidence pack {PackId}", signedPack.Pack.PackId);
var issues = new List<string>();
// 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<EvidenceResolutionResult>();
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()
};
}
/// <inheritdoc/>
public async Task<EvidencePackExport> 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")
};
}
/// <inheritdoc/>
public Task<EvidencePack?> GetAsync(
string tenantId,
string packId,
CancellationToken cancellationToken)
{
return _store.GetByIdAsync(tenantId, packId, cancellationToken);
}
/// <inheritdoc/>
public Task<IReadOnlyList<EvidencePack>> ListAsync(
string tenantId,
EvidencePackQuery? query,
CancellationToken cancellationToken)
{
return _store.ListAsync(tenantId, query, cancellationToken);
}
private async Task<EvidencePack?> 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<EvidencePackExport> 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<EvidencePackExport> 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 = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Evidence Pack: {0}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; margin: 40px; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 10px; }}
h2 {{ border-bottom: 1px solid #666; padding-bottom: 5px; margin-top: 30px; }}
code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }}
</style>
</head>
<body>
<pre>{1}</pre>
</body>
</html>
""";
}
/// <summary>
/// Exception thrown when an evidence pack is not found.
/// </summary>
public sealed class EvidencePackNotFoundException : Exception
{
/// <summary>
/// Creates a new EvidencePackNotFoundException.
/// </summary>
public EvidencePackNotFoundException(string packId)
: base($"Evidence pack not found: {packId}")
{
PackId = packId;
}
/// <summary>Gets the pack identifier.</summary>
public string PackId { get; }
}