sprints work
This commit is contained in:
457
src/__Libraries/StellaOps.Evidence.Pack/EvidencePackService.cs
Normal file
457
src/__Libraries/StellaOps.Evidence.Pack/EvidencePackService.cs
Normal file
@@ -0,0 +1,457 @@
|
||||
// <copyright file="EvidencePackService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Evidence.Pack.Models;
|
||||
|
||||
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("PDF export requires additional configuration"),
|
||||
_ => 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"
|
||||
};
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user