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

View File

@@ -0,0 +1,256 @@
// <copyright file="EvidenceResolver.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.Evidence.Pack;
/// <summary>
/// Implementation of <see cref="IEvidenceResolver"/> with pluggable type resolvers.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-004
/// </summary>
internal sealed class EvidenceResolver : IEvidenceResolver
{
private readonly ImmutableDictionary<string, ITypeResolver> _resolvers;
private readonly ILogger<EvidenceResolver> _logger;
/// <summary>
/// Creates a new EvidenceResolver.
/// </summary>
public EvidenceResolver(
IEnumerable<ITypeResolver> resolvers,
ILogger<EvidenceResolver> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var builder = ImmutableDictionary.CreateBuilder<string, ITypeResolver>(
StringComparer.OrdinalIgnoreCase);
foreach (var resolver in resolvers)
{
foreach (var type in resolver.SupportedTypes)
{
builder[type] = resolver;
}
}
_resolvers = builder.ToImmutable();
_logger.LogDebug("Initialized EvidenceResolver with {Count} type resolvers", _resolvers.Count);
}
/// <inheritdoc/>
public async Task<ResolvedEvidence> ResolveAndSnapshotAsync(
string type,
string path,
CancellationToken cancellationToken)
{
_logger.LogDebug("Resolving evidence: type={Type}, path={Path}", type, path);
if (!_resolvers.TryGetValue(type, out var resolver))
{
throw new UnsupportedEvidenceTypeException(type, _resolvers.Keys);
}
var snapshot = await resolver.ResolveAsync(type, path, cancellationToken)
.ConfigureAwait(false);
var digest = ComputeSnapshotDigest(snapshot);
_logger.LogDebug("Resolved evidence: type={Type}, digest={Digest}", type, digest);
return new ResolvedEvidence
{
Snapshot = snapshot,
Digest = digest,
ResolvedAt = DateTimeOffset.UtcNow
};
}
/// <inheritdoc/>
public async Task<EvidenceResolutionResult> VerifyEvidenceAsync(
EvidenceItem evidence,
CancellationToken cancellationToken)
{
_logger.LogDebug("Verifying evidence: {EvidenceId}", evidence.EvidenceId);
try
{
// Parse the URI to get type and path
var (type, path) = ParseStellaUri(evidence.Uri);
if (!_resolvers.TryGetValue(type, out var resolver))
{
return new EvidenceResolutionResult
{
EvidenceId = evidence.EvidenceId,
Uri = evidence.Uri,
Resolved = false,
DigestMatches = false,
Error = $"Unsupported evidence type: {type}"
};
}
// Resolve current state
var currentSnapshot = await resolver.ResolveAsync(type, path, cancellationToken)
.ConfigureAwait(false);
var currentDigest = ComputeSnapshotDigest(currentSnapshot);
var digestMatches = string.Equals(
evidence.Digest,
currentDigest,
StringComparison.OrdinalIgnoreCase);
_logger.LogDebug(
"Evidence {EvidenceId} verification: digestMatches={DigestMatches}",
evidence.EvidenceId, digestMatches);
return new EvidenceResolutionResult
{
EvidenceId = evidence.EvidenceId,
Uri = evidence.Uri,
Resolved = true,
DigestMatches = digestMatches,
Error = digestMatches ? null : "Digest mismatch - evidence has changed since collection"
};
}
catch (EvidenceNotFoundException ex)
{
_logger.LogWarning(ex, "Evidence not found: {EvidenceId}", evidence.EvidenceId);
return new EvidenceResolutionResult
{
EvidenceId = evidence.EvidenceId,
Uri = evidence.Uri,
Resolved = false,
DigestMatches = false,
Error = ex.Message
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to verify evidence: {EvidenceId}", evidence.EvidenceId);
return new EvidenceResolutionResult
{
EvidenceId = evidence.EvidenceId,
Uri = evidence.Uri,
Resolved = false,
DigestMatches = false,
Error = $"Verification failed: {ex.Message}"
};
}
}
/// <inheritdoc/>
public bool SupportsType(string type)
{
return _resolvers.ContainsKey(type);
}
private static (string type, string path) ParseStellaUri(string uri)
{
// Expected format: stella://type/path
if (!uri.StartsWith("stella://", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException($"Invalid evidence URI format: {uri}. Expected stella://type/path");
}
var withoutScheme = uri[9..]; // Remove "stella://"
var slashIndex = withoutScheme.IndexOf('/', StringComparison.Ordinal);
if (slashIndex <= 0)
{
throw new ArgumentException($"Invalid evidence URI format: {uri}. Missing path.");
}
var type = withoutScheme[..slashIndex];
var path = withoutScheme[(slashIndex + 1)..];
return (type, path);
}
private static string ComputeSnapshotDigest(EvidenceSnapshot snapshot)
{
// Canonical JSON for digest computation
var json = JsonSerializer.Serialize(new
{
type = snapshot.Type,
data = snapshot.Data.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
}, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
}
/// <summary>
/// Interface for type-specific evidence resolvers.
/// </summary>
public interface ITypeResolver
{
/// <summary>
/// Gets the evidence types this resolver can handle.
/// </summary>
IEnumerable<string> SupportedTypes { get; }
/// <summary>
/// Resolves evidence and creates a snapshot.
/// </summary>
Task<EvidenceSnapshot> ResolveAsync(
string type,
string path,
CancellationToken cancellationToken);
}
/// <summary>
/// Exception thrown when an evidence type is not supported.
/// </summary>
public sealed class UnsupportedEvidenceTypeException : Exception
{
/// <summary>
/// Creates a new UnsupportedEvidenceTypeException.
/// </summary>
public UnsupportedEvidenceTypeException(string type, IEnumerable<string> supportedTypes)
: base($"Unsupported evidence type: {type}. Supported types: {string.Join(", ", supportedTypes)}")
{
Type = type;
SupportedTypes = supportedTypes.ToImmutableArray();
}
/// <summary>Gets the unsupported type.</summary>
public string Type { get; }
/// <summary>Gets the supported types.</summary>
public ImmutableArray<string> SupportedTypes { get; }
}
/// <summary>
/// Exception thrown when evidence cannot be found.
/// </summary>
public sealed class EvidenceNotFoundException : Exception
{
/// <summary>
/// Creates a new EvidenceNotFoundException.
/// </summary>
public EvidenceNotFoundException(string type, string path)
: base($"Evidence not found: {type}/{path}")
{
Type = type;
Path = path;
}
/// <summary>Gets the evidence type.</summary>
public string Type { get; }
/// <summary>Gets the evidence path.</summary>
public string Path { get; }
}

View File

@@ -0,0 +1,134 @@
// <copyright file="IEvidencePackService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.Evidence.Pack;
/// <summary>
/// Service for creating, signing, and managing evidence packs.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-002
/// </summary>
public interface IEvidencePackService
{
/// <summary>
/// Creates an Evidence Pack from grounding validation results.
/// </summary>
/// <param name="claims">The claims to include in the pack.</param>
/// <param name="evidence">The evidence items supporting the claims.</param>
/// <param name="subject">The subject of the evidence pack.</param>
/// <param name="context">Optional context information.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created evidence pack.</returns>
Task<EvidencePack> CreateAsync(
IEnumerable<EvidenceClaim> claims,
IEnumerable<EvidenceItem> evidence,
EvidenceSubject subject,
EvidencePackContext? context,
CancellationToken cancellationToken);
/// <summary>
/// Creates an Evidence Pack from a Run's artifacts.
/// </summary>
/// <param name="runId">The run identifier.</param>
/// <param name="subject">The subject of the evidence pack.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created evidence pack.</returns>
Task<EvidencePack> CreateFromRunAsync(
string runId,
EvidenceSubject subject,
CancellationToken cancellationToken);
/// <summary>
/// Adds evidence items to an existing pack (creates new version).
/// </summary>
/// <param name="packId">The pack identifier.</param>
/// <param name="items">The evidence items to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated evidence pack (new version).</returns>
Task<EvidencePack> AddEvidenceAsync(
string packId,
IEnumerable<EvidenceItem> items,
CancellationToken cancellationToken);
/// <summary>
/// Signs an Evidence Pack with DSSE.
/// </summary>
/// <param name="pack">The evidence pack to sign.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The signed evidence pack.</returns>
Task<SignedEvidencePack> SignAsync(
EvidencePack pack,
CancellationToken cancellationToken);
/// <summary>
/// Verifies a signed Evidence Pack.
/// </summary>
/// <param name="signedPack">The signed pack to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The verification result.</returns>
Task<EvidencePackVerificationResult> VerifyAsync(
SignedEvidencePack signedPack,
CancellationToken cancellationToken);
/// <summary>
/// Exports a pack to various formats.
/// </summary>
/// <param name="packId">The pack identifier.</param>
/// <param name="format">The export format.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The exported pack.</returns>
Task<EvidencePackExport> ExportAsync(
string packId,
EvidencePackExportFormat format,
CancellationToken cancellationToken);
/// <summary>
/// Gets a pack by ID.
/// </summary>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="packId">The pack identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The evidence pack, or null if not found.</returns>
Task<EvidencePack?> GetAsync(
string tenantId,
string packId,
CancellationToken cancellationToken);
/// <summary>
/// Lists evidence packs for a tenant.
/// </summary>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="query">Optional query parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The list of evidence packs.</returns>
Task<IReadOnlyList<EvidencePack>> ListAsync(
string tenantId,
EvidencePackQuery? query,
CancellationToken cancellationToken);
}
/// <summary>
/// Query parameters for listing evidence packs.
/// </summary>
public sealed record EvidencePackQuery
{
/// <summary>Gets or sets the subject CVE ID filter.</summary>
public string? CveId { get; init; }
/// <summary>Gets or sets the subject component filter.</summary>
public string? Component { get; init; }
/// <summary>Gets or sets the associated run ID filter.</summary>
public string? RunId { get; init; }
/// <summary>Gets or sets the creation time filter (packs after this time).</summary>
public DateTimeOffset? Since { get; init; }
/// <summary>Gets or sets the maximum number of results.</summary>
public int Limit { get; init; } = 50;
/// <summary>Gets or sets the pagination cursor.</summary>
public string? Cursor { get; init; }
}

View File

@@ -0,0 +1,72 @@
// <copyright file="IEvidencePackSigner.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.Evidence.Pack;
/// <summary>
/// Signs and verifies evidence packs using DSSE.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-003
/// </summary>
public interface IEvidencePackSigner
{
/// <summary>
/// Signs an evidence pack and creates a DSSE envelope.
/// </summary>
/// <param name="pack">The evidence pack to sign.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The DSSE envelope containing the signature.</returns>
Task<DsseEnvelope> SignAsync(
EvidencePack pack,
CancellationToken cancellationToken);
/// <summary>
/// Verifies a DSSE envelope signature.
/// </summary>
/// <param name="envelope">The envelope to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The verification result.</returns>
Task<SignatureVerificationResult> VerifyAsync(
DsseEnvelope envelope,
CancellationToken cancellationToken);
}
/// <summary>
/// Result of verifying a signature.
/// </summary>
public sealed record SignatureVerificationResult
{
/// <summary>Gets whether the signature is valid.</summary>
public required bool Valid { get; init; }
/// <summary>Gets the signing key identifier.</summary>
public string? KeyId { get; init; }
/// <summary>Gets the verification timestamp.</summary>
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>Gets the failure reason if invalid.</summary>
public string? FailureReason { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static SignatureVerificationResult Success(string keyId, DateTimeOffset verifiedAt) => new()
{
Valid = true,
KeyId = keyId,
VerifiedAt = verifiedAt
};
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static SignatureVerificationResult Failure(string reason, DateTimeOffset verifiedAt) => new()
{
Valid = false,
VerifiedAt = verifiedAt,
FailureReason = reason
};
}

View File

@@ -0,0 +1,52 @@
// <copyright file="IEvidencePackStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.Evidence.Pack;
/// <summary>
/// Storage interface for evidence packs.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-005
/// </summary>
public interface IEvidencePackStore
{
/// <summary>
/// Saves an evidence pack.
/// </summary>
Task SaveAsync(EvidencePack pack, CancellationToken cancellationToken);
/// <summary>
/// Saves a signed evidence pack.
/// </summary>
Task SaveSignedAsync(SignedEvidencePack signedPack, CancellationToken cancellationToken);
/// <summary>
/// Gets an evidence pack by ID.
/// </summary>
Task<EvidencePack?> GetByIdAsync(string tenantId, string packId, CancellationToken cancellationToken);
/// <summary>
/// Gets a signed evidence pack by ID.
/// </summary>
Task<SignedEvidencePack?> GetSignedByIdAsync(string tenantId, string packId, CancellationToken cancellationToken);
/// <summary>
/// Lists evidence packs for a tenant.
/// </summary>
Task<IReadOnlyList<EvidencePack>> ListAsync(
string tenantId,
EvidencePackQuery? query,
CancellationToken cancellationToken);
/// <summary>
/// Links an evidence pack to a run.
/// </summary>
Task LinkToRunAsync(string packId, string runId, CancellationToken cancellationToken);
/// <summary>
/// Gets evidence packs for a run.
/// </summary>
Task<IReadOnlyList<EvidencePack>> GetByRunIdAsync(string runId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,58 @@
// <copyright file="IEvidenceResolver.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.Evidence.Pack;
/// <summary>
/// Resolves stella:// URIs and creates evidence snapshots.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-004
/// </summary>
public interface IEvidenceResolver
{
/// <summary>
/// Resolves a stella:// URI and creates a snapshot.
/// </summary>
/// <param name="type">The evidence type (sbom, reach, vex, etc.).</param>
/// <param name="path">The path portion of the URI.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The evidence snapshot with digest.</returns>
Task<ResolvedEvidence> ResolveAndSnapshotAsync(
string type,
string path,
CancellationToken cancellationToken);
/// <summary>
/// Verifies that evidence still matches its recorded digest.
/// </summary>
/// <param name="evidence">The evidence item to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The verification result.</returns>
Task<EvidenceResolutionResult> VerifyEvidenceAsync(
EvidenceItem evidence,
CancellationToken cancellationToken);
/// <summary>
/// Checks if a URI type is supported.
/// </summary>
/// <param name="type">The evidence type.</param>
/// <returns>True if the type is supported.</returns>
bool SupportsType(string type);
}
/// <summary>
/// Result of resolving evidence.
/// </summary>
public sealed record ResolvedEvidence
{
/// <summary>Gets the evidence snapshot.</summary>
public required EvidenceSnapshot Snapshot { get; init; }
/// <summary>Gets the content digest.</summary>
public required string Digest { get; init; }
/// <summary>Gets the resolution timestamp.</summary>
public required DateTimeOffset ResolvedAt { get; init; }
}

View File

@@ -0,0 +1,348 @@
// <copyright file="EvidencePack.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Evidence.Pack.Models;
/// <summary>
/// A shareable, signed bundle of evidence supporting claims.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-001
/// </summary>
public sealed record EvidencePack
{
/// <summary>Gets the unique pack identifier.</summary>
public required string PackId { get; init; }
/// <summary>Gets the pack schema version.</summary>
public required string Version { get; init; }
/// <summary>Gets the pack creation timestamp.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>Gets the tenant identifier.</summary>
public required string TenantId { get; init; }
/// <summary>Gets the subject this pack is about.</summary>
public required EvidenceSubject Subject { get; init; }
/// <summary>Gets the claims made in this pack.</summary>
public required ImmutableArray<EvidenceClaim> Claims { get; init; }
/// <summary>Gets the evidence items supporting the claims.</summary>
public required ImmutableArray<EvidenceItem> Evidence { get; init; }
/// <summary>Gets optional context information.</summary>
public EvidencePackContext? Context { get; init; }
/// <summary>Computes the deterministic content digest for this pack.</summary>
public string ComputeContentDigest()
{
var canonicalJson = JsonSerializer.Serialize(new
{
packId = PackId,
version = Version,
createdAt = CreatedAt.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture),
tenantId = TenantId,
subject = Subject,
claims = Claims,
evidence = Evidence
}, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson));
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
}
/// <summary>
/// The subject of an evidence pack.
/// </summary>
public sealed record EvidenceSubject
{
/// <summary>Gets the subject type.</summary>
public required EvidenceSubjectType Type { get; init; }
/// <summary>Gets the finding identifier (if applicable).</summary>
public string? FindingId { get; init; }
/// <summary>Gets the CVE identifier (if applicable).</summary>
public string? CveId { get; init; }
/// <summary>Gets the component PURL (if applicable).</summary>
public string? Component { get; init; }
/// <summary>Gets the image digest (if applicable).</summary>
public string? ImageDigest { get; init; }
/// <summary>Gets additional subject metadata.</summary>
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Types of evidence pack subjects.
/// </summary>
public enum EvidenceSubjectType
{
/// <summary>Subject is a finding.</summary>
Finding,
/// <summary>Subject is a CVE.</summary>
Cve,
/// <summary>Subject is a software component.</summary>
Component,
/// <summary>Subject is a container image.</summary>
Image,
/// <summary>Subject is a policy.</summary>
Policy,
/// <summary>Custom subject type.</summary>
Custom
}
/// <summary>
/// A claim made within an evidence pack.
/// </summary>
public sealed record EvidenceClaim
{
/// <summary>Gets the claim identifier.</summary>
public required string ClaimId { get; init; }
/// <summary>Gets the claim text.</summary>
public required string Text { get; init; }
/// <summary>Gets the claim type.</summary>
public required ClaimType Type { get; init; }
/// <summary>Gets the claim status (e.g., "affected", "not_affected").</summary>
public required string Status { get; init; }
/// <summary>Gets the confidence score (0.0-1.0).</summary>
public required double Confidence { get; init; }
/// <summary>Gets the evidence IDs supporting this claim.</summary>
public required ImmutableArray<string> EvidenceIds { get; init; }
/// <summary>Gets the claim source ("ai", "human", "system").</summary>
public string? Source { get; init; }
}
/// <summary>
/// Types of claims that can be made.
/// </summary>
public enum ClaimType
{
/// <summary>Claim about vulnerability status.</summary>
VulnerabilityStatus,
/// <summary>Claim about code reachability.</summary>
Reachability,
/// <summary>Claim about fix availability.</summary>
FixAvailability,
/// <summary>Claim about severity.</summary>
Severity,
/// <summary>Claim about exploitability.</summary>
Exploitability,
/// <summary>Claim about compliance status.</summary>
Compliance,
/// <summary>Custom claim type.</summary>
Custom
}
/// <summary>
/// An evidence item within an evidence pack.
/// </summary>
public sealed record EvidenceItem
{
/// <summary>Gets the evidence identifier.</summary>
public required string EvidenceId { get; init; }
/// <summary>Gets the evidence type.</summary>
public required EvidenceType Type { get; init; }
/// <summary>Gets the URI to the evidence source.</summary>
public required string Uri { get; init; }
/// <summary>Gets the content digest of the evidence.</summary>
public required string Digest { get; init; }
/// <summary>Gets when the evidence was collected.</summary>
public required DateTimeOffset CollectedAt { get; init; }
/// <summary>Gets the snapshot of evidence data at collection time.</summary>
public required EvidenceSnapshot Snapshot { get; init; }
/// <summary>Gets additional metadata.</summary>
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Types of evidence that can be collected.
/// </summary>
public enum EvidenceType
{
/// <summary>SBOM data.</summary>
Sbom,
/// <summary>VEX statement.</summary>
Vex,
/// <summary>Reachability analysis.</summary>
Reachability,
/// <summary>Runtime observation.</summary>
Runtime,
/// <summary>Attestation document.</summary>
Attestation,
/// <summary>Security advisory.</summary>
Advisory,
/// <summary>Patch information.</summary>
Patch,
/// <summary>Policy evaluation.</summary>
Policy,
/// <summary>OpsMemory decision.</summary>
OpsMemory,
/// <summary>Provenance information.</summary>
Provenance,
/// <summary>Vendor response.</summary>
VendorResponse,
/// <summary>Configuration data.</summary>
Configuration,
/// <summary>Custom evidence type.</summary>
Custom
}
/// <summary>
/// A snapshot of evidence data at collection time.
/// </summary>
public sealed record EvidenceSnapshot
{
/// <summary>Gets the snapshot type.</summary>
public required string Type { get; init; }
/// <summary>Gets the snapshot data.</summary>
public required ImmutableDictionary<string, object?> Data { get; init; }
/// <summary>Creates an SBOM snapshot.</summary>
public static EvidenceSnapshot Sbom(
string format,
string version,
int componentCount,
string? imageDigest = null) => new()
{
Type = "sbom",
Data = new Dictionary<string, object?>
{
["format"] = format,
["version"] = version,
["componentCount"] = componentCount,
["imageDigest"] = imageDigest
}.ToImmutableDictionary()
};
/// <summary>Creates a VEX snapshot.</summary>
public static EvidenceSnapshot Vex(
string status,
string? justification = null,
DateTimeOffset? issuedAt = null) => new()
{
Type = "vex",
Data = new Dictionary<string, object?>
{
["status"] = status,
["justification"] = justification,
["issuedAt"] = issuedAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
}.ToImmutableDictionary()
};
/// <summary>Creates a reachability snapshot.</summary>
public static EvidenceSnapshot Reachability(
string latticeState,
IEnumerable<string>? staticPath = null,
bool runtimeObserved = false,
double confidence = 0.0) => new()
{
Type = "reachability",
Data = new Dictionary<string, object?>
{
["latticeState"] = latticeState,
["staticPath"] = staticPath?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
["runtimeObserved"] = runtimeObserved,
["confidence"] = confidence
}.ToImmutableDictionary()
};
/// <summary>Creates an advisory snapshot.</summary>
public static EvidenceSnapshot Advisory(
string source,
string severity,
DateTimeOffset publishedAt,
string? fixVersion = null) => new()
{
Type = "advisory",
Data = new Dictionary<string, object?>
{
["source"] = source,
["severity"] = severity,
["publishedAt"] = publishedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
["fixVersion"] = fixVersion
}.ToImmutableDictionary()
};
/// <summary>Creates a custom snapshot.</summary>
public static EvidenceSnapshot Custom(
string type,
ImmutableDictionary<string, object?> data) => new()
{
Type = type,
Data = data
};
}
/// <summary>
/// Context information for an evidence pack.
/// </summary>
public sealed record EvidencePackContext
{
/// <summary>Gets the associated run identifier.</summary>
public string? RunId { get; init; }
/// <summary>Gets the associated conversation identifier.</summary>
public string? ConversationId { get; init; }
/// <summary>Gets the user who created the pack.</summary>
public string? UserId { get; init; }
/// <summary>Gets the tenant identifier.</summary>
public string? TenantId { get; init; }
/// <summary>Gets the generator name.</summary>
public string? GeneratedBy { get; init; }
/// <summary>Gets additional context metadata.</summary>
public ImmutableDictionary<string, string>? Metadata { get; init; }
}

View File

@@ -0,0 +1,138 @@
// <copyright file="SignedEvidencePack.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Evidence.Pack.Models;
/// <summary>
/// A signed evidence pack with DSSE envelope.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-001
/// </summary>
public sealed record SignedEvidencePack
{
/// <summary>Gets the evidence pack.</summary>
public required EvidencePack Pack { get; init; }
/// <summary>Gets the DSSE envelope containing the signature.</summary>
public required DsseEnvelope Envelope { get; init; }
/// <summary>Gets when the pack was signed.</summary>
public required DateTimeOffset SignedAt { get; init; }
}
/// <summary>
/// DSSE (Dead Simple Signing Envelope) for evidence pack signatures.
/// See: https://github.com/secure-systems-lab/dsse
/// </summary>
public sealed record DsseEnvelope
{
/// <summary>Gets the payload type URI.</summary>
public required string PayloadType { get; init; }
/// <summary>Gets the base64-encoded payload.</summary>
public required string Payload { get; init; }
/// <summary>Gets the computed payload digest.</summary>
public required string PayloadDigest { get; init; }
/// <summary>Gets the signatures.</summary>
public required ImmutableArray<DsseSignature> Signatures { get; init; }
}
/// <summary>
/// A signature within a DSSE envelope.
/// </summary>
public sealed record DsseSignature
{
/// <summary>Gets the key identifier.</summary>
public required string KeyId { get; init; }
/// <summary>Gets the base64-encoded signature.</summary>
public required string Sig { get; init; }
}
/// <summary>
/// Result of verifying an evidence pack.
/// </summary>
public sealed record EvidencePackVerificationResult
{
/// <summary>Gets whether the pack is valid.</summary>
public required bool Valid { get; init; }
/// <summary>Gets the pack content digest.</summary>
public required string PackDigest { get; init; }
/// <summary>Gets the signing key identifier.</summary>
public required string SignatureKeyId { get; init; }
/// <summary>Gets any verification issues.</summary>
public ImmutableArray<string> Issues { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Gets individual evidence resolution results.</summary>
public ImmutableArray<EvidenceResolutionResult> EvidenceResolutions { get; init; } = ImmutableArray<EvidenceResolutionResult>.Empty;
}
/// <summary>
/// Result of resolving a single evidence item.
/// </summary>
public sealed record EvidenceResolutionResult
{
/// <summary>Gets the evidence identifier.</summary>
public required string EvidenceId { get; init; }
/// <summary>Gets the evidence URI.</summary>
public required string Uri { get; init; }
/// <summary>Gets whether the evidence was resolved.</summary>
public required bool Resolved { get; init; }
/// <summary>Gets whether the digest matches.</summary>
public required bool DigestMatches { get; init; }
/// <summary>Gets any resolution error.</summary>
public string? Error { get; init; }
}
/// <summary>
/// Export format options for evidence packs.
/// </summary>
public enum EvidencePackExportFormat
{
/// <summary>Raw JSON format.</summary>
Json,
/// <summary>Signed JSON with DSSE envelope.</summary>
SignedJson,
/// <summary>Human-readable Markdown.</summary>
Markdown,
/// <summary>PDF report.</summary>
Pdf,
/// <summary>Styled HTML report.</summary>
Html
}
/// <summary>
/// Result of exporting an evidence pack.
/// </summary>
public sealed record EvidencePackExport
{
/// <summary>Gets the pack identifier.</summary>
public required string PackId { get; init; }
/// <summary>Gets the export format.</summary>
public required EvidencePackExportFormat Format { get; init; }
/// <summary>Gets the content bytes.</summary>
public required byte[] Content { get; init; }
/// <summary>Gets the content type.</summary>
public required string ContentType { get; init; }
/// <summary>Gets the suggested filename.</summary>
public required string FileName { get; init; }
}

View File

@@ -0,0 +1,75 @@
// <copyright file="NullTypeResolver.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.Evidence.Pack.Resolvers;
/// <summary>
/// A no-op type resolver that throws for all requests.
/// Used when no real resolver is configured.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-004
/// </summary>
internal sealed class NullTypeResolver : ITypeResolver
{
/// <summary>
/// Gets an empty collection of supported types.
/// </summary>
public IEnumerable<string> SupportedTypes => [];
/// <summary>
/// Always throws - this resolver handles no types.
/// </summary>
public Task<EvidenceSnapshot> ResolveAsync(
string type,
string path,
CancellationToken cancellationToken)
{
throw new NotSupportedException($"NullTypeResolver cannot resolve evidence type: {type}");
}
}
/// <summary>
/// A passthrough resolver for testing that returns a minimal snapshot.
/// </summary>
public sealed class PassthroughTypeResolver : ITypeResolver
{
private readonly ImmutableArray<string> _supportedTypes;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new PassthroughTypeResolver.
/// </summary>
public PassthroughTypeResolver(
IEnumerable<string> supportedTypes,
TimeProvider timeProvider)
{
_supportedTypes = supportedTypes.ToImmutableArray();
_timeProvider = timeProvider;
}
/// <inheritdoc/>
public IEnumerable<string> SupportedTypes => _supportedTypes;
/// <inheritdoc/>
public Task<EvidenceSnapshot> ResolveAsync(
string type,
string path,
CancellationToken cancellationToken)
{
var snapshot = new EvidenceSnapshot
{
Type = type,
Data = new Dictionary<string, object?>
{
["path"] = path,
["resolvedAt"] = _timeProvider.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture),
["source"] = "passthrough"
}.ToImmutableDictionary()
};
return Task.FromResult(snapshot);
}
}

View File

@@ -0,0 +1,130 @@
// <copyright file="ServiceCollectionExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Evidence.Pack.Resolvers;
using StellaOps.Evidence.Pack.Storage;
namespace StellaOps.Evidence.Pack;
/// <summary>
/// Extension methods for configuring Evidence Pack services.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-005
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Evidence Pack services with default configuration.
/// </summary>
public static IServiceCollection AddEvidencePack(this IServiceCollection services)
{
return services.AddEvidencePack(_ => { });
}
/// <summary>
/// Adds Evidence Pack services with custom configuration.
/// </summary>
public static IServiceCollection AddEvidencePack(
this IServiceCollection services,
Action<EvidencePackOptions> configure)
{
var options = new EvidencePackOptions();
configure(options);
// Core services
services.TryAddSingleton(TimeProvider.System);
services.TryAddScoped<IEvidencePackService, EvidencePackService>();
services.TryAddScoped<IEvidenceResolver, EvidenceResolver>();
// Default to in-memory store if not configured
services.TryAddSingleton<IEvidencePackStore, InMemoryEvidencePackStore>();
// Add null type resolver if none configured
services.TryAddEnumerable(ServiceDescriptor.Singleton<ITypeResolver, NullTypeResolver>());
return services;
}
/// <summary>
/// Adds a custom type resolver for evidence resolution.
/// </summary>
public static IServiceCollection AddEvidenceTypeResolver<TResolver>(
this IServiceCollection services)
where TResolver : class, ITypeResolver
{
services.AddSingleton<ITypeResolver, TResolver>();
return services;
}
/// <summary>
/// Adds a custom type resolver factory for evidence resolution.
/// </summary>
public static IServiceCollection AddEvidenceTypeResolver(
this IServiceCollection services,
Func<IServiceProvider, ITypeResolver> factory)
{
services.AddSingleton(factory);
return services;
}
/// <summary>
/// Adds a passthrough type resolver for testing.
/// </summary>
public static IServiceCollection AddPassthroughResolver(
this IServiceCollection services,
params string[] types)
{
services.AddSingleton<ITypeResolver>(sp =>
new PassthroughTypeResolver(types, sp.GetRequiredService<TimeProvider>()));
return services;
}
/// <summary>
/// Uses in-memory storage for Evidence Packs.
/// </summary>
public static IServiceCollection UseInMemoryEvidencePackStore(
this IServiceCollection services)
{
services.RemoveAll<IEvidencePackStore>();
services.AddSingleton<IEvidencePackStore, InMemoryEvidencePackStore>();
return services;
}
}
/// <summary>
/// Configuration options for Evidence Pack services.
/// </summary>
public sealed class EvidencePackOptions
{
/// <summary>
/// Gets or sets whether to auto-create packs from grounding results.
/// </summary>
public bool AutoCreateEnabled { get; set; } = true;
/// <summary>
/// Gets or sets the minimum grounding score for auto-creation.
/// </summary>
public double MinGroundingScore { get; set; } = 0.7;
/// <summary>
/// Gets or sets whether to auto-sign created packs.
/// </summary>
public bool AutoSign { get; set; }
/// <summary>
/// Gets or sets the signing key identifier.
/// </summary>
public string? SigningKeyId { get; set; }
/// <summary>
/// Gets or sets the retention period for unsigned packs in days.
/// </summary>
public int RetentionDays { get; set; } = 365;
/// <summary>
/// Gets or sets the retention period for signed packs in days.
/// </summary>
public int SignedRetentionDays { get; set; } = 2555; // 7 years
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Evidence.Pack</RootNamespace>
<Description>Evidence Pack library - shareable, signed bundles of evidence supporting AI recommendations</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Evidence.Pack.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,150 @@
// <copyright file="InMemoryEvidencePackStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.Evidence.Pack.Storage;
/// <summary>
/// In-memory implementation of <see cref="IEvidencePackStore"/> for testing.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-005
/// </summary>
public sealed class InMemoryEvidencePackStore : IEvidencePackStore
{
private readonly ConcurrentDictionary<string, EvidencePack> _packs = new();
private readonly ConcurrentDictionary<string, SignedEvidencePack> _signedPacks = new();
private readonly ConcurrentDictionary<string, List<string>> _runLinks = new();
/// <inheritdoc/>
public Task SaveAsync(EvidencePack pack, CancellationToken cancellationToken)
{
_packs[pack.PackId] = pack;
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task SaveSignedAsync(SignedEvidencePack signedPack, CancellationToken cancellationToken)
{
_signedPacks[signedPack.Pack.PackId] = signedPack;
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<EvidencePack?> GetByIdAsync(string tenantId, string packId, CancellationToken cancellationToken)
{
// Wildcard tenant search
if (tenantId == "*")
{
return Task.FromResult(_packs.TryGetValue(packId, out var pack) ? pack : null);
}
if (_packs.TryGetValue(packId, out var p) && p.TenantId == tenantId)
{
return Task.FromResult<EvidencePack?>(p);
}
return Task.FromResult<EvidencePack?>(null);
}
/// <inheritdoc/>
public Task<SignedEvidencePack?> GetSignedByIdAsync(string tenantId, string packId, CancellationToken cancellationToken)
{
// Wildcard tenant search
if (tenantId == "*")
{
return Task.FromResult(_signedPacks.TryGetValue(packId, out var pack) ? pack : null);
}
if (_signedPacks.TryGetValue(packId, out var sp) && sp.Pack.TenantId == tenantId)
{
return Task.FromResult<SignedEvidencePack?>(sp);
}
return Task.FromResult<SignedEvidencePack?>(null);
}
/// <inheritdoc/>
public Task<IReadOnlyList<EvidencePack>> ListAsync(
string tenantId,
EvidencePackQuery? query,
CancellationToken cancellationToken)
{
var results = _packs.Values
.Where(p => p.TenantId == tenantId);
if (query is not null)
{
if (!string.IsNullOrEmpty(query.CveId))
{
results = results.Where(p => p.Subject.CveId == query.CveId);
}
if (!string.IsNullOrEmpty(query.Component))
{
results = results.Where(p => p.Subject.Component == query.Component);
}
if (!string.IsNullOrEmpty(query.RunId))
{
var packIds = _runLinks.TryGetValue(query.RunId, out var ids)
? ids.ToHashSet()
: new HashSet<string>();
results = results.Where(p => packIds.Contains(p.PackId));
}
if (query.Since.HasValue)
{
results = results.Where(p => p.CreatedAt >= query.Since.Value);
}
results = results.Take(query.Limit);
}
return Task.FromResult<IReadOnlyList<EvidencePack>>(
results.OrderByDescending(p => p.CreatedAt).ToList());
}
/// <inheritdoc/>
public Task LinkToRunAsync(string packId, string runId, CancellationToken cancellationToken)
{
var links = _runLinks.GetOrAdd(runId, _ => new List<string>());
lock (links)
{
if (!links.Contains(packId))
{
links.Add(packId);
}
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<IReadOnlyList<EvidencePack>> GetByRunIdAsync(string runId, CancellationToken cancellationToken)
{
if (!_runLinks.TryGetValue(runId, out var packIds))
{
return Task.FromResult<IReadOnlyList<EvidencePack>>(Array.Empty<EvidencePack>());
}
var packs = packIds
.Select(id => _packs.TryGetValue(id, out var pack) ? pack : null)
.Where(p => p is not null)
.Cast<EvidencePack>()
.OrderByDescending(p => p.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<EvidencePack>>(packs);
}
/// <summary>
/// Clears all stored data (for testing).
/// </summary>
public void Clear()
{
_packs.Clear();
_signedPacks.Clear();
_runLinks.Clear();
}
}