554 lines
20 KiB
C#
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; }
|
|
}
|