sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View 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; }
}