partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API request for Codex/Zastava companion explanation generation.
|
||||
/// </summary>
|
||||
public sealed record CompanionExplainRequest
|
||||
{
|
||||
[Required]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Scope { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ScopeId { get; init; }
|
||||
|
||||
public string ExplanationType { get; init; } = "full";
|
||||
|
||||
[Required]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
public string? ComponentPurl { get; init; }
|
||||
|
||||
public bool PlainLanguage { get; init; }
|
||||
|
||||
public int MaxLength { get; init; }
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public IReadOnlyList<CompanionRuntimeSignalRequest> RuntimeSignals { get; init; } = Array.Empty<CompanionRuntimeSignalRequest>();
|
||||
|
||||
public CodexCompanionRequest ToDomain()
|
||||
{
|
||||
if (!Enum.TryParse<ExplanationType>(ExplanationType, ignoreCase: true, out var parsedType))
|
||||
{
|
||||
parsedType = StellaOps.AdvisoryAI.Explanation.ExplanationType.Full;
|
||||
}
|
||||
|
||||
return new CodexCompanionRequest
|
||||
{
|
||||
ExplanationRequest = new ExplanationRequest
|
||||
{
|
||||
FindingId = FindingId,
|
||||
ArtifactDigest = ArtifactDigest,
|
||||
Scope = Scope,
|
||||
ScopeId = ScopeId,
|
||||
ExplanationType = parsedType,
|
||||
VulnerabilityId = VulnerabilityId,
|
||||
ComponentPurl = ComponentPurl,
|
||||
PlainLanguage = PlainLanguage,
|
||||
MaxLength = MaxLength,
|
||||
CorrelationId = CorrelationId,
|
||||
},
|
||||
RuntimeSignals = RuntimeSignals.Select(static signal => new CompanionRuntimeSignal
|
||||
{
|
||||
Source = signal.Source,
|
||||
Signal = signal.Signal,
|
||||
Value = signal.Value,
|
||||
Path = signal.Path,
|
||||
Confidence = signal.Confidence,
|
||||
}).ToArray(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime signal request payload.
|
||||
/// </summary>
|
||||
public sealed record CompanionRuntimeSignalRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Source { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Signal { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Value { get; init; }
|
||||
|
||||
public string? Path { get; init; }
|
||||
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API response for Codex/Zastava companion explanation generation.
|
||||
/// </summary>
|
||||
public sealed record CompanionExplainResponse
|
||||
{
|
||||
public required string CompanionId { get; init; }
|
||||
public required string CompanionHash { get; init; }
|
||||
public required ExplainResponse Explanation { get; init; }
|
||||
public required ExplainSummaryResponse CompanionSummary { get; init; }
|
||||
public required IReadOnlyList<CompanionRuntimeSignalResponse> RuntimeHighlights { get; init; }
|
||||
|
||||
public static CompanionExplainResponse FromDomain(CodexCompanionResponse response)
|
||||
{
|
||||
return new CompanionExplainResponse
|
||||
{
|
||||
CompanionId = response.CompanionId,
|
||||
CompanionHash = response.CompanionHash,
|
||||
Explanation = ExplainResponse.FromDomain(response.Explanation),
|
||||
CompanionSummary = new ExplainSummaryResponse
|
||||
{
|
||||
Line1 = response.CompanionSummary.Line1,
|
||||
Line2 = response.CompanionSummary.Line2,
|
||||
Line3 = response.CompanionSummary.Line3,
|
||||
},
|
||||
RuntimeHighlights = response.RuntimeHighlights.Select(static signal => new CompanionRuntimeSignalResponse
|
||||
{
|
||||
Source = signal.Source,
|
||||
Signal = signal.Signal,
|
||||
Value = signal.Value,
|
||||
Path = signal.Path,
|
||||
Confidence = signal.Confidence,
|
||||
}).ToArray(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime signal response payload.
|
||||
/// </summary>
|
||||
public sealed record CompanionRuntimeSignalResponse
|
||||
{
|
||||
public required string Source { get; init; }
|
||||
public required string Signal { get; init; }
|
||||
public required string Value { get; init; }
|
||||
public string? Path { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
@@ -42,6 +42,7 @@ builder.Configuration
|
||||
|
||||
builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
||||
builder.Services.AddAdvisoryChat(builder.Configuration);
|
||||
builder.Services.TryAddSingleton<ICodexCompanionService, CodexZastavaCompanionService>();
|
||||
|
||||
// Authorization service
|
||||
builder.Services.AddSingleton<StellaOps.AdvisoryAI.WebService.Services.IAuthorizationService, StellaOps.AdvisoryAI.WebService.Services.HeaderBasedAuthorizationService>();
|
||||
@@ -140,6 +141,9 @@ app.MapPost("/v1/advisory-ai/explain", HandleExplain)
|
||||
app.MapGet("/v1/advisory-ai/explain/{explanationId}/replay", HandleExplanationReplay)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapPost("/v1/advisory-ai/companion/explain", HandleCompanionExplain)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// Remediation endpoints (SPRINT_20251226_016_AI_remedy_autopilot)
|
||||
app.MapPost("/v1/advisory-ai/remediation/plan", HandleRemediationPlan)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
@@ -383,7 +387,9 @@ static bool EnsureExplainAuthorized(HttpContext context)
|
||||
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return allowed.Contains("advisory:run") || allowed.Contains("advisory:explain");
|
||||
return allowed.Contains("advisory:run")
|
||||
|| allowed.Contains("advisory:explain")
|
||||
|| allowed.Contains("advisory:companion");
|
||||
}
|
||||
|
||||
// ZASTAVA-13: POST /v1/advisory-ai/explain
|
||||
@@ -450,6 +456,40 @@ static async Task<IResult> HandleExplanationReplay(
|
||||
}
|
||||
}
|
||||
|
||||
// SPRINT_20260208_003: POST /v1/advisory-ai/companion/explain
|
||||
static async Task<IResult> HandleCompanionExplain(
|
||||
HttpContext httpContext,
|
||||
CompanionExplainRequest request,
|
||||
ICodexCompanionService companionService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.companion_explain", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.finding_id", request.FindingId);
|
||||
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
|
||||
activity?.SetTag("advisory.runtime_signal_count", request.RuntimeSignals.Count);
|
||||
|
||||
if (!EnsureExplainAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var domainRequest = request.ToDomain();
|
||||
var result = await companionService.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("advisory.companion_id", result.CompanionId);
|
||||
activity?.SetTag("advisory.companion_hash", result.CompanionHash);
|
||||
activity?.SetTag("advisory.explanation_id", result.Explanation.ExplanationId);
|
||||
|
||||
return Results.Ok(CompanionExplainResponse.FromDomain(result));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
static bool EnsureRemediationAuthorized(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
||||
|
||||
@@ -6,3 +6,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
| SPRINT_20260208_003-WEB | DONE | Companion explain endpoint/contracts for Codex/Zastava flow. |
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Explanation;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime signal emitted by Zastava or compatible observers.
|
||||
/// </summary>
|
||||
public sealed record CompanionRuntimeSignal
|
||||
{
|
||||
public required string Source { get; init; }
|
||||
public required string Signal { get; init; }
|
||||
public required string Value { get; init; }
|
||||
public string? Path { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for Codex/Zastava companion explanation composition.
|
||||
/// </summary>
|
||||
public sealed record CodexCompanionRequest
|
||||
{
|
||||
public required ExplanationRequest ExplanationRequest { get; init; }
|
||||
public IReadOnlyList<CompanionRuntimeSignal> RuntimeSignals { get; init; } = Array.Empty<CompanionRuntimeSignal>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing base explanation plus deterministic runtime highlights.
|
||||
/// </summary>
|
||||
public sealed record CodexCompanionResponse
|
||||
{
|
||||
public required string CompanionId { get; init; }
|
||||
public required string CompanionHash { get; init; }
|
||||
public required ExplanationResult Explanation { get; init; }
|
||||
public required ExplanationSummary CompanionSummary { get; init; }
|
||||
public required IReadOnlyList<CompanionRuntimeSignal> RuntimeHighlights { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service that combines explanation output with Zastava runtime signals.
|
||||
/// </summary>
|
||||
public interface ICodexCompanionService
|
||||
{
|
||||
Task<CodexCompanionResponse> GenerateAsync(
|
||||
CodexCompanionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic implementation of the Codex/Zastava companion.
|
||||
/// </summary>
|
||||
public sealed class CodexZastavaCompanionService : ICodexCompanionService
|
||||
{
|
||||
private readonly IExplanationGenerator _explanationGenerator;
|
||||
|
||||
public CodexZastavaCompanionService(IExplanationGenerator explanationGenerator)
|
||||
{
|
||||
_explanationGenerator = explanationGenerator ?? throw new ArgumentNullException(nameof(explanationGenerator));
|
||||
}
|
||||
|
||||
public async Task<CodexCompanionResponse> GenerateAsync(
|
||||
CodexCompanionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.ExplanationRequest);
|
||||
|
||||
var explanation = await _explanationGenerator
|
||||
.GenerateAsync(request.ExplanationRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var highlights = NormalizeSignals(request.RuntimeSignals)
|
||||
.Take(5)
|
||||
.ToArray();
|
||||
|
||||
var companionSummary = BuildCompanionSummary(explanation.Summary, highlights);
|
||||
var companionHash = ComputeCompanionHash(explanation.OutputHash, highlights);
|
||||
|
||||
return new CodexCompanionResponse
|
||||
{
|
||||
CompanionId = $"companion:{companionHash}",
|
||||
CompanionHash = companionHash,
|
||||
Explanation = explanation,
|
||||
CompanionSummary = companionSummary,
|
||||
RuntimeHighlights = highlights,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CompanionRuntimeSignal> NormalizeSignals(
|
||||
IReadOnlyList<CompanionRuntimeSignal> signals)
|
||||
{
|
||||
if (signals.Count == 0)
|
||||
{
|
||||
return Array.Empty<CompanionRuntimeSignal>();
|
||||
}
|
||||
|
||||
var deduplicated = new Dictionary<string, CompanionRuntimeSignal>(StringComparer.Ordinal);
|
||||
foreach (var signal in signals)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signal.Source) ||
|
||||
string.IsNullOrWhiteSpace(signal.Signal) ||
|
||||
string.IsNullOrWhiteSpace(signal.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = new CompanionRuntimeSignal
|
||||
{
|
||||
Source = signal.Source.Trim(),
|
||||
Signal = signal.Signal.Trim(),
|
||||
Value = signal.Value.Trim(),
|
||||
Path = string.IsNullOrWhiteSpace(signal.Path) ? null : signal.Path.Trim(),
|
||||
Confidence = Math.Clamp(signal.Confidence, 0, 1),
|
||||
};
|
||||
|
||||
var key = string.Join("|", normalized.Source, normalized.Signal, normalized.Value, normalized.Path ?? string.Empty);
|
||||
if (deduplicated.TryGetValue(key, out var existing))
|
||||
{
|
||||
deduplicated[key] = normalized.Confidence >= existing.Confidence
|
||||
? normalized
|
||||
: existing;
|
||||
}
|
||||
else
|
||||
{
|
||||
deduplicated[key] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicated.Values
|
||||
.OrderByDescending(static value => value.Confidence)
|
||||
.ThenBy(static value => value.Source, StringComparer.Ordinal)
|
||||
.ThenBy(static value => value.Signal, StringComparer.Ordinal)
|
||||
.ThenBy(static value => value.Value, StringComparer.Ordinal)
|
||||
.ThenBy(static value => value.Path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static ExplanationSummary BuildCompanionSummary(
|
||||
ExplanationSummary baseSummary,
|
||||
IReadOnlyList<CompanionRuntimeSignal> highlights)
|
||||
{
|
||||
var line2 = highlights.Count == 0
|
||||
? "No Zastava runtime signals were provided; verdict is based on static evidence."
|
||||
: $"Runtime signal {highlights[0].Source}/{highlights[0].Signal} indicates '{highlights[0].Value}'.";
|
||||
|
||||
return new ExplanationSummary
|
||||
{
|
||||
Line1 = $"Companion: {baseSummary.Line1}",
|
||||
Line2 = line2,
|
||||
Line3 = baseSummary.Line3,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeCompanionHash(
|
||||
string explanationOutputHash,
|
||||
IReadOnlyList<CompanionRuntimeSignal> highlights)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(explanationOutputHash).Append('\n');
|
||||
|
||||
foreach (var highlight in highlights)
|
||||
{
|
||||
builder.Append(highlight.Source).Append('|')
|
||||
.Append(highlight.Signal).Append('|')
|
||||
.Append(highlight.Value).Append('|')
|
||||
.Append(highlight.Path ?? string.Empty).Append('|')
|
||||
.Append(highlight.Confidence.ToString("F4", System.Globalization.CultureInfo.InvariantCulture))
|
||||
.Append('\n');
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,6 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
|
||||
| AIAI-CHAT-AUDIT-0001 | DONE | Persist chat audit tables and logger. |
|
||||
| AUDIT-TESTGAP-ADVISORYAI-0001 | DONE | Added worker and unified plugin adapter tests. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
| SPRINT_20260208_003-CORE | DONE | Codex/Zastava companion core service for deterministic runtime-aware explanation composition. |
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Companion.Tests;
|
||||
|
||||
public sealed class CodexZastavaCompanionServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GenerateAsync_IsDeterministicForPermutedSignals()
|
||||
{
|
||||
var explanation = CreateExplanationResult();
|
||||
var service = new CodexZastavaCompanionService(new StubExplanationGenerator(explanation));
|
||||
var explanationRequest = CreateExplanationRequest();
|
||||
|
||||
var requestA = new CodexCompanionRequest
|
||||
{
|
||||
ExplanationRequest = explanationRequest,
|
||||
RuntimeSignals =
|
||||
[
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "entrypoint", Value = "public-api", Path = "/api", Confidence = 0.60 },
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "reachable", Value = "true", Path = "/lib/a.cs", Confidence = 0.95 },
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "reachable", Value = "true", Path = "/lib/a.cs", Confidence = 0.10 },
|
||||
new CompanionRuntimeSignal { Source = "runtime", Signal = "exploit-path", Value = "direct", Path = "/proc", Confidence = 0.85 },
|
||||
],
|
||||
};
|
||||
|
||||
var requestB = new CodexCompanionRequest
|
||||
{
|
||||
ExplanationRequest = explanationRequest,
|
||||
RuntimeSignals =
|
||||
[
|
||||
new CompanionRuntimeSignal { Source = "runtime", Signal = "exploit-path", Value = "direct", Path = "/proc", Confidence = 0.85 },
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "reachable", Value = "true", Path = "/lib/a.cs", Confidence = 0.10 },
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "entrypoint", Value = "public-api", Path = "/api", Confidence = 0.60 },
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "reachable", Value = "true", Path = "/lib/a.cs", Confidence = 0.95 },
|
||||
],
|
||||
};
|
||||
|
||||
var resultA = await service.GenerateAsync(requestA);
|
||||
var resultB = await service.GenerateAsync(requestB);
|
||||
|
||||
resultA.CompanionHash.Should().Be(resultB.CompanionHash);
|
||||
resultA.RuntimeHighlights.Should().HaveCount(3);
|
||||
resultA.RuntimeHighlights[0].Signal.Should().Be("reachable");
|
||||
resultA.RuntimeHighlights[0].Confidence.Should().Be(0.95);
|
||||
resultA.CompanionSummary.Line1.Should().StartWith("Companion:");
|
||||
resultA.CompanionSummary.Line2.Should().Contain("zastava/reachable");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GenerateAsync_WithoutSignals_UsesStaticEvidenceSummary()
|
||||
{
|
||||
var explanation = CreateExplanationResult();
|
||||
var service = new CodexZastavaCompanionService(new StubExplanationGenerator(explanation));
|
||||
|
||||
var response = await service.GenerateAsync(new CodexCompanionRequest
|
||||
{
|
||||
ExplanationRequest = CreateExplanationRequest(),
|
||||
RuntimeSignals = [],
|
||||
});
|
||||
|
||||
response.RuntimeHighlights.Should().BeEmpty();
|
||||
response.CompanionSummary.Line2.Should().Contain("No Zastava runtime signals");
|
||||
response.CompanionId.Should().StartWith("companion:");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompanionContracts_MapDomainRoundTrip()
|
||||
{
|
||||
var request = new CompanionExplainRequest
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
ArtifactDigest = "sha256:aaa",
|
||||
Scope = "image",
|
||||
ScopeId = "img:v1",
|
||||
ExplanationType = "what",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
ComponentPurl = "pkg:npm/a@1.0.0",
|
||||
PlainLanguage = true,
|
||||
MaxLength = 120,
|
||||
CorrelationId = "corr-1",
|
||||
RuntimeSignals =
|
||||
[
|
||||
new CompanionRuntimeSignalRequest
|
||||
{
|
||||
Source = "zastava",
|
||||
Signal = "reachable",
|
||||
Value = "true",
|
||||
Path = "/app/main.cs",
|
||||
Confidence = 0.8,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var domainRequest = request.ToDomain();
|
||||
domainRequest.ExplanationRequest.ExplanationType.Should().Be(ExplanationType.What);
|
||||
domainRequest.RuntimeSignals.Should().HaveCount(1);
|
||||
|
||||
var domainResponse = new CodexCompanionResponse
|
||||
{
|
||||
CompanionId = "companion:abc",
|
||||
CompanionHash = "abc",
|
||||
Explanation = CreateExplanationResult(),
|
||||
CompanionSummary = new ExplanationSummary
|
||||
{
|
||||
Line1 = "Companion: line1",
|
||||
Line2 = "Companion: line2",
|
||||
Line3 = "Companion: line3",
|
||||
},
|
||||
RuntimeHighlights =
|
||||
[
|
||||
new CompanionRuntimeSignal
|
||||
{
|
||||
Source = "zastava",
|
||||
Signal = "reachable",
|
||||
Value = "true",
|
||||
Path = "/app/main.cs",
|
||||
Confidence = 0.8,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var apiResponse = CompanionExplainResponse.FromDomain(domainResponse);
|
||||
apiResponse.CompanionId.Should().Be("companion:abc");
|
||||
apiResponse.Explanation.ExplanationId.Should().Be(CreateExplanationResult().ExplanationId);
|
||||
apiResponse.RuntimeHighlights.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
private static ExplanationRequest CreateExplanationRequest()
|
||||
{
|
||||
return new ExplanationRequest
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
ArtifactDigest = "sha256:aaa",
|
||||
Scope = "image",
|
||||
ScopeId = "img:v1",
|
||||
ExplanationType = ExplanationType.Full,
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
ComponentPurl = "pkg:npm/a@1.0.0",
|
||||
PlainLanguage = false,
|
||||
MaxLength = 0,
|
||||
CorrelationId = "corr-1",
|
||||
};
|
||||
}
|
||||
|
||||
private static ExplanationResult CreateExplanationResult()
|
||||
{
|
||||
return new ExplanationResult
|
||||
{
|
||||
ExplanationId = "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
Content = "example explanation",
|
||||
Summary = new ExplanationSummary
|
||||
{
|
||||
Line1 = "Vulnerability is present.",
|
||||
Line2 = "It is reachable from runtime entrypoints.",
|
||||
Line3 = "Patch to the recommended fixed version.",
|
||||
},
|
||||
Citations = [],
|
||||
ConfidenceScore = 0.9,
|
||||
CitationRate = 1.0,
|
||||
Authority = ExplanationAuthority.EvidenceBacked,
|
||||
EvidenceRefs = ["ev-1"],
|
||||
ModelId = "model-x",
|
||||
PromptTemplateVersion = "explain-v1",
|
||||
InputHashes = ["hash-a", "hash-b", "hash-c"],
|
||||
GeneratedAt = "2026-02-08T00:00:00.0000000Z",
|
||||
OutputHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubExplanationGenerator : IExplanationGenerator
|
||||
{
|
||||
private readonly ExplanationResult _result;
|
||||
|
||||
public StubExplanationGenerator(ExplanationResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public Task<ExplanationResult> GenerateAsync(ExplanationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_result);
|
||||
}
|
||||
|
||||
public Task<ExplanationResult> ReplayAsync(string explanationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Task<bool> ValidateAsync(ExplanationResult result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Companion.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class CompanionExplainEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompanionExplain_WithoutScopes_ReturnsForbidden()
|
||||
{
|
||||
await using var factory = new WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program>();
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Client", "companion-tests");
|
||||
|
||||
var request = new CompanionExplainRequest
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
ArtifactDigest = "sha256:aaa",
|
||||
Scope = "tenant",
|
||||
ScopeId = "tenant-a",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/companion/explain", request);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompanionExplain_WithScope_MapsRequestAndReturnsCompanionResponse()
|
||||
{
|
||||
var stub = new CapturingCompanionService();
|
||||
await using var factory = CreateFactory(stub);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Client", "companion-tests");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory:companion");
|
||||
|
||||
var request = new CompanionExplainRequest
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
ArtifactDigest = "sha256:aaa",
|
||||
Scope = "tenant",
|
||||
ScopeId = "tenant-a",
|
||||
ExplanationType = "what",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
RuntimeSignals =
|
||||
[
|
||||
new CompanionRuntimeSignalRequest
|
||||
{
|
||||
Source = "zastava",
|
||||
Signal = "reachable",
|
||||
Value = "true",
|
||||
Path = "/app/main.cs",
|
||||
Confidence = 0.9,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/companion/explain", request);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<CompanionExplainResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.CompanionId.Should().Be("companion:stub");
|
||||
payload.RuntimeHighlights.Should().ContainSingle();
|
||||
|
||||
stub.LastRequest.Should().NotBeNull();
|
||||
stub.LastRequest!.ExplanationRequest.Scope.Should().Be("tenant");
|
||||
stub.LastRequest.ExplanationRequest.ScopeId.Should().Be("tenant-a");
|
||||
stub.LastRequest.ExplanationRequest.ExplanationType.Should().Be(ExplanationType.What);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompanionExplain_WhenServiceRejectsRequest_ReturnsBadRequest()
|
||||
{
|
||||
await using var factory = CreateFactory(new ThrowingCompanionService());
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Client", "companion-tests");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory:companion");
|
||||
|
||||
var request = new CompanionExplainRequest
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
ArtifactDigest = "sha256:aaa",
|
||||
Scope = "tenant",
|
||||
ScopeId = "tenant-a",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/companion/explain", request);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
private static WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> CreateFactory(ICodexCompanionService service)
|
||||
{
|
||||
return new WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton(service);
|
||||
services.AddSingleton<ICodexCompanionService>(service);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class CapturingCompanionService : ICodexCompanionService
|
||||
{
|
||||
public CodexCompanionRequest? LastRequest { get; private set; }
|
||||
|
||||
public Task<CodexCompanionResponse> GenerateAsync(CodexCompanionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastRequest = request;
|
||||
|
||||
return Task.FromResult(new CodexCompanionResponse
|
||||
{
|
||||
CompanionId = "companion:stub",
|
||||
CompanionHash = "stub",
|
||||
Explanation = new ExplanationResult
|
||||
{
|
||||
ExplanationId = "sha256:stub",
|
||||
Content = "stub explanation",
|
||||
Summary = new ExplanationSummary
|
||||
{
|
||||
Line1 = "line1",
|
||||
Line2 = "line2",
|
||||
Line3 = "line3",
|
||||
},
|
||||
Citations = [],
|
||||
ConfidenceScore = 1.0,
|
||||
CitationRate = 1.0,
|
||||
Authority = ExplanationAuthority.EvidenceBacked,
|
||||
EvidenceRefs = [],
|
||||
ModelId = "stub-model",
|
||||
PromptTemplateVersion = "stub-template",
|
||||
InputHashes = [],
|
||||
GeneratedAt = "2026-02-08T00:00:00.0000000Z",
|
||||
OutputHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
},
|
||||
CompanionSummary = new ExplanationSummary
|
||||
{
|
||||
Line1 = "Companion: line1",
|
||||
Line2 = "Companion: line2",
|
||||
Line3 = "Companion: line3",
|
||||
},
|
||||
RuntimeHighlights =
|
||||
[
|
||||
new CompanionRuntimeSignal
|
||||
{
|
||||
Source = "zastava",
|
||||
Signal = "reachable",
|
||||
Value = "true",
|
||||
Path = "/app/main.cs",
|
||||
Confidence = 0.9,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingCompanionService : ICodexCompanionService
|
||||
{
|
||||
public Task<CodexCompanionResponse> GenerateAsync(CodexCompanionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new InvalidOperationException("invalid companion request");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.WebService\StellaOps.AdvisoryAI.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -6,3 +6,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
| SPRINT_20260208_003-TESTS | DONE | Deterministic Codex/Zastava companion service, contract tests, and endpoint integration tests. |
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure;
|
||||
using StellaOps.Attestor.ProofChain;
|
||||
using StellaOps.Attestor.Spdx3;
|
||||
using StellaOps.Attestor.Watchlist;
|
||||
using StellaOps.Attestor.WebService.Options;
|
||||
@@ -138,6 +139,7 @@ internal static class AttestorWebServiceComposition
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddAttestorInfrastructure();
|
||||
builder.Services.AddProofChainServices();
|
||||
|
||||
builder.Services.AddScoped<Services.IProofChainQueryService, Services.ProofChainQueryService>();
|
||||
builder.Services.AddScoped<Services.IProofVerificationService, Services.ProofVerificationService>();
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExceptionContracts.cs
|
||||
// Sprint: SPRINT_20260208_008_Attestor_dsse_signed_exception_objects_with_recheck_policy
|
||||
// Description: API contracts for DSSE-signed exception operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to sign and create a DSSE-signed exception.
|
||||
/// </summary>
|
||||
public sealed record SignExceptionRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The exception entry to sign.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exception")]
|
||||
public required ExceptionEntryDto Exception { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject (artifact) this exception applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required SubjectDto Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The recheck policy for this exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recheckPolicy")]
|
||||
public required RecheckPolicyDto RecheckPolicy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The environments this exception applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("environments")]
|
||||
public IReadOnlyList<string>? Environments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// IDs of violations this exception covers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("coveredViolationIds")]
|
||||
public IReadOnlyList<string>? CoveredViolationIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception entry data transfer object.
|
||||
/// </summary>
|
||||
public sealed record ExceptionEntryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Exception identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exceptionId")]
|
||||
public required string ExceptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason codes covered by this exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("coveredReasons")]
|
||||
public IReadOnlyList<string>? CoveredReasons { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tiers covered by this exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("coveredTiers")]
|
||||
public IReadOnlyList<string>? CoveredTiers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this exception expires (ISO 8601 format).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who approved this exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approvedBy")]
|
||||
public string? ApprovedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject data transfer object for API requests.
|
||||
/// </summary>
|
||||
public sealed record SubjectDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The name or identifier of the subject.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digests of the subject in algorithm:hex format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recheck policy configuration for exceptions.
|
||||
/// </summary>
|
||||
public sealed record RecheckPolicyDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Interval in days between automated rechecks. Default: 30.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recheckIntervalDays")]
|
||||
public int RecheckIntervalDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether automatic recheck scheduling is enabled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("autoRecheckEnabled")]
|
||||
public bool AutoRecheckEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum renewal count before escalation required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxRenewalCount")]
|
||||
public int? MaxRenewalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether re-approval is required on expiry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiresReapprovalOnExpiry")]
|
||||
public bool RequiresReapprovalOnExpiry { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Roles required for approval.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approvalRoles")]
|
||||
public IReadOnlyList<string>? ApprovalRoles { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response after signing an exception.
|
||||
/// </summary>
|
||||
public sealed record SignedExceptionResponseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The content-addressed ID of the signed exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exceptionContentId")]
|
||||
public required string ExceptionContentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope containing the signed statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeDto Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the exception was signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The initial status of the exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the next recheck is scheduled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nextRecheckAt")]
|
||||
public DateTimeOffset? NextRecheckAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope data transfer object.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The payload type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signatures over the payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<DsseSignatureDto> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature data transfer object.
|
||||
/// </summary>
|
||||
public sealed record DsseSignatureDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The key ID that produced this signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a signed exception.
|
||||
/// </summary>
|
||||
public sealed record VerifyExceptionRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The DSSE envelope to verify.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeDto Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed key IDs for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("allowedKeyIds")]
|
||||
public IReadOnlyList<string>? AllowedKeyIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from exception verification.
|
||||
/// </summary>
|
||||
public sealed record VerifyExceptionResponseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isValid")]
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The key ID that signed the exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The exception content ID if valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exceptionContentId")]
|
||||
public string? ExceptionContentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recheck status of the exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recheckStatus")]
|
||||
public RecheckStatusDto? RecheckStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recheck status for an exception.
|
||||
/// </summary>
|
||||
public sealed record RecheckStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether a recheck is required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recheckRequired")]
|
||||
public required bool RecheckRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the exception has expired.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isExpired")]
|
||||
public required bool IsExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the exception is expiring soon.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expiringWithinWarningWindow")]
|
||||
public required bool ExpiringWithinWarningWindow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Days until expiry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("daysUntilExpiry")]
|
||||
public int? DaysUntilExpiry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Next recheck due date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nextRecheckDue")]
|
||||
public DateTimeOffset? NextRecheckDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recommended action.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recommendedAction")]
|
||||
public required string RecommendedAction { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to renew an exception.
|
||||
/// </summary>
|
||||
public sealed record RenewExceptionRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The DSSE envelope to renew.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeDto Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new approver for the renewal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("newApprover")]
|
||||
public required string NewApprover { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional updated justification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("newJustification")]
|
||||
public string? NewJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Days to extend the expiry by.
|
||||
/// </summary>
|
||||
[JsonPropertyName("extendExpiryByDays")]
|
||||
public int? ExtendExpiryByDays { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to check recheck status of an exception.
|
||||
/// </summary>
|
||||
public sealed record CheckRecheckRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The DSSE envelope to check.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeDto Envelope { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExceptionController.cs
|
||||
// Sprint: SPRINT_20260208_008_Attestor_dsse_signed_exception_objects_with_recheck_policy
|
||||
// Description: API endpoints for DSSE-signed exception operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.ProofChain.Services;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
using StellaOps.Attestor.WebService.Options;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for DSSE-signed exception operations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("internal/api/v1/exceptions")]
|
||||
[Produces("application/json")]
|
||||
[Authorize("attestor:write")]
|
||||
public class ExceptionController : ControllerBase
|
||||
{
|
||||
private readonly IExceptionSigningService _exceptionSigningService;
|
||||
private readonly ILogger<ExceptionController> _logger;
|
||||
private readonly AttestorWebServiceFeatures _features;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExceptionController"/> class.
|
||||
/// </summary>
|
||||
public ExceptionController(
|
||||
IExceptionSigningService exceptionSigningService,
|
||||
ILogger<ExceptionController> logger,
|
||||
IOptions<AttestorWebServiceFeatures>? features = null)
|
||||
{
|
||||
_exceptionSigningService = exceptionSigningService ?? throw new ArgumentNullException(nameof(exceptionSigningService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_features = features?.Value ?? new AttestorWebServiceFeatures();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signs an exception entry and wraps it in a DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="request">The sign exception request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The signed exception response.</returns>
|
||||
[HttpPost("sign")]
|
||||
[EnableRateLimiting("attestor-submissions")]
|
||||
[ProducesResponseType(typeof(SignedExceptionResponseDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<SignedExceptionResponseDto>> SignExceptionAsync(
|
||||
[FromBody] SignExceptionRequestDto request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Signing exception {ExceptionId} for subject {SubjectName}",
|
||||
request.Exception.ExceptionId,
|
||||
request.Subject.Name);
|
||||
|
||||
// Validate request
|
||||
if (string.IsNullOrWhiteSpace(request.Exception.ExceptionId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Request",
|
||||
Detail = "ExceptionId is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Subject.Name))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Request",
|
||||
Detail = "Subject name is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// Map request to domain types
|
||||
var exception = MapToDomain(request.Exception);
|
||||
var subject = MapToDomain(request.Subject);
|
||||
var recheckPolicy = MapToDomain(request.RecheckPolicy);
|
||||
|
||||
var result = await _exceptionSigningService.SignExceptionAsync(
|
||||
exception,
|
||||
subject,
|
||||
recheckPolicy,
|
||||
request.Environments,
|
||||
request.CoveredViolationIds,
|
||||
renewsExceptionId: null,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var response = new SignedExceptionResponseDto
|
||||
{
|
||||
ExceptionContentId = result.ExceptionContentId,
|
||||
Envelope = MapToDto(result.Envelope),
|
||||
SignedAt = result.Statement.Predicate.SignedAt,
|
||||
Status = result.Statement.Predicate.Status.ToString(),
|
||||
NextRecheckAt = result.Statement.Predicate.RecheckPolicy.NextRecheckAt
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exception {ExceptionId} signed with content ID {ContentId}",
|
||||
request.Exception.ExceptionId,
|
||||
result.ExceptionContentId);
|
||||
|
||||
return CreatedAtAction(nameof(SignExceptionAsync), response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to sign exception {ExceptionId}", request.Exception.ExceptionId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
|
||||
{
|
||||
Title = "Internal Server Error",
|
||||
Detail = "An error occurred while signing the exception",
|
||||
Status = StatusCodes.Status500InternalServerError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE-signed exception envelope.
|
||||
/// </summary>
|
||||
/// <param name="request">The verify exception request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
[HttpPost("verify")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(VerifyExceptionResponseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<VerifyExceptionResponseDto>> VerifyExceptionAsync(
|
||||
[FromBody] VerifyExceptionRequestDto request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Verifying exception envelope");
|
||||
|
||||
var envelope = MapToDomain(request.Envelope);
|
||||
var allowedKeyIds = request.AllowedKeyIds ?? Array.Empty<string>();
|
||||
|
||||
var result = await _exceptionSigningService.VerifyExceptionAsync(
|
||||
envelope,
|
||||
allowedKeyIds,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
RecheckStatusDto? recheckStatus = null;
|
||||
if (result.IsValid && result.Statement is not null)
|
||||
{
|
||||
var status = _exceptionSigningService.CheckRecheckRequired(result.Statement);
|
||||
recheckStatus = MapToDto(status);
|
||||
}
|
||||
|
||||
var response = new VerifyExceptionResponseDto
|
||||
{
|
||||
IsValid = result.IsValid,
|
||||
KeyId = result.KeyId,
|
||||
ExceptionContentId = result.Statement?.Predicate.ExceptionContentId,
|
||||
Error = result.Error,
|
||||
RecheckStatus = recheckStatus
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to verify exception envelope");
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Verification Failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the recheck status of a signed exception.
|
||||
/// </summary>
|
||||
/// <param name="request">The check recheck request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The recheck status.</returns>
|
||||
[HttpPost("recheck-status")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(RecheckStatusDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<RecheckStatusDto>> CheckRecheckStatusAsync(
|
||||
[FromBody] CheckRecheckRequestDto request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Checking recheck status for exception");
|
||||
|
||||
var envelope = MapToDomain(request.Envelope);
|
||||
|
||||
// First verify to get the statement
|
||||
var verifyResult = await _exceptionSigningService.VerifyExceptionAsync(
|
||||
envelope,
|
||||
Array.Empty<string>(),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!verifyResult.IsValid || verifyResult.Statement is null)
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Envelope",
|
||||
Detail = verifyResult.Error ?? "Could not parse exception statement",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var status = _exceptionSigningService.CheckRecheckRequired(verifyResult.Statement);
|
||||
return Ok(MapToDto(status));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to check recheck status");
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Check Failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renews an expired or expiring exception.
|
||||
/// </summary>
|
||||
/// <param name="request">The renew exception request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The renewed signed exception.</returns>
|
||||
[HttpPost("renew")]
|
||||
[EnableRateLimiting("attestor-submissions")]
|
||||
[ProducesResponseType(typeof(SignedExceptionResponseDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<SignedExceptionResponseDto>> RenewExceptionAsync(
|
||||
[FromBody] RenewExceptionRequestDto request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Renewing exception with new approver {Approver}", request.NewApprover);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewApprover))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Request",
|
||||
Detail = "NewApprover is required for renewal",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var envelope = MapToDomain(request.Envelope);
|
||||
var extendBy = request.ExtendExpiryByDays.HasValue
|
||||
? TimeSpan.FromDays(request.ExtendExpiryByDays.Value)
|
||||
: (TimeSpan?)null;
|
||||
|
||||
var result = await _exceptionSigningService.RenewExceptionAsync(
|
||||
envelope,
|
||||
request.NewApprover,
|
||||
request.NewJustification,
|
||||
extendBy,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var response = new SignedExceptionResponseDto
|
||||
{
|
||||
ExceptionContentId = result.ExceptionContentId,
|
||||
Envelope = MapToDto(result.Envelope),
|
||||
SignedAt = result.Statement.Predicate.SignedAt,
|
||||
Status = result.Statement.Predicate.Status.ToString(),
|
||||
NextRecheckAt = result.Statement.Predicate.RecheckPolicy.NextRecheckAt
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exception renewed with new content ID {ContentId}",
|
||||
result.ExceptionContentId);
|
||||
|
||||
return CreatedAtAction(nameof(RenewExceptionAsync), response);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Maximum renewal count"))
|
||||
{
|
||||
_logger.LogWarning(ex, "Maximum renewal count reached");
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Renewal Limit Reached",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to renew exception");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
|
||||
{
|
||||
Title = "Internal Server Error",
|
||||
Detail = "An error occurred while renewing the exception",
|
||||
Status = StatusCodes.Status500InternalServerError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mapping Methods ---
|
||||
|
||||
private static BudgetExceptionEntry MapToDomain(ExceptionEntryDto dto) => new()
|
||||
{
|
||||
ExceptionId = dto.ExceptionId,
|
||||
CoveredReasons = dto.CoveredReasons,
|
||||
CoveredTiers = dto.CoveredTiers,
|
||||
ExpiresAt = dto.ExpiresAt,
|
||||
Justification = dto.Justification,
|
||||
ApprovedBy = dto.ApprovedBy
|
||||
};
|
||||
|
||||
private static Subject MapToDomain(SubjectDto dto) => new()
|
||||
{
|
||||
Name = dto.Name,
|
||||
Digest = dto.Digest.ToDictionary(kv => kv.Key, kv => kv.Value)
|
||||
};
|
||||
|
||||
private static ExceptionRecheckPolicy MapToDomain(RecheckPolicyDto dto) => new()
|
||||
{
|
||||
RecheckIntervalDays = dto.RecheckIntervalDays,
|
||||
AutoRecheckEnabled = dto.AutoRecheckEnabled,
|
||||
MaxRenewalCount = dto.MaxRenewalCount,
|
||||
RequiresReapprovalOnExpiry = dto.RequiresReapprovalOnExpiry,
|
||||
ApprovalRoles = dto.ApprovalRoles
|
||||
};
|
||||
|
||||
private static DsseEnvelope MapToDomain(DsseEnvelopeDto dto) => new()
|
||||
{
|
||||
PayloadType = dto.PayloadType,
|
||||
Payload = dto.Payload,
|
||||
Signatures = dto.Signatures.Select(s => new DsseSignature
|
||||
{
|
||||
KeyId = s.KeyId,
|
||||
Sig = s.Sig
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static DsseEnvelopeDto MapToDto(DsseEnvelope envelope) => new()
|
||||
{
|
||||
PayloadType = envelope.PayloadType,
|
||||
Payload = envelope.Payload,
|
||||
Signatures = envelope.Signatures.Select(s => new DsseSignatureDto
|
||||
{
|
||||
KeyId = s.KeyId,
|
||||
Sig = s.Sig
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static RecheckStatusDto MapToDto(ExceptionRecheckStatus status) => new()
|
||||
{
|
||||
RecheckRequired = status.RecheckRequired,
|
||||
IsExpired = status.IsExpired,
|
||||
ExpiringWithinWarningWindow = status.ExpiringWithinWarningWindow,
|
||||
DaysUntilExpiry = status.DaysUntilExpiry,
|
||||
NextRecheckDue = status.NextRecheckDue,
|
||||
RecommendedAction = status.RecommendedAction.ToString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISnapshotExporter.cs
|
||||
// Sprint: SPRINT_20260208_021_Attestor_snapshot_export_import_for_air_gap
|
||||
// Task: T1 — Snapshot export interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Exports attestation snapshots for transfer to air-gapped systems.
|
||||
/// Produces portable archives containing evidence, verification material,
|
||||
/// and optionally policies and trust anchors (depending on <see cref="SnapshotLevel"/>).
|
||||
/// </summary>
|
||||
public interface ISnapshotExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Exports a snapshot archive at the requested level.
|
||||
/// </summary>
|
||||
Task<SnapshotExportResult> ExportAsync(
|
||||
SnapshotExportRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the supplied archive content is a well-formed snapshot.
|
||||
/// Does not perform cryptographic verification — use <see cref="ISnapshotImporter.ValidateArchiveAsync"/>
|
||||
/// for full integrity checking.
|
||||
/// </summary>
|
||||
Task<SnapshotManifest> ParseManifestAsync(
|
||||
ReadOnlyMemory<byte> archiveContent,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISnapshotImporter.cs
|
||||
// Sprint: SPRINT_20260208_021_Attestor_snapshot_export_import_for_air_gap
|
||||
// Task: T1 — Snapshot import interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Imports attestation snapshot archives on air-gapped systems.
|
||||
/// Validates archive integrity, verifies manifest digests, and
|
||||
/// ingests entries into the local trust store.
|
||||
/// </summary>
|
||||
public interface ISnapshotImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Imports a snapshot archive, verifying integrity and ingesting entries.
|
||||
/// </summary>
|
||||
Task<SnapshotImportResult> ImportAsync(
|
||||
SnapshotImportRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates archive integrity (manifest digest + entry digests)
|
||||
/// without performing the actual import.
|
||||
/// </summary>
|
||||
Task<SnapshotImportResult> ValidateArchiveAsync(
|
||||
ReadOnlyMemory<byte> archiveContent,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SnapshotModels.cs
|
||||
// Sprint: SPRINT_20260208_021_Attestor_snapshot_export_import_for_air_gap
|
||||
// Task: T1 — Snapshot format, manifest, and level classification models
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot level classification for air-gap transfer.
|
||||
/// Higher levels include more material for fully offline verification.
|
||||
/// </summary>
|
||||
public enum SnapshotLevel
|
||||
{
|
||||
/// <summary>Level A: Attestation bundles only (requires online verification).</summary>
|
||||
LevelA = 0,
|
||||
|
||||
/// <summary>Level B: Evidence + verification material (Fulcio roots, Rekor keys).</summary>
|
||||
LevelB = 1,
|
||||
|
||||
/// <summary>Level C: Full state including policies, trust anchors, and org keys.</summary>
|
||||
LevelC = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a snapshot export or import operation.
|
||||
/// </summary>
|
||||
public enum SnapshotOperationStatus
|
||||
{
|
||||
/// <summary>Operation completed successfully.</summary>
|
||||
Success = 0,
|
||||
|
||||
/// <summary>Operation completed with warnings (e.g., missing optional material).</summary>
|
||||
PartialSuccess = 1,
|
||||
|
||||
/// <summary>Operation failed.</summary>
|
||||
Failed = 2,
|
||||
|
||||
/// <summary>Operation was cancelled.</summary>
|
||||
Cancelled = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the snapshot manifest describing one included artifact.
|
||||
/// </summary>
|
||||
public sealed record SnapshotManifestEntry
|
||||
{
|
||||
/// <summary>Relative path within the snapshot archive.</summary>
|
||||
public required string RelativePath { get; init; }
|
||||
|
||||
/// <summary>SHA-256 digest of the artifact content.</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
public required long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>Content category (e.g., "attestation", "evidence", "trust-root", "policy").</summary>
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>MIME content type.</summary>
|
||||
public string ContentType { get; init; } = "application/octet-stream";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest describing the contents and integrity of a snapshot archive.
|
||||
/// Signed via DSSE for tamper evidence.
|
||||
/// </summary>
|
||||
public sealed record SnapshotManifest
|
||||
{
|
||||
/// <summary>Content-addressed digest of the manifest itself.</summary>
|
||||
public required string ManifestDigest { get; init; }
|
||||
|
||||
/// <summary>Snapshot level classification.</summary>
|
||||
public required SnapshotLevel Level { get; init; }
|
||||
|
||||
/// <summary>Format version (semver).</summary>
|
||||
public string FormatVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>All entries included in the snapshot.</summary>
|
||||
public required ImmutableArray<SnapshotManifestEntry> Entries { get; init; }
|
||||
|
||||
/// <summary>Total uncompressed size of all entries.</summary>
|
||||
public long TotalSizeBytes => Entries.IsDefaultOrEmpty ? 0 : Entries.Sum(e => e.SizeBytes);
|
||||
|
||||
/// <summary>Count of entries.</summary>
|
||||
public int EntryCount => Entries.IsDefaultOrEmpty ? 0 : Entries.Length;
|
||||
|
||||
/// <summary>Timestamp of snapshot creation.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Optional source tenant ID.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Optional description or reason for the snapshot.</summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to export a snapshot archive.
|
||||
/// </summary>
|
||||
public sealed record SnapshotExportRequest
|
||||
{
|
||||
/// <summary>Desired snapshot level.</summary>
|
||||
public required SnapshotLevel Level { get; init; }
|
||||
|
||||
/// <summary>Artifact digests to include (empty = all available).</summary>
|
||||
public ImmutableArray<string> ArtifactDigests { get; init; } = [];
|
||||
|
||||
/// <summary>Tenant ID scope.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Description or reason for the export.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Whether to include trust root material.</summary>
|
||||
public bool IncludeTrustRoots { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to include policy bundles (Level C only).</summary>
|
||||
public bool IncludePolicies { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a snapshot export operation.
|
||||
/// </summary>
|
||||
public sealed record SnapshotExportResult
|
||||
{
|
||||
/// <summary>The generated manifest.</summary>
|
||||
public required SnapshotManifest Manifest { get; init; }
|
||||
|
||||
/// <summary>Serialized archive content (JSON manifest + metadata).</summary>
|
||||
public required ReadOnlyMemory<byte> ArchiveContent { get; init; }
|
||||
|
||||
/// <summary>Operation status.</summary>
|
||||
public required SnapshotOperationStatus Status { get; init; }
|
||||
|
||||
/// <summary>Warnings or informational messages.</summary>
|
||||
public ImmutableArray<string> Messages { get; init; } = [];
|
||||
|
||||
/// <summary>Duration of the export in milliseconds.</summary>
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to import a snapshot archive on an air-gapped system.
|
||||
/// </summary>
|
||||
public sealed record SnapshotImportRequest
|
||||
{
|
||||
/// <summary>Serialized archive content to import.</summary>
|
||||
public required ReadOnlyMemory<byte> ArchiveContent { get; init; }
|
||||
|
||||
/// <summary>Whether to verify manifest integrity before import.</summary>
|
||||
public bool VerifyIntegrity { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to skip entries that already exist locally.</summary>
|
||||
public bool SkipExisting { get; init; } = true;
|
||||
|
||||
/// <summary>Target tenant ID (overrides manifest tenant).</summary>
|
||||
public string? TargetTenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a snapshot import operation.
|
||||
/// </summary>
|
||||
public sealed record SnapshotImportResult
|
||||
{
|
||||
/// <summary>The imported manifest.</summary>
|
||||
public required SnapshotManifest Manifest { get; init; }
|
||||
|
||||
/// <summary>Operation status.</summary>
|
||||
public required SnapshotOperationStatus Status { get; init; }
|
||||
|
||||
/// <summary>Number of entries imported.</summary>
|
||||
public int ImportedCount { get; init; }
|
||||
|
||||
/// <summary>Number of entries skipped (already existed).</summary>
|
||||
public int SkippedCount { get; init; }
|
||||
|
||||
/// <summary>Number of entries that failed.</summary>
|
||||
public int FailedCount { get; init; }
|
||||
|
||||
/// <summary>Warnings, errors, or informational messages.</summary>
|
||||
public ImmutableArray<string> Messages { get; init; } = [];
|
||||
|
||||
/// <summary>Duration of the import in milliseconds.</summary>
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_20260208_021_Attestor_snapshot_export_import_for_air_gap
|
||||
// Task: T2 — DI registration for snapshot export/import services
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
|
||||
namespace StellaOps.Attestor.Offline;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration extensions for the Attestor Offline library.
|
||||
/// </summary>
|
||||
public static class OfflineServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers snapshot export/import services for air-gap transfers.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAttestorOffline(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ISnapshotExporter, SnapshotExporter>();
|
||||
services.TryAddSingleton<ISnapshotImporter, SnapshotImporter>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SnapshotExporter.cs
|
||||
// Sprint: SPRINT_20260208_021_Attestor_snapshot_export_import_for_air_gap
|
||||
// Task: T1 — Snapshot export service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Exports attestation snapshots for transfer to air-gapped systems.
|
||||
/// Produces a self-contained JSON archive containing a manifest and
|
||||
/// base64-encoded entries at the requested <see cref="SnapshotLevel"/>.
|
||||
/// </summary>
|
||||
public sealed class SnapshotExporter : ISnapshotExporter
|
||||
{
|
||||
private readonly IOfflineRootStore _rootStore;
|
||||
private readonly ILogger<SnapshotExporter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new snapshot exporter.
|
||||
/// </summary>
|
||||
public SnapshotExporter(
|
||||
IOfflineRootStore rootStore,
|
||||
ILogger<SnapshotExporter> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SnapshotExportResult> ExportAsync(
|
||||
SnapshotExportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var messages = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
// Build entries based on snapshot level
|
||||
var entriesBuilder = ImmutableArray.CreateBuilder<SnapshotManifestEntry>();
|
||||
|
||||
// Level A: attestation bundles (represented by artifact digests)
|
||||
if (!request.ArtifactDigests.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var digest in request.ArtifactDigests)
|
||||
{
|
||||
var entryBytes = Encoding.UTF8.GetBytes(digest);
|
||||
var hash = ComputeSha256(entryBytes);
|
||||
entriesBuilder.Add(new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = $"attestations/{digest}",
|
||||
Digest = hash,
|
||||
SizeBytes = entryBytes.Length,
|
||||
Category = "attestation",
|
||||
ContentType = "application/vnd.dsse+json"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Level B: add trust roots and verification material
|
||||
if (request.Level >= SnapshotLevel.LevelB && request.IncludeTrustRoots)
|
||||
{
|
||||
var trustRootEntries = BuildTrustRootEntries();
|
||||
entriesBuilder.AddRange(trustRootEntries);
|
||||
if (trustRootEntries.IsEmpty)
|
||||
{
|
||||
messages.Add("Warning: No trust roots available for inclusion.");
|
||||
}
|
||||
}
|
||||
|
||||
// Level C: add policies
|
||||
if (request.Level >= SnapshotLevel.LevelC && request.IncludePolicies)
|
||||
{
|
||||
var policyEntry = BuildPolicyPlaceholderEntry();
|
||||
entriesBuilder.Add(policyEntry);
|
||||
messages.Add("Info: Policy bundle placeholder included.");
|
||||
}
|
||||
|
||||
var entries = entriesBuilder.ToImmutable();
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Build manifest
|
||||
var manifestDigest = ComputeManifestDigest(entries, createdAt);
|
||||
var manifest = new SnapshotManifest
|
||||
{
|
||||
ManifestDigest = manifestDigest,
|
||||
Level = request.Level,
|
||||
Entries = entries,
|
||||
CreatedAt = createdAt,
|
||||
TenantId = request.TenantId,
|
||||
Description = request.Description
|
||||
};
|
||||
|
||||
// Serialize the archive
|
||||
var archiveDto = new SnapshotArchiveDto
|
||||
{
|
||||
ManifestDigest = manifest.ManifestDigest,
|
||||
Level = manifest.Level.ToString(),
|
||||
FormatVersion = manifest.FormatVersion,
|
||||
CreatedAt = manifest.CreatedAt,
|
||||
TenantId = manifest.TenantId,
|
||||
Description = manifest.Description,
|
||||
Entries = entries.Select(e => new SnapshotEntryDto
|
||||
{
|
||||
RelativePath = e.RelativePath,
|
||||
Digest = e.Digest,
|
||||
SizeBytes = e.SizeBytes,
|
||||
Category = e.Category,
|
||||
ContentType = e.ContentType
|
||||
}).ToArray()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(archiveDto, s_jsonOptions);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Snapshot exported: Level={Level}, Entries={EntryCount}, Size={SizeBytes}B, Duration={DurationMs}ms",
|
||||
request.Level, entries.Length, json.Length, sw.ElapsedMilliseconds);
|
||||
|
||||
var result = new SnapshotExportResult
|
||||
{
|
||||
Manifest = manifest,
|
||||
ArchiveContent = new ReadOnlyMemory<byte>(json),
|
||||
Status = SnapshotOperationStatus.Success,
|
||||
Messages = messages.ToImmutable(),
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SnapshotManifest> ParseManifestAsync(
|
||||
ReadOnlyMemory<byte> archiveContent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<SnapshotArchiveDto>(
|
||||
archiveContent.Span, s_jsonOptions);
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
throw new InvalidOperationException("Archive content is not a valid snapshot.");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<SnapshotLevel>(dto.Level, ignoreCase: true, out var level))
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown snapshot level: '{dto.Level}'.");
|
||||
}
|
||||
|
||||
var entries = (dto.Entries ?? []).Select(e => new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = e.RelativePath ?? string.Empty,
|
||||
Digest = e.Digest ?? string.Empty,
|
||||
SizeBytes = e.SizeBytes,
|
||||
Category = e.Category ?? string.Empty,
|
||||
ContentType = e.ContentType ?? "application/octet-stream"
|
||||
}).ToImmutableArray();
|
||||
|
||||
var manifest = new SnapshotManifest
|
||||
{
|
||||
ManifestDigest = dto.ManifestDigest ?? string.Empty,
|
||||
Level = level,
|
||||
FormatVersion = dto.FormatVersion ?? "1.0.0",
|
||||
Entries = entries,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
TenantId = dto.TenantId,
|
||||
Description = dto.Description
|
||||
};
|
||||
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────
|
||||
|
||||
private ImmutableArray<SnapshotManifestEntry> BuildTrustRootEntries()
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<SnapshotManifestEntry>();
|
||||
|
||||
// Fulcio roots placeholder — in production would iterate _rootStore.GetFulcioRootsAsync()
|
||||
var fulcioPlaceholder = Encoding.UTF8.GetBytes("fulcio-root-bundle");
|
||||
builder.Add(new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = "trust-roots/fulcio-roots.pem",
|
||||
Digest = ComputeSha256(fulcioPlaceholder),
|
||||
SizeBytes = fulcioPlaceholder.Length,
|
||||
Category = "trust-root",
|
||||
ContentType = "application/x-pem-file"
|
||||
});
|
||||
|
||||
// Rekor key placeholder
|
||||
var rekorPlaceholder = Encoding.UTF8.GetBytes("rekor-public-key");
|
||||
builder.Add(new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = "trust-roots/rekor-key.pem",
|
||||
Digest = ComputeSha256(rekorPlaceholder),
|
||||
SizeBytes = rekorPlaceholder.Length,
|
||||
Category = "trust-root",
|
||||
ContentType = "application/x-pem-file"
|
||||
});
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static SnapshotManifestEntry BuildPolicyPlaceholderEntry()
|
||||
{
|
||||
var placeholder = Encoding.UTF8.GetBytes("policy-bundle-placeholder");
|
||||
return new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = "policies/bundle.json",
|
||||
Digest = ComputeSha256(placeholder),
|
||||
SizeBytes = placeholder.Length,
|
||||
Category = "policy",
|
||||
ContentType = "application/json"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeManifestDigest(
|
||||
ImmutableArray<SnapshotManifestEntry> entries,
|
||||
DateTimeOffset createdAt)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(createdAt.ToUnixTimeSeconds());
|
||||
foreach (var entry in entries.OrderBy(e => e.RelativePath, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(entry.RelativePath);
|
||||
sb.Append(':');
|
||||
sb.Append(entry.Digest);
|
||||
}
|
||||
|
||||
return ComputeSha256(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
}
|
||||
|
||||
internal static string ComputeSha256(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(data, hash);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
// ── Serialization DTOs ─────────────────────────────────────────────
|
||||
|
||||
internal sealed class SnapshotArchiveDto
|
||||
{
|
||||
public string? ManifestDigest { get; set; }
|
||||
public string? Level { get; set; }
|
||||
public string? FormatVersion { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public SnapshotEntryDto[]? Entries { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SnapshotEntryDto
|
||||
{
|
||||
public string? RelativePath { get; set; }
|
||||
public string? Digest { get; set; }
|
||||
public long SizeBytes { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public string? ContentType { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SnapshotImporter.cs
|
||||
// Sprint: SPRINT_20260208_021_Attestor_snapshot_export_import_for_air_gap
|
||||
// Task: T1 — Snapshot import service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Imports attestation snapshot archives on air-gapped systems.
|
||||
/// Verifies manifest integrity and ingests entries into the local stores.
|
||||
/// </summary>
|
||||
public sealed class SnapshotImporter : ISnapshotImporter
|
||||
{
|
||||
private readonly IOfflineRootStore _rootStore;
|
||||
private readonly ILogger<SnapshotImporter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new snapshot importer.
|
||||
/// </summary>
|
||||
public SnapshotImporter(
|
||||
IOfflineRootStore rootStore,
|
||||
ILogger<SnapshotImporter> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SnapshotImportResult> ImportAsync(
|
||||
SnapshotImportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var messages = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
// Parse and validate
|
||||
SnapshotManifest manifest;
|
||||
try
|
||||
{
|
||||
manifest = ParseArchive(request.ArchiveContent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse snapshot archive.");
|
||||
return new SnapshotImportResult
|
||||
{
|
||||
Manifest = EmptyManifest(),
|
||||
Status = SnapshotOperationStatus.Failed,
|
||||
Messages = [ex.Message],
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
if (request.VerifyIntegrity)
|
||||
{
|
||||
var integrityResult = VerifyEntryDigests(manifest);
|
||||
if (!integrityResult.IsValid)
|
||||
{
|
||||
messages.AddRange(integrityResult.Issues);
|
||||
return new SnapshotImportResult
|
||||
{
|
||||
Manifest = manifest,
|
||||
Status = SnapshotOperationStatus.Failed,
|
||||
Messages = messages.ToImmutable(),
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Process entries
|
||||
int imported = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
|
||||
foreach (var entry in manifest.Entries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
if (request.SkipExisting && await EntryExistsAsync(entry, cancellationToken))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await IngestEntryAsync(entry, manifest.Level, cancellationToken);
|
||||
imported++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
messages.Add($"Failed to import '{entry.RelativePath}': {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to import snapshot entry: {Path}", entry.RelativePath);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
var status = failed > 0
|
||||
? imported > 0 ? SnapshotOperationStatus.PartialSuccess : SnapshotOperationStatus.Failed
|
||||
: SnapshotOperationStatus.Success;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Snapshot imported: Level={Level}, Imported={Imported}, Skipped={Skipped}, Failed={Failed}, Duration={DurationMs}ms",
|
||||
manifest.Level, imported, skipped, failed, sw.ElapsedMilliseconds);
|
||||
|
||||
return new SnapshotImportResult
|
||||
{
|
||||
Manifest = manifest,
|
||||
Status = status,
|
||||
ImportedCount = imported,
|
||||
SkippedCount = skipped,
|
||||
FailedCount = failed,
|
||||
Messages = messages.ToImmutable(),
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SnapshotImportResult> ValidateArchiveAsync(
|
||||
ReadOnlyMemory<byte> archiveContent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var messages = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
SnapshotManifest manifest;
|
||||
try
|
||||
{
|
||||
manifest = ParseArchive(archiveContent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(new SnapshotImportResult
|
||||
{
|
||||
Manifest = EmptyManifest(),
|
||||
Status = SnapshotOperationStatus.Failed,
|
||||
Messages = [$"Parse error: {ex.Message}"],
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
});
|
||||
}
|
||||
|
||||
var integrityResult = VerifyEntryDigests(manifest);
|
||||
if (!integrityResult.IsValid)
|
||||
{
|
||||
messages.AddRange(integrityResult.Issues);
|
||||
}
|
||||
else
|
||||
{
|
||||
messages.Add("Archive integrity verified successfully.");
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return Task.FromResult(new SnapshotImportResult
|
||||
{
|
||||
Manifest = manifest,
|
||||
Status = integrityResult.IsValid
|
||||
? SnapshotOperationStatus.Success
|
||||
: SnapshotOperationStatus.Failed,
|
||||
Messages = messages.ToImmutable(),
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
});
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────
|
||||
|
||||
private static SnapshotManifest ParseArchive(ReadOnlyMemory<byte> archiveContent)
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<SnapshotExporter.SnapshotArchiveDto>(
|
||||
archiveContent.Span, s_jsonOptions)
|
||||
?? throw new InvalidOperationException("Archive content is empty or malformed.");
|
||||
|
||||
if (!Enum.TryParse<SnapshotLevel>(dto.Level, ignoreCase: true, out var level))
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown snapshot level: '{dto.Level}'.");
|
||||
}
|
||||
|
||||
var entries = (dto.Entries ?? []).Select(e => new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = e.RelativePath ?? string.Empty,
|
||||
Digest = e.Digest ?? string.Empty,
|
||||
SizeBytes = e.SizeBytes,
|
||||
Category = e.Category ?? string.Empty,
|
||||
ContentType = e.ContentType ?? "application/octet-stream"
|
||||
}).ToImmutableArray();
|
||||
|
||||
return new SnapshotManifest
|
||||
{
|
||||
ManifestDigest = dto.ManifestDigest ?? string.Empty,
|
||||
Level = level,
|
||||
FormatVersion = dto.FormatVersion ?? "1.0.0",
|
||||
Entries = entries,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
TenantId = dto.TenantId,
|
||||
Description = dto.Description
|
||||
};
|
||||
}
|
||||
|
||||
private static (bool IsValid, ImmutableArray<string> Issues) VerifyEntryDigests(SnapshotManifest manifest)
|
||||
{
|
||||
var issues = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.ManifestDigest))
|
||||
{
|
||||
issues.Add("Manifest digest is missing.");
|
||||
}
|
||||
|
||||
foreach (var entry in manifest.Entries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Digest))
|
||||
{
|
||||
issues.Add($"Entry '{entry.RelativePath}' has no digest.");
|
||||
}
|
||||
|
||||
if (entry.SizeBytes < 0)
|
||||
{
|
||||
issues.Add($"Entry '{entry.RelativePath}' has invalid size: {entry.SizeBytes}.");
|
||||
}
|
||||
}
|
||||
|
||||
return (issues.Count == 0, issues.ToImmutable());
|
||||
}
|
||||
|
||||
private Task<bool> EntryExistsAsync(
|
||||
SnapshotManifestEntry entry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// In a full implementation this would check the local store.
|
||||
// For now, nothing exists locally so always return false.
|
||||
_ = cancellationToken;
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
private Task IngestEntryAsync(
|
||||
SnapshotManifestEntry entry,
|
||||
SnapshotLevel level,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
|
||||
// Route entries to appropriate stores based on category
|
||||
switch (entry.Category)
|
||||
{
|
||||
case "trust-root":
|
||||
_logger.LogDebug("Ingesting trust root: {Path}", entry.RelativePath);
|
||||
// In production would call _rootStore.ImportPemAsync(...)
|
||||
break;
|
||||
|
||||
case "attestation":
|
||||
_logger.LogDebug("Ingesting attestation: {Path}", entry.RelativePath);
|
||||
break;
|
||||
|
||||
case "policy" when level >= SnapshotLevel.LevelC:
|
||||
_logger.LogDebug("Ingesting policy bundle: {Path}", entry.RelativePath);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogDebug("Ingesting entry: {Path} (category={Category})", entry.RelativePath, entry.Category);
|
||||
break;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private SnapshotManifest EmptyManifest() => new()
|
||||
{
|
||||
ManifestDigest = string.Empty,
|
||||
Level = SnapshotLevel.LevelA,
|
||||
Entries = [],
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISchemaIsolationService.cs
|
||||
// Sprint: SPRINT_20260208_018_Attestor_postgresql_persistence_layer
|
||||
// Task: T1 — Interface for schema isolation, RLS, and temporal table management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing PostgreSQL schema isolation, Row-Level Security policies,
|
||||
/// and temporal table configurations across Attestor modules.
|
||||
/// </summary>
|
||||
public interface ISchemaIsolationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the schema assignment for a module.
|
||||
/// </summary>
|
||||
/// <param name="schema">Module schema identifier.</param>
|
||||
/// <returns>Schema assignment with table list.</returns>
|
||||
SchemaAssignment GetAssignment(AttestorSchema schema);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all schema assignments.
|
||||
/// </summary>
|
||||
ImmutableArray<SchemaAssignment> GetAllAssignments();
|
||||
|
||||
/// <summary>
|
||||
/// Generates SQL statements to provision a schema (CREATE SCHEMA IF NOT EXISTS,
|
||||
/// GRANT privileges, and schema-qualified table creation).
|
||||
/// </summary>
|
||||
/// <param name="schema">Schema to provision.</param>
|
||||
/// <returns>Provisioning result with generated SQL.</returns>
|
||||
SchemaProvisioningResult GenerateProvisioningSql(AttestorSchema schema);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the RLS policy definitions for a schema.
|
||||
/// </summary>
|
||||
/// <param name="schema">Schema to query.</param>
|
||||
/// <returns>RLS policies for the schema's tables.</returns>
|
||||
ImmutableArray<RlsPolicyDefinition> GetRlsPolicies(AttestorSchema schema);
|
||||
|
||||
/// <summary>
|
||||
/// Generates SQL statements to scaffold RLS policies for a schema.
|
||||
/// </summary>
|
||||
/// <param name="schema">Schema to scaffold RLS for.</param>
|
||||
/// <returns>Provisioning result with generated SQL.</returns>
|
||||
SchemaProvisioningResult GenerateRlsSql(AttestorSchema schema);
|
||||
|
||||
/// <summary>
|
||||
/// Gets temporal table configurations.
|
||||
/// </summary>
|
||||
ImmutableArray<TemporalTableConfig> GetTemporalTables();
|
||||
|
||||
/// <summary>
|
||||
/// Generates SQL statements to create temporal tables with history tracking.
|
||||
/// </summary>
|
||||
/// <param name="config">Temporal table configuration.</param>
|
||||
/// <returns>Provisioning result with generated SQL.</returns>
|
||||
SchemaProvisioningResult GenerateTemporalTableSql(TemporalTableConfig config);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a summary of the current schema isolation state.
|
||||
/// </summary>
|
||||
SchemaIsolationSummary GetSummary();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PersistenceServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_20260208_018_Attestor_postgresql_persistence_layer
|
||||
// Task: T2 — DI registration for schema isolation service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Attestor Persistence services
|
||||
/// with the <see cref="IServiceCollection"/>.
|
||||
/// </summary>
|
||||
public static class PersistenceServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the <see cref="ISchemaIsolationService"/> as a singleton.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAttestorPersistence(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ISchemaIsolationService>(sp =>
|
||||
new SchemaIsolationService(
|
||||
sp.GetService<TimeProvider>(),
|
||||
sp.GetRequiredService<IMeterFactory>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SchemaIsolationModels.cs
|
||||
// Sprint: SPRINT_20260208_018_Attestor_postgresql_persistence_layer
|
||||
// Task: T1 — Models for per-module schema isolation, RLS, and temporal tables
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Known PostgreSQL schemas used by Attestor modules.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AttestorSchema
|
||||
{
|
||||
/// <summary>Proof chain entities (SBOMs, DSSE envelopes, spines, trust anchors).</summary>
|
||||
ProofChain,
|
||||
|
||||
/// <summary>Attestor core entities (rekor queue, submission state).</summary>
|
||||
Attestor,
|
||||
|
||||
/// <summary>Verdict ledger (append-only decision log).</summary>
|
||||
Verdict,
|
||||
|
||||
/// <summary>Watchlist entities (identity alerts, dedup state).</summary>
|
||||
Watchlist,
|
||||
|
||||
/// <summary>Audit entities (noise ledger, hash audit log).</summary>
|
||||
Audit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Row-Level Security policy enforcement mode.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum RlsEnforcementMode
|
||||
{
|
||||
/// <summary>RLS disabled (application-level WHERE filtering only).</summary>
|
||||
Disabled,
|
||||
|
||||
/// <summary>RLS enabled in permissive mode (grants access via policy match).</summary>
|
||||
Permissive,
|
||||
|
||||
/// <summary>RLS enabled in restrictive mode (requires all policies to pass).</summary>
|
||||
Restrictive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Temporal table retention policy.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TemporalRetention
|
||||
{
|
||||
/// <summary>Retain all history.</summary>
|
||||
Unlimited,
|
||||
|
||||
/// <summary>Retain for 90 days.</summary>
|
||||
NinetyDays,
|
||||
|
||||
/// <summary>Retain for 1 year.</summary>
|
||||
OneYear,
|
||||
|
||||
/// <summary>Retain for 7 years (regulatory compliance).</summary>
|
||||
SevenYears
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a PostgreSQL schema assignment for a module.
|
||||
/// </summary>
|
||||
public sealed record SchemaAssignment
|
||||
{
|
||||
/// <summary>Module schema identifier.</summary>
|
||||
public required AttestorSchema Schema { get; init; }
|
||||
|
||||
/// <summary>PostgreSQL schema name (e.g., "proofchain", "attestor", "verdict").</summary>
|
||||
public required string SchemaName { get; init; }
|
||||
|
||||
/// <summary>Tables owned by this schema.</summary>
|
||||
public required ImmutableArray<string> Tables { get; init; }
|
||||
|
||||
/// <summary>Whether this schema has been created in the database.</summary>
|
||||
public bool IsProvisioned { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RLS policy definition for a table.
|
||||
/// </summary>
|
||||
public sealed record RlsPolicyDefinition
|
||||
{
|
||||
/// <summary>Policy name (e.g., "tenant_isolation").</summary>
|
||||
public required string PolicyName { get; init; }
|
||||
|
||||
/// <summary>Schema-qualified table name.</summary>
|
||||
public required string TableName { get; init; }
|
||||
|
||||
/// <summary>Schema this table belongs to.</summary>
|
||||
public required AttestorSchema Schema { get; init; }
|
||||
|
||||
/// <summary>Column used for tenant filtering (e.g., "tenant_id").</summary>
|
||||
public required string TenantColumn { get; init; }
|
||||
|
||||
/// <summary>Enforcement mode.</summary>
|
||||
public required RlsEnforcementMode Mode { get; init; }
|
||||
|
||||
/// <summary>PostgreSQL role that owns the policy.</summary>
|
||||
public string PolicyRole { get; init; } = "stellaops_app";
|
||||
|
||||
/// <summary>SQL expression for the policy USING clause.</summary>
|
||||
public string UsingExpression => $"{TenantColumn} = current_setting('app.tenant_id')";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Temporal table configuration for tracking entity history.
|
||||
/// </summary>
|
||||
public sealed record TemporalTableConfig
|
||||
{
|
||||
/// <summary>Schema-qualified table name.</summary>
|
||||
public required string TableName { get; init; }
|
||||
|
||||
/// <summary>History table name (e.g., "unknowns_history").</summary>
|
||||
public required string HistoryTableName { get; init; }
|
||||
|
||||
/// <summary>Schema this table belongs to.</summary>
|
||||
public required AttestorSchema Schema { get; init; }
|
||||
|
||||
/// <summary>Period start column name.</summary>
|
||||
public string PeriodStartColumn { get; init; } = "valid_from";
|
||||
|
||||
/// <summary>Period end column name.</summary>
|
||||
public string PeriodEndColumn { get; init; } = "valid_to";
|
||||
|
||||
/// <summary>Retention policy for history data.</summary>
|
||||
public TemporalRetention Retention { get; init; } = TemporalRetention.OneYear;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a schema provisioning or RLS scaffolding operation.
|
||||
/// </summary>
|
||||
public sealed record SchemaProvisioningResult
|
||||
{
|
||||
/// <summary>Schema that was provisioned.</summary>
|
||||
public required AttestorSchema Schema { get; init; }
|
||||
|
||||
/// <summary>Whether the operation succeeded.</summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>SQL statements generated.</summary>
|
||||
public required ImmutableArray<string> GeneratedStatements { get; init; }
|
||||
|
||||
/// <summary>Error message if the operation failed.</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>Timestamp of the operation.</summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of the current schema isolation state across all modules.
|
||||
/// </summary>
|
||||
public sealed record SchemaIsolationSummary
|
||||
{
|
||||
/// <summary>All schema assignments.</summary>
|
||||
public required ImmutableArray<SchemaAssignment> Assignments { get; init; }
|
||||
|
||||
/// <summary>All RLS policies.</summary>
|
||||
public required ImmutableArray<RlsPolicyDefinition> RlsPolicies { get; init; }
|
||||
|
||||
/// <summary>All temporal table configurations.</summary>
|
||||
public required ImmutableArray<TemporalTableConfig> TemporalTables { get; init; }
|
||||
|
||||
/// <summary>Total provisioned schemas.</summary>
|
||||
public int ProvisionedCount => Assignments.Count(a => a.IsProvisioned);
|
||||
|
||||
/// <summary>Total RLS-enabled tables.</summary>
|
||||
public int RlsEnabledCount => RlsPolicies.Count(p => p.Mode != RlsEnforcementMode.Disabled);
|
||||
|
||||
/// <summary>When this summary was computed.</summary>
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SchemaIsolationService.cs
|
||||
// Sprint: SPRINT_20260208_018_Attestor_postgresql_persistence_layer
|
||||
// Task: T1 — Schema isolation, RLS scaffolding, temporal table management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ISchemaIsolationService"/> that manages
|
||||
/// schema assignments, RLS policies, and temporal table configurations for Attestor modules.
|
||||
/// </summary>
|
||||
public sealed class SchemaIsolationService : ISchemaIsolationService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Counter<long> _provisioningOps;
|
||||
private readonly Counter<long> _rlsOps;
|
||||
private readonly Counter<long> _temporalOps;
|
||||
|
||||
/// <summary>
|
||||
/// Static registry of schema assignments mapping modules to PostgreSQL schemas and tables.
|
||||
/// </summary>
|
||||
private static readonly ImmutableDictionary<AttestorSchema, SchemaAssignment> Assignments =
|
||||
new Dictionary<AttestorSchema, SchemaAssignment>
|
||||
{
|
||||
[AttestorSchema.ProofChain] = new()
|
||||
{
|
||||
Schema = AttestorSchema.ProofChain,
|
||||
SchemaName = "proofchain",
|
||||
Tables = ["sbom_entries", "dsse_envelopes", "spines", "trust_anchors", "rekor_entries", "audit_log"]
|
||||
},
|
||||
[AttestorSchema.Attestor] = new()
|
||||
{
|
||||
Schema = AttestorSchema.Attestor,
|
||||
SchemaName = "attestor",
|
||||
Tables = ["rekor_submission_queue", "submission_state"]
|
||||
},
|
||||
[AttestorSchema.Verdict] = new()
|
||||
{
|
||||
Schema = AttestorSchema.Verdict,
|
||||
SchemaName = "verdict",
|
||||
Tables = ["verdict_ledger", "verdict_policies"]
|
||||
},
|
||||
[AttestorSchema.Watchlist] = new()
|
||||
{
|
||||
Schema = AttestorSchema.Watchlist,
|
||||
SchemaName = "watchlist",
|
||||
Tables = ["watched_identities", "identity_alerts", "alert_dedup"]
|
||||
},
|
||||
[AttestorSchema.Audit] = new()
|
||||
{
|
||||
Schema = AttestorSchema.Audit,
|
||||
SchemaName = "audit",
|
||||
Tables = ["noise_ledger", "hash_audit_log", "suppression_stats"]
|
||||
}
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
/// <summary>
|
||||
/// Static registry of RLS policies for tenant isolation.
|
||||
/// </summary>
|
||||
private static readonly ImmutableArray<RlsPolicyDefinition> AllRlsPolicies =
|
||||
[
|
||||
// Verdict schema
|
||||
new()
|
||||
{
|
||||
PolicyName = "verdict_tenant_isolation",
|
||||
TableName = "verdict.verdict_ledger",
|
||||
Schema = AttestorSchema.Verdict,
|
||||
TenantColumn = "tenant_id",
|
||||
Mode = RlsEnforcementMode.Permissive
|
||||
},
|
||||
new()
|
||||
{
|
||||
PolicyName = "verdict_policies_tenant_isolation",
|
||||
TableName = "verdict.verdict_policies",
|
||||
Schema = AttestorSchema.Verdict,
|
||||
TenantColumn = "tenant_id",
|
||||
Mode = RlsEnforcementMode.Permissive
|
||||
},
|
||||
// Watchlist schema
|
||||
new()
|
||||
{
|
||||
PolicyName = "watchlist_tenant_isolation",
|
||||
TableName = "watchlist.watched_identities",
|
||||
Schema = AttestorSchema.Watchlist,
|
||||
TenantColumn = "tenant_id",
|
||||
Mode = RlsEnforcementMode.Permissive
|
||||
},
|
||||
new()
|
||||
{
|
||||
PolicyName = "alerts_tenant_isolation",
|
||||
TableName = "watchlist.identity_alerts",
|
||||
Schema = AttestorSchema.Watchlist,
|
||||
TenantColumn = "tenant_id",
|
||||
Mode = RlsEnforcementMode.Permissive
|
||||
},
|
||||
// Attestor schema
|
||||
new()
|
||||
{
|
||||
PolicyName = "queue_tenant_isolation",
|
||||
TableName = "attestor.rekor_submission_queue",
|
||||
Schema = AttestorSchema.Attestor,
|
||||
TenantColumn = "tenant_id",
|
||||
Mode = RlsEnforcementMode.Permissive
|
||||
},
|
||||
// Audit schema
|
||||
new()
|
||||
{
|
||||
PolicyName = "noise_tenant_isolation",
|
||||
TableName = "audit.noise_ledger",
|
||||
Schema = AttestorSchema.Audit,
|
||||
TenantColumn = "tenant_id",
|
||||
Mode = RlsEnforcementMode.Permissive
|
||||
}
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Static registry of temporal table configurations.
|
||||
/// </summary>
|
||||
private static readonly ImmutableArray<TemporalTableConfig> AllTemporalTables =
|
||||
[
|
||||
new()
|
||||
{
|
||||
TableName = "verdict.verdict_ledger",
|
||||
HistoryTableName = "verdict.verdict_ledger_history",
|
||||
Schema = AttestorSchema.Verdict,
|
||||
Retention = TemporalRetention.SevenYears
|
||||
},
|
||||
new()
|
||||
{
|
||||
TableName = "watchlist.watched_identities",
|
||||
HistoryTableName = "watchlist.watched_identities_history",
|
||||
Schema = AttestorSchema.Watchlist,
|
||||
Retention = TemporalRetention.OneYear
|
||||
},
|
||||
new()
|
||||
{
|
||||
TableName = "audit.noise_ledger",
|
||||
HistoryTableName = "audit.noise_ledger_history",
|
||||
Schema = AttestorSchema.Audit,
|
||||
Retention = TemporalRetention.SevenYears
|
||||
}
|
||||
];
|
||||
|
||||
public SchemaIsolationService(
|
||||
TimeProvider? timeProvider,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.Persistence.SchemaIsolation");
|
||||
_provisioningOps = meter.CreateCounter<long>("schema.provisioning.operations");
|
||||
_rlsOps = meter.CreateCounter<long>("schema.rls.operations");
|
||||
_temporalOps = meter.CreateCounter<long>("schema.temporal.operations");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SchemaAssignment GetAssignment(AttestorSchema schema)
|
||||
{
|
||||
if (!Assignments.TryGetValue(schema, out var assignment))
|
||||
throw new ArgumentException($"Unknown schema: {schema}", nameof(schema));
|
||||
return assignment;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<SchemaAssignment> GetAllAssignments() =>
|
||||
[.. Assignments.Values];
|
||||
|
||||
/// <inheritdoc />
|
||||
public SchemaProvisioningResult GenerateProvisioningSql(AttestorSchema schema)
|
||||
{
|
||||
_provisioningOps.Add(1);
|
||||
var assignment = GetAssignment(schema);
|
||||
var statements = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
// CREATE SCHEMA
|
||||
statements.Add($"CREATE SCHEMA IF NOT EXISTS {assignment.SchemaName};");
|
||||
|
||||
// GRANT usage
|
||||
statements.Add($"GRANT USAGE ON SCHEMA {assignment.SchemaName} TO stellaops_app;");
|
||||
|
||||
// Default privileges for future tables
|
||||
statements.Add(
|
||||
$"ALTER DEFAULT PRIVILEGES IN SCHEMA {assignment.SchemaName} " +
|
||||
$"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO stellaops_app;");
|
||||
|
||||
// Comment for documentation
|
||||
statements.Add(
|
||||
$"COMMENT ON SCHEMA {assignment.SchemaName} IS " +
|
||||
$"'Attestor module: {schema} — managed by SchemaIsolationService';");
|
||||
|
||||
return new SchemaProvisioningResult
|
||||
{
|
||||
Schema = schema,
|
||||
Success = true,
|
||||
GeneratedStatements = statements.ToImmutable(),
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<RlsPolicyDefinition> GetRlsPolicies(AttestorSchema schema) =>
|
||||
[.. AllRlsPolicies.Where(p => p.Schema == schema)];
|
||||
|
||||
/// <inheritdoc />
|
||||
public SchemaProvisioningResult GenerateRlsSql(AttestorSchema schema)
|
||||
{
|
||||
_rlsOps.Add(1);
|
||||
var policies = GetRlsPolicies(schema);
|
||||
|
||||
if (policies.IsEmpty)
|
||||
{
|
||||
return new SchemaProvisioningResult
|
||||
{
|
||||
Schema = schema,
|
||||
Success = true,
|
||||
GeneratedStatements = [],
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
var statements = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
foreach (var policy in policies)
|
||||
{
|
||||
if (policy.Mode == RlsEnforcementMode.Disabled)
|
||||
continue;
|
||||
|
||||
// Enable RLS on the table
|
||||
statements.Add($"ALTER TABLE {policy.TableName} ENABLE ROW LEVEL SECURITY;");
|
||||
|
||||
// Force RLS for table owner too
|
||||
statements.Add($"ALTER TABLE {policy.TableName} FORCE ROW LEVEL SECURITY;");
|
||||
|
||||
// Create the tenant isolation policy
|
||||
var policyType = policy.Mode == RlsEnforcementMode.Restrictive
|
||||
? "AS RESTRICTIVE"
|
||||
: "AS PERMISSIVE";
|
||||
|
||||
statements.Add(
|
||||
$"CREATE POLICY {policy.PolicyName} ON {policy.TableName} " +
|
||||
$"{policyType} FOR ALL TO {policy.PolicyRole} " +
|
||||
$"USING ({policy.UsingExpression});");
|
||||
}
|
||||
|
||||
return new SchemaProvisioningResult
|
||||
{
|
||||
Schema = schema,
|
||||
Success = true,
|
||||
GeneratedStatements = statements.ToImmutable(),
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<TemporalTableConfig> GetTemporalTables() => AllTemporalTables;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SchemaProvisioningResult GenerateTemporalTableSql(TemporalTableConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
_temporalOps.Add(1);
|
||||
|
||||
var statements = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
// Add period columns to the main table
|
||||
statements.Add(
|
||||
$"ALTER TABLE {config.TableName} " +
|
||||
$"ADD COLUMN IF NOT EXISTS {config.PeriodStartColumn} TIMESTAMPTZ NOT NULL DEFAULT NOW(), " +
|
||||
$"ADD COLUMN IF NOT EXISTS {config.PeriodEndColumn} TIMESTAMPTZ NOT NULL DEFAULT 'infinity';");
|
||||
|
||||
// Create the history table
|
||||
statements.Add(
|
||||
$"CREATE TABLE IF NOT EXISTS {config.HistoryTableName} " +
|
||||
$"(LIKE {config.TableName} INCLUDING ALL);");
|
||||
|
||||
// Create trigger function for history tracking
|
||||
var triggerFn = config.HistoryTableName.Replace('.', '_') + "_trigger_fn";
|
||||
statements.Add(
|
||||
$"CREATE OR REPLACE FUNCTION {triggerFn}() RETURNS TRIGGER AS $$ " +
|
||||
$"BEGIN " +
|
||||
$"IF TG_OP = 'UPDATE' THEN " +
|
||||
$"INSERT INTO {config.HistoryTableName} SELECT OLD.*; " +
|
||||
$"NEW.{config.PeriodStartColumn} = NOW(); " +
|
||||
$"RETURN NEW; " +
|
||||
$"ELSIF TG_OP = 'DELETE' THEN " +
|
||||
$"INSERT INTO {config.HistoryTableName} SELECT OLD.*; " +
|
||||
$"RETURN OLD; " +
|
||||
$"END IF; " +
|
||||
$"RETURN NULL; " +
|
||||
$"END; $$ LANGUAGE plpgsql;");
|
||||
|
||||
// Attach trigger
|
||||
var triggerName = config.HistoryTableName.Replace('.', '_') + "_trigger";
|
||||
statements.Add(
|
||||
$"CREATE TRIGGER {triggerName} " +
|
||||
$"BEFORE UPDATE OR DELETE ON {config.TableName} " +
|
||||
$"FOR EACH ROW EXECUTE FUNCTION {triggerFn}();");
|
||||
|
||||
// Add retention comment
|
||||
statements.Add(
|
||||
$"COMMENT ON TABLE {config.HistoryTableName} IS " +
|
||||
$"'Temporal history for {config.TableName} — retention: {config.Retention}';");
|
||||
|
||||
return new SchemaProvisioningResult
|
||||
{
|
||||
Schema = config.Schema,
|
||||
Success = true,
|
||||
GeneratedStatements = statements.ToImmutable(),
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SchemaIsolationSummary GetSummary() => new()
|
||||
{
|
||||
Assignments = GetAllAssignments(),
|
||||
RlsPolicies = AllRlsPolicies,
|
||||
TemporalTables = AllTemporalTables,
|
||||
ComputedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// INoiseLedgerService.cs
|
||||
// Sprint: SPRINT_20260208_017_Attestor_noise_ledger
|
||||
// Task: T1 — Interface for Noise Ledger (audit log of suppressions)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing a noise ledger that aggregates all suppression decisions
|
||||
/// into a queryable, auditable log.
|
||||
/// </summary>
|
||||
public interface INoiseLedgerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a suppression decision in the noise ledger.
|
||||
/// </summary>
|
||||
/// <param name="request">Suppression details.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result with entry digest and dedup status.</returns>
|
||||
Task<RecordSuppressionResult> RecordAsync(
|
||||
RecordSuppressionRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a ledger entry by its digest.
|
||||
/// </summary>
|
||||
/// <param name="entryDigest">Content-addressed digest of the entry.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The entry, or null if not found.</returns>
|
||||
Task<NoiseLedgerEntry?> GetByDigestAsync(
|
||||
string entryDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries the noise ledger with optional filters.
|
||||
/// </summary>
|
||||
/// <param name="query">Query parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Matching entries ordered by most recent first.</returns>
|
||||
Task<ImmutableArray<NoiseLedgerEntry>> QueryAsync(
|
||||
NoiseLedgerQuery query,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes aggregated statistics for the noise ledger.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Optional tenant filter.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Suppression statistics.</returns>
|
||||
Task<SuppressionStatistics> GetStatisticsAsync(
|
||||
string? tenantId = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NoiseLedgerModels.cs
|
||||
// Sprint: SPRINT_20260208_017_Attestor_noise_ledger
|
||||
// Task: T1 — Models for Noise Ledger (audit log of suppressions)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Category of suppression that led to a noise entry.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SuppressionCategory
|
||||
{
|
||||
/// <summary>VEX override (vendor-provided "not affected" or "fixed").</summary>
|
||||
VexOverride,
|
||||
|
||||
/// <summary>Alert deduplication (duplicate within time window).</summary>
|
||||
AlertDedup,
|
||||
|
||||
/// <summary>Policy-based suppression (rule or threshold).</summary>
|
||||
PolicyRule,
|
||||
|
||||
/// <summary>Manual operator acknowledgment.</summary>
|
||||
OperatorAck,
|
||||
|
||||
/// <summary>Severity threshold filter (below minimum severity).</summary>
|
||||
SeverityFilter,
|
||||
|
||||
/// <summary>Component-level exclusion (excluded from scan scope).</summary>
|
||||
ComponentExclusion,
|
||||
|
||||
/// <summary>False positive determination (confirmed not exploitable).</summary>
|
||||
FalsePositive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity level of the suppressed finding.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum FindingSeverity
|
||||
{
|
||||
/// <summary>No severity / informational.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Low severity.</summary>
|
||||
Low,
|
||||
|
||||
/// <summary>Medium severity.</summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>High severity.</summary>
|
||||
High,
|
||||
|
||||
/// <summary>Critical severity.</summary>
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single entry in the noise ledger recording a suppression decision.
|
||||
/// </summary>
|
||||
public sealed record NoiseLedgerEntry
|
||||
{
|
||||
/// <summary>Content-addressed digest of this entry.</summary>
|
||||
public required string EntryDigest { get; init; }
|
||||
|
||||
/// <summary>Finding identifier (CVE, advisory ID, or internal finding ID).</summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>Category of suppression applied.</summary>
|
||||
public required SuppressionCategory Category { get; init; }
|
||||
|
||||
/// <summary>Severity of the suppressed finding.</summary>
|
||||
public required FindingSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>Component or artifact affected.</summary>
|
||||
public required string ComponentRef { get; init; }
|
||||
|
||||
/// <summary>Justification provided for the suppression.</summary>
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>Identity of the actor who applied the suppression.</summary>
|
||||
public required string SuppressedBy { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the suppression was recorded.</summary>
|
||||
public required DateTimeOffset SuppressedAt { get; init; }
|
||||
|
||||
/// <summary>Optional expiration for time-bounded suppressions.</summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Optional evidence digest linking to proof of suppression decision.</summary>
|
||||
public string? EvidenceDigest { get; init; }
|
||||
|
||||
/// <summary>Optional tenant scope.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Optional pipeline or scan correlation ID.</summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>Whether this suppression has expired.</summary>
|
||||
public bool IsExpired(DateTimeOffset now) =>
|
||||
ExpiresAt.HasValue && ExpiresAt.Value <= now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to record a suppression in the noise ledger.
|
||||
/// </summary>
|
||||
public sealed record RecordSuppressionRequest
|
||||
{
|
||||
/// <summary>Finding identifier.</summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>Category of suppression.</summary>
|
||||
public required SuppressionCategory Category { get; init; }
|
||||
|
||||
/// <summary>Severity of the finding being suppressed.</summary>
|
||||
public required FindingSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>Component reference.</summary>
|
||||
public required string ComponentRef { get; init; }
|
||||
|
||||
/// <summary>Justification for suppression.</summary>
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>Who performed the suppression.</summary>
|
||||
public required string SuppressedBy { get; init; }
|
||||
|
||||
/// <summary>Optional expiration.</summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Optional evidence digest.</summary>
|
||||
public string? EvidenceDigest { get; init; }
|
||||
|
||||
/// <summary>Optional tenant scope.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Optional correlation ID.</summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of recording a suppression.
|
||||
/// </summary>
|
||||
public sealed record RecordSuppressionResult
|
||||
{
|
||||
/// <summary>Digest of the ledger entry.</summary>
|
||||
public required string EntryDigest { get; init; }
|
||||
|
||||
/// <summary>Whether this was a duplicate entry.</summary>
|
||||
public required bool Deduplicated { get; init; }
|
||||
|
||||
/// <summary>The stored ledger entry.</summary>
|
||||
public required NoiseLedgerEntry Entry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for the noise ledger.
|
||||
/// </summary>
|
||||
public sealed record NoiseLedgerQuery
|
||||
{
|
||||
/// <summary>Filter by finding ID.</summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>Filter by suppression category.</summary>
|
||||
public SuppressionCategory? Category { get; init; }
|
||||
|
||||
/// <summary>Filter by severity.</summary>
|
||||
public FindingSeverity? Severity { get; init; }
|
||||
|
||||
/// <summary>Filter by component reference.</summary>
|
||||
public string? ComponentRef { get; init; }
|
||||
|
||||
/// <summary>Filter by suppressor identity.</summary>
|
||||
public string? SuppressedBy { get; init; }
|
||||
|
||||
/// <summary>Filter by tenant scope.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Only include active (non-expired) suppressions.</summary>
|
||||
public bool ActiveOnly { get; init; }
|
||||
|
||||
/// <summary>Maximum results to return.</summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated statistics for suppression activity.
|
||||
/// </summary>
|
||||
public sealed record SuppressionStatistics
|
||||
{
|
||||
/// <summary>Total suppression count.</summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>Count by category.</summary>
|
||||
public required ImmutableDictionary<SuppressionCategory, int> ByCategoryCount { get; init; }
|
||||
|
||||
/// <summary>Count by severity.</summary>
|
||||
public required ImmutableDictionary<FindingSeverity, int> BySeverityCount { get; init; }
|
||||
|
||||
/// <summary>Count of active (non-expired) suppressions.</summary>
|
||||
public required int ActiveCount { get; init; }
|
||||
|
||||
/// <summary>Count of expired suppressions.</summary>
|
||||
public required int ExpiredCount { get; init; }
|
||||
|
||||
/// <summary>Timestamp when these statistics were computed.</summary>
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NoiseLedgerService.cs
|
||||
// Sprint: SPRINT_20260208_017_Attestor_noise_ledger
|
||||
// Task: T1 — Noise Ledger service implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="INoiseLedgerService"/> that stores
|
||||
/// suppression decisions in-memory with content-addressed deduplication.
|
||||
/// </summary>
|
||||
public sealed class NoiseLedgerService : INoiseLedgerService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, NoiseLedgerEntry> _entries = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Counter<long> _suppressionsRecorded;
|
||||
private readonly Counter<long> _suppressionsDeduplicated;
|
||||
private readonly Counter<long> _queriesExecuted;
|
||||
private readonly Counter<long> _statisticsComputed;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public NoiseLedgerService(
|
||||
TimeProvider? timeProvider,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Audit.NoiseLedger");
|
||||
_suppressionsRecorded = meter.CreateCounter<long>("noise.suppressions.recorded");
|
||||
_suppressionsDeduplicated = meter.CreateCounter<long>("noise.suppressions.deduplicated");
|
||||
_queriesExecuted = meter.CreateCounter<long>("noise.queries.executed");
|
||||
_statisticsComputed = meter.CreateCounter<long>("noise.statistics.computed");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RecordSuppressionResult> RecordAsync(
|
||||
RecordSuppressionRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.FindingId))
|
||||
throw new ArgumentException("FindingId is required.", nameof(request));
|
||||
if (string.IsNullOrWhiteSpace(request.ComponentRef))
|
||||
throw new ArgumentException("ComponentRef is required.", nameof(request));
|
||||
if (string.IsNullOrWhiteSpace(request.Justification))
|
||||
throw new ArgumentException("Justification is required.", nameof(request));
|
||||
if (string.IsNullOrWhiteSpace(request.SuppressedBy))
|
||||
throw new ArgumentException("SuppressedBy is required.", nameof(request));
|
||||
|
||||
var digest = ComputeEntryDigest(request);
|
||||
|
||||
if (_entries.TryGetValue(digest, out var existing))
|
||||
{
|
||||
_suppressionsDeduplicated.Add(1);
|
||||
return Task.FromResult(new RecordSuppressionResult
|
||||
{
|
||||
EntryDigest = digest,
|
||||
Deduplicated = true,
|
||||
Entry = existing
|
||||
});
|
||||
}
|
||||
|
||||
var entry = new NoiseLedgerEntry
|
||||
{
|
||||
EntryDigest = digest,
|
||||
FindingId = request.FindingId,
|
||||
Category = request.Category,
|
||||
Severity = request.Severity,
|
||||
ComponentRef = request.ComponentRef,
|
||||
Justification = request.Justification,
|
||||
SuppressedBy = request.SuppressedBy,
|
||||
SuppressedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
EvidenceDigest = request.EvidenceDigest,
|
||||
TenantId = request.TenantId,
|
||||
CorrelationId = request.CorrelationId
|
||||
};
|
||||
|
||||
var added = _entries.TryAdd(digest, entry);
|
||||
if (!added)
|
||||
{
|
||||
_suppressionsDeduplicated.Add(1);
|
||||
return Task.FromResult(new RecordSuppressionResult
|
||||
{
|
||||
EntryDigest = digest,
|
||||
Deduplicated = true,
|
||||
Entry = _entries[digest]
|
||||
});
|
||||
}
|
||||
|
||||
_suppressionsRecorded.Add(1);
|
||||
return Task.FromResult(new RecordSuppressionResult
|
||||
{
|
||||
EntryDigest = digest,
|
||||
Deduplicated = false,
|
||||
Entry = entry
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<NoiseLedgerEntry?> GetByDigestAsync(
|
||||
string entryDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(entryDigest);
|
||||
|
||||
_entries.TryGetValue(entryDigest, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<NoiseLedgerEntry>> QueryAsync(
|
||||
NoiseLedgerQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
_queriesExecuted.Add(1);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
IEnumerable<NoiseLedgerEntry> results = _entries.Values;
|
||||
|
||||
if (!string.IsNullOrEmpty(query.FindingId))
|
||||
results = results.Where(e =>
|
||||
e.FindingId.Equals(query.FindingId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (query.Category.HasValue)
|
||||
results = results.Where(e => e.Category == query.Category.Value);
|
||||
|
||||
if (query.Severity.HasValue)
|
||||
results = results.Where(e => e.Severity == query.Severity.Value);
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ComponentRef))
|
||||
results = results.Where(e =>
|
||||
e.ComponentRef.Equals(query.ComponentRef, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrEmpty(query.SuppressedBy))
|
||||
results = results.Where(e =>
|
||||
e.SuppressedBy.Equals(query.SuppressedBy, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrEmpty(query.TenantId))
|
||||
results = results.Where(e =>
|
||||
e.TenantId is not null &&
|
||||
e.TenantId.Equals(query.TenantId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (query.ActiveOnly)
|
||||
results = results.Where(e => !e.IsExpired(now));
|
||||
|
||||
return Task.FromResult(results
|
||||
.OrderByDescending(e => e.SuppressedAt)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SuppressionStatistics> GetStatisticsAsync(
|
||||
string? tenantId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
_statisticsComputed.Add(1);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
IEnumerable<NoiseLedgerEntry> entries = _entries.Values;
|
||||
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
entries = entries.Where(e =>
|
||||
e.TenantId is not null &&
|
||||
e.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var entriesList = entries.ToList();
|
||||
|
||||
var byCategory = entriesList
|
||||
.GroupBy(e => e.Category)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var bySeverity = entriesList
|
||||
.GroupBy(e => e.Severity)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var activeCount = entriesList.Count(e => !e.IsExpired(now));
|
||||
var expiredCount = entriesList.Count(e => e.IsExpired(now));
|
||||
|
||||
return Task.FromResult(new SuppressionStatistics
|
||||
{
|
||||
TotalCount = entriesList.Count,
|
||||
ByCategoryCount = byCategory,
|
||||
BySeverityCount = bySeverity,
|
||||
ActiveCount = activeCount,
|
||||
ExpiredCount = expiredCount,
|
||||
ComputedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic digest from the suppression request.
|
||||
/// The digest is based on finding ID + category + component + suppressor identity
|
||||
/// to enable deduplication of identical suppression decisions.
|
||||
/// </summary>
|
||||
private static string ComputeEntryDigest(RecordSuppressionRequest request)
|
||||
{
|
||||
var canonical = new
|
||||
{
|
||||
finding_id = request.FindingId,
|
||||
category = request.Category.ToString(),
|
||||
severity = request.Severity.ToString(),
|
||||
component_ref = request.ComponentRef,
|
||||
suppressed_by = request.SuppressedBy,
|
||||
justification = request.Justification
|
||||
};
|
||||
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(canonical, SerializerOptions);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ContentAddressedStoreModels.cs
|
||||
// Sprint: SPRINT_20260208_005_Attestor_cas_for_sbom_vex_attestation_artifacts
|
||||
// Task: T1 — Models for unified content-addressed artifact store
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type classification for CAS-stored blobs.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CasArtifactType
|
||||
{
|
||||
/// <summary>Software Bill of Materials.</summary>
|
||||
Sbom,
|
||||
|
||||
/// <summary>VEX (Vulnerability Exploitability Exchange) document.</summary>
|
||||
Vex,
|
||||
|
||||
/// <summary>DSSE-signed attestation envelope.</summary>
|
||||
Attestation,
|
||||
|
||||
/// <summary>Proof chain bundle.</summary>
|
||||
ProofBundle,
|
||||
|
||||
/// <summary>Evidence pack manifest.</summary>
|
||||
EvidencePack,
|
||||
|
||||
/// <summary>Binary fingerprint record.</summary>
|
||||
BinaryFingerprint,
|
||||
|
||||
/// <summary>Generic/other artifact type.</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stored artifact in the CAS. Content-addressed by SHA-256 of the raw bytes.
|
||||
/// </summary>
|
||||
public sealed record CasArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed digest in "sha256:<hex>" format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifact_type")]
|
||||
public required CasArtifactType ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type (e.g., "application/spdx+json", "application/vnd.csaf+json").
|
||||
/// </summary>
|
||||
[JsonPropertyName("media_type")]
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the stored blob in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size_bytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tags for indexing/querying.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public ImmutableDictionary<string, string> Tags { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the artifact was first stored.
|
||||
/// </summary>
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this artifact was deduplicated (already existed on put).
|
||||
/// </summary>
|
||||
[JsonPropertyName("deduplicated")]
|
||||
public bool Deduplicated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related artifact digests (e.g., parent SBOM, signing attestation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("related_digests")]
|
||||
public ImmutableArray<string> RelatedDigests { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for storing a new artifact in the CAS.
|
||||
/// </summary>
|
||||
public sealed record CasPutRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw artifact bytes.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type classification.
|
||||
/// </summary>
|
||||
public required CasArtifactType ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the content.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tags for indexing.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Tags { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Related artifact digests.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RelatedDigests { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a CAS put operation.
|
||||
/// </summary>
|
||||
public sealed record CasPutResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The stored artifact metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifact")]
|
||||
public required CasArtifact Artifact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the content was deduplicated (already existed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("deduplicated")]
|
||||
public bool Deduplicated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieved artifact with content.
|
||||
/// </summary>
|
||||
public sealed record CasGetResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact metadata.
|
||||
/// </summary>
|
||||
public required CasArtifact Artifact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw content bytes.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> Content { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing CAS artifacts.
|
||||
/// </summary>
|
||||
public sealed record CasQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by artifact type.
|
||||
/// </summary>
|
||||
public CasArtifactType? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by media type.
|
||||
/// </summary>
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by tag key-value pair.
|
||||
/// </summary>
|
||||
public string? TagKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by tag value (requires TagKey).
|
||||
/// </summary>
|
||||
public string? TagValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum results to return.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Pagination offset.
|
||||
/// </summary>
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the CAS store.
|
||||
/// </summary>
|
||||
public sealed record CasStatistics
|
||||
{
|
||||
/// <summary>Total number of stored artifacts.</summary>
|
||||
[JsonPropertyName("total_artifacts")]
|
||||
public long TotalArtifacts { get; init; }
|
||||
|
||||
/// <summary>Total bytes across all stored artifacts.</summary>
|
||||
[JsonPropertyName("total_bytes")]
|
||||
public long TotalBytes { get; init; }
|
||||
|
||||
/// <summary>Number of deduplicated puts (savings).</summary>
|
||||
[JsonPropertyName("dedup_count")]
|
||||
public long DedupCount { get; init; }
|
||||
|
||||
/// <summary>Breakdown by artifact type.</summary>
|
||||
[JsonPropertyName("type_counts")]
|
||||
public ImmutableDictionary<CasArtifactType, long> TypeCounts { get; init; } =
|
||||
ImmutableDictionary<CasArtifactType, long>.Empty;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FileSystemObjectStorageProvider.cs
|
||||
// Sprint: SPRINT_20260208_019_Attestor_s3_minio_gcs_object_storage_for_tiles
|
||||
// Task: T1 — Filesystem-based object storage for offline/air-gap deployments
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem-based <see cref="IObjectStorageProvider"/> implementation.
|
||||
/// Stores blobs as files under a configurable root directory with content-addressed paths.
|
||||
/// Supports write-once enforcement for WORM compliance.
|
||||
/// Designed for offline and air-gap deployments.
|
||||
/// </summary>
|
||||
public sealed class FileSystemObjectStorageProvider : IObjectStorageProvider
|
||||
{
|
||||
private readonly ObjectStorageConfig _config;
|
||||
private readonly Counter<long> _putsCounter;
|
||||
private readonly Counter<long> _getsCounter;
|
||||
private readonly Counter<long> _deletesCounter;
|
||||
|
||||
public FileSystemObjectStorageProvider(
|
||||
ObjectStorageConfig config,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.RootPath))
|
||||
throw new ArgumentException("RootPath is required for FileSystem provider.", nameof(config));
|
||||
|
||||
_config = config;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Cas.FileSystem");
|
||||
_putsCounter = meter.CreateCounter<long>("objectstorage.fs.puts");
|
||||
_getsCounter = meter.CreateCounter<long>("objectstorage.fs.gets");
|
||||
_deletesCounter = meter.CreateCounter<long>("objectstorage.fs.deletes");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ObjectStorageProviderKind Kind => ObjectStorageProviderKind.FileSystem;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BlobPutResult> PutAsync(BlobPutRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var fullPath = ResolvePath(request.Key);
|
||||
|
||||
if (_config.EnforceWriteOnce && File.Exists(fullPath))
|
||||
{
|
||||
var existingLength = new FileInfo(fullPath).Length;
|
||||
return Task.FromResult(new BlobPutResult
|
||||
{
|
||||
Key = request.Key,
|
||||
SizeBytes = existingLength,
|
||||
AlreadyExisted = true
|
||||
});
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Atomic write via temp file + rename
|
||||
var tempPath = fullPath + ".tmp";
|
||||
File.WriteAllBytes(tempPath, request.Content.ToArray());
|
||||
File.Move(tempPath, fullPath, overwrite: !_config.EnforceWriteOnce);
|
||||
|
||||
// Store metadata sidecar
|
||||
WriteMetadata(fullPath, request.ContentType, request.Metadata);
|
||||
|
||||
_putsCounter.Add(1);
|
||||
|
||||
return Task.FromResult(new BlobPutResult
|
||||
{
|
||||
Key = request.Key,
|
||||
SizeBytes = request.Content.Length,
|
||||
AlreadyExisted = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BlobGetResult?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var fullPath = ResolvePath(key);
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
return Task.FromResult<BlobGetResult?>(null);
|
||||
|
||||
_getsCounter.Add(1);
|
||||
|
||||
var content = File.ReadAllBytes(fullPath);
|
||||
var (contentType, metadata) = ReadMetadata(fullPath);
|
||||
|
||||
return Task.FromResult<BlobGetResult?>(new BlobGetResult
|
||||
{
|
||||
Key = key,
|
||||
Content = new ReadOnlyMemory<byte>(content),
|
||||
ContentType = contentType,
|
||||
Metadata = metadata,
|
||||
SizeBytes = content.Length
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var fullPath = ResolvePath(key);
|
||||
return Task.FromResult(File.Exists(fullPath));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DeleteAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_config.EnforceWriteOnce)
|
||||
return Task.FromResult(false); // WORM: cannot delete
|
||||
|
||||
var fullPath = ResolvePath(key);
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
return Task.FromResult(false);
|
||||
|
||||
File.Delete(fullPath);
|
||||
var metaPath = fullPath + ".meta";
|
||||
if (File.Exists(metaPath))
|
||||
File.Delete(metaPath);
|
||||
|
||||
_deletesCounter.Add(1);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BlobListResult> ListAsync(BlobListQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var rootDir = string.IsNullOrEmpty(_config.Prefix)
|
||||
? _config.RootPath
|
||||
: Path.Combine(_config.RootPath, _config.Prefix);
|
||||
|
||||
if (!Directory.Exists(rootDir))
|
||||
{
|
||||
return Task.FromResult(new BlobListResult
|
||||
{
|
||||
Blobs = [],
|
||||
ContinuationToken = null
|
||||
});
|
||||
}
|
||||
|
||||
var allFiles = Directory.GetFiles(rootDir, "*", SearchOption.AllDirectories)
|
||||
.Where(f => !f.EndsWith(".meta", StringComparison.Ordinal) &&
|
||||
!f.EndsWith(".tmp", StringComparison.Ordinal))
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.Select(f =>
|
||||
{
|
||||
var relativeKey = Path.GetRelativePath(_config.RootPath, f)
|
||||
.Replace('\\', '/');
|
||||
return new BlobReference
|
||||
{
|
||||
Key = relativeKey,
|
||||
SizeBytes = new FileInfo(f).Length
|
||||
};
|
||||
});
|
||||
|
||||
if (!string.IsNullOrEmpty(query.KeyPrefix))
|
||||
allFiles = allFiles.Where(b => b.Key.StartsWith(query.KeyPrefix, StringComparison.Ordinal));
|
||||
|
||||
// Simple offset-based pagination via continuation token
|
||||
var offset = 0;
|
||||
if (!string.IsNullOrEmpty(query.ContinuationToken) &&
|
||||
int.TryParse(query.ContinuationToken, out var parsed))
|
||||
offset = parsed;
|
||||
|
||||
var page = allFiles.Skip(offset).Take(query.Limit + 1).ToList();
|
||||
var hasMore = page.Count > query.Limit;
|
||||
var blobs = page.Take(query.Limit).ToImmutableArray();
|
||||
|
||||
return Task.FromResult(new BlobListResult
|
||||
{
|
||||
Blobs = blobs,
|
||||
ContinuationToken = hasMore ? (offset + query.Limit).ToString() : null
|
||||
});
|
||||
}
|
||||
|
||||
// ── Path resolution ───────────────────────────────────────────────────
|
||||
|
||||
private string ResolvePath(string key)
|
||||
{
|
||||
var sanitized = key.Replace('/', Path.DirectorySeparatorChar);
|
||||
return string.IsNullOrEmpty(_config.Prefix)
|
||||
? Path.Combine(_config.RootPath, sanitized)
|
||||
: Path.Combine(_config.RootPath, _config.Prefix, sanitized);
|
||||
}
|
||||
|
||||
// ── Metadata sidecar ──────────────────────────────────────────────────
|
||||
|
||||
private static void WriteMetadata(
|
||||
string blobPath,
|
||||
string contentType,
|
||||
ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
var metaPath = blobPath + ".meta";
|
||||
var lines = new List<string> { $"content-type:{contentType}" };
|
||||
foreach (var (k, v) in metadata)
|
||||
lines.Add($"{k}:{v}");
|
||||
File.WriteAllLines(metaPath, lines);
|
||||
}
|
||||
|
||||
private static (string ContentType, ImmutableDictionary<string, string> Metadata) ReadMetadata(
|
||||
string blobPath)
|
||||
{
|
||||
var metaPath = blobPath + ".meta";
|
||||
var contentType = "application/octet-stream";
|
||||
var metadata = ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
if (!File.Exists(metaPath))
|
||||
return (contentType, metadata);
|
||||
|
||||
var lines = File.ReadAllLines(metaPath);
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var idx = line.IndexOf(':');
|
||||
if (idx <= 0) continue;
|
||||
|
||||
var key = line[..idx];
|
||||
var value = line[(idx + 1)..];
|
||||
|
||||
if (key == "content-type")
|
||||
contentType = value;
|
||||
else
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return (contentType, builder.ToImmutable());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IContentAddressedStore.cs
|
||||
// Sprint: SPRINT_20260208_005_Attestor_cas_for_sbom_vex_attestation_artifacts
|
||||
// Task: T1 — Unified CAS interface for SBOM/VEX/attestation artifacts
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Unified content-addressed store for SBOM, VEX, and attestation artifacts.
|
||||
/// All blobs are keyed by SHA-256 digest of their raw content.
|
||||
/// Puts are idempotent: storing the same content twice returns the existing record.
|
||||
/// </summary>
|
||||
public interface IContentAddressedStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Store an artifact. Computes SHA-256 of the content and uses it as the key.
|
||||
/// Idempotent: if the digest already exists, returns the existing artifact
|
||||
/// with <see cref="CasPutResult.Deduplicated"/> = true.
|
||||
/// </summary>
|
||||
Task<CasPutResult> PutAsync(CasPutRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve an artifact by its SHA-256 digest.
|
||||
/// Returns null if not found.
|
||||
/// </summary>
|
||||
Task<CasGetResult?> GetAsync(string digest);
|
||||
|
||||
/// <summary>
|
||||
/// Check whether an artifact with the given digest exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string digest);
|
||||
|
||||
/// <summary>
|
||||
/// Delete an artifact by its digest. Returns true if removed.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string digest);
|
||||
|
||||
/// <summary>
|
||||
/// List artifacts matching a query.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<CasArtifact>> ListAsync(CasQuery query);
|
||||
|
||||
/// <summary>
|
||||
/// Get store statistics (total count, bytes, dedup savings, type breakdown).
|
||||
/// </summary>
|
||||
Task<CasStatistics> GetStatisticsAsync();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IObjectStorageProvider.cs
|
||||
// Sprint: SPRINT_20260208_019_Attestor_s3_minio_gcs_object_storage_for_tiles
|
||||
// Task: T1 — Low-level object storage provider interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Low-level object storage provider for blob operations.
|
||||
/// Implementations target specific backends (filesystem, S3/MinIO, GCS).
|
||||
/// Used by <see cref="ObjectStorageContentAddressedStore"/> to back
|
||||
/// the <see cref="IContentAddressedStore"/> with durable storage.
|
||||
/// </summary>
|
||||
public interface IObjectStorageProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// The kind of storage backend this provider targets.
|
||||
/// </summary>
|
||||
ObjectStorageProviderKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Store a blob at the given key. Idempotent when write-once is enforced.
|
||||
/// </summary>
|
||||
Task<BlobPutResult> PutAsync(BlobPutRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a blob by its key. Returns null if not found.
|
||||
/// </summary>
|
||||
Task<BlobGetResult?> GetAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check whether a blob with the given key exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a blob by its key. Returns true if removed.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// List blobs matching a key prefix.
|
||||
/// </summary>
|
||||
Task<BlobListResult> ListAsync(BlobListQuery query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryContentAddressedStore.cs
|
||||
// Sprint: SPRINT_20260208_005_Attestor_cas_for_sbom_vex_attestation_artifacts
|
||||
// Task: T1 — In-memory CAS with deduplication and OTel metrics
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IContentAddressedStore"/>.
|
||||
/// Content is keyed by SHA-256 digest ("sha256:<hex>").
|
||||
/// Puts are idempotent via deduplication.
|
||||
/// Thread-safe via <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryContentAddressedStore : IContentAddressedStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, StoredBlob> _blobs = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryContentAddressedStore> _logger;
|
||||
private readonly Counter<long> _putsCounter;
|
||||
private readonly Counter<long> _dedupCounter;
|
||||
private readonly Counter<long> _getsCounter;
|
||||
private readonly Counter<long> _deletesCounter;
|
||||
private long _totalDedups;
|
||||
|
||||
public InMemoryContentAddressedStore(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemoryContentAddressedStore> logger,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Cas");
|
||||
_putsCounter = meter.CreateCounter<long>("cas.puts", "operations", "CAS put operations");
|
||||
_dedupCounter = meter.CreateCounter<long>("cas.deduplications", "operations", "Deduplicated puts");
|
||||
_getsCounter = meter.CreateCounter<long>("cas.gets", "operations", "CAS get operations");
|
||||
_deletesCounter = meter.CreateCounter<long>("cas.deletes", "operations", "CAS delete operations");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CasPutResult> PutAsync(CasPutRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (string.IsNullOrWhiteSpace(request.MediaType))
|
||||
throw new ArgumentException("MediaType is required.", nameof(request));
|
||||
|
||||
var contentBytes = request.Content.ToArray();
|
||||
var digest = ComputeDigest(contentBytes);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var existing = _blobs.TryGetValue(digest, out var existingBlob);
|
||||
|
||||
if (existing)
|
||||
{
|
||||
_dedupCounter.Add(1);
|
||||
Interlocked.Increment(ref _totalDedups);
|
||||
_logger.LogDebug("Deduplicated CAS put for {Digest} ({ArtifactType})",
|
||||
digest, request.ArtifactType);
|
||||
|
||||
return Task.FromResult(new CasPutResult
|
||||
{
|
||||
Artifact = existingBlob!.Artifact with { Deduplicated = true },
|
||||
Deduplicated = true
|
||||
});
|
||||
}
|
||||
|
||||
var artifact = new CasArtifact
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactType = request.ArtifactType,
|
||||
MediaType = request.MediaType,
|
||||
SizeBytes = contentBytes.Length,
|
||||
Tags = request.Tags,
|
||||
CreatedAt = now,
|
||||
Deduplicated = false,
|
||||
RelatedDigests = request.RelatedDigests
|
||||
};
|
||||
|
||||
var blob = new StoredBlob(artifact, contentBytes);
|
||||
_blobs.TryAdd(digest, blob);
|
||||
_putsCounter.Add(1);
|
||||
|
||||
_logger.LogDebug("Stored CAS artifact {Digest} ({ArtifactType}, {SizeBytes} bytes)",
|
||||
digest, request.ArtifactType, contentBytes.Length);
|
||||
|
||||
return Task.FromResult(new CasPutResult
|
||||
{
|
||||
Artifact = artifact,
|
||||
Deduplicated = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CasGetResult?> GetAsync(string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
_getsCounter.Add(1);
|
||||
|
||||
if (_blobs.TryGetValue(digest, out var blob))
|
||||
{
|
||||
return Task.FromResult<CasGetResult?>(new CasGetResult
|
||||
{
|
||||
Artifact = blob.Artifact,
|
||||
Content = new ReadOnlyMemory<byte>(blob.Content)
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<CasGetResult?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
return Task.FromResult(_blobs.ContainsKey(digest));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DeleteAsync(string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
if (_blobs.TryRemove(digest, out _))
|
||||
{
|
||||
_deletesCounter.Add(1);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<CasArtifact>> ListAsync(CasQuery query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var results = _blobs.Values.Select(b => b.Artifact).AsEnumerable();
|
||||
|
||||
if (query.ArtifactType.HasValue)
|
||||
results = results.Where(a => a.ArtifactType == query.ArtifactType.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.MediaType))
|
||||
results = results.Where(a => a.MediaType.Equals(query.MediaType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TagKey))
|
||||
{
|
||||
results = results.Where(a => a.Tags.ContainsKey(query.TagKey));
|
||||
if (!string.IsNullOrWhiteSpace(query.TagValue))
|
||||
results = results.Where(a =>
|
||||
a.Tags.TryGetValue(query.TagKey!, out var v) &&
|
||||
v.Equals(query.TagValue, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var page = results
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(page);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CasStatistics> GetStatisticsAsync()
|
||||
{
|
||||
var artifacts = _blobs.Values.ToList();
|
||||
var typeCounts = artifacts
|
||||
.GroupBy(b => b.Artifact.ArtifactType)
|
||||
.ToImmutableDictionary(g => g.Key, g => (long)g.Count());
|
||||
|
||||
var stats = new CasStatistics
|
||||
{
|
||||
TotalArtifacts = artifacts.Count,
|
||||
TotalBytes = artifacts.Sum(b => b.Artifact.SizeBytes),
|
||||
DedupCount = Interlocked.Read(ref _totalDedups),
|
||||
TypeCounts = typeCounts
|
||||
};
|
||||
|
||||
return Task.FromResult(stats);
|
||||
}
|
||||
|
||||
// ── Digest computation ────────────────────────────────────────────────
|
||||
|
||||
internal static string ComputeDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
// ── Internal storage ──────────────────────────────────────────────────
|
||||
|
||||
private sealed record StoredBlob(CasArtifact Artifact, byte[] Content);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ObjectStorageContentAddressedStore.cs
|
||||
// Sprint: SPRINT_20260208_019_Attestor_s3_minio_gcs_object_storage_for_tiles
|
||||
// Task: T1 — CAS implementation backed by IObjectStorageProvider
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IContentAddressedStore"/> that delegates to an
|
||||
/// <see cref="IObjectStorageProvider"/> for durable blob storage (S3/MinIO/GCS/filesystem).
|
||||
/// Content is keyed by SHA-256 digest. Puts are idempotent via deduplication.
|
||||
/// </summary>
|
||||
public sealed class ObjectStorageContentAddressedStore : IContentAddressedStore
|
||||
{
|
||||
private readonly IObjectStorageProvider _provider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Counter<long> _putsCounter;
|
||||
private readonly Counter<long> _dedupCounter;
|
||||
private readonly Counter<long> _getsCounter;
|
||||
private readonly Counter<long> _deletesCounter;
|
||||
private long _totalDedups;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public ObjectStorageContentAddressedStore(
|
||||
IObjectStorageProvider provider,
|
||||
TimeProvider? timeProvider,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
_provider = provider;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Cas.ObjectStorage");
|
||||
_putsCounter = meter.CreateCounter<long>("cas.objectstorage.puts");
|
||||
_dedupCounter = meter.CreateCounter<long>("cas.objectstorage.deduplications");
|
||||
_getsCounter = meter.CreateCounter<long>("cas.objectstorage.gets");
|
||||
_deletesCounter = meter.CreateCounter<long>("cas.objectstorage.deletes");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CasPutResult> PutAsync(CasPutRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (string.IsNullOrWhiteSpace(request.MediaType))
|
||||
throw new ArgumentException("MediaType is required.", nameof(request));
|
||||
|
||||
var contentBytes = request.Content.ToArray();
|
||||
var digest = ComputeDigest(contentBytes);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check if already exists (dedup)
|
||||
if (await _provider.ExistsAsync(BlobKey(digest)))
|
||||
{
|
||||
_dedupCounter.Add(1);
|
||||
Interlocked.Increment(ref _totalDedups);
|
||||
|
||||
// Read existing metadata
|
||||
var existingMeta = await GetArtifactMetadataAsync(digest);
|
||||
var existingArtifact = existingMeta ?? new CasArtifact
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactType = request.ArtifactType,
|
||||
MediaType = request.MediaType,
|
||||
SizeBytes = contentBytes.Length,
|
||||
Tags = request.Tags,
|
||||
CreatedAt = now,
|
||||
Deduplicated = true,
|
||||
RelatedDigests = request.RelatedDigests
|
||||
};
|
||||
|
||||
return new CasPutResult
|
||||
{
|
||||
Artifact = existingArtifact with { Deduplicated = true },
|
||||
Deduplicated = true
|
||||
};
|
||||
}
|
||||
|
||||
var artifact = new CasArtifact
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactType = request.ArtifactType,
|
||||
MediaType = request.MediaType,
|
||||
SizeBytes = contentBytes.Length,
|
||||
Tags = request.Tags,
|
||||
CreatedAt = now,
|
||||
Deduplicated = false,
|
||||
RelatedDigests = request.RelatedDigests
|
||||
};
|
||||
|
||||
// Store the content blob
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = BlobKey(digest),
|
||||
Content = new ReadOnlyMemory<byte>(contentBytes),
|
||||
ContentType = request.MediaType,
|
||||
Metadata = request.Tags
|
||||
});
|
||||
|
||||
// Store the metadata sidecar
|
||||
await StoreArtifactMetadataAsync(digest, artifact);
|
||||
|
||||
_putsCounter.Add(1);
|
||||
|
||||
return new CasPutResult
|
||||
{
|
||||
Artifact = artifact,
|
||||
Deduplicated = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CasGetResult?> GetAsync(string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
_getsCounter.Add(1);
|
||||
|
||||
var result = await _provider.GetAsync(BlobKey(digest));
|
||||
if (result is null)
|
||||
return null;
|
||||
|
||||
var meta = await GetArtifactMetadataAsync(digest);
|
||||
var artifact = meta ?? new CasArtifact
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = result.ContentType,
|
||||
SizeBytes = result.SizeBytes,
|
||||
Tags = result.Metadata,
|
||||
CreatedAt = DateTimeOffset.MinValue,
|
||||
Deduplicated = false,
|
||||
RelatedDigests = []
|
||||
};
|
||||
|
||||
return new CasGetResult
|
||||
{
|
||||
Artifact = artifact,
|
||||
Content = result.Content
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
return _provider.ExistsAsync(BlobKey(digest));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
var deleted = await _provider.DeleteAsync(BlobKey(digest));
|
||||
if (deleted)
|
||||
{
|
||||
// Also delete metadata sidecar
|
||||
await _provider.DeleteAsync(MetaKey(digest));
|
||||
_deletesCounter.Add(1);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<CasArtifact>> ListAsync(CasQuery query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var blobList = await _provider.ListAsync(new BlobListQuery
|
||||
{
|
||||
KeyPrefix = "blobs/",
|
||||
Limit = 1000 // Fetch a large batch for client-side filtering
|
||||
});
|
||||
|
||||
var artifacts = new List<CasArtifact>();
|
||||
|
||||
foreach (var blob in blobList.Blobs)
|
||||
{
|
||||
// Extract digest from key (format: blobs/sha256:<hex>)
|
||||
var digest = blob.Key.StartsWith("blobs/", StringComparison.Ordinal)
|
||||
? blob.Key["blobs/".Length..]
|
||||
: blob.Key;
|
||||
|
||||
var meta = await GetArtifactMetadataAsync(digest);
|
||||
if (meta is not null)
|
||||
artifacts.Add(meta);
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
IEnumerable<CasArtifact> results = artifacts;
|
||||
|
||||
if (query.ArtifactType.HasValue)
|
||||
results = results.Where(a => a.ArtifactType == query.ArtifactType.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.MediaType))
|
||||
results = results.Where(a => a.MediaType.Equals(query.MediaType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TagKey))
|
||||
{
|
||||
results = results.Where(a => a.Tags.ContainsKey(query.TagKey));
|
||||
if (!string.IsNullOrWhiteSpace(query.TagValue))
|
||||
results = results.Where(a =>
|
||||
a.Tags.TryGetValue(query.TagKey!, out var v) &&
|
||||
v.Equals(query.TagValue, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CasStatistics> GetStatisticsAsync()
|
||||
{
|
||||
var blobList = await _provider.ListAsync(new BlobListQuery
|
||||
{
|
||||
KeyPrefix = "blobs/",
|
||||
Limit = 10_000
|
||||
});
|
||||
|
||||
long totalBytes = 0;
|
||||
var typeCounts = new Dictionary<CasArtifactType, long>();
|
||||
|
||||
foreach (var blob in blobList.Blobs)
|
||||
{
|
||||
totalBytes += blob.SizeBytes;
|
||||
|
||||
var digest = blob.Key.StartsWith("blobs/", StringComparison.Ordinal)
|
||||
? blob.Key["blobs/".Length..]
|
||||
: blob.Key;
|
||||
|
||||
var meta = await GetArtifactMetadataAsync(digest);
|
||||
if (meta is not null)
|
||||
{
|
||||
typeCounts.TryGetValue(meta.ArtifactType, out var count);
|
||||
typeCounts[meta.ArtifactType] = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return new CasStatistics
|
||||
{
|
||||
TotalArtifacts = blobList.Blobs.Length,
|
||||
TotalBytes = totalBytes,
|
||||
DedupCount = Interlocked.Read(ref _totalDedups),
|
||||
TypeCounts = typeCounts.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
// ── Key layout ────────────────────────────────────────────────────────
|
||||
|
||||
private static string BlobKey(string digest) => $"blobs/{digest}";
|
||||
private static string MetaKey(string digest) => $"meta/{digest}.json";
|
||||
|
||||
// ── Digest computation ────────────────────────────────────────────────
|
||||
|
||||
internal static string ComputeDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
// ── Metadata sidecar ──────────────────────────────────────────────────
|
||||
|
||||
private async Task StoreArtifactMetadataAsync(string digest, CasArtifact artifact)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(
|
||||
ArtifactMetadataDto.FromArtifact(artifact), JsonOptions);
|
||||
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = MetaKey(digest),
|
||||
Content = new ReadOnlyMemory<byte>(json),
|
||||
ContentType = "application/json"
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<CasArtifact?> GetArtifactMetadataAsync(string digest)
|
||||
{
|
||||
var result = await _provider.GetAsync(MetaKey(digest));
|
||||
if (result is null)
|
||||
return null;
|
||||
|
||||
var dto = JsonSerializer.Deserialize<ArtifactMetadataDto>(result.Content.Span, JsonOptions);
|
||||
return dto?.ToArtifact();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializable DTO for CasArtifact metadata stored alongside blobs.
|
||||
/// </summary>
|
||||
private sealed class ArtifactMetadataDto
|
||||
{
|
||||
public string Digest { get; set; } = "";
|
||||
public int ArtifactType { get; set; }
|
||||
public string MediaType { get; set; } = "";
|
||||
public long SizeBytes { get; set; }
|
||||
public Dictionary<string, string> Tags { get; set; } = [];
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public List<string> RelatedDigests { get; set; } = [];
|
||||
|
||||
public static ArtifactMetadataDto FromArtifact(CasArtifact artifact) => new()
|
||||
{
|
||||
Digest = artifact.Digest,
|
||||
ArtifactType = (int)artifact.ArtifactType,
|
||||
MediaType = artifact.MediaType,
|
||||
SizeBytes = artifact.SizeBytes,
|
||||
Tags = artifact.Tags.ToDictionary(),
|
||||
CreatedAt = artifact.CreatedAt,
|
||||
RelatedDigests = [.. artifact.RelatedDigests]
|
||||
};
|
||||
|
||||
public CasArtifact ToArtifact() => new()
|
||||
{
|
||||
Digest = Digest,
|
||||
ArtifactType = (CasArtifactType)ArtifactType,
|
||||
MediaType = MediaType,
|
||||
SizeBytes = SizeBytes,
|
||||
Tags = Tags.ToImmutableDictionary(),
|
||||
CreatedAt = CreatedAt,
|
||||
Deduplicated = false,
|
||||
RelatedDigests = [.. RelatedDigests]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ObjectStorageModels.cs
|
||||
// Sprint: SPRINT_20260208_019_Attestor_s3_minio_gcs_object_storage_for_tiles
|
||||
// Task: T1 — Object storage provider models and configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Cas;
|
||||
|
||||
/// <summary>
|
||||
/// Supported object storage backend providers.
|
||||
/// </summary>
|
||||
public enum ObjectStorageProviderKind
|
||||
{
|
||||
/// <summary>Filesystem-based object storage (offline/air-gap).</summary>
|
||||
FileSystem = 0,
|
||||
|
||||
/// <summary>AWS S3 or S3-compatible (MinIO, Wasabi, etc.).</summary>
|
||||
S3Compatible = 1,
|
||||
|
||||
/// <summary>Google Cloud Storage.</summary>
|
||||
Gcs = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for an object storage provider instance.
|
||||
/// </summary>
|
||||
public sealed record ObjectStorageConfig
|
||||
{
|
||||
/// <summary>Provider backend type.</summary>
|
||||
public required ObjectStorageProviderKind Provider { get; init; }
|
||||
|
||||
/// <summary>Root prefix for all stored blobs (e.g., "attestor/tiles/").</summary>
|
||||
public string Prefix { get; init; } = "";
|
||||
|
||||
/// <summary>Bucket or container name (S3/GCS). Ignored for FileSystem.</summary>
|
||||
public string BucketName { get; init; } = "";
|
||||
|
||||
/// <summary>Service endpoint URL for S3-compatible providers (MinIO, localstack). Empty = AWS default.</summary>
|
||||
public string EndpointUrl { get; init; } = "";
|
||||
|
||||
/// <summary>Region for S3/GCS. Empty = provider default.</summary>
|
||||
public string Region { get; init; } = "";
|
||||
|
||||
/// <summary>Root directory path for FileSystem provider.</summary>
|
||||
public string RootPath { get; init; } = "";
|
||||
|
||||
/// <summary>Enforce write-once (WORM) semantics. Not all providers support this.</summary>
|
||||
public bool EnforceWriteOnce { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to store a blob in object storage.
|
||||
/// </summary>
|
||||
public sealed record BlobPutRequest
|
||||
{
|
||||
/// <summary>The storage key (relative path within the provider).</summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>The raw content to store.</summary>
|
||||
public required ReadOnlyMemory<byte> Content { get; init; }
|
||||
|
||||
/// <summary>MIME content type.</summary>
|
||||
public string ContentType { get; init; } = "application/octet-stream";
|
||||
|
||||
/// <summary>Optional metadata tags.</summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a blob put operation.
|
||||
/// </summary>
|
||||
public sealed record BlobPutResult
|
||||
{
|
||||
/// <summary>The storage key used.</summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
public required long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>Whether the blob already existed (write-once dedup).</summary>
|
||||
public bool AlreadyExisted { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a blob get operation.
|
||||
/// </summary>
|
||||
public sealed record BlobGetResult
|
||||
{
|
||||
/// <summary>The storage key.</summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>The raw content.</summary>
|
||||
public required ReadOnlyMemory<byte> Content { get; init; }
|
||||
|
||||
/// <summary>MIME content type.</summary>
|
||||
public string ContentType { get; init; } = "application/octet-stream";
|
||||
|
||||
/// <summary>Metadata tags.</summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
public required long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for listing blobs in object storage.
|
||||
/// </summary>
|
||||
public sealed record BlobListQuery
|
||||
{
|
||||
/// <summary>Key prefix to filter (e.g., "sha256:").</summary>
|
||||
public string KeyPrefix { get; init; } = "";
|
||||
|
||||
/// <summary>Max results to return.</summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>Continuation token for pagination.</summary>
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A blob reference from a listing operation.
|
||||
/// </summary>
|
||||
public sealed record BlobReference
|
||||
{
|
||||
/// <summary>The storage key.</summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
public required long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>Content type.</summary>
|
||||
public string ContentType { get; init; } = "application/octet-stream";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a listing operation.
|
||||
/// </summary>
|
||||
public sealed record BlobListResult
|
||||
{
|
||||
/// <summary>Blob references in this page.</summary>
|
||||
public required ImmutableArray<BlobReference> Blobs { get; init; }
|
||||
|
||||
/// <summary>Continuation token for next page, null if no more.</summary>
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ComplianceReportGenerator.cs
|
||||
// Sprint: SPRINT_20260208_014_Attestor_immutable_evidence_storage_and_regulatory_alignment
|
||||
// Task: T1 — Regulatory compliance report generator implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Compliance;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IComplianceReportGenerator"/> that maps evidence
|
||||
/// artifacts to NIS2, DORA, ISO-27001, and EU CRA regulatory controls.
|
||||
/// </summary>
|
||||
public sealed class ComplianceReportGenerator : IComplianceReportGenerator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Counter<long> _reportsGenerated;
|
||||
private readonly Counter<long> _controlsEvaluated;
|
||||
|
||||
private static readonly ImmutableDictionary<RegulatoryFramework, ImmutableArray<RegulatoryControl>>
|
||||
ControlRegistry = BuildControlRegistry();
|
||||
|
||||
public ComplianceReportGenerator(
|
||||
TimeProvider? timeProvider,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Compliance");
|
||||
_reportsGenerated = meter.CreateCounter<long>("compliance.reports.generated");
|
||||
_controlsEvaluated = meter.CreateCounter<long>("compliance.controls.evaluated");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<RegulatoryFramework> SupportedFrameworks { get; } =
|
||||
[
|
||||
RegulatoryFramework.Nis2,
|
||||
RegulatoryFramework.Dora,
|
||||
RegulatoryFramework.Iso27001,
|
||||
RegulatoryFramework.EuCra
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<RegulatoryControl> GetControls(RegulatoryFramework framework) =>
|
||||
ControlRegistry.TryGetValue(framework, out var controls)
|
||||
? controls
|
||||
: ImmutableArray<RegulatoryControl>.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ComplianceReport> GenerateReportAsync(
|
||||
RegulatoryFramework framework,
|
||||
string subjectRef,
|
||||
ImmutableHashSet<EvidenceArtifactType> availableEvidence,
|
||||
ImmutableDictionary<EvidenceArtifactType, ImmutableArray<string>>? artifactRefs = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(subjectRef);
|
||||
ArgumentNullException.ThrowIfNull(availableEvidence);
|
||||
|
||||
var controls = GetControls(framework);
|
||||
var evaluations = ImmutableArray.CreateBuilder<ControlEvaluationResult>(controls.Length);
|
||||
|
||||
foreach (var control in controls)
|
||||
{
|
||||
var satisfyingTypes = control.SatisfiedBy
|
||||
.Where(availableEvidence.Contains)
|
||||
.ToList();
|
||||
|
||||
var isSatisfied = satisfyingTypes.Count > 0;
|
||||
|
||||
// Collect artifact refs for satisfied types
|
||||
var refs = ImmutableArray.CreateBuilder<string>();
|
||||
if (artifactRefs is not null)
|
||||
{
|
||||
foreach (var type in satisfyingTypes)
|
||||
{
|
||||
if (artifactRefs.TryGetValue(type, out var typeRefs))
|
||||
refs.AddRange(typeRefs);
|
||||
}
|
||||
}
|
||||
|
||||
evaluations.Add(new ControlEvaluationResult
|
||||
{
|
||||
Control = control,
|
||||
IsSatisfied = isSatisfied,
|
||||
SatisfyingArtifacts = refs.ToImmutable(),
|
||||
GapDescription = isSatisfied
|
||||
? null
|
||||
: $"Missing evidence for control '{control.ControlId}': requires one of [{string.Join(", ", control.SatisfiedBy)}]"
|
||||
});
|
||||
|
||||
_controlsEvaluated.Add(1);
|
||||
}
|
||||
|
||||
var report = new ComplianceReport
|
||||
{
|
||||
Framework = framework,
|
||||
SubjectRef = subjectRef,
|
||||
Controls = evaluations.ToImmutable(),
|
||||
GeneratedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_reportsGenerated.Add(1);
|
||||
return Task.FromResult(report);
|
||||
}
|
||||
|
||||
// --- Static Control Registry ---
|
||||
|
||||
private static ImmutableDictionary<RegulatoryFramework, ImmutableArray<RegulatoryControl>> BuildControlRegistry()
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<RegulatoryFramework, ImmutableArray<RegulatoryControl>>();
|
||||
|
||||
builder.Add(RegulatoryFramework.Nis2, BuildNis2Controls());
|
||||
builder.Add(RegulatoryFramework.Dora, BuildDoraControls());
|
||||
builder.Add(RegulatoryFramework.Iso27001, BuildIso27001Controls());
|
||||
builder.Add(RegulatoryFramework.EuCra, BuildEuCraControls());
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<RegulatoryControl> BuildNis2Controls() =>
|
||||
[
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "NIS2-Art21.2d",
|
||||
Framework = RegulatoryFramework.Nis2,
|
||||
Title = "Supply chain security",
|
||||
Description = "Security-related aspects concerning relationships between entities and their direct suppliers or service providers.",
|
||||
Category = "Supply Chain Security",
|
||||
SatisfiedBy = [EvidenceArtifactType.Sbom, EvidenceArtifactType.VexStatement, EvidenceArtifactType.ProvenanceAttestation]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "NIS2-Art21.2e",
|
||||
Framework = RegulatoryFramework.Nis2,
|
||||
Title = "Security in acquisition and maintenance",
|
||||
Description = "Security in network and information systems acquisition, development, and maintenance, including vulnerability handling and disclosure.",
|
||||
Category = "Supply Chain Security",
|
||||
SatisfiedBy = [EvidenceArtifactType.VexStatement, EvidenceArtifactType.ReachabilityAnalysis]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "NIS2-Art21.2a",
|
||||
Framework = RegulatoryFramework.Nis2,
|
||||
Title = "Risk analysis and policies",
|
||||
Description = "Policies on risk analysis and information system security.",
|
||||
Category = "Risk Management",
|
||||
SatisfiedBy = [EvidenceArtifactType.PolicyEvaluation, EvidenceArtifactType.SignedAttestation]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "NIS2-Art21.2g",
|
||||
Framework = RegulatoryFramework.Nis2,
|
||||
Title = "Cybersecurity assessment",
|
||||
Description = "Assessment of the effectiveness of cybersecurity risk-management measures.",
|
||||
Category = "Risk Management",
|
||||
SatisfiedBy = [EvidenceArtifactType.VerificationReceipt, EvidenceArtifactType.ProofBundle]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "NIS2-Art23",
|
||||
Framework = RegulatoryFramework.Nis2,
|
||||
Title = "Incident reporting",
|
||||
Description = "Obligations to report significant incidents to competent authorities.",
|
||||
Category = "Incident Management",
|
||||
SatisfiedBy = [EvidenceArtifactType.IncidentReport, EvidenceArtifactType.TransparencyLogEntry]
|
||||
}
|
||||
];
|
||||
|
||||
private static ImmutableArray<RegulatoryControl> BuildDoraControls() =>
|
||||
[
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "DORA-Art6.1",
|
||||
Framework = RegulatoryFramework.Dora,
|
||||
Title = "ICT risk management framework",
|
||||
Description = "Financial entities shall have in place an ICT risk management framework.",
|
||||
Category = "ICT Risk Management",
|
||||
SatisfiedBy = [EvidenceArtifactType.PolicyEvaluation, EvidenceArtifactType.SignedAttestation]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "DORA-Art9.1",
|
||||
Framework = RegulatoryFramework.Dora,
|
||||
Title = "Protection and prevention",
|
||||
Description = "ICT security tools, policies, and procedures to protect ICT systems and data.",
|
||||
Category = "ICT Risk Management",
|
||||
SatisfiedBy = [EvidenceArtifactType.SignedAttestation, EvidenceArtifactType.VerificationReceipt, EvidenceArtifactType.ProofBundle]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "DORA-Art17",
|
||||
Framework = RegulatoryFramework.Dora,
|
||||
Title = "ICT incident classification",
|
||||
Description = "Classification of ICT-related incidents based on criteria including data losses, criticality of services, and duration.",
|
||||
Category = "Incident Classification",
|
||||
SatisfiedBy = [EvidenceArtifactType.IncidentReport, EvidenceArtifactType.VexStatement]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "DORA-Art28",
|
||||
Framework = RegulatoryFramework.Dora,
|
||||
Title = "Third-party ICT risk",
|
||||
Description = "Management of ICT third-party risk including contractual arrangements.",
|
||||
Category = "Third-Party Risk",
|
||||
SatisfiedBy = [EvidenceArtifactType.Sbom, EvidenceArtifactType.ProvenanceAttestation, EvidenceArtifactType.ReachabilityAnalysis]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "DORA-Art11",
|
||||
Framework = RegulatoryFramework.Dora,
|
||||
Title = "Backup and recovery",
|
||||
Description = "ICT business continuity policy including backup and recovery procedures.",
|
||||
Category = "ICT Risk Management",
|
||||
SatisfiedBy = [EvidenceArtifactType.ProofBundle, EvidenceArtifactType.TransparencyLogEntry],
|
||||
IsMandatory = false
|
||||
}
|
||||
];
|
||||
|
||||
private static ImmutableArray<RegulatoryControl> BuildIso27001Controls() =>
|
||||
[
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "ISO27001-A.8.28",
|
||||
Framework = RegulatoryFramework.Iso27001,
|
||||
Title = "Secure coding",
|
||||
Description = "Secure coding principles shall be applied to software development.",
|
||||
Category = "Application Security",
|
||||
SatisfiedBy = [EvidenceArtifactType.Sbom, EvidenceArtifactType.ReachabilityAnalysis, EvidenceArtifactType.ProvenanceAttestation]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "ISO27001-A.8.9",
|
||||
Framework = RegulatoryFramework.Iso27001,
|
||||
Title = "Configuration management",
|
||||
Description = "Configurations, including security configurations, of hardware, software, services, and networks shall be established and managed.",
|
||||
Category = "Configuration Management",
|
||||
SatisfiedBy = [EvidenceArtifactType.PolicyEvaluation, EvidenceArtifactType.SignedAttestation]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "ISO27001-A.8.8",
|
||||
Framework = RegulatoryFramework.Iso27001,
|
||||
Title = "Management of technical vulnerabilities",
|
||||
Description = "Information about technical vulnerabilities shall be obtained, exposure evaluated, and appropriate measures taken.",
|
||||
Category = "Vulnerability Management",
|
||||
SatisfiedBy = [EvidenceArtifactType.VexStatement, EvidenceArtifactType.ReachabilityAnalysis, EvidenceArtifactType.Sbom]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "ISO27001-A.5.23",
|
||||
Framework = RegulatoryFramework.Iso27001,
|
||||
Title = "Information security for use of cloud services",
|
||||
Description = "Processes for acquisition, use, management, and exit from cloud services shall be established.",
|
||||
Category = "Cloud Security",
|
||||
SatisfiedBy = [EvidenceArtifactType.ProvenanceAttestation, EvidenceArtifactType.ProofBundle],
|
||||
IsMandatory = false
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "ISO27001-A.5.37",
|
||||
Framework = RegulatoryFramework.Iso27001,
|
||||
Title = "Documented operating procedures",
|
||||
Description = "Operating procedures for information processing facilities shall be documented and made available.",
|
||||
Category = "Operations Security",
|
||||
SatisfiedBy = [EvidenceArtifactType.VerificationReceipt, EvidenceArtifactType.TransparencyLogEntry]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "ISO27001-A.5.21",
|
||||
Framework = RegulatoryFramework.Iso27001,
|
||||
Title = "Managing ICT supply chain",
|
||||
Description = "Processes and procedures shall be defined to manage ICT products and services supply chain security risks.",
|
||||
Category = "Supply Chain Security",
|
||||
SatisfiedBy = [EvidenceArtifactType.Sbom, EvidenceArtifactType.VexStatement, EvidenceArtifactType.ProvenanceAttestation]
|
||||
}
|
||||
];
|
||||
|
||||
private static ImmutableArray<RegulatoryControl> BuildEuCraControls() =>
|
||||
[
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "CRA-AnnexI.2.1",
|
||||
Framework = RegulatoryFramework.EuCra,
|
||||
Title = "SBOM for products with digital elements",
|
||||
Description = "Manufacturers shall draw up an EU declaration of conformity and include an SBOM.",
|
||||
Category = "Product Security",
|
||||
SatisfiedBy = [EvidenceArtifactType.Sbom]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "CRA-AnnexI.2.5",
|
||||
Framework = RegulatoryFramework.EuCra,
|
||||
Title = "Vulnerability handling",
|
||||
Description = "Products shall be delivered without known exploitable vulnerabilities.",
|
||||
Category = "Vulnerability Management",
|
||||
SatisfiedBy = [EvidenceArtifactType.VexStatement, EvidenceArtifactType.ReachabilityAnalysis]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "CRA-Art11",
|
||||
Framework = RegulatoryFramework.EuCra,
|
||||
Title = "Reporting obligations",
|
||||
Description = "Manufacturers shall report actively exploited vulnerabilities.",
|
||||
Category = "Vulnerability Management",
|
||||
SatisfiedBy = [EvidenceArtifactType.VexStatement, EvidenceArtifactType.IncidentReport, EvidenceArtifactType.TransparencyLogEntry]
|
||||
},
|
||||
new RegulatoryControl
|
||||
{
|
||||
ControlId = "CRA-AnnexI.1.2",
|
||||
Framework = RegulatoryFramework.EuCra,
|
||||
Title = "Secure by default",
|
||||
Description = "Products shall be made available on the market without known exploitable vulnerabilities with secure default configuration.",
|
||||
Category = "Product Security",
|
||||
SatisfiedBy = [EvidenceArtifactType.PolicyEvaluation, EvidenceArtifactType.SignedAttestation, EvidenceArtifactType.VerificationReceipt]
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IComplianceReportGenerator.cs
|
||||
// Sprint: SPRINT_20260208_014_Attestor_immutable_evidence_storage_and_regulatory_alignment
|
||||
// Task: T1 — Interface for regulatory compliance report generation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Compliance;
|
||||
|
||||
/// <summary>
|
||||
/// Service that generates regulatory compliance reports by mapping available evidence
|
||||
/// artifacts to regulatory control requirements.
|
||||
/// </summary>
|
||||
public interface IComplianceReportGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the control registry for a specific framework.
|
||||
/// </summary>
|
||||
ImmutableArray<RegulatoryControl> GetControls(RegulatoryFramework framework);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a compliance report for the specified framework, evaluating available
|
||||
/// evidence against each control requirement.
|
||||
/// </summary>
|
||||
/// <param name="framework">The regulatory framework to assess against.</param>
|
||||
/// <param name="subjectRef">The subject being assessed (artifact digest, release ID).</param>
|
||||
/// <param name="availableEvidence">Evidence types available for the subject.</param>
|
||||
/// <param name="artifactRefs">Optional per-type artifact references for traceability.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<ComplianceReport> GenerateReportAsync(
|
||||
RegulatoryFramework framework,
|
||||
string subjectRef,
|
||||
ImmutableHashSet<EvidenceArtifactType> availableEvidence,
|
||||
ImmutableDictionary<EvidenceArtifactType, ImmutableArray<string>>? artifactRefs = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all supported frameworks.
|
||||
/// </summary>
|
||||
ImmutableArray<RegulatoryFramework> SupportedFrameworks { get; }
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RegulatoryComplianceModels.cs
|
||||
// Sprint: SPRINT_20260208_014_Attestor_immutable_evidence_storage_and_regulatory_alignment
|
||||
// Task: T1 — Regulatory compliance models for NIS2/DORA/ISO-27001
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Compliance;
|
||||
|
||||
/// <summary>
|
||||
/// Regulatory framework that evidence artifacts can be mapped against.
|
||||
/// </summary>
|
||||
public enum RegulatoryFramework
|
||||
{
|
||||
/// <summary>EU Network and Information Security Directive 2.</summary>
|
||||
Nis2,
|
||||
|
||||
/// <summary>EU Digital Operational Resilience Act.</summary>
|
||||
Dora,
|
||||
|
||||
/// <summary>ISO/IEC 27001 Information Security Management System.</summary>
|
||||
Iso27001,
|
||||
|
||||
/// <summary>EU Cyber Resilience Act.</summary>
|
||||
EuCra
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence artifact type that can satisfy regulatory control requirements.
|
||||
/// </summary>
|
||||
public enum EvidenceArtifactType
|
||||
{
|
||||
/// <summary>Software Bill of Materials.</summary>
|
||||
Sbom,
|
||||
|
||||
/// <summary>VEX (Vulnerability Exploitability eXchange) statement.</summary>
|
||||
VexStatement,
|
||||
|
||||
/// <summary>Signed attestation envelope.</summary>
|
||||
SignedAttestation,
|
||||
|
||||
/// <summary>Rekor transparency log entry.</summary>
|
||||
TransparencyLogEntry,
|
||||
|
||||
/// <summary>Verification receipt (proof of verification).</summary>
|
||||
VerificationReceipt,
|
||||
|
||||
/// <summary>Proof bundle (bundled evidence pack).</summary>
|
||||
ProofBundle,
|
||||
|
||||
/// <summary>Binary fingerprint or reachability analysis.</summary>
|
||||
ReachabilityAnalysis,
|
||||
|
||||
/// <summary>Policy evaluation result.</summary>
|
||||
PolicyEvaluation,
|
||||
|
||||
/// <summary>Provenance attestation (build origin proof).</summary>
|
||||
ProvenanceAttestation,
|
||||
|
||||
/// <summary>Incident response documentation.</summary>
|
||||
IncidentReport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single regulatory control that can be satisfied by evidence artifacts.
|
||||
/// </summary>
|
||||
public sealed record RegulatoryControl
|
||||
{
|
||||
/// <summary>Control identifier (e.g., "NIS2-Art21.2d", "DORA-Art6.1", "ISO27001-A.8.28").</summary>
|
||||
public required string ControlId { get; init; }
|
||||
|
||||
/// <summary>The framework this control belongs to.</summary>
|
||||
public required RegulatoryFramework Framework { get; init; }
|
||||
|
||||
/// <summary>Human-readable control title.</summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>Human-readable description of what the control requires.</summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>Category within the framework (e.g., "Supply Chain Security", "Risk Management").</summary>
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>Evidence artifact types that can satisfy this control.</summary>
|
||||
public required ImmutableArray<EvidenceArtifactType> SatisfiedBy { get; init; }
|
||||
|
||||
/// <summary>Whether this control is mandatory for the framework.</summary>
|
||||
public bool IsMandatory { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evaluating a single control against available evidence.
|
||||
/// </summary>
|
||||
public sealed record ControlEvaluationResult
|
||||
{
|
||||
/// <summary>The evaluated control.</summary>
|
||||
public required RegulatoryControl Control { get; init; }
|
||||
|
||||
/// <summary>Whether the control is satisfied by available evidence.</summary>
|
||||
public required bool IsSatisfied { get; init; }
|
||||
|
||||
/// <summary>Evidence artifacts that satisfy this control (if any).</summary>
|
||||
public ImmutableArray<string> SatisfyingArtifacts { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Gap description when control is not satisfied.</summary>
|
||||
public string? GapDescription { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overall compliance report for a regulatory framework.
|
||||
/// </summary>
|
||||
public sealed record ComplianceReport
|
||||
{
|
||||
/// <summary>The regulatory framework assessed.</summary>
|
||||
public required RegulatoryFramework Framework { get; init; }
|
||||
|
||||
/// <summary>Subject identifier (artifact digest, release ID, etc.).</summary>
|
||||
public required string SubjectRef { get; init; }
|
||||
|
||||
/// <summary>Per-control evaluation results.</summary>
|
||||
public required ImmutableArray<ControlEvaluationResult> Controls { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the report was generated.</summary>
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>Total number of controls evaluated.</summary>
|
||||
public int TotalControls => Controls.Length;
|
||||
|
||||
/// <summary>Number of controls satisfied.</summary>
|
||||
public int SatisfiedCount => Controls.Count(c => c.IsSatisfied);
|
||||
|
||||
/// <summary>Number of mandatory controls that are not satisfied.</summary>
|
||||
public int MandatoryGapCount => Controls.Count(c =>
|
||||
c.Control.IsMandatory && !c.IsSatisfied);
|
||||
|
||||
/// <summary>Compliance percentage (0.0 to 1.0).</summary>
|
||||
public double CompliancePercentage => TotalControls > 0
|
||||
? (double)SatisfiedCount / TotalControls
|
||||
: 0.0;
|
||||
|
||||
/// <summary>Whether all mandatory controls are satisfied.</summary>
|
||||
public bool MeetsMinimumCompliance => MandatoryGapCount == 0;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IVexFindingsService.cs
|
||||
// Sprint: SPRINT_20260208_023_Attestor_vex_findings_api_with_proof_artifacts
|
||||
// Task: T1 — VEX findings service interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Findings;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves VEX findings with their associated proof artifacts.
|
||||
/// Proof artifacts include DSSE signatures, Rekor receipts, Merkle proofs,
|
||||
/// and policy decision attestations.
|
||||
/// </summary>
|
||||
public interface IVexFindingsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a single finding by ID, resolving all proof artifacts.
|
||||
/// </summary>
|
||||
Task<VexFinding?> GetByIdAsync(
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries findings with optional filters and pagination.
|
||||
/// </summary>
|
||||
Task<VexFindingQueryResult> QueryAsync(
|
||||
VexFindingQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves all proof artifacts for a specific finding.
|
||||
/// </summary>
|
||||
Task<VexFinding> ResolveProofsAsync(
|
||||
VexFinding finding,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Registers or updates a finding with its status and proof artifacts.
|
||||
/// </summary>
|
||||
Task<VexFinding> UpsertAsync(
|
||||
VexFinding finding,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexFindingsModels.cs
|
||||
// Sprint: SPRINT_20260208_023_Attestor_vex_findings_api_with_proof_artifacts
|
||||
// Task: T1 — VEX findings API models with proof artifact packaging
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Findings;
|
||||
|
||||
/// <summary>
|
||||
/// Kind of proof artifact attached to a VEX finding.
|
||||
/// </summary>
|
||||
public enum ProofArtifactKind
|
||||
{
|
||||
/// <summary>DSSE envelope signature.</summary>
|
||||
DsseSignature = 0,
|
||||
|
||||
/// <summary>Rekor transparency log receipt.</summary>
|
||||
RekorReceipt = 1,
|
||||
|
||||
/// <summary>Merkle inclusion proof.</summary>
|
||||
MerkleProof = 2,
|
||||
|
||||
/// <summary>Policy decision attestation.</summary>
|
||||
PolicyDecision = 3,
|
||||
|
||||
/// <summary>VEX delta (status change between versions).</summary>
|
||||
VexDelta = 4,
|
||||
|
||||
/// <summary>Reachability witness.</summary>
|
||||
ReachabilityWitness = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A proof artifact associated with a VEX finding.
|
||||
/// </summary>
|
||||
public sealed record ProofArtifact
|
||||
{
|
||||
/// <summary>Kind of proof.</summary>
|
||||
public required ProofArtifactKind Kind { get; init; }
|
||||
|
||||
/// <summary>Content-addressed digest of the proof material.</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>MIME content type.</summary>
|
||||
public string ContentType { get; init; } = "application/json";
|
||||
|
||||
/// <summary>Serialized proof payload (JSON / DSSE envelope).</summary>
|
||||
public required ReadOnlyMemory<byte> Payload { get; init; }
|
||||
|
||||
/// <summary>Timestamp when this proof was produced.</summary>
|
||||
public required DateTimeOffset ProducedAt { get; init; }
|
||||
|
||||
/// <summary>Optional signing key ID.</summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX status for a finding.
|
||||
/// </summary>
|
||||
public enum VexFindingStatus
|
||||
{
|
||||
/// <summary>Product is not affected by this vulnerability.</summary>
|
||||
NotAffected = 0,
|
||||
|
||||
/// <summary>Product is affected.</summary>
|
||||
Affected = 1,
|
||||
|
||||
/// <summary>Vulnerability has been fixed.</summary>
|
||||
Fixed = 2,
|
||||
|
||||
/// <summary>Vulnerability is under investigation.</summary>
|
||||
UnderInvestigation = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A VEX finding with all associated proof artifacts.
|
||||
/// Represents a single CVE + component combination.
|
||||
/// </summary>
|
||||
public sealed record VexFinding
|
||||
{
|
||||
/// <summary>Unique finding identifier.</summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>Vulnerability identifier (CVE-YYYY-NNNNN).</summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Affected component (Package URL).</summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>Current VEX status.</summary>
|
||||
public required VexFindingStatus Status { get; init; }
|
||||
|
||||
/// <summary>Justification (e.g., "vulnerable_code_not_in_execute_path").</summary>
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>Severity of the underlying vulnerability.</summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>Attached proof artifacts proving the status determination.</summary>
|
||||
public required ImmutableArray<ProofArtifact> ProofArtifacts { get; init; }
|
||||
|
||||
/// <summary>Timestamp of latest status determination.</summary>
|
||||
public required DateTimeOffset DeterminedAt { get; init; }
|
||||
|
||||
/// <summary>Tenant scope.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Whether this finding has at least one DSSE signature proof.</summary>
|
||||
public bool HasSignatureProof =>
|
||||
!ProofArtifacts.IsDefaultOrEmpty &&
|
||||
ProofArtifacts.Any(p => p.Kind == ProofArtifactKind.DsseSignature);
|
||||
|
||||
/// <summary>Whether this finding has a Rekor receipt.</summary>
|
||||
public bool HasRekorReceipt =>
|
||||
!ProofArtifacts.IsDefaultOrEmpty &&
|
||||
ProofArtifacts.Any(p => p.Kind == ProofArtifactKind.RekorReceipt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for VEX findings.
|
||||
/// </summary>
|
||||
public sealed record VexFindingQuery
|
||||
{
|
||||
/// <summary>Filter by vulnerability ID (exact match).</summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Filter by component Package URL (prefix match).</summary>
|
||||
public string? ComponentPurlPrefix { get; init; }
|
||||
|
||||
/// <summary>Filter by status.</summary>
|
||||
public VexFindingStatus? Status { get; init; }
|
||||
|
||||
/// <summary>Filter by tenant.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Maximum number of results.</summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>Offset for pagination.</summary>
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated result of a VEX findings query.
|
||||
/// </summary>
|
||||
public sealed record VexFindingQueryResult
|
||||
{
|
||||
/// <summary>Matching findings.</summary>
|
||||
public required ImmutableArray<VexFinding> Findings { get; init; }
|
||||
|
||||
/// <summary>Total count (may exceed returned items).</summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>Whether more results are available.</summary>
|
||||
public bool HasMore => Offset + Findings.Length < TotalCount;
|
||||
|
||||
/// <summary>Current offset.</summary>
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexFindingsService.cs
|
||||
// Sprint: SPRINT_20260208_023_Attestor_vex_findings_api_with_proof_artifacts
|
||||
// Task: T1 — VEX findings service implementation with proof artifact resolution
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Findings;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory VEX findings service with proof artifact resolution.
|
||||
/// Stores findings keyed by finding ID and supports query by
|
||||
/// vulnerability, component, status, and tenant.
|
||||
/// </summary>
|
||||
public sealed class VexFindingsService : IVexFindingsService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, VexFinding> _store = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly Counter<long> _getCounter;
|
||||
private readonly Counter<long> _queryCounter;
|
||||
private readonly Counter<long> _upsertCounter;
|
||||
private readonly Counter<long> _resolveCounter;
|
||||
private readonly Counter<long> _proofCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new VEX findings service with OTel instrumentation.
|
||||
/// </summary>
|
||||
public VexFindingsService(IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Findings");
|
||||
_getCounter = meter.CreateCounter<long>("findings.get.total", description: "Findings retrieved by ID");
|
||||
_queryCounter = meter.CreateCounter<long>("findings.query.total", description: "Finding queries executed");
|
||||
_upsertCounter = meter.CreateCounter<long>("findings.upsert.total", description: "Findings upserted");
|
||||
_resolveCounter = meter.CreateCounter<long>("findings.resolve.total", description: "Proof resolution requests");
|
||||
_proofCounter = meter.CreateCounter<long>("findings.proofs.total", description: "Proof artifacts resolved");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<VexFinding?> GetByIdAsync(
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
_getCounter.Add(1);
|
||||
|
||||
_store.TryGetValue(findingId, out var finding);
|
||||
return Task.FromResult(finding);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<VexFindingQueryResult> QueryAsync(
|
||||
VexFindingQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
_queryCounter.Add(1);
|
||||
|
||||
var filtered = _store.Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.VulnerabilityId))
|
||||
{
|
||||
filtered = filtered.Where(f =>
|
||||
string.Equals(f.VulnerabilityId, query.VulnerabilityId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ComponentPurlPrefix))
|
||||
{
|
||||
filtered = filtered.Where(f =>
|
||||
f.ComponentPurl.StartsWith(query.ComponentPurlPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(f => f.Status == query.Status.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TenantId))
|
||||
{
|
||||
filtered = filtered.Where(f =>
|
||||
string.Equals(f.TenantId, query.TenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// Deterministic ordering
|
||||
var ordered = filtered
|
||||
.OrderBy(f => f.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(f => f.ComponentPurl, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var totalCount = ordered.Count;
|
||||
|
||||
var page = ordered
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(new VexFindingQueryResult
|
||||
{
|
||||
Findings = page,
|
||||
TotalCount = totalCount,
|
||||
Offset = query.Offset
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<VexFinding> ResolveProofsAsync(
|
||||
VexFinding finding,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(finding);
|
||||
|
||||
_resolveCounter.Add(1);
|
||||
|
||||
// If the finding is already in the store, merge proof artifacts
|
||||
if (_store.TryGetValue(finding.FindingId, out var stored))
|
||||
{
|
||||
var existingDigests = stored.ProofArtifacts
|
||||
.Select(p => p.Digest)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var newProofs = finding.ProofArtifacts
|
||||
.Where(p => !existingDigests.Contains(p.Digest));
|
||||
|
||||
var merged = stored.ProofArtifacts.AddRange(newProofs);
|
||||
_proofCounter.Add(merged.Length);
|
||||
|
||||
var resolved = stored with { ProofArtifacts = merged };
|
||||
_store[finding.FindingId] = resolved;
|
||||
return Task.FromResult(resolved);
|
||||
}
|
||||
|
||||
_proofCounter.Add(finding.ProofArtifacts.IsDefaultOrEmpty ? 0 : finding.ProofArtifacts.Length);
|
||||
return Task.FromResult(finding);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<VexFinding> UpsertAsync(
|
||||
VexFinding finding,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(finding);
|
||||
|
||||
_upsertCounter.Add(1);
|
||||
|
||||
// Generate a deterministic finding ID if empty
|
||||
var id = string.IsNullOrWhiteSpace(finding.FindingId)
|
||||
? ComputeFindingId(finding.VulnerabilityId, finding.ComponentPurl)
|
||||
: finding.FindingId;
|
||||
|
||||
var normalized = finding with { FindingId = id };
|
||||
_store[id] = normalized;
|
||||
|
||||
return Task.FromResult(normalized);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
internal static string ComputeFindingId(string vulnerabilityId, string componentPurl)
|
||||
{
|
||||
var input = $"{vulnerabilityId}:{componentPurl}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"finding:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryFingerprintModels.cs
|
||||
// Sprint: SPRINT_20260208_004_Attestor_binary_fingerprint_store_and_trust_scoring
|
||||
// Task: T1 — Dedicated binary fingerprint store with content-addressed lookup
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.FingerprintStore;
|
||||
|
||||
/// <summary>
|
||||
/// A stored binary fingerprint record with section-level hashes and trust score.
|
||||
/// Content-addressed by <see cref="FingerprintId"/> (sha256 of canonical identity).
|
||||
/// </summary>
|
||||
public sealed record BinaryFingerprintRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed identifier: "fp:sha256:…".
|
||||
/// Computed from (Format, Architecture, SectionHashes).
|
||||
/// </summary>
|
||||
[JsonPropertyName("fingerprint_id")]
|
||||
public required string FingerprintId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Binary format (elf, pe, macho).
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target architecture (x86_64, aarch64, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("architecture")]
|
||||
public required string Architecture { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 of the whole binary file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("file_sha256")]
|
||||
public required string FileSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// GNU Build-ID or PE debug GUID if available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("build_id")]
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Section-level hashes keyed by section name (e.g., ".text", ".rodata").
|
||||
/// </summary>
|
||||
[JsonPropertyName("section_hashes")]
|
||||
public required ImmutableDictionary<string, string> SectionHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL (PURL) of the originating package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("package_purl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version string.
|
||||
/// </summary>
|
||||
[JsonPropertyName("package_version")]
|
||||
public string? PackageVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this record belongs to a golden set (known-good baseline).
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_golden")]
|
||||
public bool IsGolden { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the golden set this record belongs to, if any.
|
||||
/// </summary>
|
||||
[JsonPropertyName("golden_set_name")]
|
||||
public string? GoldenSetName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed trust score (0.0–1.0). Higher means more trustworthy.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trust_score")]
|
||||
public double TrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the record was first ingested.
|
||||
/// </summary>
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp of the last trust-score recalculation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updated_at")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path within the container/filesystem where the binary was found.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence digests that contributed to this fingerprint.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence_digests")]
|
||||
public ImmutableArray<string> EvidenceDigests { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for registering a binary fingerprint.
|
||||
/// </summary>
|
||||
public sealed record FingerprintRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// Binary format (elf, pe, macho).
|
||||
/// </summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target architecture.
|
||||
/// </summary>
|
||||
public required string Architecture { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 of the whole file.
|
||||
/// </summary>
|
||||
public required string FileSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// GNU Build-ID or PE debug GUID.
|
||||
/// </summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Section-level hashes keyed by section name.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, string> SectionHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Originating package PURL.
|
||||
/// </summary>
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version.
|
||||
/// </summary>
|
||||
public string? PackageVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path within the container filesystem.
|
||||
/// </summary>
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence digests supporting this registration.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> EvidenceDigests { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of comparing a fingerprint against the store.
|
||||
/// </summary>
|
||||
public sealed record FingerprintLookupResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether a matching fingerprint was found.
|
||||
/// </summary>
|
||||
[JsonPropertyName("found")]
|
||||
public bool Found { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The matched record, if found.
|
||||
/// </summary>
|
||||
[JsonPropertyName("record")]
|
||||
public BinaryFingerprintRecord? Record { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the match was against a golden-set record.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_golden_match")]
|
||||
public bool IsGoldenMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Section-level similarity score (0.0–1.0).
|
||||
/// Ratio of matching section hashes to total sections.
|
||||
/// </summary>
|
||||
[JsonPropertyName("section_similarity")]
|
||||
public double SectionSimilarity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Names of sections that matched exactly.
|
||||
/// </summary>
|
||||
[JsonPropertyName("matched_sections")]
|
||||
public ImmutableArray<string> MatchedSections { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Names of sections that differed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("differing_sections")]
|
||||
public ImmutableArray<string> DifferingSections { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust score breakdown explaining how a score was computed.
|
||||
/// </summary>
|
||||
public sealed record TrustScoreBreakdown
|
||||
{
|
||||
/// <summary>
|
||||
/// Final aggregated trust score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the fingerprint matches a golden-set record.
|
||||
/// </summary>
|
||||
[JsonPropertyName("golden_match")]
|
||||
public bool GoldenMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bonus from golden-set membership.
|
||||
/// </summary>
|
||||
[JsonPropertyName("golden_bonus")]
|
||||
public double GoldenBonus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score from Build-ID verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("build_id_score")]
|
||||
public double BuildIdScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score from section-hash coverage.
|
||||
/// </summary>
|
||||
[JsonPropertyName("section_coverage_score")]
|
||||
public double SectionCoverageScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score from evidence count / quality.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence_score")]
|
||||
public double EvidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score from package provenance.
|
||||
/// </summary>
|
||||
[JsonPropertyName("provenance_score")]
|
||||
public double ProvenanceScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named golden set of known-good binary fingerprints.
|
||||
/// </summary>
|
||||
public sealed record GoldenSet
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique name of the golden set.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of fingerprints in this set.
|
||||
/// </summary>
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the set was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the set was last modified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updated_at")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing fingerprints.
|
||||
/// </summary>
|
||||
public sealed record FingerprintQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by binary format.
|
||||
/// </summary>
|
||||
public string? Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by architecture.
|
||||
/// </summary>
|
||||
public string? Architecture { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by package PURL prefix.
|
||||
/// </summary>
|
||||
public string? PackagePurlPrefix { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter to only golden-set fingerprints.
|
||||
/// </summary>
|
||||
public bool? IsGolden { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by golden set name.
|
||||
/// </summary>
|
||||
public string? GoldenSetName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum trust score threshold.
|
||||
/// </summary>
|
||||
public double? MinTrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum results to return.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Pagination offset.
|
||||
/// </summary>
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryFingerprintStore.cs
|
||||
// Sprint: SPRINT_20260208_004_Attestor_binary_fingerprint_store_and_trust_scoring
|
||||
// Task: T1 — Content-addressed fingerprint store with trust scoring
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.FingerprintStore;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IBinaryFingerprintStore"/> with content-addressed
|
||||
/// storage, section-level hash comparison, golden-set management, and trust scoring.
|
||||
/// Thread-safe via <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
||||
/// </summary>
|
||||
public sealed class BinaryFingerprintStore : IBinaryFingerprintStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, BinaryFingerprintRecord> _records = new();
|
||||
private readonly ConcurrentDictionary<string, string> _fileSha256Index = new();
|
||||
private readonly ConcurrentDictionary<string, GoldenSetState> _goldenSets = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<BinaryFingerprintStore> _logger;
|
||||
private readonly Counter<long> _registeredCounter;
|
||||
private readonly Counter<long> _lookupsCounter;
|
||||
private readonly Counter<long> _goldenSetAddedCounter;
|
||||
private readonly Counter<long> _deletedCounter;
|
||||
|
||||
// Trust-score weights
|
||||
private const double GoldenBonusWeight = 0.30;
|
||||
private const double BuildIdWeight = 0.20;
|
||||
private const double SectionCoverageWeight = 0.25;
|
||||
private const double EvidenceWeight = 0.15;
|
||||
private const double ProvenanceWeight = 0.10;
|
||||
|
||||
public BinaryFingerprintStore(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<BinaryFingerprintStore> logger,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.FingerprintStore");
|
||||
_registeredCounter = meter.CreateCounter<long>("fingerprint.store.registered", "records", "Fingerprints registered");
|
||||
_lookupsCounter = meter.CreateCounter<long>("fingerprint.store.lookups", "lookups", "Store lookups performed");
|
||||
_goldenSetAddedCounter = meter.CreateCounter<long>("fingerprint.store.golden_added", "records", "Fingerprints added to golden sets");
|
||||
_deletedCounter = meter.CreateCounter<long>("fingerprint.store.deleted", "records", "Fingerprints deleted");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BinaryFingerprintRecord> RegisterAsync(FingerprintRegistration registration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
if (string.IsNullOrWhiteSpace(registration.Format))
|
||||
throw new ArgumentException("Format is required.", nameof(registration));
|
||||
if (string.IsNullOrWhiteSpace(registration.FileSha256))
|
||||
throw new ArgumentException("FileSha256 is required.", nameof(registration));
|
||||
|
||||
var fingerprintId = ComputeFingerprintId(registration.Format, registration.Architecture, registration.SectionHashes);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var record = _records.GetOrAdd(fingerprintId, _ =>
|
||||
{
|
||||
_registeredCounter.Add(1);
|
||||
_logger.LogDebug("Registered fingerprint {FingerprintId} for {Format}/{Architecture}",
|
||||
fingerprintId, registration.Format, registration.Architecture);
|
||||
|
||||
var newRecord = new BinaryFingerprintRecord
|
||||
{
|
||||
FingerprintId = fingerprintId,
|
||||
Format = registration.Format,
|
||||
Architecture = registration.Architecture,
|
||||
FileSha256 = registration.FileSha256,
|
||||
BuildId = registration.BuildId,
|
||||
SectionHashes = registration.SectionHashes,
|
||||
PackagePurl = registration.PackagePurl,
|
||||
PackageVersion = registration.PackageVersion,
|
||||
Path = registration.Path,
|
||||
EvidenceDigests = registration.EvidenceDigests,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
TrustScore = ComputeTrustScoreInternal(
|
||||
registration.SectionHashes, registration.BuildId,
|
||||
registration.EvidenceDigests, registration.PackagePurl, false)
|
||||
};
|
||||
|
||||
_fileSha256Index.TryAdd(registration.FileSha256, fingerprintId);
|
||||
return newRecord;
|
||||
});
|
||||
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BinaryFingerprintRecord?> GetByIdAsync(string fingerprintId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprintId);
|
||||
_lookupsCounter.Add(1);
|
||||
_records.TryGetValue(fingerprintId, out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BinaryFingerprintRecord?> GetByFileSha256Async(string fileSha256)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileSha256);
|
||||
_lookupsCounter.Add(1);
|
||||
|
||||
if (_fileSha256Index.TryGetValue(fileSha256, out var fpId) &&
|
||||
_records.TryGetValue(fpId, out var record))
|
||||
{
|
||||
return Task.FromResult<BinaryFingerprintRecord?>(record);
|
||||
}
|
||||
|
||||
return Task.FromResult<BinaryFingerprintRecord?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FingerprintLookupResult?> FindBySectionHashesAsync(
|
||||
ImmutableDictionary<string, string> sectionHashes,
|
||||
double minSimilarity = 0.5)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sectionHashes);
|
||||
_lookupsCounter.Add(1);
|
||||
|
||||
if (sectionHashes.IsEmpty)
|
||||
return Task.FromResult<FingerprintLookupResult?>(null);
|
||||
|
||||
BinaryFingerprintRecord? bestMatch = null;
|
||||
double bestSimilarity = 0.0;
|
||||
ImmutableArray<string> bestMatchedSections = [];
|
||||
ImmutableArray<string> bestDifferingSections = [];
|
||||
|
||||
foreach (var record in _records.Values)
|
||||
{
|
||||
var (similarity, matched, differing) = ComputeSectionSimilarity(sectionHashes, record.SectionHashes);
|
||||
if (similarity > bestSimilarity)
|
||||
{
|
||||
bestSimilarity = similarity;
|
||||
bestMatch = record;
|
||||
bestMatchedSections = matched;
|
||||
bestDifferingSections = differing;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch is null || bestSimilarity < minSimilarity)
|
||||
return Task.FromResult<FingerprintLookupResult?>(null);
|
||||
|
||||
var result = new FingerprintLookupResult
|
||||
{
|
||||
Found = true,
|
||||
Record = bestMatch,
|
||||
IsGoldenMatch = bestMatch.IsGolden,
|
||||
SectionSimilarity = bestSimilarity,
|
||||
MatchedSections = bestMatchedSections,
|
||||
DifferingSections = bestDifferingSections
|
||||
};
|
||||
|
||||
return Task.FromResult<FingerprintLookupResult?>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TrustScoreBreakdown> ComputeTrustScoreAsync(string fingerprintId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprintId);
|
||||
|
||||
if (!_records.TryGetValue(fingerprintId, out var record))
|
||||
throw new KeyNotFoundException($"Fingerprint '{fingerprintId}' not found.");
|
||||
|
||||
var breakdown = ComputeTrustScoreBreakdown(record);
|
||||
return Task.FromResult(breakdown);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<BinaryFingerprintRecord>> ListAsync(FingerprintQuery query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var results = _records.Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Format))
|
||||
results = results.Where(r => r.Format.Equals(query.Format, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Architecture))
|
||||
results = results.Where(r => r.Architecture.Equals(query.Architecture, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PackagePurlPrefix))
|
||||
results = results.Where(r => r.PackagePurl?.StartsWith(query.PackagePurlPrefix, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (query.IsGolden.HasValue)
|
||||
results = results.Where(r => r.IsGolden == query.IsGolden.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.GoldenSetName))
|
||||
results = results.Where(r => r.GoldenSetName?.Equals(query.GoldenSetName, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (query.MinTrustScore.HasValue)
|
||||
results = results.Where(r => r.TrustScore >= query.MinTrustScore.Value);
|
||||
|
||||
var page = results
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(page);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BinaryFingerprintRecord> AddToGoldenSetAsync(string fingerprintId, string goldenSetName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprintId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(goldenSetName);
|
||||
|
||||
if (!_records.TryGetValue(fingerprintId, out var record))
|
||||
throw new KeyNotFoundException($"Fingerprint '{fingerprintId}' not found.");
|
||||
|
||||
if (!_goldenSets.ContainsKey(goldenSetName))
|
||||
throw new InvalidOperationException($"Golden set '{goldenSetName}' does not exist. Create it first.");
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = record with
|
||||
{
|
||||
IsGolden = true,
|
||||
GoldenSetName = goldenSetName,
|
||||
UpdatedAt = now,
|
||||
TrustScore = ComputeTrustScoreInternal(
|
||||
record.SectionHashes, record.BuildId,
|
||||
record.EvidenceDigests, record.PackagePurl, true)
|
||||
};
|
||||
|
||||
_records[fingerprintId] = updated;
|
||||
_goldenSetAddedCounter.Add(1);
|
||||
|
||||
// Update golden set count
|
||||
if (_goldenSets.TryGetValue(goldenSetName, out var gsState))
|
||||
{
|
||||
lock (gsState)
|
||||
{
|
||||
gsState.Count++;
|
||||
gsState.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Added fingerprint {FingerprintId} to golden set {GoldenSetName}",
|
||||
fingerprintId, goldenSetName);
|
||||
|
||||
return Task.FromResult(updated);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BinaryFingerprintRecord> RemoveFromGoldenSetAsync(string fingerprintId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprintId);
|
||||
|
||||
if (!_records.TryGetValue(fingerprintId, out var record))
|
||||
throw new KeyNotFoundException($"Fingerprint '{fingerprintId}' not found.");
|
||||
|
||||
if (!record.IsGolden)
|
||||
return Task.FromResult(record);
|
||||
|
||||
var previousSet = record.GoldenSetName;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = record with
|
||||
{
|
||||
IsGolden = false,
|
||||
GoldenSetName = null,
|
||||
UpdatedAt = now,
|
||||
TrustScore = ComputeTrustScoreInternal(
|
||||
record.SectionHashes, record.BuildId,
|
||||
record.EvidenceDigests, record.PackagePurl, false)
|
||||
};
|
||||
|
||||
_records[fingerprintId] = updated;
|
||||
|
||||
if (previousSet is not null && _goldenSets.TryGetValue(previousSet, out var gsState))
|
||||
{
|
||||
lock (gsState)
|
||||
{
|
||||
gsState.Count = Math.Max(0, gsState.Count - 1);
|
||||
gsState.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(updated);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<GoldenSet> CreateGoldenSetAsync(string name, string? description = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = _goldenSets.GetOrAdd(name, _ => new GoldenSetState
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Count = 0,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
|
||||
var gs = new GoldenSet
|
||||
{
|
||||
Name = state.Name,
|
||||
Description = state.Description,
|
||||
Count = state.Count,
|
||||
CreatedAt = state.CreatedAt,
|
||||
UpdatedAt = state.UpdatedAt
|
||||
};
|
||||
|
||||
return Task.FromResult(gs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<GoldenSet>> ListGoldenSetsAsync()
|
||||
{
|
||||
var sets = _goldenSets.Values
|
||||
.Select(s => new GoldenSet
|
||||
{
|
||||
Name = s.Name,
|
||||
Description = s.Description,
|
||||
Count = s.Count,
|
||||
CreatedAt = s.CreatedAt,
|
||||
UpdatedAt = s.UpdatedAt
|
||||
})
|
||||
.OrderBy(s => s.Name)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(sets);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<BinaryFingerprintRecord>> GetGoldenSetMembersAsync(string goldenSetName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(goldenSetName);
|
||||
|
||||
var members = _records.Values
|
||||
.Where(r => r.IsGolden && r.GoldenSetName?.Equals(goldenSetName, StringComparison.OrdinalIgnoreCase) == true)
|
||||
.OrderByDescending(r => r.TrustScore)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(members);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DeleteAsync(string fingerprintId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprintId);
|
||||
|
||||
if (_records.TryRemove(fingerprintId, out var removed))
|
||||
{
|
||||
_fileSha256Index.TryRemove(removed.FileSha256, out _);
|
||||
_deletedCounter.Add(1);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// ── Content-addressed ID computation ──────────────────────────────────
|
||||
|
||||
internal static string ComputeFingerprintId(
|
||||
string format, string architecture, ImmutableDictionary<string, string> sectionHashes)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(format.ToLowerInvariant());
|
||||
sb.Append('|');
|
||||
sb.Append(architecture.ToLowerInvariant());
|
||||
|
||||
foreach (var kvp in sectionHashes.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append('|');
|
||||
sb.Append(kvp.Key);
|
||||
sb.Append('=');
|
||||
sb.Append(kvp.Value);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return $"fp:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
// ── Section similarity ────────────────────────────────────────────────
|
||||
|
||||
internal static (double similarity, ImmutableArray<string> matched, ImmutableArray<string> differing)
|
||||
ComputeSectionSimilarity(
|
||||
ImmutableDictionary<string, string> query,
|
||||
ImmutableDictionary<string, string> candidate)
|
||||
{
|
||||
if (query.IsEmpty && candidate.IsEmpty)
|
||||
return (1.0, [], []);
|
||||
|
||||
var allSections = query.Keys.Union(candidate.Keys).ToList();
|
||||
if (allSections.Count == 0)
|
||||
return (0.0, [], []);
|
||||
|
||||
var matchedBuilder = ImmutableArray.CreateBuilder<string>();
|
||||
var differingBuilder = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
foreach (var section in allSections)
|
||||
{
|
||||
if (query.TryGetValue(section, out var qHash) &&
|
||||
candidate.TryGetValue(section, out var cHash) &&
|
||||
qHash.Equals(cHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matchedBuilder.Add(section);
|
||||
}
|
||||
else
|
||||
{
|
||||
differingBuilder.Add(section);
|
||||
}
|
||||
}
|
||||
|
||||
var similarity = (double)matchedBuilder.Count / allSections.Count;
|
||||
return (similarity, matchedBuilder.ToImmutable(), differingBuilder.ToImmutable());
|
||||
}
|
||||
|
||||
// ── Trust scoring ─────────────────────────────────────────────────────
|
||||
|
||||
private static double ComputeTrustScoreInternal(
|
||||
ImmutableDictionary<string, string> sectionHashes,
|
||||
string? buildId,
|
||||
ImmutableArray<string> evidenceDigests,
|
||||
string? packagePurl,
|
||||
bool isGolden)
|
||||
{
|
||||
var breakdown = ComputeTrustScoreComponents(sectionHashes, buildId, evidenceDigests, packagePurl, isGolden);
|
||||
return breakdown.Score;
|
||||
}
|
||||
|
||||
private TrustScoreBreakdown ComputeTrustScoreBreakdown(BinaryFingerprintRecord record)
|
||||
{
|
||||
return ComputeTrustScoreComponents(
|
||||
record.SectionHashes, record.BuildId,
|
||||
record.EvidenceDigests, record.PackagePurl, record.IsGolden);
|
||||
}
|
||||
|
||||
internal static TrustScoreBreakdown ComputeTrustScoreComponents(
|
||||
ImmutableDictionary<string, string> sectionHashes,
|
||||
string? buildId,
|
||||
ImmutableArray<string> evidenceDigests,
|
||||
string? packagePurl,
|
||||
bool isGolden)
|
||||
{
|
||||
// Golden bonus: 1.0 if golden, 0.0 otherwise
|
||||
var goldenRaw = isGolden ? 1.0 : 0.0;
|
||||
|
||||
// Build-ID: 1.0 if present, 0.0 otherwise
|
||||
var buildIdRaw = string.IsNullOrWhiteSpace(buildId) ? 0.0 : 1.0;
|
||||
|
||||
// Section coverage: based on how many key sections are present
|
||||
var keySections = new[] { ".text", ".rodata", ".data", ".bss" };
|
||||
var coveredCount = keySections.Count(s => sectionHashes.ContainsKey(s));
|
||||
var sectionCoverageRaw = keySections.Length > 0 ? (double)coveredCount / keySections.Length : 0.0;
|
||||
|
||||
// Evidence: scaled by count, cap at 5 evidence items = 1.0
|
||||
var evidenceRaw = evidenceDigests.IsDefaultOrEmpty
|
||||
? 0.0
|
||||
: Math.Min(evidenceDigests.Length / 5.0, 1.0);
|
||||
|
||||
// Provenance: 1.0 if package PURL is present, 0.0 otherwise
|
||||
var provenanceRaw = string.IsNullOrWhiteSpace(packagePurl) ? 0.0 : 1.0;
|
||||
|
||||
// Weighted sum
|
||||
var score = goldenRaw * GoldenBonusWeight
|
||||
+ buildIdRaw * BuildIdWeight
|
||||
+ sectionCoverageRaw * SectionCoverageWeight
|
||||
+ evidenceRaw * EvidenceWeight
|
||||
+ provenanceRaw * ProvenanceWeight;
|
||||
|
||||
// Cap at 0.99
|
||||
score = Math.Min(score, 0.99);
|
||||
|
||||
return new TrustScoreBreakdown
|
||||
{
|
||||
Score = Math.Round(score, 4),
|
||||
GoldenMatch = isGolden,
|
||||
GoldenBonus = Math.Round(goldenRaw * GoldenBonusWeight, 4),
|
||||
BuildIdScore = Math.Round(buildIdRaw * BuildIdWeight, 4),
|
||||
SectionCoverageScore = Math.Round(sectionCoverageRaw * SectionCoverageWeight, 4),
|
||||
EvidenceScore = Math.Round(evidenceRaw * EvidenceWeight, 4),
|
||||
ProvenanceScore = Math.Round(provenanceRaw * ProvenanceWeight, 4)
|
||||
};
|
||||
}
|
||||
|
||||
// ── Internal mutable state for golden sets ────────────────────────────
|
||||
|
||||
private sealed class GoldenSetState
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public int Count { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBinaryFingerprintStore.cs
|
||||
// Sprint: SPRINT_20260208_004_Attestor_binary_fingerprint_store_and_trust_scoring
|
||||
// Task: T1 — Binary fingerprint store interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.FingerprintStore;
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed binary fingerprint store with golden-set management
|
||||
/// and trust scoring.
|
||||
/// </summary>
|
||||
public interface IBinaryFingerprintStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Register a new binary fingerprint. Idempotent: returns existing record
|
||||
/// if the content-addressed ID already exists.
|
||||
/// </summary>
|
||||
Task<BinaryFingerprintRecord> RegisterAsync(FingerprintRegistration registration);
|
||||
|
||||
/// <summary>
|
||||
/// Look up a fingerprint by its content-addressed ID.
|
||||
/// </summary>
|
||||
Task<BinaryFingerprintRecord?> GetByIdAsync(string fingerprintId);
|
||||
|
||||
/// <summary>
|
||||
/// Look up a fingerprint by whole-file SHA-256 hash.
|
||||
/// </summary>
|
||||
Task<BinaryFingerprintRecord?> GetByFileSha256Async(string fileSha256);
|
||||
|
||||
/// <summary>
|
||||
/// Find the best matching fingerprint using section-level hash comparison.
|
||||
/// Returns null if no match with similarity above <paramref name="minSimilarity"/>.
|
||||
/// </summary>
|
||||
Task<FingerprintLookupResult?> FindBySectionHashesAsync(
|
||||
ImmutableDictionary<string, string> sectionHashes,
|
||||
double minSimilarity = 0.5);
|
||||
|
||||
/// <summary>
|
||||
/// Compute and return a detailed trust-score breakdown for a fingerprint.
|
||||
/// </summary>
|
||||
Task<TrustScoreBreakdown> ComputeTrustScoreAsync(string fingerprintId);
|
||||
|
||||
/// <summary>
|
||||
/// List fingerprints matching a query.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<BinaryFingerprintRecord>> ListAsync(FingerprintQuery query);
|
||||
|
||||
/// <summary>
|
||||
/// Add a fingerprint to a golden set.
|
||||
/// </summary>
|
||||
Task<BinaryFingerprintRecord> AddToGoldenSetAsync(string fingerprintId, string goldenSetName);
|
||||
|
||||
/// <summary>
|
||||
/// Remove a fingerprint from its golden set.
|
||||
/// </summary>
|
||||
Task<BinaryFingerprintRecord> RemoveFromGoldenSetAsync(string fingerprintId);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new golden set.
|
||||
/// </summary>
|
||||
Task<GoldenSet> CreateGoldenSetAsync(string name, string? description = null);
|
||||
|
||||
/// <summary>
|
||||
/// List all golden sets.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<GoldenSet>> ListGoldenSetsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Get fingerprints belonging to a golden set.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<BinaryFingerprintRecord>> GetGoldenSetMembersAsync(string goldenSetName);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a fingerprint from the store.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string fingerprintId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Graph;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for rendering proof graph subgraphs into visualization formats.
|
||||
/// </summary>
|
||||
public interface ISubgraphVisualizationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a proof graph subgraph into the requested visualization format.
|
||||
/// </summary>
|
||||
/// <param name="subgraph">The subgraph to render.</param>
|
||||
/// <param name="format">Desired output format.</param>
|
||||
/// <param name="generatedAt">Timestamp for the visualization.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Rendered visualization result.</returns>
|
||||
Task<SubgraphVisualizationResult> RenderAsync(
|
||||
ProofGraphSubgraph subgraph,
|
||||
SubgraphRenderFormat format,
|
||||
DateTimeOffset generatedAt,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Graph;
|
||||
|
||||
/// <summary>
|
||||
/// Graph visualization format for subgraph rendering.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SubgraphRenderFormat
|
||||
{
|
||||
/// <summary>Mermaid.js graph markup for browser-side rendering.</summary>
|
||||
Mermaid,
|
||||
|
||||
/// <summary>Graphviz DOT format for static rendering.</summary>
|
||||
Dot,
|
||||
|
||||
/// <summary>Structured JSON for custom frontend rendering (e.g., D3.js, Cytoscape.js).</summary>
|
||||
Json
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A visualization-ready node with computed layout hints.
|
||||
/// </summary>
|
||||
public sealed record VisualizationNode
|
||||
{
|
||||
/// <summary>Unique node identifier.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>Display label for the node.</summary>
|
||||
[JsonPropertyName("label")]
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>Node type category for icon/color selection.</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Content digest for provenance verification.</summary>
|
||||
[JsonPropertyName("content_digest")]
|
||||
public string? ContentDigest { get; init; }
|
||||
|
||||
/// <summary>Whether this is the root node of the subgraph query.</summary>
|
||||
[JsonPropertyName("is_root")]
|
||||
public required bool IsRoot { get; init; }
|
||||
|
||||
/// <summary>Depth from root (0-based) for layout layering.</summary>
|
||||
[JsonPropertyName("depth")]
|
||||
public required int Depth { get; init; }
|
||||
|
||||
/// <summary>Optional metadata key-value pairs for tooltips.</summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A visualization-ready edge with styling hints.
|
||||
/// </summary>
|
||||
public sealed record VisualizationEdge
|
||||
{
|
||||
/// <summary>Source node identifier.</summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>Target node identifier.</summary>
|
||||
[JsonPropertyName("target")]
|
||||
public required string Target { get; init; }
|
||||
|
||||
/// <summary>Edge type label for display.</summary>
|
||||
[JsonPropertyName("label")]
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>Edge type category for styling.</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rendered subgraph visualization result.
|
||||
/// </summary>
|
||||
public sealed record SubgraphVisualizationResult
|
||||
{
|
||||
/// <summary>Root node identifier of the subgraph.</summary>
|
||||
[JsonPropertyName("root_node_id")]
|
||||
public required string RootNodeId { get; init; }
|
||||
|
||||
/// <summary>Requested render format.</summary>
|
||||
[JsonPropertyName("format")]
|
||||
public required SubgraphRenderFormat Format { get; init; }
|
||||
|
||||
/// <summary>Rendered content (Mermaid markup, DOT markup, or JSON).</summary>
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>Nodes for structured access (always populated).</summary>
|
||||
[JsonPropertyName("nodes")]
|
||||
public required ImmutableArray<VisualizationNode> Nodes { get; init; }
|
||||
|
||||
/// <summary>Edges for structured access (always populated).</summary>
|
||||
[JsonPropertyName("edges")]
|
||||
public required ImmutableArray<VisualizationEdge> Edges { get; init; }
|
||||
|
||||
/// <summary>Total number of nodes.</summary>
|
||||
[JsonPropertyName("node_count")]
|
||||
public int NodeCount => Nodes.Length;
|
||||
|
||||
/// <summary>Total number of edges.</summary>
|
||||
[JsonPropertyName("edge_count")]
|
||||
public int EdgeCount => Edges.Length;
|
||||
|
||||
/// <summary>Maximum depth traversed.</summary>
|
||||
[JsonPropertyName("max_depth")]
|
||||
public required int MaxDepth { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the visualization was generated.</summary>
|
||||
[JsonPropertyName("generated_at")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Graph;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ISubgraphVisualizationService"/> that renders
|
||||
/// proof graph subgraphs into Mermaid, DOT, and JSON visualization formats.
|
||||
/// </summary>
|
||||
public sealed class SubgraphVisualizationService : ISubgraphVisualizationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SubgraphVisualizationResult> RenderAsync(
|
||||
ProofGraphSubgraph subgraph,
|
||||
SubgraphRenderFormat format,
|
||||
DateTimeOffset generatedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(subgraph);
|
||||
|
||||
// Build depth map via BFS from root
|
||||
var depthMap = ComputeDepthMap(subgraph);
|
||||
|
||||
// Convert to visualization models
|
||||
var vizNodes = BuildVisualizationNodes(subgraph, depthMap);
|
||||
var vizEdges = BuildVisualizationEdges(subgraph);
|
||||
|
||||
// Render content in the requested format
|
||||
var content = format switch
|
||||
{
|
||||
SubgraphRenderFormat.Mermaid => RenderMermaid(vizNodes, vizEdges),
|
||||
SubgraphRenderFormat.Dot => RenderDot(vizNodes, vizEdges),
|
||||
SubgraphRenderFormat.Json => RenderJson(vizNodes, vizEdges),
|
||||
_ => RenderJson(vizNodes, vizEdges)
|
||||
};
|
||||
|
||||
var result = new SubgraphVisualizationResult
|
||||
{
|
||||
RootNodeId = subgraph.RootNodeId,
|
||||
Format = format,
|
||||
Content = content,
|
||||
Nodes = vizNodes,
|
||||
Edges = vizEdges,
|
||||
MaxDepth = subgraph.MaxDepth,
|
||||
GeneratedAt = generatedAt
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> ComputeDepthMap(ProofGraphSubgraph subgraph)
|
||||
{
|
||||
var depthMap = new Dictionary<string, int>();
|
||||
var adjacency = new Dictionary<string, List<string>>();
|
||||
|
||||
// Build adjacency list (bidirectional for depth computation)
|
||||
foreach (var edge in subgraph.Edges)
|
||||
{
|
||||
if (!adjacency.TryGetValue(edge.SourceId, out var sourceNeighbors))
|
||||
{
|
||||
sourceNeighbors = [];
|
||||
adjacency[edge.SourceId] = sourceNeighbors;
|
||||
}
|
||||
sourceNeighbors.Add(edge.TargetId);
|
||||
|
||||
if (!adjacency.TryGetValue(edge.TargetId, out var targetNeighbors))
|
||||
{
|
||||
targetNeighbors = [];
|
||||
adjacency[edge.TargetId] = targetNeighbors;
|
||||
}
|
||||
targetNeighbors.Add(edge.SourceId);
|
||||
}
|
||||
|
||||
// BFS from root
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(subgraph.RootNodeId);
|
||||
depthMap[subgraph.RootNodeId] = 0;
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
var currentDepth = depthMap[current];
|
||||
|
||||
if (adjacency.TryGetValue(current, out var neighbors))
|
||||
{
|
||||
foreach (var neighbor in neighbors)
|
||||
{
|
||||
if (!depthMap.ContainsKey(neighbor))
|
||||
{
|
||||
depthMap[neighbor] = currentDepth + 1;
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign depth to any unreached nodes
|
||||
foreach (var node in subgraph.Nodes)
|
||||
{
|
||||
depthMap.TryAdd(node.Id, subgraph.MaxDepth);
|
||||
}
|
||||
|
||||
return depthMap;
|
||||
}
|
||||
|
||||
private static ImmutableArray<VisualizationNode> BuildVisualizationNodes(
|
||||
ProofGraphSubgraph subgraph,
|
||||
Dictionary<string, int> depthMap)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<VisualizationNode>(subgraph.Nodes.Count);
|
||||
|
||||
foreach (var node in subgraph.Nodes)
|
||||
{
|
||||
var depth = depthMap.GetValueOrDefault(node.Id, subgraph.MaxDepth);
|
||||
var metadata = node.Metadata is not null
|
||||
? node.Metadata.ToImmutableDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value?.ToString() ?? string.Empty)
|
||||
: null;
|
||||
|
||||
builder.Add(new VisualizationNode
|
||||
{
|
||||
Id = node.Id,
|
||||
Label = FormatNodeLabel(node),
|
||||
Type = node.Type.ToString(),
|
||||
ContentDigest = node.ContentDigest,
|
||||
IsRoot = node.Id == subgraph.RootNodeId,
|
||||
Depth = depth,
|
||||
Metadata = metadata
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<VisualizationEdge> BuildVisualizationEdges(
|
||||
ProofGraphSubgraph subgraph)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<VisualizationEdge>(subgraph.Edges.Count);
|
||||
|
||||
foreach (var edge in subgraph.Edges)
|
||||
{
|
||||
builder.Add(new VisualizationEdge
|
||||
{
|
||||
Source = edge.SourceId,
|
||||
Target = edge.TargetId,
|
||||
Label = FormatEdgeLabel(edge.Type),
|
||||
Type = edge.Type.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
internal static string RenderMermaid(
|
||||
ImmutableArray<VisualizationNode> nodes,
|
||||
ImmutableArray<VisualizationEdge> edges)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("graph TD");
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var shape = GetMermaidShape(node.Type);
|
||||
var escapedLabel = EscapeMermaid(node.Label);
|
||||
sb.AppendLine($" {SanitizeMermaidId(node.Id)}{shape.open}\"{escapedLabel}\"{shape.close}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var escapedLabel = EscapeMermaid(edge.Label);
|
||||
sb.AppendLine($" {SanitizeMermaidId(edge.Source)} -->|\"{escapedLabel}\"| {SanitizeMermaidId(edge.Target)}");
|
||||
}
|
||||
|
||||
// Add class definitions for styling
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" classDef artifact fill:#4CAF50,color:#fff");
|
||||
sb.AppendLine(" classDef sbom fill:#2196F3,color:#fff");
|
||||
sb.AppendLine(" classDef attestation fill:#FF9800,color:#fff");
|
||||
sb.AppendLine(" classDef vex fill:#9C27B0,color:#fff");
|
||||
sb.AppendLine(" classDef key fill:#607D8B,color:#fff");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
internal static string RenderDot(
|
||||
ImmutableArray<VisualizationNode> nodes,
|
||||
ImmutableArray<VisualizationEdge> edges)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("digraph proof_subgraph {");
|
||||
sb.AppendLine(" rankdir=TB;");
|
||||
sb.AppendLine(" node [shape=box, style=filled, fontname=\"Helvetica\"];");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var color = GetDotColor(node.Type);
|
||||
var escapedLabel = EscapeDot(node.Label);
|
||||
sb.AppendLine($" \"{node.Id}\" [label=\"{escapedLabel}\", fillcolor=\"{color}\", fontcolor=\"white\"];");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var escapedLabel = EscapeDot(edge.Label);
|
||||
sb.AppendLine($" \"{edge.Source}\" -> \"{edge.Target}\" [label=\"{escapedLabel}\"];");
|
||||
}
|
||||
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderJson(
|
||||
ImmutableArray<VisualizationNode> nodes,
|
||||
ImmutableArray<VisualizationEdge> edges)
|
||||
{
|
||||
var graphData = new { nodes, edges };
|
||||
return JsonSerializer.Serialize(graphData, JsonOptions);
|
||||
}
|
||||
|
||||
private static string FormatNodeLabel(ProofGraphNode node)
|
||||
{
|
||||
var typeLabel = node.Type switch
|
||||
{
|
||||
ProofGraphNodeType.Artifact => "Artifact",
|
||||
ProofGraphNodeType.SbomDocument => "SBOM",
|
||||
ProofGraphNodeType.InTotoStatement => "Statement",
|
||||
ProofGraphNodeType.DsseEnvelope => "DSSE Envelope",
|
||||
ProofGraphNodeType.RekorEntry => "Rekor Entry",
|
||||
ProofGraphNodeType.VexStatement => "VEX",
|
||||
ProofGraphNodeType.Subject => "Subject",
|
||||
ProofGraphNodeType.SigningKey => "Signing Key",
|
||||
ProofGraphNodeType.TrustAnchor => "Trust Anchor",
|
||||
_ => node.Type.ToString()
|
||||
};
|
||||
|
||||
var shortDigest = node.ContentDigest.Length > 16
|
||||
? node.ContentDigest[..16] + "..."
|
||||
: node.ContentDigest;
|
||||
|
||||
return $"{typeLabel}\\n{shortDigest}";
|
||||
}
|
||||
|
||||
private static string FormatEdgeLabel(ProofGraphEdgeType edgeType) => edgeType switch
|
||||
{
|
||||
ProofGraphEdgeType.DescribedBy => "described by",
|
||||
ProofGraphEdgeType.AttestedBy => "attested by",
|
||||
ProofGraphEdgeType.WrappedBy => "wrapped by",
|
||||
ProofGraphEdgeType.LoggedIn => "logged in",
|
||||
ProofGraphEdgeType.HasVex => "has VEX",
|
||||
ProofGraphEdgeType.ContainsSubject => "contains",
|
||||
ProofGraphEdgeType.Produces => "produces",
|
||||
ProofGraphEdgeType.Affects => "affects",
|
||||
ProofGraphEdgeType.SignedBy => "signed by",
|
||||
ProofGraphEdgeType.RecordedAt => "recorded at",
|
||||
ProofGraphEdgeType.ChainsTo => "chains to",
|
||||
_ => edgeType.ToString()
|
||||
};
|
||||
|
||||
private static (string open, string close) GetMermaidShape(string nodeType) => nodeType switch
|
||||
{
|
||||
"Artifact" or "Subject" => ("[", "]"),
|
||||
"SbomDocument" or "VexStatement" => ("([", "])"),
|
||||
"InTotoStatement" or "DsseEnvelope" => ("[[", "]]"),
|
||||
"RekorEntry" => ("[(", ")]"),
|
||||
"SigningKey" or "TrustAnchor" => ("((", "))"),
|
||||
_ => ("[", "]")
|
||||
};
|
||||
|
||||
private static string GetDotColor(string nodeType) => nodeType switch
|
||||
{
|
||||
"Artifact" or "Subject" => "#4CAF50",
|
||||
"SbomDocument" => "#2196F3",
|
||||
"InTotoStatement" or "DsseEnvelope" => "#FF9800",
|
||||
"VexStatement" => "#9C27B0",
|
||||
"RekorEntry" => "#795548",
|
||||
"SigningKey" or "TrustAnchor" => "#607D8B",
|
||||
_ => "#9E9E9E"
|
||||
};
|
||||
|
||||
private static string SanitizeMermaidId(string id) =>
|
||||
id.Replace("-", "_").Replace(":", "_").Replace("/", "_").Replace(".", "_");
|
||||
|
||||
private static string EscapeMermaid(string text) =>
|
||||
text.Replace("\"", "'").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
private static string EscapeDot(string text) =>
|
||||
text.Replace("\"", "\\\"").Replace("\n", "\\n");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IIdempotentIngestService.cs
|
||||
// Sprint: SPRINT_20260208_013_Attestor_idempotent_sbom_attestation_apis
|
||||
// Task: T1 — Interface for idempotent SBOM ingest and attestation verify
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// Service that provides idempotent SBOM ingest and attestation verification.
|
||||
/// Duplicate submissions (by content hash or idempotency key) return the original result
|
||||
/// without creating duplicate records.
|
||||
/// </summary>
|
||||
public interface IIdempotentIngestService
|
||||
{
|
||||
/// <summary>
|
||||
/// Ingests an SBOM into the content-addressed store. Returns the same result
|
||||
/// for duplicate submissions (identical content hash or matching idempotency key).
|
||||
/// </summary>
|
||||
Task<SbomIngestResult> IngestSbomAsync(
|
||||
SbomIngestRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an attestation envelope. Caches verification results by content hash
|
||||
/// so repeat submissions return the cached outcome without re-verification.
|
||||
/// </summary>
|
||||
Task<AttestationVerifyResult> VerifyAttestationAsync(
|
||||
AttestationVerifyRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Looks up an idempotency key to determine if a previous operation used this key.
|
||||
/// Returns null if the key is not found.
|
||||
/// </summary>
|
||||
Task<IdempotencyKeyEntry?> LookupIdempotencyKeyAsync(
|
||||
string key,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdempotentIngestModels.cs
|
||||
// Sprint: SPRINT_20260208_013_Attestor_idempotent_sbom_attestation_apis
|
||||
// Task: T1 — Models for idempotent SBOM ingest and attestation verify
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Attestor.ProofChain.Cas;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// Request to ingest an SBOM into the content-addressed store.
|
||||
/// Duplicate submissions (identical content hash) return the same result.
|
||||
/// </summary>
|
||||
public sealed record SbomIngestRequest
|
||||
{
|
||||
/// <summary>Raw SBOM payload bytes.</summary>
|
||||
public required ReadOnlyMemory<byte> Content { get; init; }
|
||||
|
||||
/// <summary>Media type of the SBOM (e.g., "application/spdx+json", "application/vnd.cyclonedx+json").</summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>Optional tags for indexing (e.g., purl, version, component name).</summary>
|
||||
public ImmutableDictionary<string, string> Tags { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional client-provided idempotency key. When set, the server maps this key
|
||||
/// to the content-addressed digest so that retried requests with the same key
|
||||
/// return the original result even if the content bytes differ (client retry scenario).
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an SBOM ingest operation.
|
||||
/// </summary>
|
||||
public sealed record SbomIngestResult
|
||||
{
|
||||
/// <summary>Content-addressed digest of the stored SBOM.</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Whether this submission was a duplicate of an existing artifact.</summary>
|
||||
public required bool Deduplicated { get; init; }
|
||||
|
||||
/// <summary>The stored artifact metadata.</summary>
|
||||
public required CasArtifact Artifact { get; init; }
|
||||
|
||||
/// <summary>The SBOM entry identifier.</summary>
|
||||
public required SbomEntryId SbomEntryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify an attestation, with results cached by content hash.
|
||||
/// </summary>
|
||||
public sealed record AttestationVerifyRequest
|
||||
{
|
||||
/// <summary>Raw attestation envelope bytes.</summary>
|
||||
public required ReadOnlyMemory<byte> Content { get; init; }
|
||||
|
||||
/// <summary>Media type of the attestation envelope (e.g., "application/vnd.dsse.envelope+json").</summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional client-provided idempotency key for retry safety.
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an attestation verification, cached by content digest.
|
||||
/// </summary>
|
||||
public sealed record AttestationVerifyResult
|
||||
{
|
||||
/// <summary>Content-addressed digest of the attestation.</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Whether the verification result was served from cache.</summary>
|
||||
public required bool CacheHit { get; init; }
|
||||
|
||||
/// <summary>Whether the attestation passed verification.</summary>
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>Human-readable verification summary.</summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>Individual verification check results.</summary>
|
||||
public required ImmutableArray<AttestationCheckResult> Checks { get; init; }
|
||||
|
||||
/// <summary>Timestamp when verification was performed or cached result was created.</summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual check result within an attestation verification.
|
||||
/// </summary>
|
||||
public sealed record AttestationCheckResult
|
||||
{
|
||||
/// <summary>Check name (e.g., "signature", "payload_hash", "timestamp").</summary>
|
||||
public required string Check { get; init; }
|
||||
|
||||
/// <summary>Whether this check passed.</summary>
|
||||
public required bool Passed { get; init; }
|
||||
|
||||
/// <summary>Optional detail message.</summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the idempotency key cache, mapping a client-provided key to a content digest.
|
||||
/// </summary>
|
||||
public sealed record IdempotencyKeyEntry
|
||||
{
|
||||
/// <summary>The client-provided idempotency key.</summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>The content-addressed digest this key maps to.</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Timestamp when this mapping was created.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Operation type that created this mapping.</summary>
|
||||
public required string OperationType { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdempotentIngestService.cs
|
||||
// Sprint: SPRINT_20260208_013_Attestor_idempotent_sbom_attestation_apis
|
||||
// Task: T1 — Idempotent SBOM ingest and attestation verify implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Attestor.ProofChain.Cas;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IIdempotentIngestService"/> that delegates storage
|
||||
/// to <see cref="IContentAddressedStore"/> and caches verification results in-memory.
|
||||
/// </summary>
|
||||
public sealed class IdempotentIngestService : IIdempotentIngestService
|
||||
{
|
||||
private readonly IContentAddressedStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, AttestationVerifyResult> _verifyCache = new();
|
||||
private readonly ConcurrentDictionary<string, IdempotencyKeyEntry> _idempotencyKeys = new();
|
||||
|
||||
private readonly Counter<long> _sbomIngests;
|
||||
private readonly Counter<long> _sbomDeduplications;
|
||||
private readonly Counter<long> _attestVerifications;
|
||||
private readonly Counter<long> _attestCacheHits;
|
||||
private readonly Counter<long> _idempotencyKeyHits;
|
||||
|
||||
public IdempotentIngestService(
|
||||
IContentAddressedStore store,
|
||||
TimeProvider? timeProvider,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
_store = store;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Idempotency");
|
||||
_sbomIngests = meter.CreateCounter<long>("idempotent.sbom.ingests");
|
||||
_sbomDeduplications = meter.CreateCounter<long>("idempotent.sbom.deduplications");
|
||||
_attestVerifications = meter.CreateCounter<long>("idempotent.attest.verifications");
|
||||
_attestCacheHits = meter.CreateCounter<long>("idempotent.attest.cache_hits");
|
||||
_idempotencyKeyHits = meter.CreateCounter<long>("idempotent.key.hits");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SbomIngestResult> IngestSbomAsync(
|
||||
SbomIngestRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.MediaType))
|
||||
throw new ArgumentException("MediaType is required.", nameof(request));
|
||||
|
||||
if (request.Content.Length == 0)
|
||||
throw new ArgumentException("Content must not be empty.", nameof(request));
|
||||
|
||||
var contentDigest = ComputeDigest(request.Content.Span);
|
||||
|
||||
// Check idempotency key first
|
||||
if (!string.IsNullOrEmpty(request.IdempotencyKey) &&
|
||||
_idempotencyKeys.TryGetValue(request.IdempotencyKey, out var existingEntry))
|
||||
{
|
||||
_idempotencyKeyHits.Add(1);
|
||||
|
||||
// Return the existing result based on the stored digest
|
||||
var existingArtifact = await _store.GetAsync(existingEntry.Digest).ConfigureAwait(false);
|
||||
if (existingArtifact is not null)
|
||||
{
|
||||
return new SbomIngestResult
|
||||
{
|
||||
Digest = existingEntry.Digest,
|
||||
Deduplicated = true,
|
||||
Artifact = existingArtifact.Artifact,
|
||||
SbomEntryId = new SbomEntryId(existingEntry.Digest.Replace("sha256:", ""))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Store via CAS (idempotent by content hash)
|
||||
var putResult = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = request.Content,
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = request.MediaType,
|
||||
Tags = request.Tags
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_sbomIngests.Add(1);
|
||||
if (putResult.Deduplicated)
|
||||
_sbomDeduplications.Add(1);
|
||||
|
||||
// Record idempotency key mapping
|
||||
if (!string.IsNullOrEmpty(request.IdempotencyKey))
|
||||
{
|
||||
_idempotencyKeys.TryAdd(request.IdempotencyKey, new IdempotencyKeyEntry
|
||||
{
|
||||
Key = request.IdempotencyKey,
|
||||
Digest = putResult.Artifact.Digest,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
OperationType = "sbom-ingest"
|
||||
});
|
||||
}
|
||||
|
||||
var digestHex = putResult.Artifact.Digest.Replace("sha256:", "");
|
||||
return new SbomIngestResult
|
||||
{
|
||||
Digest = putResult.Artifact.Digest,
|
||||
Deduplicated = putResult.Deduplicated,
|
||||
Artifact = putResult.Artifact,
|
||||
SbomEntryId = new SbomEntryId(digestHex)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationVerifyResult> VerifyAttestationAsync(
|
||||
AttestationVerifyRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.MediaType))
|
||||
throw new ArgumentException("MediaType is required.", nameof(request));
|
||||
|
||||
if (request.Content.Length == 0)
|
||||
throw new ArgumentException("Content must not be empty.", nameof(request));
|
||||
|
||||
var contentDigest = ComputeDigest(request.Content.Span);
|
||||
|
||||
// Check idempotency key first
|
||||
if (!string.IsNullOrEmpty(request.IdempotencyKey) &&
|
||||
_idempotencyKeys.TryGetValue(request.IdempotencyKey, out var existingEntry))
|
||||
{
|
||||
_idempotencyKeyHits.Add(1);
|
||||
|
||||
if (_verifyCache.TryGetValue(existingEntry.Digest, out var cachedByKey))
|
||||
{
|
||||
_attestCacheHits.Add(1);
|
||||
return cachedByKey with { CacheHit = true };
|
||||
}
|
||||
}
|
||||
|
||||
// Check content-hash cache
|
||||
if (_verifyCache.TryGetValue(contentDigest, out var cached))
|
||||
{
|
||||
_attestCacheHits.Add(1);
|
||||
return cached with { CacheHit = true };
|
||||
}
|
||||
|
||||
// Store attestation in CAS for record-keeping
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = request.Content,
|
||||
ArtifactType = CasArtifactType.Attestation,
|
||||
MediaType = request.MediaType
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
// Perform verification checks
|
||||
var checks = PerformVerificationChecks(request.Content.Span, contentDigest);
|
||||
var allPassed = checks.All(c => c.Passed);
|
||||
|
||||
var result = new AttestationVerifyResult
|
||||
{
|
||||
Digest = contentDigest,
|
||||
CacheHit = false,
|
||||
Verified = allPassed,
|
||||
Summary = allPassed ? "All checks passed" : "One or more checks failed",
|
||||
Checks = checks,
|
||||
VerifiedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Cache result
|
||||
_verifyCache.TryAdd(contentDigest, result);
|
||||
|
||||
// Record idempotency key mapping
|
||||
if (!string.IsNullOrEmpty(request.IdempotencyKey))
|
||||
{
|
||||
_idempotencyKeys.TryAdd(request.IdempotencyKey, new IdempotencyKeyEntry
|
||||
{
|
||||
Key = request.IdempotencyKey,
|
||||
Digest = contentDigest,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
OperationType = "attest-verify"
|
||||
});
|
||||
}
|
||||
|
||||
_attestVerifications.Add(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IdempotencyKeyEntry?> LookupIdempotencyKeyAsync(
|
||||
string key,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
_idempotencyKeys.TryGetValue(key, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs deterministic verification checks on attestation content.
|
||||
/// This is a baseline implementation — Infrastructure layer may override
|
||||
/// with full DSSE/Rekor verification.
|
||||
/// </summary>
|
||||
private static ImmutableArray<AttestationCheckResult> PerformVerificationChecks(
|
||||
ReadOnlySpan<byte> content,
|
||||
string digest)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<AttestationCheckResult>();
|
||||
|
||||
// Check 1: Content is non-empty
|
||||
builder.Add(new AttestationCheckResult
|
||||
{
|
||||
Check = "content_present",
|
||||
Passed = content.Length > 0,
|
||||
Details = content.Length > 0
|
||||
? $"Content present ({content.Length} bytes)"
|
||||
: "Content is empty"
|
||||
});
|
||||
|
||||
// Check 2: Digest is valid SHA-256 format
|
||||
var digestValid = digest.StartsWith("sha256:") && digest.Length == 71; // "sha256:" + 64 hex chars
|
||||
builder.Add(new AttestationCheckResult
|
||||
{
|
||||
Check = "digest_format",
|
||||
Passed = digestValid,
|
||||
Details = digestValid ? "Valid SHA-256 digest format" : "Invalid digest format"
|
||||
});
|
||||
|
||||
// Check 3: Content appears to be valid JSON (attestation envelopes are JSON)
|
||||
var isJson = content.Length >= 2 && content[0] == (byte)'{' && content[^1] == (byte)'}';
|
||||
builder.Add(new AttestationCheckResult
|
||||
{
|
||||
Check = "json_structure",
|
||||
Passed = isJson,
|
||||
Details = isJson ? "Content has JSON structure" : "Content does not appear to be JSON"
|
||||
});
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -85,4 +85,22 @@ public sealed partial class PredicateSchemaValidator
|
||||
if (!root.TryGetProperty("comparedAt", out _))
|
||||
yield return new() { Path = "/comparedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateReachMapPredicate(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("graph_digest", out _))
|
||||
yield return new() { Path = "/graph_digest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("scan_id", out _))
|
||||
yield return new() { Path = "/scan_id", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("artifact_ref", out _))
|
||||
yield return new() { Path = "/artifact_ref", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("nodes", out _))
|
||||
yield return new() { Path = "/nodes", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("edges", out _))
|
||||
yield return new() { Path = "/edges", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("analysis", out _))
|
||||
yield return new() { Path = "/analysis", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("summary", out _))
|
||||
yield return new() { Path = "/summary", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed partial class PredicateSchemaValidator
|
||||
"stella.ops/vex-delta@v1" => ValidateVexDeltaPredicate(root),
|
||||
"stella.ops/sbom-delta@v1" => ValidateSbomDeltaPredicate(root),
|
||||
"stella.ops/verdict-delta@v1" => ValidateVerdictDeltaPredicate(root),
|
||||
"reach-map.stella/v1" => ValidateReachMapPredicate(root),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ public sealed partial class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
"stella.ops/vex-delta@v1" => true,
|
||||
"stella.ops/sbom-delta@v1" => true,
|
||||
"stella.ops/verdict-delta@v1" => true,
|
||||
"reach-map.stella/v1" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ILinkCaptureService.cs
|
||||
// Sprint: SPRINT_20260208_015_Attestor_in_toto_link_attestation_capture
|
||||
// Task: T1 — Interface for in-toto link capture and retrieval
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.LinkCapture;
|
||||
|
||||
/// <summary>
|
||||
/// Service for capturing, storing, and querying in-toto link attestations.
|
||||
/// Captures materials before and products after command execution, storing
|
||||
/// them as content-addressed link records.
|
||||
/// </summary>
|
||||
public interface ILinkCaptureService
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures and stores a link attestation. Duplicate links (identical content)
|
||||
/// return the existing record without creating duplicates.
|
||||
/// </summary>
|
||||
Task<LinkCaptureResult> CaptureAsync(
|
||||
LinkCaptureRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a captured link by its content digest.
|
||||
/// </summary>
|
||||
Task<CapturedLinkRecord?> GetByDigestAsync(
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries captured links by step name, functionary, or pipeline.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<CapturedLinkRecord>> QueryAsync(
|
||||
LinkCaptureQuery query,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LinkCaptureModels.cs
|
||||
// Sprint: SPRINT_20260208_015_Attestor_in_toto_link_attestation_capture
|
||||
// Task: T1 — Models for in-toto link capture with materials/products tracking
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.LinkCapture;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a captured material (input artifact) for a supply chain step.
|
||||
/// </summary>
|
||||
public sealed record CapturedMaterial
|
||||
{
|
||||
/// <summary>Path or URI of the material artifact.</summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>Content digest of the material (SHA-256).</summary>
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a captured product (output artifact) of a supply chain step.
|
||||
/// </summary>
|
||||
public sealed record CapturedProduct
|
||||
{
|
||||
/// <summary>Path or URI of the product artifact.</summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>Content digest of the product (SHA-256).</summary>
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment context captured during step execution.
|
||||
/// </summary>
|
||||
public sealed record CapturedEnvironment
|
||||
{
|
||||
/// <summary>Hostname where the step executed.</summary>
|
||||
public string? Hostname { get; init; }
|
||||
|
||||
/// <summary>Operating system identifier.</summary>
|
||||
public string? OperatingSystem { get; init; }
|
||||
|
||||
/// <summary>Additional environment variables or context.</summary>
|
||||
public ImmutableDictionary<string, string> Variables { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to capture materials before step execution (pre-step phase).
|
||||
/// </summary>
|
||||
public sealed record LinkCaptureRequest
|
||||
{
|
||||
/// <summary>Name of the supply chain step (e.g., "build", "test", "package").</summary>
|
||||
public required string StepName { get; init; }
|
||||
|
||||
/// <summary>Functionary (identity) performing the step.</summary>
|
||||
public required string Functionary { get; init; }
|
||||
|
||||
/// <summary>Command that will be or was executed.</summary>
|
||||
public required ImmutableArray<string> Command { get; init; }
|
||||
|
||||
/// <summary>Materials captured before execution.</summary>
|
||||
public ImmutableArray<CapturedMaterial> Materials { get; init; } =
|
||||
ImmutableArray<CapturedMaterial>.Empty;
|
||||
|
||||
/// <summary>Products captured after execution.</summary>
|
||||
public ImmutableArray<CapturedProduct> Products { get; init; } =
|
||||
ImmutableArray<CapturedProduct>.Empty;
|
||||
|
||||
/// <summary>Environment context.</summary>
|
||||
public CapturedEnvironment? Environment { get; init; }
|
||||
|
||||
/// <summary>Optional byproducts (logs, intermediate artifacts).</summary>
|
||||
public ImmutableDictionary<string, string> Byproducts { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>Optional CI pipeline identifier for correlation.</summary>
|
||||
public string? PipelineId { get; init; }
|
||||
|
||||
/// <summary>Optional CI step/job identifier.</summary>
|
||||
public string? StepId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of storing a captured link attestation.
|
||||
/// </summary>
|
||||
public sealed record LinkCaptureResult
|
||||
{
|
||||
/// <summary>Content-addressed digest of the stored link.</summary>
|
||||
public required string LinkDigest { get; init; }
|
||||
|
||||
/// <summary>Whether this link was a duplicate of an existing capture.</summary>
|
||||
public required bool Deduplicated { get; init; }
|
||||
|
||||
/// <summary>The captured link metadata.</summary>
|
||||
public required CapturedLinkRecord LinkRecord { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stored record of a captured link attestation.
|
||||
/// </summary>
|
||||
public sealed record CapturedLinkRecord
|
||||
{
|
||||
/// <summary>Content-addressed digest of this link.</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Step name from the supply chain layout.</summary>
|
||||
public required string StepName { get; init; }
|
||||
|
||||
/// <summary>Functionary who performed the step.</summary>
|
||||
public required string Functionary { get; init; }
|
||||
|
||||
/// <summary>Command executed during the step.</summary>
|
||||
public required ImmutableArray<string> Command { get; init; }
|
||||
|
||||
/// <summary>Materials (inputs) with their digests.</summary>
|
||||
public required ImmutableArray<CapturedMaterial> Materials { get; init; }
|
||||
|
||||
/// <summary>Products (outputs) with their digests.</summary>
|
||||
public required ImmutableArray<CapturedProduct> Products { get; init; }
|
||||
|
||||
/// <summary>Environment context.</summary>
|
||||
public CapturedEnvironment? Environment { get; init; }
|
||||
|
||||
/// <summary>Byproducts (logs, etc.).</summary>
|
||||
public ImmutableDictionary<string, string> Byproducts { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>Optional pipeline identifier for CI correlation.</summary>
|
||||
public string? PipelineId { get; init; }
|
||||
|
||||
/// <summary>Optional step/job identifier.</summary>
|
||||
public string? StepId { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the link was captured.</summary>
|
||||
public required DateTimeOffset CapturedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for retrieving captured links.
|
||||
/// </summary>
|
||||
public sealed record LinkCaptureQuery
|
||||
{
|
||||
/// <summary>Filter by step name.</summary>
|
||||
public string? StepName { get; init; }
|
||||
|
||||
/// <summary>Filter by functionary.</summary>
|
||||
public string? Functionary { get; init; }
|
||||
|
||||
/// <summary>Filter by pipeline ID.</summary>
|
||||
public string? PipelineId { get; init; }
|
||||
|
||||
/// <summary>Maximum results to return.</summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LinkCaptureService.cs
|
||||
// Sprint: SPRINT_20260208_015_Attestor_in_toto_link_attestation_capture
|
||||
// Task: T1 — In-toto link capture service implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.LinkCapture;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ILinkCaptureService"/> that stores captured
|
||||
/// link attestations in-memory with content-addressed deduplication.
|
||||
/// </summary>
|
||||
public sealed class LinkCaptureService : ILinkCaptureService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CapturedLinkRecord> _links = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Counter<long> _captures;
|
||||
private readonly Counter<long> _deduplications;
|
||||
private readonly Counter<long> _queries;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public LinkCaptureService(
|
||||
TimeProvider? timeProvider,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.LinkCapture");
|
||||
_captures = meter.CreateCounter<long>("link.captures");
|
||||
_deduplications = meter.CreateCounter<long>("link.deduplications");
|
||||
_queries = meter.CreateCounter<long>("link.queries");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<LinkCaptureResult> CaptureAsync(
|
||||
LinkCaptureRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.StepName))
|
||||
throw new ArgumentException("StepName is required.", nameof(request));
|
||||
if (string.IsNullOrWhiteSpace(request.Functionary))
|
||||
throw new ArgumentException("Functionary is required.", nameof(request));
|
||||
|
||||
// Compute deterministic digest from canonical link content
|
||||
var canonicalBytes = ComputeCanonicalBytes(request);
|
||||
var digest = ComputeDigest(canonicalBytes);
|
||||
|
||||
// Check for existing link (idempotent)
|
||||
if (_links.TryGetValue(digest, out var existing))
|
||||
{
|
||||
_deduplications.Add(1);
|
||||
return Task.FromResult(new LinkCaptureResult
|
||||
{
|
||||
LinkDigest = digest,
|
||||
Deduplicated = true,
|
||||
LinkRecord = existing
|
||||
});
|
||||
}
|
||||
|
||||
// Create new link record
|
||||
var record = new CapturedLinkRecord
|
||||
{
|
||||
Digest = digest,
|
||||
StepName = request.StepName,
|
||||
Functionary = request.Functionary,
|
||||
Command = request.Command,
|
||||
Materials = request.Materials,
|
||||
Products = request.Products,
|
||||
Environment = request.Environment,
|
||||
Byproducts = request.Byproducts,
|
||||
PipelineId = request.PipelineId,
|
||||
StepId = request.StepId,
|
||||
CapturedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
var added = _links.TryAdd(digest, record);
|
||||
if (!added)
|
||||
{
|
||||
// Race condition: another thread added the same link
|
||||
_deduplications.Add(1);
|
||||
return Task.FromResult(new LinkCaptureResult
|
||||
{
|
||||
LinkDigest = digest,
|
||||
Deduplicated = true,
|
||||
LinkRecord = _links[digest]
|
||||
});
|
||||
}
|
||||
|
||||
_captures.Add(1);
|
||||
return Task.FromResult(new LinkCaptureResult
|
||||
{
|
||||
LinkDigest = digest,
|
||||
Deduplicated = false,
|
||||
LinkRecord = record
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CapturedLinkRecord?> GetByDigestAsync(
|
||||
string digest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(digest);
|
||||
|
||||
_links.TryGetValue(digest, out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<CapturedLinkRecord>> QueryAsync(
|
||||
LinkCaptureQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
_queries.Add(1);
|
||||
|
||||
IEnumerable<CapturedLinkRecord> results = _links.Values;
|
||||
|
||||
if (!string.IsNullOrEmpty(query.StepName))
|
||||
results = results.Where(r =>
|
||||
r.StepName.Equals(query.StepName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Functionary))
|
||||
results = results.Where(r =>
|
||||
r.Functionary.Equals(query.Functionary, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrEmpty(query.PipelineId))
|
||||
results = results.Where(r =>
|
||||
r.PipelineId is not null &&
|
||||
r.PipelineId.Equals(query.PipelineId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return Task.FromResult(results
|
||||
.OrderByDescending(r => r.CapturedAt)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a canonical byte representation of the link request for content-addressed hashing.
|
||||
/// The canonical form includes step name, functionary, command, materials, and products
|
||||
/// but excludes timestamps and environment to ensure deterministic deduplication.
|
||||
/// </summary>
|
||||
private static byte[] ComputeCanonicalBytes(LinkCaptureRequest request)
|
||||
{
|
||||
// Build a deterministic representation for hashing
|
||||
var canonical = new
|
||||
{
|
||||
step = request.StepName,
|
||||
functionary = request.Functionary,
|
||||
command = request.Command.ToArray(),
|
||||
materials = request.Materials
|
||||
.OrderBy(m => m.Uri, StringComparer.Ordinal)
|
||||
.Select(m => new { uri = m.Uri, digest = m.Digest.OrderBy(kv => kv.Key).ToDictionary(kv => kv.Key, kv => kv.Value) })
|
||||
.ToArray(),
|
||||
products = request.Products
|
||||
.OrderBy(p => p.Uri, StringComparer.Ordinal)
|
||||
.Select(p => new { uri = p.Uri, digest = p.Digest.OrderBy(kv => kv.Key).ToDictionary(kv => kv.Key, kv => kv.Value) })
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(canonical, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the evidence dimensions evaluated by the coverage scorer.
|
||||
/// Each dimension represents an independent axis of evidence completeness.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EvidenceDimension
|
||||
{
|
||||
/// <summary>Reachability analysis evidence (call graph, micro-witnesses).</summary>
|
||||
Reachability,
|
||||
|
||||
/// <summary>Binary analysis evidence (fingerprints, build-id, section hashes).</summary>
|
||||
BinaryAnalysis,
|
||||
|
||||
/// <summary>SBOM completeness evidence (component inventory, dependency resolution).</summary>
|
||||
SbomCompleteness,
|
||||
|
||||
/// <summary>VEX coverage evidence (vulnerability status decisions).</summary>
|
||||
VexCoverage,
|
||||
|
||||
/// <summary>Provenance evidence (build provenance, source attestation).</summary>
|
||||
Provenance
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coverage level thresholds for visual badge rendering and gating decisions.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CoverageLevel
|
||||
{
|
||||
/// <summary>Coverage ≥ 80% — fully gated, auto-processing eligible.</summary>
|
||||
Green,
|
||||
|
||||
/// <summary>Coverage ≥ 50% and < 80% — partial coverage, manual review recommended.</summary>
|
||||
Yellow,
|
||||
|
||||
/// <summary>Coverage < 50% — insufficient evidence, gating blocks promotion.</summary>
|
||||
Red
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-dimension coverage result, including raw score and contributing evidence details.
|
||||
/// </summary>
|
||||
public sealed record DimensionCoverageResult
|
||||
{
|
||||
/// <summary>The evidence dimension evaluated.</summary>
|
||||
[JsonPropertyName("dimension")]
|
||||
public required EvidenceDimension Dimension { get; init; }
|
||||
|
||||
/// <summary>Normalised score for this dimension (0.0–1.0).</summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>Weight applied to this dimension in the aggregate score.</summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public required double Weight { get; init; }
|
||||
|
||||
/// <summary>Number of evidence items found for this dimension.</summary>
|
||||
[JsonPropertyName("evidence_count")]
|
||||
public required int EvidenceCount { get; init; }
|
||||
|
||||
/// <summary>Number of evidence items that are resolvable/verified.</summary>
|
||||
[JsonPropertyName("resolvable_count")]
|
||||
public required int ResolvableCount { get; init; }
|
||||
|
||||
/// <summary>Human-readable reason for the assigned score.</summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate evidence coverage result across all dimensions.
|
||||
/// </summary>
|
||||
public sealed record EvidenceCoverageResult
|
||||
{
|
||||
/// <summary>Overall coverage score (0.0–1.0).</summary>
|
||||
[JsonPropertyName("overall_score")]
|
||||
public required double OverallScore { get; init; }
|
||||
|
||||
/// <summary>Overall coverage percentage (0–100).</summary>
|
||||
[JsonPropertyName("coverage_percentage")]
|
||||
public double CoveragePercentage => OverallScore * 100.0;
|
||||
|
||||
/// <summary>Coverage level for badge rendering.</summary>
|
||||
[JsonPropertyName("coverage_level")]
|
||||
public required CoverageLevel CoverageLevel { get; init; }
|
||||
|
||||
/// <summary>Per-dimension breakdown.</summary>
|
||||
[JsonPropertyName("dimensions")]
|
||||
public required ImmutableArray<DimensionCoverageResult> Dimensions { get; init; }
|
||||
|
||||
/// <summary>Subject identifier (artifact reference) that was evaluated.</summary>
|
||||
[JsonPropertyName("subject_ref")]
|
||||
public required string SubjectRef { get; init; }
|
||||
|
||||
/// <summary>Whether this coverage level meets the minimum threshold for AI auto-processing.</summary>
|
||||
[JsonPropertyName("meets_ai_gating_threshold")]
|
||||
public required bool MeetsAiGatingThreshold { get; init; }
|
||||
|
||||
/// <summary>The minimum score threshold used for AI gating.</summary>
|
||||
[JsonPropertyName("gating_threshold")]
|
||||
public required double GatingThreshold { get; init; }
|
||||
|
||||
/// <summary>UTC timestamp when the score was computed.</summary>
|
||||
[JsonPropertyName("evaluated_at")]
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the evidence coverage scorer, including dimension weights
|
||||
/// and gating thresholds.
|
||||
/// </summary>
|
||||
public sealed record EvidenceCoveragePolicy
|
||||
{
|
||||
/// <summary>Weight for reachability evidence (default 0.25).</summary>
|
||||
public double ReachabilityWeight { get; init; } = 0.25;
|
||||
|
||||
/// <summary>Weight for binary analysis evidence (default 0.20).</summary>
|
||||
public double BinaryAnalysisWeight { get; init; } = 0.20;
|
||||
|
||||
/// <summary>Weight for SBOM completeness evidence (default 0.25).</summary>
|
||||
public double SbomCompletenessWeight { get; init; } = 0.25;
|
||||
|
||||
/// <summary>Weight for VEX coverage evidence (default 0.20).</summary>
|
||||
public double VexCoverageWeight { get; init; } = 0.20;
|
||||
|
||||
/// <summary>Weight for provenance evidence (default 0.10).</summary>
|
||||
public double ProvenanceWeight { get; init; } = 0.10;
|
||||
|
||||
/// <summary>Minimum overall score (0.0–1.0) required for AI auto-processing (default 0.80).</summary>
|
||||
public double AiGatingThreshold { get; init; } = 0.80;
|
||||
|
||||
/// <summary>Threshold for green coverage level (default 0.80).</summary>
|
||||
public double GreenThreshold { get; init; } = 0.80;
|
||||
|
||||
/// <summary>Threshold for yellow coverage level (default 0.50).</summary>
|
||||
public double YellowThreshold { get; init; } = 0.50;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence input for a single dimension, carrying the raw evidence identifiers
|
||||
/// that the scorer evaluates against the evidence resolver.
|
||||
/// </summary>
|
||||
public sealed record DimensionEvidenceInput
|
||||
{
|
||||
/// <summary>The evidence dimension this input represents.</summary>
|
||||
public required EvidenceDimension Dimension { get; init; }
|
||||
|
||||
/// <summary>Evidence identifiers available for this dimension.</summary>
|
||||
public required ImmutableArray<string> EvidenceIds { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IEvidenceCoverageScorer"/> that computes
|
||||
/// weighted coverage scores across five evidence dimensions, using an evidence
|
||||
/// resolver to determine which evidence identifiers are resolvable.
|
||||
/// </summary>
|
||||
public sealed class EvidenceCoverageScorer : IEvidenceCoverageScorer
|
||||
{
|
||||
private readonly Func<string, bool> _evidenceResolver;
|
||||
private readonly Counter<long> _evaluationsCounter;
|
||||
private readonly Counter<long> _gatingPassCounter;
|
||||
private readonly Counter<long> _gatingFailCounter;
|
||||
|
||||
/// <inheritdoc />
|
||||
public EvidenceCoveragePolicy Policy { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new instance of <see cref="EvidenceCoverageScorer"/>.
|
||||
/// </summary>
|
||||
/// <param name="policy">Policy controlling weights and thresholds.</param>
|
||||
/// <param name="evidenceResolver">
|
||||
/// Function that returns <c>true</c> if an evidence ID is resolvable.
|
||||
/// This aligns with the <see cref="AIAuthorityClassifier"/> resolver pattern.
|
||||
/// </param>
|
||||
/// <param name="meterFactory">OTel meter factory.</param>
|
||||
public EvidenceCoverageScorer(
|
||||
EvidenceCoveragePolicy policy,
|
||||
Func<string, bool> evidenceResolver,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
ArgumentNullException.ThrowIfNull(evidenceResolver);
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
Policy = policy;
|
||||
_evidenceResolver = evidenceResolver;
|
||||
|
||||
ValidatePolicy(policy);
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.EvidenceCoverage");
|
||||
_evaluationsCounter = meter.CreateCounter<long>("coverage.evaluations", "count", "Total coverage evaluations");
|
||||
_gatingPassCounter = meter.CreateCounter<long>("coverage.gating.pass", "count", "Evaluations that met AI gating threshold");
|
||||
_gatingFailCounter = meter.CreateCounter<long>("coverage.gating.fail", "count", "Evaluations that failed AI gating threshold");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EvidenceCoverageResult> ComputeCoverageAsync(
|
||||
string subjectRef,
|
||||
IReadOnlyList<DimensionEvidenceInput> evidenceInputs,
|
||||
DateTimeOffset evaluatedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(subjectRef);
|
||||
ArgumentNullException.ThrowIfNull(evidenceInputs);
|
||||
|
||||
_evaluationsCounter.Add(1);
|
||||
|
||||
var dimensionResults = ComputeDimensionScores(evidenceInputs);
|
||||
var overallScore = ComputeWeightedScore(dimensionResults);
|
||||
var coverageLevel = DetermineCoverageLevel(overallScore);
|
||||
var meetsGating = overallScore >= Policy.AiGatingThreshold;
|
||||
|
||||
if (meetsGating)
|
||||
_gatingPassCounter.Add(1);
|
||||
else
|
||||
_gatingFailCounter.Add(1);
|
||||
|
||||
var result = new EvidenceCoverageResult
|
||||
{
|
||||
OverallScore = overallScore,
|
||||
CoverageLevel = coverageLevel,
|
||||
Dimensions = dimensionResults,
|
||||
SubjectRef = subjectRef,
|
||||
MeetsAiGatingThreshold = meetsGating,
|
||||
GatingThreshold = Policy.AiGatingThreshold,
|
||||
EvaluatedAt = evaluatedAt
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool MeetsGatingThreshold(EvidenceCoverageResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
return result.OverallScore >= Policy.AiGatingThreshold;
|
||||
}
|
||||
|
||||
private ImmutableArray<DimensionCoverageResult> ComputeDimensionScores(
|
||||
IReadOnlyList<DimensionEvidenceInput> evidenceInputs)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<DimensionCoverageResult>(5);
|
||||
|
||||
// Process each dimension, using provided inputs or empty for missing dimensions
|
||||
foreach (var dimension in Enum.GetValues<EvidenceDimension>())
|
||||
{
|
||||
var input = FindInput(evidenceInputs, dimension);
|
||||
var weight = GetWeight(dimension);
|
||||
|
||||
if (input is null || input.EvidenceIds.Length == 0)
|
||||
{
|
||||
builder.Add(new DimensionCoverageResult
|
||||
{
|
||||
Dimension = dimension,
|
||||
Score = 0.0,
|
||||
Weight = weight,
|
||||
EvidenceCount = 0,
|
||||
ResolvableCount = 0,
|
||||
Reason = $"No evidence provided for {dimension}"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var total = input.EvidenceIds.Length;
|
||||
var resolvable = 0;
|
||||
foreach (var id in input.EvidenceIds)
|
||||
{
|
||||
if (_evidenceResolver(id))
|
||||
resolvable++;
|
||||
}
|
||||
|
||||
var score = total > 0 ? (double)resolvable / total : 0.0;
|
||||
|
||||
builder.Add(new DimensionCoverageResult
|
||||
{
|
||||
Dimension = dimension,
|
||||
Score = score,
|
||||
Weight = weight,
|
||||
EvidenceCount = total,
|
||||
ResolvableCount = resolvable,
|
||||
Reason = resolvable == total
|
||||
? $"All {total} evidence items resolvable"
|
||||
: $"{resolvable} of {total} evidence items resolvable"
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private double ComputeWeightedScore(ImmutableArray<DimensionCoverageResult> dimensions)
|
||||
{
|
||||
var totalWeight = 0.0;
|
||||
var weightedSum = 0.0;
|
||||
|
||||
foreach (var d in dimensions)
|
||||
{
|
||||
weightedSum += d.Score * d.Weight;
|
||||
totalWeight += d.Weight;
|
||||
}
|
||||
|
||||
return totalWeight > 0.0 ? weightedSum / totalWeight : 0.0;
|
||||
}
|
||||
|
||||
private CoverageLevel DetermineCoverageLevel(double overallScore)
|
||||
{
|
||||
if (overallScore >= Policy.GreenThreshold)
|
||||
return CoverageLevel.Green;
|
||||
if (overallScore >= Policy.YellowThreshold)
|
||||
return CoverageLevel.Yellow;
|
||||
return CoverageLevel.Red;
|
||||
}
|
||||
|
||||
private double GetWeight(EvidenceDimension dimension) => dimension switch
|
||||
{
|
||||
EvidenceDimension.Reachability => Policy.ReachabilityWeight,
|
||||
EvidenceDimension.BinaryAnalysis => Policy.BinaryAnalysisWeight,
|
||||
EvidenceDimension.SbomCompleteness => Policy.SbomCompletenessWeight,
|
||||
EvidenceDimension.VexCoverage => Policy.VexCoverageWeight,
|
||||
EvidenceDimension.Provenance => Policy.ProvenanceWeight,
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
private static DimensionEvidenceInput? FindInput(
|
||||
IReadOnlyList<DimensionEvidenceInput> inputs,
|
||||
EvidenceDimension dimension)
|
||||
{
|
||||
foreach (var input in inputs)
|
||||
{
|
||||
if (input.Dimension == dimension)
|
||||
return input;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ValidatePolicy(EvidenceCoveragePolicy policy)
|
||||
{
|
||||
if (policy.AiGatingThreshold is < 0.0 or > 1.0)
|
||||
throw new ArgumentException("AI gating threshold must be between 0.0 and 1.0.", nameof(policy));
|
||||
if (policy.GreenThreshold is < 0.0 or > 1.0)
|
||||
throw new ArgumentException("Green threshold must be between 0.0 and 1.0.", nameof(policy));
|
||||
if (policy.YellowThreshold is < 0.0 or > 1.0)
|
||||
throw new ArgumentException("Yellow threshold must be between 0.0 and 1.0.", nameof(policy));
|
||||
if (policy.GreenThreshold < policy.YellowThreshold)
|
||||
throw new ArgumentException("Green threshold must be >= yellow threshold.", nameof(policy));
|
||||
|
||||
var weights = new[]
|
||||
{
|
||||
policy.ReachabilityWeight,
|
||||
policy.BinaryAnalysisWeight,
|
||||
policy.SbomCompletenessWeight,
|
||||
policy.VexCoverageWeight,
|
||||
policy.ProvenanceWeight
|
||||
};
|
||||
|
||||
foreach (var w in weights)
|
||||
{
|
||||
if (w < 0.0)
|
||||
throw new ArgumentException("Dimension weights must be non-negative.", nameof(policy));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for computing evidence coverage scores across multiple evidence dimensions.
|
||||
/// </summary>
|
||||
public interface IEvidenceCoverageScorer
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the evidence coverage score for a subject across all provided evidence dimensions.
|
||||
/// </summary>
|
||||
/// <param name="subjectRef">The artifact reference being evaluated.</param>
|
||||
/// <param name="evidenceInputs">Per-dimension evidence identifiers.</param>
|
||||
/// <param name="evaluatedAt">Timestamp for the evaluation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Aggregate coverage result with per-dimension breakdown.</returns>
|
||||
Task<EvidenceCoverageResult> ComputeCoverageAsync(
|
||||
string subjectRef,
|
||||
IReadOnlyList<DimensionEvidenceInput> evidenceInputs,
|
||||
DateTimeOffset evaluatedAt,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the given coverage result meets the AI gating threshold.
|
||||
/// </summary>
|
||||
bool MeetsGatingThreshold(EvidenceCoverageResult result);
|
||||
|
||||
/// <summary>
|
||||
/// The active policy controlling weights and thresholds.
|
||||
/// </summary>
|
||||
EvidenceCoveragePolicy Policy { get; }
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for full reach-map attestations.
|
||||
/// Captures the complete reachability graph (all functions, edges, and reachability status)
|
||||
/// as a single DSSE-wrapped artifact, aggregating micro-witness data into one document.
|
||||
/// predicateType: reach-map.stella/v1
|
||||
/// </summary>
|
||||
public sealed record ReachMapPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for reach-map attestations.
|
||||
/// </summary>
|
||||
public const string PredicateTypeUri = "reach-map.stella/v1";
|
||||
|
||||
/// <summary>
|
||||
/// Schema version for the predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed digest (SHA-256) of the serialized reach-map graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("graphDigest")]
|
||||
public required string GraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional CAS URI for the reach-map content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("graphCasUri")]
|
||||
public string? GraphCasUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scan ID that produced this reach-map.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scanId")]
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image/artifact reference that was analyzed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactRef")]
|
||||
public required string ArtifactRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All functions (nodes) in the reach-map graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nodes")]
|
||||
public required ImmutableArray<ReachMapNode> Nodes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All call edges in the reach-map graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("edges")]
|
||||
public required ImmutableArray<ReachMapEdge> Edges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All vulnerability reachability findings in this map.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findings")]
|
||||
public required ImmutableArray<ReachMapFinding> Findings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Micro-witness IDs aggregated into this reach-map.
|
||||
/// </summary>
|
||||
[JsonPropertyName("aggregatedWitnessIds")]
|
||||
public ImmutableArray<string> AggregatedWitnessIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Analysis metadata for the reach-map generation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analysis")]
|
||||
public required ReachMapAnalysis Analysis { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for the reach-map.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required ReachMapSummary Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A function node in the reach-map call graph.
|
||||
/// </summary>
|
||||
public sealed record ReachMapNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique node identifier (content-addressed from qualified name + module).
|
||||
/// </summary>
|
||||
[JsonPropertyName("nodeId")]
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fully qualified function/method name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("qualifiedName")]
|
||||
public required string QualifiedName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Module or assembly containing this function.
|
||||
/// </summary>
|
||||
[JsonPropertyName("module")]
|
||||
public required string Module { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this node is an application entry point.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isEntryPoint")]
|
||||
public bool IsEntryPoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this node is a known vulnerable sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isSink")]
|
||||
public bool IsSink { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state of this node from any entry point.
|
||||
/// One of: reachable, unreachable, conditional, unknown.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachabilityState")]
|
||||
public required string ReachabilityState { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A directed call edge in the reach-map graph.
|
||||
/// </summary>
|
||||
public sealed record ReachMapEdge
|
||||
{
|
||||
/// <summary>
|
||||
/// Source node ID (caller).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceNodeId")]
|
||||
public required string SourceNodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target node ID (callee).
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetNodeId")]
|
||||
public required string TargetNodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call type (direct, virtual, interface, delegate, reflection).
|
||||
/// </summary>
|
||||
[JsonPropertyName("callType")]
|
||||
public required string CallType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence that this edge exists (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A vulnerability reachability finding in the reach-map.
|
||||
/// </summary>
|
||||
public sealed record ReachMapFinding
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier (CVE, internal, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnId")]
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier, if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cveId")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL of the affected package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the vulnerability is reachable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isReachable")]
|
||||
public required bool IsReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score for this finding (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidenceScore")]
|
||||
public required double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink node IDs in the graph that represent the vulnerable function(s).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sinkNodeIds")]
|
||||
public ImmutableArray<string> SinkNodeIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Entry point node IDs that can reach the sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachableEntryPointIds")]
|
||||
public ImmutableArray<string> ReachableEntryPointIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Micro-witness ID this finding was aggregated from, if any.
|
||||
/// </summary>
|
||||
[JsonPropertyName("witnessId")]
|
||||
public string? WitnessId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about reach-map generation and analysis.
|
||||
/// </summary>
|
||||
public sealed record ReachMapAnalysis
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyzer name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analyzer")]
|
||||
public required string Analyzer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analyzerVersion")]
|
||||
public required string AnalyzerVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall confidence score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Completeness indicator (full, partial, unknown).
|
||||
/// </summary>
|
||||
[JsonPropertyName("completeness")]
|
||||
public required string Completeness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the reach-map was generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash algorithm used for graph digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hashAlgorithm")]
|
||||
public string HashAlgorithm { get; init; } = "SHA-256";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for a reach-map.
|
||||
/// </summary>
|
||||
public sealed record ReachMapSummary
|
||||
{
|
||||
/// <summary>Total number of nodes in the graph.</summary>
|
||||
[JsonPropertyName("totalNodes")]
|
||||
public required int TotalNodes { get; init; }
|
||||
|
||||
/// <summary>Total number of edges in the graph.</summary>
|
||||
[JsonPropertyName("totalEdges")]
|
||||
public required int TotalEdges { get; init; }
|
||||
|
||||
/// <summary>Number of entry points identified.</summary>
|
||||
[JsonPropertyName("entryPointCount")]
|
||||
public required int EntryPointCount { get; init; }
|
||||
|
||||
/// <summary>Number of vulnerable sinks identified.</summary>
|
||||
[JsonPropertyName("sinkCount")]
|
||||
public required int SinkCount { get; init; }
|
||||
|
||||
/// <summary>Number of reachable findings.</summary>
|
||||
[JsonPropertyName("reachableCount")]
|
||||
public required int ReachableCount { get; init; }
|
||||
|
||||
/// <summary>Number of unreachable findings.</summary>
|
||||
[JsonPropertyName("unreachableCount")]
|
||||
public required int UnreachableCount { get; init; }
|
||||
|
||||
/// <summary>Number of micro-witnesses aggregated.</summary>
|
||||
[JsonPropertyName("aggregatedWitnessCount")]
|
||||
public required int AggregatedWitnessCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ProofChainServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_20260208_008_Attestor_dsse_signed_exception_objects_with_recheck_policy
|
||||
// Description: DI registration for ProofChain services including exception signing.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Attestor.ProofChain.Audit;
|
||||
using StellaOps.Attestor.ProofChain.Cas;
|
||||
using StellaOps.Attestor.ProofChain.Compliance;
|
||||
using StellaOps.Attestor.ProofChain.FingerprintStore;
|
||||
using StellaOps.Attestor.ProofChain.Graph;
|
||||
using StellaOps.Attestor.ProofChain.Idempotency;
|
||||
using StellaOps.Attestor.ProofChain.Json;
|
||||
using StellaOps.Attestor.ProofChain.LinkCapture;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
using StellaOps.Attestor.ProofChain.Receipts;
|
||||
using StellaOps.Attestor.ProofChain.Rekor;
|
||||
using StellaOps.Attestor.ProofChain.Findings;
|
||||
using StellaOps.Attestor.ProofChain.Replay;
|
||||
using StellaOps.Attestor.ProofChain.Services;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering ProofChain services with the DI container.
|
||||
/// </summary>
|
||||
public static class ProofChainServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds ProofChain services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddProofChainServices(this IServiceCollection services)
|
||||
{
|
||||
// JSON canonicalizer for deterministic hashing
|
||||
services.AddSingleton<IJsonCanonicalizer, Rfc8785JsonCanonicalizer>();
|
||||
|
||||
// Exception signing service
|
||||
services.AddScoped<IExceptionSigningService, ExceptionSigningService>();
|
||||
|
||||
// Binary fingerprint store with trust scoring
|
||||
services.TryAddSingleton<IBinaryFingerprintStore, BinaryFingerprintStore>();
|
||||
|
||||
// Content-addressed store for SBOM/VEX/attestation artifacts
|
||||
services.TryAddSingleton<IContentAddressedStore, InMemoryContentAddressedStore>();
|
||||
|
||||
// Crypto-sovereign profile resolver (region-based algorithm selection)
|
||||
// Uses TryAdd so the Attestor Infrastructure layer can register a registry-aware
|
||||
// implementation that bridges ICryptoProviderRegistry before this fallback applies.
|
||||
services.TryAddSingleton<ICryptoProfileResolver>(sp =>
|
||||
new DefaultCryptoProfileResolver(
|
||||
CryptoSovereignRegion.International,
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// DSSE envelope size guard (pre-submission validation with hash-only fallback)
|
||||
services.TryAddSingleton<IDsseEnvelopeSizeGuard>(sp =>
|
||||
new DsseEnvelopeSizeGuard(
|
||||
null, // Uses default policy (100KB soft, 1MB hard)
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// Evidence coverage scorer for AI gating decisions
|
||||
// Uses TryAdd so Infrastructure can register a persistence-backed resolver.
|
||||
services.TryAddSingleton<IEvidenceCoverageScorer>(sp =>
|
||||
new EvidenceCoverageScorer(
|
||||
new EvidenceCoveragePolicy(),
|
||||
_ => false, // Default resolver: no evidence resolvable until Infrastructure provides one
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// Subgraph visualization service for evidence graph rendering
|
||||
services.TryAddSingleton<ISubgraphVisualizationService, SubgraphVisualizationService>();
|
||||
|
||||
// Field-level ownership validator for receipts and bundles
|
||||
services.TryAddSingleton<IFieldOwnershipValidator, FieldOwnershipValidator>();
|
||||
|
||||
// Idempotent SBOM ingest and attestation verify service
|
||||
services.TryAddSingleton<IIdempotentIngestService>(sp =>
|
||||
new IdempotentIngestService(
|
||||
sp.GetRequiredService<IContentAddressedStore>(),
|
||||
sp.GetService<TimeProvider>(),
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// Regulatory compliance report generator (NIS2/DORA/ISO-27001/EU CRA)
|
||||
services.TryAddSingleton<IComplianceReportGenerator>(sp =>
|
||||
new ComplianceReportGenerator(
|
||||
sp.GetService<TimeProvider>(),
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// In-toto link attestation capture service
|
||||
services.TryAddSingleton<ILinkCaptureService>(sp =>
|
||||
new LinkCaptureService(
|
||||
sp.GetService<TimeProvider>(),
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// Bundle rotation and re-signing service (monthly cadence)
|
||||
services.TryAddSingleton<IBundleRotationService>(sp =>
|
||||
new BundleRotationService(
|
||||
sp.GetRequiredService<IProofChainKeyStore>(),
|
||||
sp.GetService<TimeProvider>(),
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// Noise ledger (audit log of suppression decisions)
|
||||
services.TryAddSingleton<INoiseLedgerService>(sp =>
|
||||
new NoiseLedgerService(
|
||||
sp.GetService<TimeProvider>(),
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// Object storage provider (filesystem default, S3/MinIO/GCS via override)
|
||||
services.TryAddSingleton<IObjectStorageProvider>(sp =>
|
||||
new FileSystemObjectStorageProvider(
|
||||
sp.GetRequiredService<ObjectStorageConfig>(),
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// Score replay and verification service (deterministic replay with DSSE attestation)
|
||||
services.TryAddSingleton<IScoreReplayService>(sp =>
|
||||
new ScoreReplayService(
|
||||
sp.GetService<TimeProvider>(),
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// Unknowns five-dimensional triage scorer (P/E/U/C/S with Hot/Warm/Cold bands)
|
||||
services.TryAddSingleton<IUnknownsTriageScorer>(sp =>
|
||||
new UnknownsTriageScorer(
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// VEX findings service with proof artifact resolution
|
||||
services.TryAddSingleton<IVexFindingsService>(sp =>
|
||||
new VexFindingsService(
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
// VEX receipt sidebar service (receipt DTO formatting for UI)
|
||||
services.TryAddSingleton<IReceiptSidebarService>(sp =>
|
||||
new ReceiptSidebarService(
|
||||
sp.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Receipts;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the module responsible for populating a field.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum OwnerModule
|
||||
{
|
||||
/// <summary>Core attestor framework (timestamps, IDs, versions).</summary>
|
||||
Core,
|
||||
|
||||
/// <summary>Signing module (signatures, key references).</summary>
|
||||
Signing,
|
||||
|
||||
/// <summary>Rekor module (transparency log entries, inclusion proofs).</summary>
|
||||
Rekor,
|
||||
|
||||
/// <summary>Verification module (checks, results, trust anchors).</summary>
|
||||
Verification,
|
||||
|
||||
/// <summary>SBOM/VEX module (SBOM documents, VEX statements).</summary>
|
||||
SbomVex,
|
||||
|
||||
/// <summary>Provenance module (build provenance, source attestation).</summary>
|
||||
Provenance,
|
||||
|
||||
/// <summary>Policy module (policy evaluation, gating decisions).</summary>
|
||||
Policy,
|
||||
|
||||
/// <summary>External system or user-supplied data.</summary>
|
||||
External
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes field ownership and write responsibility for a single field path.
|
||||
/// </summary>
|
||||
public sealed record FieldOwnershipEntry
|
||||
{
|
||||
/// <summary>Dot-delimited field path (e.g., "checks[].keyId").</summary>
|
||||
[JsonPropertyName("field_path")]
|
||||
public required string FieldPath { get; init; }
|
||||
|
||||
/// <summary>Module responsible for writing this field.</summary>
|
||||
[JsonPropertyName("owner")]
|
||||
public required OwnerModule Owner { get; init; }
|
||||
|
||||
/// <summary>Whether this field is required.</summary>
|
||||
[JsonPropertyName("required")]
|
||||
public required bool IsRequired { get; init; }
|
||||
|
||||
/// <summary>Human-readable description of the field's purpose.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which module populated a field at runtime, for audit purposes.
|
||||
/// </summary>
|
||||
public sealed record FieldPopulationRecord
|
||||
{
|
||||
/// <summary>Dot-delimited field path.</summary>
|
||||
[JsonPropertyName("field_path")]
|
||||
public required string FieldPath { get; init; }
|
||||
|
||||
/// <summary>Module that actually populated this field.</summary>
|
||||
[JsonPropertyName("populated_by")]
|
||||
public required OwnerModule PopulatedBy { get; init; }
|
||||
|
||||
/// <summary>Whether the field was populated (has a non-null value).</summary>
|
||||
[JsonPropertyName("is_populated")]
|
||||
public required bool IsPopulated { get; init; }
|
||||
|
||||
/// <summary>Whether the populating module matches the declared owner.</summary>
|
||||
[JsonPropertyName("ownership_valid")]
|
||||
public required bool OwnershipValid { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation result for a field ownership audit.
|
||||
/// </summary>
|
||||
public sealed record FieldOwnershipValidationResult
|
||||
{
|
||||
/// <summary>The document type that was validated (e.g., "VerificationReceipt", "ProofBundle").</summary>
|
||||
[JsonPropertyName("document_type")]
|
||||
public required string DocumentType { get; init; }
|
||||
|
||||
/// <summary>Per-field population records.</summary>
|
||||
[JsonPropertyName("fields")]
|
||||
public required ImmutableArray<FieldPopulationRecord> Fields { get; init; }
|
||||
|
||||
/// <summary>Total fields in the ownership map.</summary>
|
||||
[JsonPropertyName("total_fields")]
|
||||
public int TotalFields => Fields.Length;
|
||||
|
||||
/// <summary>Number of fields that are populated.</summary>
|
||||
[JsonPropertyName("populated_count")]
|
||||
public int PopulatedCount => Fields.Count(f => f.IsPopulated);
|
||||
|
||||
/// <summary>Number of fields with valid ownership (populated by declared owner).</summary>
|
||||
[JsonPropertyName("valid_count")]
|
||||
public int ValidCount => Fields.Count(f => f.OwnershipValid);
|
||||
|
||||
/// <summary>Number of required fields that are missing.</summary>
|
||||
[JsonPropertyName("missing_required_count")]
|
||||
public required int MissingRequiredCount { get; init; }
|
||||
|
||||
/// <summary>Whether all ownership constraints pass.</summary>
|
||||
[JsonPropertyName("is_valid")]
|
||||
public bool IsValid => MissingRequiredCount == 0 && Fields.All(f => f.OwnershipValid || !f.IsPopulated);
|
||||
|
||||
/// <summary>UTC timestamp of the validation.</summary>
|
||||
[JsonPropertyName("validated_at")]
|
||||
public required DateTimeOffset ValidatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete ownership map for a document type, containing the schema of expected
|
||||
/// ownership plus methods to validate at runtime.
|
||||
/// </summary>
|
||||
public sealed record FieldOwnershipMap
|
||||
{
|
||||
/// <summary>The document type this map describes.</summary>
|
||||
[JsonPropertyName("document_type")]
|
||||
public required string DocumentType { get; init; }
|
||||
|
||||
/// <summary>Schema version of this ownership map.</summary>
|
||||
[JsonPropertyName("schema_version")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>Ordered list of field ownership entries.</summary>
|
||||
[JsonPropertyName("entries")]
|
||||
public required ImmutableArray<FieldOwnershipEntry> Entries { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Receipts;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IFieldOwnershipValidator"/> that validates
|
||||
/// field-level ownership for verification receipts based on a static ownership map.
|
||||
/// </summary>
|
||||
public sealed class FieldOwnershipValidator : IFieldOwnershipValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Static ownership map for <see cref="VerificationReceipt"/> fields.
|
||||
/// </summary>
|
||||
public static readonly FieldOwnershipMap DefaultReceiptMap = new()
|
||||
{
|
||||
DocumentType = "VerificationReceipt",
|
||||
Entries =
|
||||
[
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "proofBundleId",
|
||||
Owner = OwnerModule.Core,
|
||||
IsRequired = true,
|
||||
Description = "Content-addressed identifier linking to the verified proof bundle."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "verifiedAt",
|
||||
Owner = OwnerModule.Core,
|
||||
IsRequired = true,
|
||||
Description = "UTC timestamp when verification was performed."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "verifierVersion",
|
||||
Owner = OwnerModule.Core,
|
||||
IsRequired = true,
|
||||
Description = "Version of the verifier tool that produced this receipt."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "anchorId",
|
||||
Owner = OwnerModule.Verification,
|
||||
IsRequired = true,
|
||||
Description = "Trust anchor identifier used for verification."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "result",
|
||||
Owner = OwnerModule.Verification,
|
||||
IsRequired = true,
|
||||
Description = "Overall verification result (Pass/Fail)."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "checks",
|
||||
Owner = OwnerModule.Verification,
|
||||
IsRequired = true,
|
||||
Description = "List of individual verification checks performed."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "checks[].check",
|
||||
Owner = OwnerModule.Verification,
|
||||
IsRequired = true,
|
||||
Description = "Name/type of the verification check."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "checks[].status",
|
||||
Owner = OwnerModule.Verification,
|
||||
IsRequired = true,
|
||||
Description = "Result status of the individual check."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "checks[].keyId",
|
||||
Owner = OwnerModule.Signing,
|
||||
IsRequired = false,
|
||||
Description = "Signing key identifier used in the check."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "checks[].logIndex",
|
||||
Owner = OwnerModule.Rekor,
|
||||
IsRequired = false,
|
||||
Description = "Rekor transparency log index for the entry."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "checks[].expected",
|
||||
Owner = OwnerModule.Verification,
|
||||
IsRequired = false,
|
||||
Description = "Expected value for comparison checks."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "checks[].actual",
|
||||
Owner = OwnerModule.Verification,
|
||||
IsRequired = false,
|
||||
Description = "Actual observed value for comparison checks."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "checks[].details",
|
||||
Owner = OwnerModule.Verification,
|
||||
IsRequired = false,
|
||||
Description = "Additional human-readable details about the check."
|
||||
},
|
||||
new FieldOwnershipEntry
|
||||
{
|
||||
FieldPath = "toolDigests",
|
||||
Owner = OwnerModule.Core,
|
||||
IsRequired = false,
|
||||
Description = "Content digests of tools used in verification."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public FieldOwnershipMap ReceiptOwnershipMap => DefaultReceiptMap;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FieldOwnershipValidationResult> ValidateReceiptOwnershipAsync(
|
||||
VerificationReceipt receipt,
|
||||
DateTimeOffset validatedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(receipt);
|
||||
|
||||
var fields = ImmutableArray.CreateBuilder<FieldPopulationRecord>();
|
||||
var missingRequired = 0;
|
||||
|
||||
// Validate top-level fields
|
||||
AddTopLevelField(fields, "proofBundleId", OwnerModule.Core, true,
|
||||
receipt.ProofBundleId is not null);
|
||||
AddTopLevelField(fields, "verifiedAt", OwnerModule.Core, true,
|
||||
receipt.VerifiedAt != default);
|
||||
AddTopLevelField(fields, "verifierVersion", OwnerModule.Core, true,
|
||||
!string.IsNullOrEmpty(receipt.VerifierVersion));
|
||||
AddTopLevelField(fields, "anchorId", OwnerModule.Verification, true,
|
||||
receipt.AnchorId is not null);
|
||||
AddTopLevelField(fields, "result", OwnerModule.Verification, true,
|
||||
true); // Enum always has a value
|
||||
AddTopLevelField(fields, "checks", OwnerModule.Verification, true,
|
||||
receipt.Checks is not null && receipt.Checks.Count > 0);
|
||||
AddTopLevelField(fields, "toolDigests", OwnerModule.Core, false,
|
||||
receipt.ToolDigests is not null && receipt.ToolDigests.Count > 0);
|
||||
|
||||
// Validate check-level fields
|
||||
if (receipt.Checks is not null)
|
||||
{
|
||||
foreach (var check in receipt.Checks)
|
||||
{
|
||||
AddTopLevelField(fields, "checks[].check", OwnerModule.Verification, true,
|
||||
!string.IsNullOrEmpty(check.Check));
|
||||
AddTopLevelField(fields, "checks[].status", OwnerModule.Verification, true,
|
||||
true); // Enum always has a value
|
||||
AddTopLevelField(fields, "checks[].keyId", OwnerModule.Signing, false,
|
||||
!string.IsNullOrEmpty(check.KeyId));
|
||||
AddTopLevelField(fields, "checks[].logIndex", OwnerModule.Rekor, false,
|
||||
check.LogIndex.HasValue);
|
||||
AddTopLevelField(fields, "checks[].expected", OwnerModule.Verification, false,
|
||||
!string.IsNullOrEmpty(check.Expected));
|
||||
AddTopLevelField(fields, "checks[].actual", OwnerModule.Verification, false,
|
||||
!string.IsNullOrEmpty(check.Actual));
|
||||
AddTopLevelField(fields, "checks[].details", OwnerModule.Verification, false,
|
||||
!string.IsNullOrEmpty(check.Details));
|
||||
}
|
||||
}
|
||||
|
||||
// Count missing required fields
|
||||
foreach (var entry in DefaultReceiptMap.Entries)
|
||||
{
|
||||
if (entry.IsRequired)
|
||||
{
|
||||
var populationRecords = fields.Where(f =>
|
||||
f.FieldPath == entry.FieldPath).ToList();
|
||||
|
||||
if (populationRecords.Count == 0 || populationRecords.Any(p => !p.IsPopulated))
|
||||
{
|
||||
// Check if any population record shows this required field as missing
|
||||
var isMissing = populationRecords.Count == 0 ||
|
||||
populationRecords.All(p => !p.IsPopulated);
|
||||
if (isMissing)
|
||||
missingRequired++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result = new FieldOwnershipValidationResult
|
||||
{
|
||||
DocumentType = "VerificationReceipt",
|
||||
Fields = fields.ToImmutable(),
|
||||
MissingRequiredCount = missingRequired,
|
||||
ValidatedAt = validatedAt
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static void AddTopLevelField(
|
||||
ImmutableArray<FieldPopulationRecord>.Builder fields,
|
||||
string fieldPath,
|
||||
OwnerModule declaredOwner,
|
||||
bool isRequired,
|
||||
bool isPopulated)
|
||||
{
|
||||
fields.Add(new FieldPopulationRecord
|
||||
{
|
||||
FieldPath = fieldPath,
|
||||
PopulatedBy = declaredOwner,
|
||||
IsPopulated = isPopulated,
|
||||
// Ownership is valid when: field is populated by declared owner, or field is not populated
|
||||
OwnershipValid = true // Static map always matches; runtime overrides would change this
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Receipts;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for validating field-level ownership of receipts and bundles.
|
||||
/// </summary>
|
||||
public interface IFieldOwnershipValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the ownership map for verification receipts.
|
||||
/// </summary>
|
||||
FieldOwnershipMap ReceiptOwnershipMap { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates field-level ownership for a verification receipt.
|
||||
/// </summary>
|
||||
/// <param name="receipt">The receipt to validate.</param>
|
||||
/// <param name="validatedAt">Timestamp for the validation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Validation result with per-field ownership details.</returns>
|
||||
Task<FieldOwnershipValidationResult> ValidateReceiptOwnershipAsync(
|
||||
VerificationReceipt receipt,
|
||||
DateTimeOffset validatedAt,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IReceiptSidebarService.cs
|
||||
// Sprint: SPRINT_20260208_024_Attestor_vex_receipt_sidebar
|
||||
// Task: T1 — Receipt sidebar service interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Receipts;
|
||||
|
||||
/// <summary>
|
||||
/// Formats verification receipts into sidebar-ready DTOs for the VEX receipt
|
||||
/// sidebar UI component. Combines receipt data with VEX decision context.
|
||||
/// </summary>
|
||||
public interface IReceiptSidebarService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a sidebar detail for a specific receipt by bundle ID.
|
||||
/// </summary>
|
||||
Task<ReceiptSidebarDetail?> GetDetailAsync(
|
||||
ReceiptSidebarRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a full sidebar context combining receipt detail with
|
||||
/// VEX decision and justification.
|
||||
/// </summary>
|
||||
Task<VexReceiptSidebarContext?> GetContextAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Formats a <see cref="VerificationReceipt"/> into a sidebar detail DTO.
|
||||
/// </summary>
|
||||
ReceiptSidebarDetail FormatReceipt(VerificationReceipt receipt);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReceiptSidebarModels.cs
|
||||
// Sprint: SPRINT_20260208_024_Attestor_vex_receipt_sidebar
|
||||
// Task: T1 — Receipt sidebar DTO models for VEX receipt detail rendering
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Receipts;
|
||||
|
||||
/// <summary>
|
||||
/// Verification status of a receipt's cryptographic proofs.
|
||||
/// </summary>
|
||||
public enum ReceiptVerificationStatus
|
||||
{
|
||||
/// <summary>All checks passed.</summary>
|
||||
Verified = 0,
|
||||
|
||||
/// <summary>Some checks failed.</summary>
|
||||
PartiallyVerified = 1,
|
||||
|
||||
/// <summary>Verification has not been performed.</summary>
|
||||
Unverified = 2,
|
||||
|
||||
/// <summary>Verification failed.</summary>
|
||||
Failed = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single verification check formatted for sidebar display.
|
||||
/// </summary>
|
||||
public sealed record ReceiptCheckDetail
|
||||
{
|
||||
/// <summary>Human-readable check name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Whether this check passed.</summary>
|
||||
public required bool Passed { get; init; }
|
||||
|
||||
/// <summary>Key identifier used (if applicable).</summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>Rekor log index (if applicable).</summary>
|
||||
public long? LogIndex { get; init; }
|
||||
|
||||
/// <summary>Human-readable detail or reason.</summary>
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Receipt detail DTO formatted for sidebar rendering.
|
||||
/// Contains all information needed to display a VEX receipt in the UI sidebar.
|
||||
/// </summary>
|
||||
public sealed record ReceiptSidebarDetail
|
||||
{
|
||||
/// <summary>Proof bundle identifier.</summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>Timestamp of verification.</summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>Version of the verifier that produced the receipt.</summary>
|
||||
public required string VerifierVersion { get; init; }
|
||||
|
||||
/// <summary>Trust anchor used for verification.</summary>
|
||||
public required string AnchorId { get; init; }
|
||||
|
||||
/// <summary>Overall verification status for UI display.</summary>
|
||||
public required ReceiptVerificationStatus VerificationStatus { get; init; }
|
||||
|
||||
/// <summary>Individual check details.</summary>
|
||||
public required ImmutableArray<ReceiptCheckDetail> Checks { get; init; }
|
||||
|
||||
/// <summary>Total number of checks.</summary>
|
||||
public int TotalChecks => Checks.IsDefaultOrEmpty ? 0 : Checks.Length;
|
||||
|
||||
/// <summary>Number of passed checks.</summary>
|
||||
public int PassedChecks => Checks.IsDefaultOrEmpty ? 0 : Checks.Count(c => c.Passed);
|
||||
|
||||
/// <summary>Number of failed checks.</summary>
|
||||
public int FailedChecks => TotalChecks - PassedChecks;
|
||||
|
||||
/// <summary>Whether DSSE signature was verified.</summary>
|
||||
public bool DsseVerified { get; init; }
|
||||
|
||||
/// <summary>Whether Rekor inclusion was verified.</summary>
|
||||
public bool RekorInclusionVerified { get; init; }
|
||||
|
||||
/// <summary>Tool digests used during verification.</summary>
|
||||
public ImmutableDictionary<string, string>? ToolDigests { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX receipt sidebar context: the receipt detail plus the associated
|
||||
/// verdict decision and justification.
|
||||
/// </summary>
|
||||
public sealed record VexReceiptSidebarContext
|
||||
{
|
||||
/// <summary>The receipt detail formatted for sidebar display.</summary>
|
||||
public required ReceiptSidebarDetail Receipt { get; init; }
|
||||
|
||||
/// <summary>VEX decision (not_affected / affected / fixed / under_investigation).</summary>
|
||||
public string? Decision { get; init; }
|
||||
|
||||
/// <summary>Justification for the VEX decision.</summary>
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>Evidence references supporting the decision.</summary>
|
||||
public ImmutableArray<string> EvidenceRefs { get; init; } = [];
|
||||
|
||||
/// <summary>Finding identifier (CVE + component).</summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>Vulnerability identifier.</summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Component Package URL.</summary>
|
||||
public string? ComponentPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to get sidebar detail for a receipt.
|
||||
/// </summary>
|
||||
public sealed record ReceiptSidebarRequest
|
||||
{
|
||||
/// <summary>Proof bundle ID to look up.</summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>Whether to include verification check details.</summary>
|
||||
public bool IncludeChecks { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to include tool digest information.</summary>
|
||||
public bool IncludeToolDigests { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReceiptSidebarService.cs
|
||||
// Sprint: SPRINT_20260208_024_Attestor_vex_receipt_sidebar
|
||||
// Task: T1 — Receipt sidebar service implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Receipts;
|
||||
|
||||
/// <summary>
|
||||
/// Formats verification receipts into sidebar-ready DTOs.
|
||||
/// Maintains an in-memory index of receipts by bundle ID for fast lookup.
|
||||
/// </summary>
|
||||
public sealed class ReceiptSidebarService : IReceiptSidebarService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, VerificationReceipt> _receipts = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, VexReceiptSidebarContext> _contexts = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly Counter<long> _getDetailCounter;
|
||||
private readonly Counter<long> _getContextCounter;
|
||||
private readonly Counter<long> _formatCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new receipt sidebar service with OTel instrumentation.
|
||||
/// </summary>
|
||||
public ReceiptSidebarService(IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Receipts.Sidebar");
|
||||
_getDetailCounter = meter.CreateCounter<long>("sidebar.detail.total", description: "Sidebar detail requests");
|
||||
_getContextCounter = meter.CreateCounter<long>("sidebar.context.total", description: "Sidebar context requests");
|
||||
_formatCounter = meter.CreateCounter<long>("sidebar.format.total", description: "Receipts formatted for sidebar");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a receipt for sidebar lookup.
|
||||
/// </summary>
|
||||
public void Register(VerificationReceipt receipt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(receipt);
|
||||
_receipts[receipt.ProofBundleId.ToString()] = receipt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a full sidebar context (receipt + VEX decision).
|
||||
/// </summary>
|
||||
public void RegisterContext(string bundleId, VexReceiptSidebarContext context)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_contexts[bundleId] = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<ReceiptSidebarDetail?> GetDetailAsync(
|
||||
ReceiptSidebarRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_getDetailCounter.Add(1);
|
||||
|
||||
if (!_receipts.TryGetValue(request.BundleId, out var receipt))
|
||||
{
|
||||
return Task.FromResult<ReceiptSidebarDetail?>(null);
|
||||
}
|
||||
|
||||
var detail = FormatReceiptInternal(receipt, request.IncludeChecks, request.IncludeToolDigests);
|
||||
return Task.FromResult<ReceiptSidebarDetail?>(detail);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<VexReceiptSidebarContext?> GetContextAsync(
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
|
||||
_getContextCounter.Add(1);
|
||||
|
||||
if (_contexts.TryGetValue(bundleId, out var context))
|
||||
{
|
||||
return Task.FromResult<VexReceiptSidebarContext?>(context);
|
||||
}
|
||||
|
||||
// Fallback: build context from receipt only (no VEX decision)
|
||||
if (_receipts.TryGetValue(bundleId, out var receipt))
|
||||
{
|
||||
var detail = FormatReceipt(receipt);
|
||||
var fallback = new VexReceiptSidebarContext { Receipt = detail };
|
||||
return Task.FromResult<VexReceiptSidebarContext?>(fallback);
|
||||
}
|
||||
|
||||
return Task.FromResult<VexReceiptSidebarContext?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ReceiptSidebarDetail FormatReceipt(VerificationReceipt receipt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(receipt);
|
||||
|
||||
_formatCounter.Add(1);
|
||||
|
||||
return FormatReceiptInternal(receipt, includeChecks: true, includeToolDigests: true);
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────────
|
||||
|
||||
private static ReceiptSidebarDetail FormatReceiptInternal(
|
||||
VerificationReceipt receipt,
|
||||
bool includeChecks,
|
||||
bool includeToolDigests)
|
||||
{
|
||||
var checks = includeChecks
|
||||
? receipt.Checks.Select(c => new ReceiptCheckDetail
|
||||
{
|
||||
Name = c.Check,
|
||||
Passed = c.Status == VerificationResult.Pass,
|
||||
KeyId = c.KeyId,
|
||||
LogIndex = c.LogIndex,
|
||||
Detail = FormatCheckDetail(c)
|
||||
}).ToImmutableArray()
|
||||
: [];
|
||||
|
||||
var dsseVerified = receipt.Checks.Any(c =>
|
||||
c.Check.Contains("dsse", StringComparison.OrdinalIgnoreCase) &&
|
||||
c.Status == VerificationResult.Pass);
|
||||
|
||||
var rekorVerified = receipt.Checks.Any(c =>
|
||||
c.Check.Contains("rekor", StringComparison.OrdinalIgnoreCase) &&
|
||||
c.Status == VerificationResult.Pass);
|
||||
|
||||
var verificationStatus = DeriveVerificationStatus(receipt);
|
||||
|
||||
var toolDigests = includeToolDigests && receipt.ToolDigests is not null
|
||||
? receipt.ToolDigests.ToImmutableDictionary()
|
||||
: null;
|
||||
|
||||
return new ReceiptSidebarDetail
|
||||
{
|
||||
BundleId = receipt.ProofBundleId.ToString(),
|
||||
VerifiedAt = receipt.VerifiedAt,
|
||||
VerifierVersion = receipt.VerifierVersion,
|
||||
AnchorId = receipt.AnchorId.ToString(),
|
||||
VerificationStatus = verificationStatus,
|
||||
Checks = checks,
|
||||
DsseVerified = dsseVerified,
|
||||
RekorInclusionVerified = rekorVerified,
|
||||
ToolDigests = toolDigests
|
||||
};
|
||||
}
|
||||
|
||||
internal static ReceiptVerificationStatus DeriveVerificationStatus(VerificationReceipt receipt)
|
||||
{
|
||||
if (receipt.Checks.Count == 0)
|
||||
{
|
||||
return ReceiptVerificationStatus.Unverified;
|
||||
}
|
||||
|
||||
var allPassed = receipt.Checks.All(c => c.Status == VerificationResult.Pass);
|
||||
var anyPassed = receipt.Checks.Any(c => c.Status == VerificationResult.Pass);
|
||||
|
||||
if (allPassed)
|
||||
return ReceiptVerificationStatus.Verified;
|
||||
|
||||
if (anyPassed)
|
||||
return ReceiptVerificationStatus.PartiallyVerified;
|
||||
|
||||
return ReceiptVerificationStatus.Failed;
|
||||
}
|
||||
|
||||
private static string? FormatCheckDetail(VerificationCheck check)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(check.Details))
|
||||
return check.Details;
|
||||
|
||||
if (check.Expected is not null && check.Actual is not null)
|
||||
return $"Expected: {check.Expected}, Actual: {check.Actual}";
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Rekor;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDsseEnvelopeSizeGuard"/>.
|
||||
/// Validates DSSE envelope size against a configurable policy and determines
|
||||
/// the submission mode: full envelope, hash-only fallback, chunked, or rejected.
|
||||
/// </summary>
|
||||
public sealed class DsseEnvelopeSizeGuard : IDsseEnvelopeSizeGuard
|
||||
{
|
||||
private readonly Counter<long> _validationCounter;
|
||||
private readonly Counter<long> _hashOnlyCounter;
|
||||
private readonly Counter<long> _chunkedCounter;
|
||||
private readonly Counter<long> _rejectedCounter;
|
||||
|
||||
public DsseEnvelopeSizeGuard(
|
||||
DsseEnvelopeSizePolicy? policy,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
Policy = policy ?? new DsseEnvelopeSizePolicy();
|
||||
|
||||
ValidatePolicy(Policy);
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.EnvelopeSize");
|
||||
_validationCounter = meter.CreateCounter<long>("envelope_size.validations", description: "Total envelope size validations");
|
||||
_hashOnlyCounter = meter.CreateCounter<long>("envelope_size.hash_only_fallbacks", description: "Hash-only fallback activations");
|
||||
_chunkedCounter = meter.CreateCounter<long>("envelope_size.chunked", description: "Chunked submission activations");
|
||||
_rejectedCounter = meter.CreateCounter<long>("envelope_size.rejections", description: "Envelope rejections");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DsseEnvelopeSizePolicy Policy { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EnvelopeSizeValidation> ValidateAsync(
|
||||
DsseEnvelope envelope,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope);
|
||||
return ValidateBytesAsync(envelopeBytes, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EnvelopeSizeValidation> ValidateAsync(
|
||||
ReadOnlyMemory<byte> envelopeBytes,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (envelopeBytes.IsEmpty)
|
||||
{
|
||||
return Task.FromResult(new EnvelopeSizeValidation
|
||||
{
|
||||
Mode = EnvelopeSubmissionMode.Rejected,
|
||||
EnvelopeSizeBytes = 0,
|
||||
Policy = Policy,
|
||||
RejectionReason = "Envelope is empty."
|
||||
});
|
||||
}
|
||||
|
||||
return ValidateBytesAsync(envelopeBytes.ToArray(), ct);
|
||||
}
|
||||
|
||||
private Task<EnvelopeSizeValidation> ValidateBytesAsync(byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
_validationCounter.Add(1);
|
||||
|
||||
long size = bytes.Length;
|
||||
|
||||
// Under soft limit: full envelope submission
|
||||
if (size <= Policy.SoftLimitBytes)
|
||||
{
|
||||
return Task.FromResult(new EnvelopeSizeValidation
|
||||
{
|
||||
Mode = EnvelopeSubmissionMode.FullEnvelope,
|
||||
EnvelopeSizeBytes = size,
|
||||
Policy = Policy
|
||||
});
|
||||
}
|
||||
|
||||
// Over hard limit: always rejected
|
||||
if (size > Policy.HardLimitBytes)
|
||||
{
|
||||
_rejectedCounter.Add(1);
|
||||
return Task.FromResult(new EnvelopeSizeValidation
|
||||
{
|
||||
Mode = EnvelopeSubmissionMode.Rejected,
|
||||
EnvelopeSizeBytes = size,
|
||||
Policy = Policy,
|
||||
RejectionReason = $"Envelope size {size} bytes exceeds hard limit of {Policy.HardLimitBytes} bytes."
|
||||
});
|
||||
}
|
||||
|
||||
// Between soft and hard limit: fallback mode
|
||||
if (Policy.EnableChunking)
|
||||
{
|
||||
_chunkedCounter.Add(1);
|
||||
var manifest = BuildChunkManifest(bytes);
|
||||
return Task.FromResult(new EnvelopeSizeValidation
|
||||
{
|
||||
Mode = EnvelopeSubmissionMode.Chunked,
|
||||
EnvelopeSizeBytes = size,
|
||||
Policy = Policy,
|
||||
ChunkManifest = manifest
|
||||
});
|
||||
}
|
||||
|
||||
if (Policy.EnableHashOnlyFallback)
|
||||
{
|
||||
_hashOnlyCounter.Add(1);
|
||||
var digest = ComputeDigest(bytes);
|
||||
return Task.FromResult(new EnvelopeSizeValidation
|
||||
{
|
||||
Mode = EnvelopeSubmissionMode.HashOnly,
|
||||
EnvelopeSizeBytes = size,
|
||||
Policy = Policy,
|
||||
PayloadDigest = digest
|
||||
});
|
||||
}
|
||||
|
||||
// Both fallbacks disabled: reject
|
||||
_rejectedCounter.Add(1);
|
||||
return Task.FromResult(new EnvelopeSizeValidation
|
||||
{
|
||||
Mode = EnvelopeSubmissionMode.Rejected,
|
||||
EnvelopeSizeBytes = size,
|
||||
Policy = Policy,
|
||||
RejectionReason = $"Envelope size {size} bytes exceeds soft limit of {Policy.SoftLimitBytes} bytes and all fallback modes are disabled."
|
||||
});
|
||||
}
|
||||
|
||||
internal EnvelopeChunkManifest BuildChunkManifest(byte[] envelopeBytes)
|
||||
{
|
||||
var chunkSize = Policy.ChunkSizeBytes;
|
||||
var totalSize = envelopeBytes.Length;
|
||||
var chunkCount = (totalSize + chunkSize - 1) / chunkSize;
|
||||
|
||||
var originalDigest = ComputeDigest(envelopeBytes);
|
||||
var chunks = ImmutableArray.CreateBuilder<ChunkDescriptor>(chunkCount);
|
||||
|
||||
for (int i = 0; i < chunkCount; i++)
|
||||
{
|
||||
var offset = i * chunkSize;
|
||||
var length = Math.Min(chunkSize, totalSize - offset);
|
||||
var chunkBytes = new ReadOnlySpan<byte>(envelopeBytes, offset, length);
|
||||
var chunkDigest = ComputeDigest(chunkBytes);
|
||||
|
||||
chunks.Add(new ChunkDescriptor
|
||||
{
|
||||
Index = i,
|
||||
SizeBytes = length,
|
||||
Digest = chunkDigest,
|
||||
Offset = offset
|
||||
});
|
||||
}
|
||||
|
||||
return new EnvelopeChunkManifest
|
||||
{
|
||||
TotalSizeBytes = totalSize,
|
||||
ChunkCount = chunkCount,
|
||||
OriginalDigest = originalDigest,
|
||||
Chunks = chunks.ToImmutable()
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ComputeDigest(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
internal static string ComputeDigest(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(data, hash);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static void ValidatePolicy(DsseEnvelopeSizePolicy policy)
|
||||
{
|
||||
if (policy.SoftLimitBytes <= 0)
|
||||
throw new ArgumentException("SoftLimitBytes must be positive.", nameof(policy));
|
||||
if (policy.HardLimitBytes <= 0)
|
||||
throw new ArgumentException("HardLimitBytes must be positive.", nameof(policy));
|
||||
if (policy.HardLimitBytes < policy.SoftLimitBytes)
|
||||
throw new ArgumentException("HardLimitBytes must be >= SoftLimitBytes.", nameof(policy));
|
||||
if (policy.ChunkSizeBytes <= 0)
|
||||
throw new ArgumentException("ChunkSizeBytes must be positive.", nameof(policy));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Rekor;
|
||||
|
||||
/// <summary>
|
||||
/// Submission mode for DSSE envelopes, determined by size validation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EnvelopeSubmissionMode
|
||||
{
|
||||
/// <summary>Full envelope submitted to Rekor as-is.</summary>
|
||||
FullEnvelope,
|
||||
|
||||
/// <summary>Only the payload hash is submitted (oversized envelope fallback).</summary>
|
||||
HashOnly,
|
||||
|
||||
/// <summary>Envelope is split into chunks with a manifest linking them.</summary>
|
||||
Chunked,
|
||||
|
||||
/// <summary>Submission rejected — envelope exceeds hard limit even with fallback.</summary>
|
||||
Rejected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Size policy for DSSE envelope submission to transparency logs.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeSizePolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Soft limit in bytes. Envelopes exceeding this trigger hash-only fallback.
|
||||
/// Default: 102,400 (100 KB).
|
||||
/// </summary>
|
||||
public int SoftLimitBytes { get; init; } = 102_400;
|
||||
|
||||
/// <summary>
|
||||
/// Hard limit in bytes. Envelopes exceeding this are rejected entirely.
|
||||
/// Default: 1,048,576 (1 MB).
|
||||
/// </summary>
|
||||
public int HardLimitBytes { get; init; } = 1_048_576;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum size of a single chunk in chunked mode.
|
||||
/// Default: 65,536 (64 KB).
|
||||
/// </summary>
|
||||
public int ChunkSizeBytes { get; init; } = 65_536;
|
||||
|
||||
/// <summary>
|
||||
/// Whether hash-only fallback is enabled. If disabled, oversized envelopes are rejected.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool EnableHashOnlyFallback { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether chunked mode is enabled for envelopes between soft and hard limits.
|
||||
/// Default: false (hash-only preferred over chunking).
|
||||
/// </summary>
|
||||
public bool EnableChunking { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash algorithm used for hash-only mode digest computation.
|
||||
/// Default: "SHA-256".
|
||||
/// </summary>
|
||||
public string HashAlgorithm { get; init; } = "SHA-256";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of envelope size validation against the configured policy.
|
||||
/// </summary>
|
||||
public sealed record EnvelopeSizeValidation
|
||||
{
|
||||
/// <summary>The determined submission mode.</summary>
|
||||
public required EnvelopeSubmissionMode Mode { get; init; }
|
||||
|
||||
/// <summary>Original envelope size in bytes.</summary>
|
||||
public required long EnvelopeSizeBytes { get; init; }
|
||||
|
||||
/// <summary>The policy that was applied.</summary>
|
||||
public required DsseEnvelopeSizePolicy Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload hash digest for hash-only mode (e.g., "sha256:abcdef...").
|
||||
/// Null when mode is FullEnvelope or Rejected.
|
||||
/// </summary>
|
||||
public string? PayloadDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Chunk manifest for chunked mode. Null when not chunked.
|
||||
/// </summary>
|
||||
public EnvelopeChunkManifest? ChunkManifest { get; init; }
|
||||
|
||||
/// <summary>Rejection reason, if applicable.</summary>
|
||||
public string? RejectionReason { get; init; }
|
||||
|
||||
/// <summary>Whether the envelope passed validation (not rejected).</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsAccepted => Mode != EnvelopeSubmissionMode.Rejected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest linking chunked DSSE envelope fragments.
|
||||
/// Each chunk is content-addressed by SHA-256 digest.
|
||||
/// </summary>
|
||||
public sealed record EnvelopeChunkManifest
|
||||
{
|
||||
/// <summary>Total size of the original envelope in bytes.</summary>
|
||||
public required long TotalSizeBytes { get; init; }
|
||||
|
||||
/// <summary>Number of chunks.</summary>
|
||||
public required int ChunkCount { get; init; }
|
||||
|
||||
/// <summary>SHA-256 digest of the complete original envelope.</summary>
|
||||
public required string OriginalDigest { get; init; }
|
||||
|
||||
/// <summary>Ordered list of chunk descriptors.</summary>
|
||||
public required ImmutableArray<ChunkDescriptor> Chunks { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Descriptor for a single chunk in a chunked envelope submission.
|
||||
/// </summary>
|
||||
public sealed record ChunkDescriptor
|
||||
{
|
||||
/// <summary>Zero-based chunk index.</summary>
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>Chunk size in bytes.</summary>
|
||||
public required int SizeBytes { get; init; }
|
||||
|
||||
/// <summary>SHA-256 digest of the chunk content.</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Byte offset in the original envelope.</summary>
|
||||
public required long Offset { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Rekor;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-submission size guard for DSSE envelopes.
|
||||
/// Validates envelope size against policy and determines submission mode:
|
||||
/// full envelope, hash-only fallback, chunked, or rejected.
|
||||
/// </summary>
|
||||
public interface IDsseEnvelopeSizeGuard
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate a DSSE envelope against the configured size policy.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope to validate.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Size validation result with determined submission mode.</returns>
|
||||
Task<EnvelopeSizeValidation> ValidateAsync(
|
||||
DsseEnvelope envelope,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validate raw envelope bytes against the configured size policy.
|
||||
/// </summary>
|
||||
/// <param name="envelopeBytes">Serialized DSSE envelope bytes.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Size validation result with determined submission mode.</returns>
|
||||
Task<EnvelopeSizeValidation> ValidateAsync(
|
||||
ReadOnlyMemory<byte> envelopeBytes,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the active size policy.
|
||||
/// </summary>
|
||||
DsseEnvelopeSizePolicy Policy { get; }
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Rekor;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="ReachMapPredicate"/> by aggregating micro-witness data,
|
||||
/// nodes, edges, and findings into a single reach-map document.
|
||||
/// </summary>
|
||||
public sealed class ReachMapBuilder
|
||||
{
|
||||
private readonly List<ReachMapNode> _nodes = [];
|
||||
private readonly List<ReachMapEdge> _edges = [];
|
||||
private readonly List<ReachMapFinding> _findings = [];
|
||||
private readonly List<string> _witnessIds = [];
|
||||
|
||||
private string? _scanId;
|
||||
private string? _artifactRef;
|
||||
private string? _analyzer;
|
||||
private string? _analyzerVersion;
|
||||
private double _confidence;
|
||||
private string _completeness = "unknown";
|
||||
private DateTimeOffset _generatedAt;
|
||||
private string? _graphCasUri;
|
||||
|
||||
/// <summary>
|
||||
/// Set the scan ID.
|
||||
/// </summary>
|
||||
public ReachMapBuilder WithScanId(string scanId)
|
||||
{
|
||||
_scanId = scanId ?? throw new ArgumentNullException(nameof(scanId));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the artifact reference (image/package).
|
||||
/// </summary>
|
||||
public ReachMapBuilder WithArtifactRef(string artifactRef)
|
||||
{
|
||||
_artifactRef = artifactRef ?? throw new ArgumentNullException(nameof(artifactRef));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the analyzer metadata.
|
||||
/// </summary>
|
||||
public ReachMapBuilder WithAnalyzer(string analyzer, string version, double confidence, string completeness)
|
||||
{
|
||||
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
|
||||
_analyzerVersion = version ?? throw new ArgumentNullException(nameof(version));
|
||||
_confidence = confidence;
|
||||
_completeness = completeness ?? throw new ArgumentNullException(nameof(completeness));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the generation timestamp.
|
||||
/// </summary>
|
||||
public ReachMapBuilder WithGeneratedAt(DateTimeOffset generatedAt)
|
||||
{
|
||||
_generatedAt = generatedAt;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the optional CAS URI for the graph content.
|
||||
/// </summary>
|
||||
public ReachMapBuilder WithGraphCasUri(string casUri)
|
||||
{
|
||||
_graphCasUri = casUri;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a function node to the graph.
|
||||
/// </summary>
|
||||
public ReachMapBuilder AddNode(ReachMapNode node)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
_nodes.Add(node);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add multiple function nodes to the graph.
|
||||
/// </summary>
|
||||
public ReachMapBuilder AddNodes(IEnumerable<ReachMapNode> nodes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodes);
|
||||
_nodes.AddRange(nodes);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a call edge to the graph.
|
||||
/// </summary>
|
||||
public ReachMapBuilder AddEdge(ReachMapEdge edge)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edge);
|
||||
_edges.Add(edge);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add multiple call edges to the graph.
|
||||
/// </summary>
|
||||
public ReachMapBuilder AddEdges(IEnumerable<ReachMapEdge> edges)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edges);
|
||||
_edges.AddRange(edges);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a vulnerability reachability finding.
|
||||
/// </summary>
|
||||
public ReachMapBuilder AddFinding(ReachMapFinding finding)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(finding);
|
||||
_findings.Add(finding);
|
||||
if (finding.WitnessId is not null)
|
||||
{
|
||||
_witnessIds.Add(finding.WitnessId);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add multiple vulnerability reachability findings.
|
||||
/// </summary>
|
||||
public ReachMapBuilder AddFindings(IEnumerable<ReachMapFinding> findings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(findings);
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
AddFinding(finding);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add an aggregated micro-witness ID.
|
||||
/// </summary>
|
||||
public ReachMapBuilder AddWitnessId(string witnessId)
|
||||
{
|
||||
_witnessIds.Add(witnessId ?? throw new ArgumentNullException(nameof(witnessId)));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the <see cref="ReachMapPredicate"/> from accumulated data.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If required fields are missing.</exception>
|
||||
public ReachMapPredicate Build()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_scanId))
|
||||
throw new InvalidOperationException("ScanId is required.");
|
||||
if (string.IsNullOrWhiteSpace(_artifactRef))
|
||||
throw new InvalidOperationException("ArtifactRef is required.");
|
||||
if (string.IsNullOrWhiteSpace(_analyzer))
|
||||
throw new InvalidOperationException("Analyzer is required.");
|
||||
if (string.IsNullOrWhiteSpace(_analyzerVersion))
|
||||
throw new InvalidOperationException("AnalyzerVersion is required.");
|
||||
|
||||
var nodes = _nodes.ToImmutableArray();
|
||||
var edges = _edges.ToImmutableArray();
|
||||
var findings = _findings.ToImmutableArray();
|
||||
var witnessIds = _witnessIds.Distinct().ToImmutableArray();
|
||||
|
||||
var graphDigest = ComputeGraphDigest(nodes, edges, findings);
|
||||
|
||||
var entryPointCount = nodes.Count(n => n.IsEntryPoint);
|
||||
var sinkCount = nodes.Count(n => n.IsSink);
|
||||
var reachableCount = findings.Count(f => f.IsReachable);
|
||||
var unreachableCount = findings.Count(f => !f.IsReachable);
|
||||
|
||||
return new ReachMapPredicate
|
||||
{
|
||||
GraphDigest = graphDigest,
|
||||
GraphCasUri = _graphCasUri,
|
||||
ScanId = _scanId,
|
||||
ArtifactRef = _artifactRef,
|
||||
Nodes = nodes,
|
||||
Edges = edges,
|
||||
Findings = findings,
|
||||
AggregatedWitnessIds = witnessIds,
|
||||
Analysis = new ReachMapAnalysis
|
||||
{
|
||||
Analyzer = _analyzer,
|
||||
AnalyzerVersion = _analyzerVersion,
|
||||
Confidence = _confidence,
|
||||
Completeness = _completeness,
|
||||
GeneratedAt = _generatedAt
|
||||
},
|
||||
Summary = new ReachMapSummary
|
||||
{
|
||||
TotalNodes = nodes.Length,
|
||||
TotalEdges = edges.Length,
|
||||
EntryPointCount = entryPointCount,
|
||||
SinkCount = sinkCount,
|
||||
ReachableCount = reachableCount,
|
||||
UnreachableCount = unreachableCount,
|
||||
AggregatedWitnessCount = witnessIds.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute a deterministic SHA-256 digest of the graph structure.
|
||||
/// The digest is computed over sorted node IDs, sorted edge pairs, and sorted finding keys.
|
||||
/// </summary>
|
||||
internal static string ComputeGraphDigest(
|
||||
ImmutableArray<ReachMapNode> nodes,
|
||||
ImmutableArray<ReachMapEdge> edges,
|
||||
ImmutableArray<ReachMapFinding> findings)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Sorted nodes by nodeId
|
||||
foreach (var node in nodes.OrderBy(n => n.NodeId, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append("N:").Append(node.NodeId).Append(':').Append(node.ReachabilityState).Append('|');
|
||||
}
|
||||
|
||||
// Sorted edges by source+target
|
||||
foreach (var edge in edges.OrderBy(e => e.SourceNodeId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetNodeId, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append("E:").Append(edge.SourceNodeId).Append('>').Append(edge.TargetNodeId).Append('|');
|
||||
}
|
||||
|
||||
// Sorted findings by vulnId
|
||||
foreach (var finding in findings.OrderBy(f => f.VulnId, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append("F:").Append(finding.VulnId).Append(':').Append(finding.IsReachable).Append('|');
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IScoreReplayService.cs
|
||||
// Sprint: SPRINT_20260208_020_Attestor_score_replay_and_verification
|
||||
// Task: T1 — Score replay and comparison service interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Service for replaying verdict scores, comparing replay results,
|
||||
/// and producing DSSE-ready attestations for audit evidence.
|
||||
/// </summary>
|
||||
public interface IScoreReplayService
|
||||
{
|
||||
/// <summary>
|
||||
/// Replay a verdict score by re-executing the scoring computation
|
||||
/// with the captured inputs from the original verdict.
|
||||
/// </summary>
|
||||
Task<ScoreReplayResult> ReplayAsync(ScoreReplayRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Compare two replay results to quantify divergence.
|
||||
/// </summary>
|
||||
Task<ScoreComparisonResult> CompareAsync(
|
||||
ScoreReplayResult resultA,
|
||||
ScoreReplayResult resultB,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Produce a DSSE-ready attestation from a replay result.
|
||||
/// The attestation payload uses type <c>application/vnd.stella.score+json</c>.
|
||||
/// </summary>
|
||||
Task<ScoreReplayAttestation> CreateAttestationAsync(
|
||||
ScoreReplayResult result,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a previously computed replay result by its digest.
|
||||
/// Returns null if not found.
|
||||
/// </summary>
|
||||
Task<ScoreReplayResult?> GetByDigestAsync(string replayDigest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Query replay results with optional filters.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<ScoreReplayResult>> QueryAsync(
|
||||
ScoreReplayQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScoreReplayModels.cs
|
||||
// Sprint: SPRINT_20260208_020_Attestor_score_replay_and_verification
|
||||
// Task: T1 — Score replay and comparison models
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Request to replay a verdict score by re-executing scoring with captured inputs.
|
||||
/// </summary>
|
||||
public sealed record ScoreReplayRequest
|
||||
{
|
||||
/// <summary>Verdict ID to replay (content-addressed digest).</summary>
|
||||
public required string VerdictId { get; init; }
|
||||
|
||||
/// <summary>Original score value (0.0–1.0) from the verdict.</summary>
|
||||
public required decimal OriginalScore { get; init; }
|
||||
|
||||
/// <summary>Captured scoring inputs (e.g., policy weights, coverage data).</summary>
|
||||
public required ImmutableDictionary<string, string> ScoringInputs { get; init; }
|
||||
|
||||
/// <summary>Policy run ID that produced the original score.</summary>
|
||||
public string? PolicyRunId { get; init; }
|
||||
|
||||
/// <summary>Original determinism hash for comparison.</summary>
|
||||
public string? OriginalDeterminismHash { get; init; }
|
||||
|
||||
/// <summary>Tenant ID for scoping.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome status of a score replay attempt.
|
||||
/// </summary>
|
||||
public enum ScoreReplayStatus
|
||||
{
|
||||
/// <summary>Replay matched the original score exactly.</summary>
|
||||
Matched = 0,
|
||||
|
||||
/// <summary>Replay diverged from the original score.</summary>
|
||||
Diverged = 1,
|
||||
|
||||
/// <summary>Replay failed due to missing or invalid inputs.</summary>
|
||||
FailedMissingInputs = 2,
|
||||
|
||||
/// <summary>Replay failed due to an internal error.</summary>
|
||||
FailedError = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a score replay attempt.
|
||||
/// </summary>
|
||||
public sealed record ScoreReplayResult
|
||||
{
|
||||
/// <summary>Unique digest identifying this replay result.</summary>
|
||||
public required string ReplayDigest { get; init; }
|
||||
|
||||
/// <summary>The verdict ID that was replayed.</summary>
|
||||
public required string VerdictId { get; init; }
|
||||
|
||||
/// <summary>Outcome status.</summary>
|
||||
public required ScoreReplayStatus Status { get; init; }
|
||||
|
||||
/// <summary>The replayed score (0.0–1.0).</summary>
|
||||
public required decimal ReplayedScore { get; init; }
|
||||
|
||||
/// <summary>The original score for comparison.</summary>
|
||||
public required decimal OriginalScore { get; init; }
|
||||
|
||||
/// <summary>Determinism hash computed from the replayed scoring inputs.</summary>
|
||||
public required string DeterminismHash { get; init; }
|
||||
|
||||
/// <summary>Whether the original determinism hash matches the replayed one.</summary>
|
||||
public bool DeterminismHashMatches { get; init; }
|
||||
|
||||
/// <summary>Absolute divergence between original and replayed score.</summary>
|
||||
public decimal Divergence { get; init; }
|
||||
|
||||
/// <summary>Timestamp of the replay.</summary>
|
||||
public required DateTimeOffset ReplayedAt { get; init; }
|
||||
|
||||
/// <summary>Duration of the replay in milliseconds.</summary>
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
/// <summary>Error message if replay failed.</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to compare two score replay results.
|
||||
/// </summary>
|
||||
public sealed record ScoreComparisonRequest
|
||||
{
|
||||
/// <summary>First replay result digest.</summary>
|
||||
public required string ReplayDigestA { get; init; }
|
||||
|
||||
/// <summary>Second replay result digest.</summary>
|
||||
public required string ReplayDigestB { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of comparing two score replays.
|
||||
/// </summary>
|
||||
public sealed record ScoreComparisonResult
|
||||
{
|
||||
/// <summary>First replay digest.</summary>
|
||||
public required string ReplayDigestA { get; init; }
|
||||
|
||||
/// <summary>Second replay digest.</summary>
|
||||
public required string ReplayDigestB { get; init; }
|
||||
|
||||
/// <summary>Score from first replay.</summary>
|
||||
public required decimal ScoreA { get; init; }
|
||||
|
||||
/// <summary>Score from second replay.</summary>
|
||||
public required decimal ScoreB { get; init; }
|
||||
|
||||
/// <summary>Absolute divergence between the two scores.</summary>
|
||||
public decimal Divergence { get; init; }
|
||||
|
||||
/// <summary>Whether both replays produced deterministically identical results.</summary>
|
||||
public bool IsDeterministic { get; init; }
|
||||
|
||||
/// <summary>Details about scoring input differences, if any.</summary>
|
||||
public ImmutableArray<string> DifferenceDetails { get; init; } = [];
|
||||
|
||||
/// <summary>Timestamp of the comparison.</summary>
|
||||
public required DateTimeOffset ComparedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE-signed replay attestation for audit evidence.
|
||||
/// Payload type: application/vnd.stella.score+json
|
||||
/// </summary>
|
||||
public sealed record ScoreReplayAttestation
|
||||
{
|
||||
/// <summary>Content-addressed digest of the attestation.</summary>
|
||||
public required string AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>The replay result being attested.</summary>
|
||||
public required ScoreReplayResult ReplayResult { get; init; }
|
||||
|
||||
/// <summary>DSSE payload type.</summary>
|
||||
public string PayloadType { get; init; } = "application/vnd.stella.score+json";
|
||||
|
||||
/// <summary>Serialized payload (JSON-encoded replay result).</summary>
|
||||
public required ReadOnlyMemory<byte> Payload { get; init; }
|
||||
|
||||
/// <summary>Timestamp of attestation creation.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Signing key ID used, or null if unsigned (pre-signing).</summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for retrieving replay results.
|
||||
/// </summary>
|
||||
public sealed record ScoreReplayQuery
|
||||
{
|
||||
/// <summary>Filter by verdict ID.</summary>
|
||||
public string? VerdictId { get; init; }
|
||||
|
||||
/// <summary>Filter by tenant ID.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Filter by status.</summary>
|
||||
public ScoreReplayStatus? Status { get; init; }
|
||||
|
||||
/// <summary>Max results to return.</summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScoreReplayService.cs
|
||||
// Sprint: SPRINT_20260208_020_Attestor_score_replay_and_verification
|
||||
// Task: T1 — Score replay, comparison, and DSSE attestation service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IScoreReplayService"/>.
|
||||
/// Re-executes scoring with captured inputs, computes determinism hashes,
|
||||
/// and produces DSSE-ready attestations with payload type
|
||||
/// <c>application/vnd.stella.score+json</c>.
|
||||
/// </summary>
|
||||
public sealed class ScoreReplayService : IScoreReplayService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ScoreReplayResult> _results = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Counter<long> _replaysCounter;
|
||||
private readonly Counter<long> _matchesCounter;
|
||||
private readonly Counter<long> _divergencesCounter;
|
||||
private readonly Counter<long> _comparisonsCounter;
|
||||
private readonly Counter<long> _attestationsCounter;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public ScoreReplayService(
|
||||
TimeProvider? timeProvider,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Replay.Score");
|
||||
_replaysCounter = meter.CreateCounter<long>("score.replays.executed");
|
||||
_matchesCounter = meter.CreateCounter<long>("score.replays.matched");
|
||||
_divergencesCounter = meter.CreateCounter<long>("score.replays.diverged");
|
||||
_comparisonsCounter = meter.CreateCounter<long>("score.comparisons.executed");
|
||||
_attestationsCounter = meter.CreateCounter<long>("score.attestations.created");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ScoreReplayResult> ReplayAsync(
|
||||
ScoreReplayRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.VerdictId))
|
||||
throw new ArgumentException("VerdictId is required.", nameof(request));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Compute determinism hash from sorted scoring inputs
|
||||
var deterministicHash = ComputeDeterminismHash(request.ScoringInputs);
|
||||
|
||||
// Re-execute scoring: deterministic computation from inputs
|
||||
var replayedScore = ComputeScore(request.ScoringInputs);
|
||||
|
||||
sw.Stop();
|
||||
_replaysCounter.Add(1);
|
||||
|
||||
var divergence = Math.Abs(request.OriginalScore - replayedScore);
|
||||
var status = divergence == 0m
|
||||
? ScoreReplayStatus.Matched
|
||||
: ScoreReplayStatus.Diverged;
|
||||
|
||||
if (status == ScoreReplayStatus.Matched)
|
||||
_matchesCounter.Add(1);
|
||||
else
|
||||
_divergencesCounter.Add(1);
|
||||
|
||||
var hashMatches = request.OriginalDeterminismHash is null ||
|
||||
string.Equals(request.OriginalDeterminismHash, deterministicHash,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Compute replay digest for content-addressing
|
||||
var replayDigest = ComputeReplayDigest(request.VerdictId, deterministicHash, now);
|
||||
|
||||
var result = new ScoreReplayResult
|
||||
{
|
||||
ReplayDigest = replayDigest,
|
||||
VerdictId = request.VerdictId,
|
||||
Status = status,
|
||||
ReplayedScore = replayedScore,
|
||||
OriginalScore = request.OriginalScore,
|
||||
DeterminismHash = deterministicHash,
|
||||
DeterminismHashMatches = hashMatches,
|
||||
Divergence = divergence,
|
||||
ReplayedAt = now,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
_results.TryAdd(replayDigest, result);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ScoreComparisonResult> CompareAsync(
|
||||
ScoreReplayResult resultA,
|
||||
ScoreReplayResult resultB,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resultA);
|
||||
ArgumentNullException.ThrowIfNull(resultB);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_comparisonsCounter.Add(1);
|
||||
|
||||
var divergence = Math.Abs(resultA.ReplayedScore - resultB.ReplayedScore);
|
||||
var isDeterministic = divergence == 0m &&
|
||||
string.Equals(resultA.DeterminismHash, resultB.DeterminismHash,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var differences = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
if (resultA.ReplayedScore != resultB.ReplayedScore)
|
||||
differences.Add($"Score divergence: {resultA.ReplayedScore} vs {resultB.ReplayedScore} (delta: {divergence})");
|
||||
|
||||
if (!string.Equals(resultA.DeterminismHash, resultB.DeterminismHash, StringComparison.OrdinalIgnoreCase))
|
||||
differences.Add($"Determinism hash mismatch: {resultA.DeterminismHash} vs {resultB.DeterminismHash}");
|
||||
|
||||
if (resultA.Status != resultB.Status)
|
||||
differences.Add($"Status mismatch: {resultA.Status} vs {resultB.Status}");
|
||||
|
||||
return Task.FromResult(new ScoreComparisonResult
|
||||
{
|
||||
ReplayDigestA = resultA.ReplayDigest,
|
||||
ReplayDigestB = resultB.ReplayDigest,
|
||||
ScoreA = resultA.ReplayedScore,
|
||||
ScoreB = resultB.ReplayedScore,
|
||||
Divergence = divergence,
|
||||
IsDeterministic = isDeterministic,
|
||||
DifferenceDetails = differences.ToImmutable(),
|
||||
ComparedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ScoreReplayAttestation> CreateAttestationAsync(
|
||||
ScoreReplayResult result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_attestationsCounter.Add(1);
|
||||
|
||||
var payloadJson = JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||
var attestationDigest = ComputeDigest(payloadJson);
|
||||
|
||||
return Task.FromResult(new ScoreReplayAttestation
|
||||
{
|
||||
AttestationDigest = attestationDigest,
|
||||
ReplayResult = result,
|
||||
Payload = new ReadOnlyMemory<byte>(payloadJson),
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ScoreReplayResult?> GetByDigestAsync(
|
||||
string replayDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(replayDigest);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_results.TryGetValue(replayDigest, out var result);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<ScoreReplayResult>> QueryAsync(
|
||||
ScoreReplayQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
IEnumerable<ScoreReplayResult> results = _results.Values;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.VerdictId))
|
||||
results = results.Where(r =>
|
||||
r.VerdictId.Equals(query.VerdictId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (query.Status.HasValue)
|
||||
results = results.Where(r => r.Status == query.Status.Value);
|
||||
|
||||
return Task.FromResult(results
|
||||
.OrderByDescending(r => r.ReplayedAt)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray());
|
||||
}
|
||||
|
||||
// ── Deterministic scoring ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Re-execute scoring from captured inputs. Uses deterministic computation:
|
||||
/// weighted average of numeric input values, normalized to [0, 1].
|
||||
/// </summary>
|
||||
internal static decimal ComputeScore(ImmutableDictionary<string, string> inputs)
|
||||
{
|
||||
if (inputs.IsEmpty)
|
||||
return 0m;
|
||||
|
||||
decimal weightedSum = 0m;
|
||||
decimal totalWeight = 0m;
|
||||
|
||||
foreach (var (key, value) in inputs.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!decimal.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, out var numericValue))
|
||||
continue;
|
||||
|
||||
// Weight derived from ordinal position (deterministic)
|
||||
var weight = 1m;
|
||||
if (key.Contains("weight", StringComparison.OrdinalIgnoreCase))
|
||||
weight = Math.Max(0.01m, Math.Abs(numericValue));
|
||||
else
|
||||
{
|
||||
weightedSum += numericValue * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalWeight == 0m)
|
||||
return 0m;
|
||||
|
||||
var raw = weightedSum / totalWeight;
|
||||
return Math.Clamp(raw, 0m, 1m);
|
||||
}
|
||||
|
||||
// ── Hashing helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Compute determinism hash from sorted scoring inputs.
|
||||
/// </summary>
|
||||
internal static string ComputeDeterminismHash(ImmutableDictionary<string, string> inputs)
|
||||
{
|
||||
var canonical = new StringBuilder();
|
||||
foreach (var (key, value) in inputs.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
canonical.Append(key);
|
||||
canonical.Append('=');
|
||||
canonical.Append(value);
|
||||
canonical.Append('\n');
|
||||
}
|
||||
|
||||
return ComputeDigest(Encoding.UTF8.GetBytes(canonical.ToString()));
|
||||
}
|
||||
|
||||
private static string ComputeReplayDigest(string verdictId, string deterministicHash, DateTimeOffset timestamp)
|
||||
{
|
||||
var input = $"{verdictId}:{deterministicHash}:{timestamp:O}";
|
||||
return ComputeDigest(Encoding.UTF8.GetBytes(input));
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExceptionSigningService.cs
|
||||
// Sprint: SPRINT_20260208_008_Attestor_dsse_signed_exception_objects_with_recheck_policy
|
||||
// Description: Service for signing and managing DSSE-signed exceptions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Json;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for signing exception objects and managing their recheck policies.
|
||||
/// </summary>
|
||||
public sealed class ExceptionSigningService : IExceptionSigningService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = null,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly TimeSpan DefaultWarningWindow = TimeSpan.FromDays(7);
|
||||
private static readonly TimeSpan DefaultRenewalExtension = TimeSpan.FromDays(90);
|
||||
|
||||
private readonly IProofChainSigner _signer;
|
||||
private readonly IJsonCanonicalizer _canonicalizer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExceptionSigningService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="signer">The proof chain signer for DSSE operations.</param>
|
||||
/// <param name="canonicalizer">The JSON canonicalizer for deterministic hashing.</param>
|
||||
/// <param name="timeProvider">The time provider for deterministic time operations.</param>
|
||||
public ExceptionSigningService(
|
||||
IProofChainSigner signer,
|
||||
IJsonCanonicalizer canonicalizer,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SignedExceptionResult> SignExceptionAsync(
|
||||
BudgetExceptionEntry exception,
|
||||
Subject subject,
|
||||
ExceptionRecheckPolicy recheckPolicy,
|
||||
IReadOnlyList<string>? environments = null,
|
||||
IReadOnlyList<string>? coveredViolationIds = null,
|
||||
string? renewsExceptionId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
ArgumentNullException.ThrowIfNull(subject);
|
||||
ArgumentNullException.ThrowIfNull(recheckPolicy);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Compute content-addressed ID for the exception
|
||||
var exceptionContentId = ComputeExceptionContentId(exception);
|
||||
|
||||
// Determine initial status
|
||||
var status = DetermineInitialStatus(exception, recheckPolicy, now);
|
||||
|
||||
// Calculate next recheck time if auto-recheck is enabled
|
||||
var policyWithNextRecheck = recheckPolicy with
|
||||
{
|
||||
NextRecheckAt = recheckPolicy.AutoRecheckEnabled
|
||||
? now.AddDays(recheckPolicy.RecheckIntervalDays)
|
||||
: recheckPolicy.NextRecheckAt
|
||||
};
|
||||
|
||||
var payload = new DsseSignedExceptionPayload
|
||||
{
|
||||
Exception = exception,
|
||||
ExceptionContentId = exceptionContentId,
|
||||
SignedAt = now,
|
||||
RecheckPolicy = policyWithNextRecheck,
|
||||
Environments = environments,
|
||||
CoveredViolationIds = coveredViolationIds,
|
||||
RenewsExceptionId = renewsExceptionId,
|
||||
Status = status
|
||||
};
|
||||
|
||||
var statement = new DsseSignedExceptionStatement
|
||||
{
|
||||
Subject = new[] { subject },
|
||||
Predicate = payload
|
||||
};
|
||||
|
||||
var envelope = await _signer.SignStatementAsync(
|
||||
statement,
|
||||
SigningKeyProfile.Exception,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return new SignedExceptionResult(envelope, statement, exceptionContentId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionVerificationResult> VerifyExceptionAsync(
|
||||
DsseEnvelope envelope,
|
||||
IReadOnlyList<string> allowedKeyIds,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ArgumentNullException.ThrowIfNull(allowedKeyIds);
|
||||
|
||||
var signatureResult = await _signer.VerifyEnvelopeAsync(envelope, allowedKeyIds, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!signatureResult.IsValid)
|
||||
{
|
||||
return new ExceptionVerificationResult(
|
||||
IsValid: false,
|
||||
KeyId: null,
|
||||
Statement: null,
|
||||
Error: signatureResult.Error ?? "Signature verification failed");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
var statement = JsonSerializer.Deserialize<DsseSignedExceptionStatement>(
|
||||
payloadBytes,
|
||||
SerializerOptions);
|
||||
|
||||
if (statement is null)
|
||||
{
|
||||
return new ExceptionVerificationResult(
|
||||
IsValid: false,
|
||||
KeyId: signatureResult.KeyId,
|
||||
Statement: null,
|
||||
Error: "Failed to deserialize statement payload");
|
||||
}
|
||||
|
||||
if (statement.PredicateType != DsseSignedExceptionStatement.PredicateTypeUri)
|
||||
{
|
||||
return new ExceptionVerificationResult(
|
||||
IsValid: false,
|
||||
KeyId: signatureResult.KeyId,
|
||||
Statement: null,
|
||||
Error: $"Unexpected predicate type: {statement.PredicateType}");
|
||||
}
|
||||
|
||||
return new ExceptionVerificationResult(
|
||||
IsValid: true,
|
||||
KeyId: signatureResult.KeyId,
|
||||
Statement: statement,
|
||||
Error: null);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new ExceptionVerificationResult(
|
||||
IsValid: false,
|
||||
KeyId: signatureResult.KeyId,
|
||||
Statement: null,
|
||||
Error: $"Failed to parse statement: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ExceptionRecheckStatus CheckRecheckRequired(DsseSignedExceptionStatement statement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var payload = statement.Predicate;
|
||||
var exception = payload.Exception;
|
||||
|
||||
// Check if explicitly revoked
|
||||
if (payload.Status == ExceptionStatus.Revoked)
|
||||
{
|
||||
return new ExceptionRecheckStatus
|
||||
{
|
||||
RecheckRequired = false,
|
||||
IsExpired = false,
|
||||
ExpiringWithinWarningWindow = false,
|
||||
NextRecheckDue = null,
|
||||
DaysUntilExpiry = null,
|
||||
RecommendedAction = RecheckAction.Revoked
|
||||
};
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
var isExpired = exception.ExpiresAt.HasValue && exception.ExpiresAt.Value <= now;
|
||||
int? daysUntilExpiry = exception.ExpiresAt.HasValue
|
||||
? (int)(exception.ExpiresAt.Value - now).TotalDays
|
||||
: null;
|
||||
|
||||
var expiringWithinWarning = exception.ExpiresAt.HasValue
|
||||
&& exception.ExpiresAt.Value > now
|
||||
&& exception.ExpiresAt.Value <= now.Add(DefaultWarningWindow);
|
||||
|
||||
// Check if recheck is due
|
||||
var recheckDue = payload.RecheckPolicy.AutoRecheckEnabled
|
||||
&& payload.RecheckPolicy.NextRecheckAt.HasValue
|
||||
&& payload.RecheckPolicy.NextRecheckAt.Value <= now;
|
||||
|
||||
// Determine recommended action
|
||||
RecheckAction recommendedAction;
|
||||
if (isExpired)
|
||||
{
|
||||
recommendedAction = RecheckAction.RenewalRequired;
|
||||
}
|
||||
else if (recheckDue)
|
||||
{
|
||||
recommendedAction = RecheckAction.RecheckDue;
|
||||
}
|
||||
else if (expiringWithinWarning)
|
||||
{
|
||||
recommendedAction = RecheckAction.RenewalRecommended;
|
||||
}
|
||||
else
|
||||
{
|
||||
recommendedAction = RecheckAction.None;
|
||||
}
|
||||
|
||||
return new ExceptionRecheckStatus
|
||||
{
|
||||
RecheckRequired = recheckDue || isExpired,
|
||||
IsExpired = isExpired,
|
||||
ExpiringWithinWarningWindow = expiringWithinWarning,
|
||||
NextRecheckDue = payload.RecheckPolicy.NextRecheckAt,
|
||||
DaysUntilExpiry = daysUntilExpiry,
|
||||
RecommendedAction = recommendedAction
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SignedExceptionResult> RenewExceptionAsync(
|
||||
DsseEnvelope originalEnvelope,
|
||||
string newApprover,
|
||||
string? newJustification = null,
|
||||
TimeSpan? extendExpiryBy = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(originalEnvelope);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newApprover);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var extension = extendExpiryBy ?? DefaultRenewalExtension;
|
||||
|
||||
// Parse the original envelope
|
||||
var payloadBytes = Convert.FromBase64String(originalEnvelope.Payload);
|
||||
var originalStatement = JsonSerializer.Deserialize<DsseSignedExceptionStatement>(
|
||||
payloadBytes,
|
||||
SerializerOptions)
|
||||
?? throw new InvalidOperationException("Failed to parse original exception statement");
|
||||
|
||||
var originalPayload = originalStatement.Predicate;
|
||||
var originalException = originalPayload.Exception;
|
||||
|
||||
// Check renewal count limits
|
||||
var currentRenewalCount = originalPayload.RecheckPolicy.RenewalCount;
|
||||
if (originalPayload.RecheckPolicy.MaxRenewalCount.HasValue
|
||||
&& currentRenewalCount >= originalPayload.RecheckPolicy.MaxRenewalCount.Value)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Maximum renewal count ({originalPayload.RecheckPolicy.MaxRenewalCount.Value}) reached. Escalated approval required.");
|
||||
}
|
||||
|
||||
// Create renewed exception
|
||||
var renewedExpiry = originalException.ExpiresAt.HasValue
|
||||
? now.Add(extension)
|
||||
: (DateTimeOffset?)null;
|
||||
|
||||
var renewedException = originalException with
|
||||
{
|
||||
ExpiresAt = renewedExpiry,
|
||||
ApprovedBy = newApprover,
|
||||
Justification = newJustification ?? originalException.Justification
|
||||
};
|
||||
|
||||
// Update recheck policy
|
||||
var renewedPolicy = originalPayload.RecheckPolicy with
|
||||
{
|
||||
RenewalCount = currentRenewalCount + 1,
|
||||
LastRecheckAt = now,
|
||||
NextRecheckAt = originalPayload.RecheckPolicy.AutoRecheckEnabled
|
||||
? now.AddDays(originalPayload.RecheckPolicy.RecheckIntervalDays)
|
||||
: originalPayload.RecheckPolicy.NextRecheckAt
|
||||
};
|
||||
|
||||
// Get subject from original
|
||||
var subject = originalStatement.Subject.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Original statement has no subject");
|
||||
|
||||
return await SignExceptionAsync(
|
||||
renewedException,
|
||||
subject,
|
||||
renewedPolicy,
|
||||
originalPayload.Environments,
|
||||
originalPayload.CoveredViolationIds,
|
||||
renewsExceptionId: originalPayload.ExceptionContentId,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string ComputeExceptionContentId(BudgetExceptionEntry exception)
|
||||
{
|
||||
// Create a deterministic representation for hashing
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(exception, SerializerOptions);
|
||||
var canonical = _canonicalizer.Canonicalize(json);
|
||||
var hash = SHA256.HashData(canonical);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static ExceptionStatus DetermineInitialStatus(
|
||||
BudgetExceptionEntry exception,
|
||||
ExceptionRecheckPolicy recheckPolicy,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
// If approval is pending
|
||||
if (string.IsNullOrWhiteSpace(exception.ApprovedBy))
|
||||
{
|
||||
return ExceptionStatus.PendingApproval;
|
||||
}
|
||||
|
||||
// If already expired
|
||||
if (exception.ExpiresAt.HasValue && exception.ExpiresAt.Value <= now)
|
||||
{
|
||||
return ExceptionStatus.Expired;
|
||||
}
|
||||
|
||||
// If recheck is overdue
|
||||
if (recheckPolicy.AutoRecheckEnabled
|
||||
&& recheckPolicy.NextRecheckAt.HasValue
|
||||
&& recheckPolicy.NextRecheckAt.Value <= now)
|
||||
{
|
||||
return ExceptionStatus.PendingRecheck;
|
||||
}
|
||||
|
||||
return ExceptionStatus.Active;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IExceptionSigningService.cs
|
||||
// Sprint: SPRINT_20260208_008_Attestor_dsse_signed_exception_objects_with_recheck_policy
|
||||
// Description: Service interface for signing and managing DSSE-signed exceptions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for signing exception objects and managing their recheck policies.
|
||||
/// </summary>
|
||||
public interface IExceptionSigningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign an exception entry and wrap it in a DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception entry to sign.</param>
|
||||
/// <param name="subject">The subject (artifact) this exception applies to.</param>
|
||||
/// <param name="recheckPolicy">The recheck policy for this exception.</param>
|
||||
/// <param name="environments">The environments this exception applies to.</param>
|
||||
/// <param name="coveredViolationIds">IDs of violations this exception covers.</param>
|
||||
/// <param name="renewsExceptionId">ID of the exception this renews (for renewal chains).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The signed exception result containing the DSSE envelope and statement.</returns>
|
||||
Task<SignedExceptionResult> SignExceptionAsync(
|
||||
BudgetExceptionEntry exception,
|
||||
Subject subject,
|
||||
ExceptionRecheckPolicy recheckPolicy,
|
||||
IReadOnlyList<string>? environments = null,
|
||||
IReadOnlyList<string>? coveredViolationIds = null,
|
||||
string? renewsExceptionId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a DSSE-signed exception envelope.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope to verify.</param>
|
||||
/// <param name="allowedKeyIds">The key IDs allowed to have signed this exception.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<ExceptionVerificationResult> VerifyExceptionAsync(
|
||||
DsseEnvelope envelope,
|
||||
IReadOnlyList<string> allowedKeyIds,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if an exception requires recheck based on its policy and current time.
|
||||
/// </summary>
|
||||
/// <param name="statement">The signed exception statement to check.</param>
|
||||
/// <returns>The recheck status indicating whether action is required.</returns>
|
||||
ExceptionRecheckStatus CheckRecheckRequired(DsseSignedExceptionStatement statement);
|
||||
|
||||
/// <summary>
|
||||
/// Renew an expired or expiring exception by creating a new signed version.
|
||||
/// </summary>
|
||||
/// <param name="originalEnvelope">The original DSSE envelope to renew.</param>
|
||||
/// <param name="newApprover">The approver for the renewal.</param>
|
||||
/// <param name="newJustification">Optional updated justification.</param>
|
||||
/// <param name="extendExpiryBy">Optional duration to extend the expiry by.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The renewed signed exception result.</returns>
|
||||
Task<SignedExceptionResult> RenewExceptionAsync(
|
||||
DsseEnvelope originalEnvelope,
|
||||
string newApprover,
|
||||
string? newJustification = null,
|
||||
TimeSpan? extendExpiryBy = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing an exception.
|
||||
/// </summary>
|
||||
/// <param name="Envelope">The DSSE envelope containing the signed statement.</param>
|
||||
/// <param name="Statement">The signed exception statement.</param>
|
||||
/// <param name="ExceptionContentId">The content-addressed ID of the exception.</param>
|
||||
public sealed record SignedExceptionResult(
|
||||
DsseEnvelope Envelope,
|
||||
DsseSignedExceptionStatement Statement,
|
||||
string ExceptionContentId);
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a signed exception.
|
||||
/// </summary>
|
||||
/// <param name="IsValid">Whether the signature is valid.</param>
|
||||
/// <param name="KeyId">The key ID that signed the exception (if valid).</param>
|
||||
/// <param name="Statement">The extracted statement (if valid and parseable).</param>
|
||||
/// <param name="Error">Error message if verification failed.</param>
|
||||
public sealed record ExceptionVerificationResult(
|
||||
bool IsValid,
|
||||
string? KeyId,
|
||||
DsseSignedExceptionStatement? Statement,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Status of an exception's recheck requirement.
|
||||
/// </summary>
|
||||
public sealed record ExceptionRecheckStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether a recheck is currently required.
|
||||
/// </summary>
|
||||
public required bool RecheckRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the exception has expired.
|
||||
/// </summary>
|
||||
public required bool IsExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the exception will expire within the warning window (default 7 days).
|
||||
/// </summary>
|
||||
public required bool ExpiringWithinWarningWindow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the next recheck is due (if any).
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextRecheckDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Days until expiry (negative if already expired).
|
||||
/// </summary>
|
||||
public int? DaysUntilExpiry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The recommended action for the exception.
|
||||
/// </summary>
|
||||
public required RecheckAction RecommendedAction { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recommended action for an exception based on its recheck status.
|
||||
/// </summary>
|
||||
public enum RecheckAction
|
||||
{
|
||||
/// <summary>
|
||||
/// No action required; exception is valid.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Exception is expiring soon; renewal recommended.
|
||||
/// </summary>
|
||||
RenewalRecommended,
|
||||
|
||||
/// <summary>
|
||||
/// Recheck is due; exception should be re-evaluated.
|
||||
/// </summary>
|
||||
RecheckDue,
|
||||
|
||||
/// <summary>
|
||||
/// Exception has expired; must be renewed or replaced.
|
||||
/// </summary>
|
||||
RenewalRequired,
|
||||
|
||||
/// <summary>
|
||||
/// Exception has been revoked; cannot be used.
|
||||
/// </summary>
|
||||
Revoked
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IUnknownsTriageScorer.cs
|
||||
// Sprint: SPRINT_20260208_022_Attestor_unknowns_five_dimensional_triage_scoring
|
||||
// Task: T1 — Five-dimensional triage scoring interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Computes five-dimensional triage scores (P/E/U/C/S) for unknowns
|
||||
/// and classifies them into Hot/Warm/Cold temperature bands.
|
||||
/// </summary>
|
||||
public interface IUnknownsTriageScorer
|
||||
{
|
||||
/// <summary>
|
||||
/// Scores a batch of unknowns using the provided per-item dimension scores
|
||||
/// and configurable weights/thresholds.
|
||||
/// </summary>
|
||||
TriageScoringResult Score(TriageScoringRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a composite score from a single five-dimensional score vector
|
||||
/// using the provided weights.
|
||||
/// </summary>
|
||||
double ComputeComposite(TriageScore score, TriageDimensionWeights? weights = null);
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a composite score into a temperature band.
|
||||
/// </summary>
|
||||
TriageBand Classify(double compositeScore, TriageBandThresholds? thresholds = null);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TriageScoringModels.cs
|
||||
// Sprint: SPRINT_20260208_022_Attestor_unknowns_five_dimensional_triage_scoring
|
||||
// Task: T1 — Five-dimensional triage scoring models (P/E/U/C/S with Hot/Warm/Cold)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Temperature band for classifying unknowns by their composite triage score.
|
||||
/// </summary>
|
||||
public enum TriageBand
|
||||
{
|
||||
/// <summary>Requires immediate triage (composite score >= Hot threshold).</summary>
|
||||
Hot = 0,
|
||||
|
||||
/// <summary>Scheduled review (composite score between Warm and Hot thresholds).</summary>
|
||||
Warm = 1,
|
||||
|
||||
/// <summary>Archive / low priority (composite score below Warm threshold).</summary>
|
||||
Cold = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Five-dimensional triage score for an unknown.
|
||||
/// Dimensions: P (Probability), E (Exposure), U (Uncertainty), C (Consequence), S (Signal freshness).
|
||||
/// Each dimension is in [0.0, 1.0].
|
||||
/// </summary>
|
||||
public sealed record TriageScore
|
||||
{
|
||||
/// <summary>P: Probability of exploitability or relevance (0 = unlikely, 1 = certain).</summary>
|
||||
public required double Probability { get; init; }
|
||||
|
||||
/// <summary>E: Exposure of the affected component (0 = internal, 1 = internet-facing).</summary>
|
||||
public required double Exposure { get; init; }
|
||||
|
||||
/// <summary>U: Uncertainty / confidence deficit (0 = fully understood, 1 = unknown).</summary>
|
||||
public required double Uncertainty { get; init; }
|
||||
|
||||
/// <summary>C: Consequence / impact severity (0 = negligible, 1 = catastrophic).</summary>
|
||||
public required double Consequence { get; init; }
|
||||
|
||||
/// <summary>S: Signal freshness / recency of intelligence (0 = stale, 1 = just reported).</summary>
|
||||
public required double SignalFreshness { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configurable dimension weights for composite score computation.
|
||||
/// All weights must be non-negative. They are normalized during scoring.
|
||||
/// </summary>
|
||||
public sealed record TriageDimensionWeights
|
||||
{
|
||||
/// <summary>Weight for Probability dimension.</summary>
|
||||
public double P { get; init; } = 0.30;
|
||||
|
||||
/// <summary>Weight for Exposure dimension.</summary>
|
||||
public double E { get; init; } = 0.25;
|
||||
|
||||
/// <summary>Weight for Uncertainty dimension.</summary>
|
||||
public double U { get; init; } = 0.20;
|
||||
|
||||
/// <summary>Weight for Consequence dimension.</summary>
|
||||
public double C { get; init; } = 0.15;
|
||||
|
||||
/// <summary>Weight for Signal freshness dimension.</summary>
|
||||
public double S { get; init; } = 0.10;
|
||||
|
||||
/// <summary>Default weights: P=0.30, E=0.25, U=0.20, C=0.15, S=0.10.</summary>
|
||||
public static TriageDimensionWeights Default { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Threshold configuration for Hot/Warm/Cold banding.
|
||||
/// </summary>
|
||||
public sealed record TriageBandThresholds
|
||||
{
|
||||
/// <summary>Composite score at or above which an unknown is classified as Hot.</summary>
|
||||
public double HotThreshold { get; init; } = 0.70;
|
||||
|
||||
/// <summary>Composite score at or above which an unknown is classified as Warm.</summary>
|
||||
public double WarmThreshold { get; init; } = 0.40;
|
||||
|
||||
/// <summary>Default thresholds: Hot >= 0.70, Warm >= 0.40, Cold below 0.40.</summary>
|
||||
public static TriageBandThresholds Default { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of scoring a single unknown with the 5D triage model.
|
||||
/// </summary>
|
||||
public sealed record TriageScoredItem
|
||||
{
|
||||
/// <summary>The original unknown item.</summary>
|
||||
public required UnknownItem Unknown { get; init; }
|
||||
|
||||
/// <summary>Five-dimensional score.</summary>
|
||||
public required TriageScore Score { get; init; }
|
||||
|
||||
/// <summary>Composite score computed from weighted dimensions, in [0.0, 1.0].</summary>
|
||||
public required double CompositeScore { get; init; }
|
||||
|
||||
/// <summary>Temperature band classification.</summary>
|
||||
public required TriageBand Band { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to compute triage scores for a set of unknowns.
|
||||
/// </summary>
|
||||
public sealed record TriageScoringRequest
|
||||
{
|
||||
/// <summary>Unknowns to score.</summary>
|
||||
public required IReadOnlyList<UnknownItem> Unknowns { get; init; }
|
||||
|
||||
/// <summary>Per-unknown dimension scores. Key is (PackageUrl, ReasonCode) pair.</summary>
|
||||
public required IReadOnlyDictionary<(string PackageUrl, string ReasonCode), TriageScore> Scores { get; init; }
|
||||
|
||||
/// <summary>Dimension weights (uses default if null).</summary>
|
||||
public TriageDimensionWeights? Weights { get; init; }
|
||||
|
||||
/// <summary>Band thresholds (uses default if null).</summary>
|
||||
public TriageBandThresholds? Thresholds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of scoring a batch of unknowns.
|
||||
/// </summary>
|
||||
public sealed record TriageScoringResult
|
||||
{
|
||||
/// <summary>All scored items, ordered by composite score descending.</summary>
|
||||
public required ImmutableArray<TriageScoredItem> Items { get; init; }
|
||||
|
||||
/// <summary>Count of items in the Hot band.</summary>
|
||||
public int HotCount => Items.IsDefaultOrEmpty ? 0 : Items.Count(i => i.Band == TriageBand.Hot);
|
||||
|
||||
/// <summary>Count of items in the Warm band.</summary>
|
||||
public int WarmCount => Items.IsDefaultOrEmpty ? 0 : Items.Count(i => i.Band == TriageBand.Warm);
|
||||
|
||||
/// <summary>Count of items in the Cold band.</summary>
|
||||
public int ColdCount => Items.IsDefaultOrEmpty ? 0 : Items.Count(i => i.Band == TriageBand.Cold);
|
||||
|
||||
/// <summary>Weights used for scoring.</summary>
|
||||
public required TriageDimensionWeights Weights { get; init; }
|
||||
|
||||
/// <summary>Thresholds used for banding.</summary>
|
||||
public required TriageBandThresholds Thresholds { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnknownsTriageScorer.cs
|
||||
// Sprint: SPRINT_20260208_022_Attestor_unknowns_five_dimensional_triage_scoring
|
||||
// Task: T1 — Five-dimensional triage scoring service implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic five-dimensional triage scorer for unknowns.
|
||||
/// Computes P/E/U/C/S composite scores with configurable weights and
|
||||
/// classifies into Hot/Warm/Cold bands.
|
||||
/// </summary>
|
||||
public sealed class UnknownsTriageScorer : IUnknownsTriageScorer
|
||||
{
|
||||
private readonly Counter<long> _scoredCounter;
|
||||
private readonly Counter<long> _hotCounter;
|
||||
private readonly Counter<long> _warmCounter;
|
||||
private readonly Counter<long> _coldCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new triage scorer with OTel instrumentation.
|
||||
/// </summary>
|
||||
public UnknownsTriageScorer(IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Triage");
|
||||
_scoredCounter = meter.CreateCounter<long>("triage.scored.total", description: "Total unknowns scored");
|
||||
_hotCounter = meter.CreateCounter<long>("triage.band.hot.total", description: "Unknowns classified as Hot");
|
||||
_warmCounter = meter.CreateCounter<long>("triage.band.warm.total", description: "Unknowns classified as Warm");
|
||||
_coldCounter = meter.CreateCounter<long>("triage.band.cold.total", description: "Unknowns classified as Cold");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TriageScoringResult Score(TriageScoringRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var weights = request.Weights ?? TriageDimensionWeights.Default;
|
||||
var thresholds = request.Thresholds ?? TriageBandThresholds.Default;
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<TriageScoredItem>(request.Unknowns.Count);
|
||||
|
||||
foreach (var unknown in request.Unknowns)
|
||||
{
|
||||
var key = (unknown.PackageUrl, unknown.ReasonCode);
|
||||
|
||||
if (!request.Scores.TryGetValue(key, out var score))
|
||||
{
|
||||
// No score provided — default to zero vector (Cold)
|
||||
score = new TriageScore
|
||||
{
|
||||
Probability = 0,
|
||||
Exposure = 0,
|
||||
Uncertainty = 0,
|
||||
Consequence = 0,
|
||||
SignalFreshness = 0
|
||||
};
|
||||
}
|
||||
|
||||
var composite = ComputeCompositeInternal(score, weights);
|
||||
var band = ClassifyInternal(composite, thresholds);
|
||||
|
||||
builder.Add(new TriageScoredItem
|
||||
{
|
||||
Unknown = unknown,
|
||||
Score = score,
|
||||
CompositeScore = composite,
|
||||
Band = band
|
||||
});
|
||||
|
||||
_scoredCounter.Add(1);
|
||||
IncrementBandCounter(band);
|
||||
}
|
||||
|
||||
// Sort descending by composite score for deterministic output
|
||||
var items = builder
|
||||
.OrderByDescending(i => i.CompositeScore)
|
||||
.ThenBy(i => i.Unknown.PackageUrl, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.Unknown.ReasonCode, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new TriageScoringResult
|
||||
{
|
||||
Items = items,
|
||||
Weights = weights,
|
||||
Thresholds = thresholds
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public double ComputeComposite(TriageScore score, TriageDimensionWeights? weights = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(score);
|
||||
return ComputeCompositeInternal(score, weights ?? TriageDimensionWeights.Default);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TriageBand Classify(double compositeScore, TriageBandThresholds? thresholds = null)
|
||||
{
|
||||
return ClassifyInternal(compositeScore, thresholds ?? TriageBandThresholds.Default);
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────────
|
||||
|
||||
internal static double ComputeCompositeInternal(TriageScore score, TriageDimensionWeights weights)
|
||||
{
|
||||
var totalWeight = weights.P + weights.E + weights.U + weights.C + weights.S;
|
||||
|
||||
if (totalWeight <= 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
var raw =
|
||||
(Clamp01(score.Probability) * weights.P) +
|
||||
(Clamp01(score.Exposure) * weights.E) +
|
||||
(Clamp01(score.Uncertainty) * weights.U) +
|
||||
(Clamp01(score.Consequence) * weights.C) +
|
||||
(Clamp01(score.SignalFreshness) * weights.S);
|
||||
|
||||
// Normalize and clamp to [0, 1]
|
||||
return Clamp01(raw / totalWeight);
|
||||
}
|
||||
|
||||
internal static TriageBand ClassifyInternal(double compositeScore, TriageBandThresholds thresholds)
|
||||
{
|
||||
if (compositeScore >= thresholds.HotThreshold)
|
||||
return TriageBand.Hot;
|
||||
|
||||
if (compositeScore >= thresholds.WarmThreshold)
|
||||
return TriageBand.Warm;
|
||||
|
||||
return TriageBand.Cold;
|
||||
}
|
||||
|
||||
private static double Clamp01(double value) => Math.Clamp(value, 0.0, 1.0);
|
||||
|
||||
private void IncrementBandCounter(TriageBand band)
|
||||
{
|
||||
switch (band)
|
||||
{
|
||||
case TriageBand.Hot:
|
||||
_hotCounter.Add(1);
|
||||
break;
|
||||
case TriageBand.Warm:
|
||||
_warmCounter.Add(1);
|
||||
break;
|
||||
case TriageBand.Cold:
|
||||
_coldCounter.Add(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleRotationModels.cs
|
||||
// Sprint: SPRINT_20260208_016_Attestor_monthly_bundle_rotation_and_re_signing
|
||||
// Task: T1 — Models for monthly bundle rotation and re-signing workflows
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Status of a bundle rotation operation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum RotationStatus
|
||||
{
|
||||
/// <summary>Rotation is pending execution.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Old bundle verified successfully; ready for re-signing.</summary>
|
||||
Verified,
|
||||
|
||||
/// <summary>Bundle re-signed with new key.</summary>
|
||||
ReSigned,
|
||||
|
||||
/// <summary>Rotation completed and transition attestation recorded.</summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>Rotation failed (verification or re-signing error).</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Rotation was skipped (e.g., bundle already uses current key).</summary>
|
||||
Skipped
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotation cadence for scheduled bundle rotation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum RotationCadence
|
||||
{
|
||||
/// <summary>Monthly rotation (default).</summary>
|
||||
Monthly,
|
||||
|
||||
/// <summary>Quarterly rotation.</summary>
|
||||
Quarterly,
|
||||
|
||||
/// <summary>On-demand (manual trigger).</summary>
|
||||
OnDemand
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a key transition for bundle re-signing.
|
||||
/// </summary>
|
||||
public sealed record KeyTransition
|
||||
{
|
||||
/// <summary>Key ID of the old (outgoing) signing key.</summary>
|
||||
public required string OldKeyId { get; init; }
|
||||
|
||||
/// <summary>Key ID of the new (incoming) signing key.</summary>
|
||||
public required string NewKeyId { get; init; }
|
||||
|
||||
/// <summary>Algorithm used by the new key (e.g., "ECDSA-P256", "Ed25519").</summary>
|
||||
public required string NewKeyAlgorithm { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the transition becomes effective.</summary>
|
||||
public required DateTimeOffset EffectiveAt { get; init; }
|
||||
|
||||
/// <summary>Optional grace period during which both keys are valid.</summary>
|
||||
public TimeSpan GracePeriod { get; init; } = TimeSpan.FromDays(7);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to initiate a bundle rotation cycle.
|
||||
/// </summary>
|
||||
public sealed record BundleRotationRequest
|
||||
{
|
||||
/// <summary>Unique identifier for this rotation cycle.</summary>
|
||||
public required string RotationId { get; init; }
|
||||
|
||||
/// <summary>Key transition details.</summary>
|
||||
public required KeyTransition Transition { get; init; }
|
||||
|
||||
/// <summary>Digests of bundles to rotate.</summary>
|
||||
public required ImmutableArray<string> BundleDigests { get; init; }
|
||||
|
||||
/// <summary>Rotation cadence that triggered this request.</summary>
|
||||
public RotationCadence Cadence { get; init; } = RotationCadence.Monthly;
|
||||
|
||||
/// <summary>Optional tenant or organization scope.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single bundle's rotation operation.
|
||||
/// </summary>
|
||||
public sealed record BundleRotationEntry
|
||||
{
|
||||
/// <summary>Digest of the original bundle.</summary>
|
||||
public required string OriginalDigest { get; init; }
|
||||
|
||||
/// <summary>Digest of the re-signed bundle (null if failed/skipped).</summary>
|
||||
public string? NewDigest { get; init; }
|
||||
|
||||
/// <summary>Status of this bundle's rotation.</summary>
|
||||
public required RotationStatus Status { get; init; }
|
||||
|
||||
/// <summary>Error message if rotation failed.</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>Timestamp of this entry's status change.</summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a complete bundle rotation cycle.
|
||||
/// </summary>
|
||||
public sealed record BundleRotationResult
|
||||
{
|
||||
/// <summary>Rotation cycle ID from the request.</summary>
|
||||
public required string RotationId { get; init; }
|
||||
|
||||
/// <summary>Key transition details.</summary>
|
||||
public required KeyTransition Transition { get; init; }
|
||||
|
||||
/// <summary>Per-bundle rotation entries.</summary>
|
||||
public required ImmutableArray<BundleRotationEntry> Entries { get; init; }
|
||||
|
||||
/// <summary>Overall status of the rotation cycle.</summary>
|
||||
public required RotationStatus OverallStatus { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the rotation cycle started.</summary>
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the rotation cycle completed.</summary>
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
/// <summary>Number of bundles successfully re-signed.</summary>
|
||||
public int SuccessCount => Entries.Count(e => e.Status is RotationStatus.ReSigned or RotationStatus.Completed);
|
||||
|
||||
/// <summary>Number of bundles that failed.</summary>
|
||||
public int FailureCount => Entries.Count(e => e.Status == RotationStatus.Failed);
|
||||
|
||||
/// <summary>Number of bundles that were skipped.</summary>
|
||||
public int SkippedCount => Entries.Count(e => e.Status == RotationStatus.Skipped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transition attestation recording a key rotation event for audit.
|
||||
/// </summary>
|
||||
public sealed record TransitionAttestation
|
||||
{
|
||||
/// <summary>Unique attestation identifier.</summary>
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>Rotation cycle ID this attestation covers.</summary>
|
||||
public required string RotationId { get; init; }
|
||||
|
||||
/// <summary>Key transition details.</summary>
|
||||
public required KeyTransition Transition { get; init; }
|
||||
|
||||
/// <summary>Digest of the rotation result for integrity verification.</summary>
|
||||
public required string ResultDigest { get; init; }
|
||||
|
||||
/// <summary>Timestamp of the attestation.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Count of bundles processed in this rotation.</summary>
|
||||
public required int BundlesProcessed { get; init; }
|
||||
|
||||
/// <summary>Count of bundles successfully re-signed.</summary>
|
||||
public required int BundlesSucceeded { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotation schedule entry describing when the next rotation should occur.
|
||||
/// </summary>
|
||||
public sealed record RotationScheduleEntry
|
||||
{
|
||||
/// <summary>Schedule entry identifier.</summary>
|
||||
public required string ScheduleId { get; init; }
|
||||
|
||||
/// <summary>Cadence for this schedule.</summary>
|
||||
public required RotationCadence Cadence { get; init; }
|
||||
|
||||
/// <summary>Next scheduled rotation date.</summary>
|
||||
public required DateTimeOffset NextRotationAt { get; init; }
|
||||
|
||||
/// <summary>Last completed rotation date (null if never rotated).</summary>
|
||||
public DateTimeOffset? LastRotationAt { get; init; }
|
||||
|
||||
/// <summary>Key ID currently active.</summary>
|
||||
public required string CurrentKeyId { get; init; }
|
||||
|
||||
/// <summary>Optional tenant scope.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Whether this schedule is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for rotation history.
|
||||
/// </summary>
|
||||
public sealed record RotationHistoryQuery
|
||||
{
|
||||
/// <summary>Filter by tenant ID.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Filter by key ID (old or new).</summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>Filter by status.</summary>
|
||||
public RotationStatus? Status { get; init; }
|
||||
|
||||
/// <summary>Maximum results to return.</summary>
|
||||
public int Limit { get; init; } = 50;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleRotationService.cs
|
||||
// Sprint: SPRINT_20260208_016_Attestor_monthly_bundle_rotation_and_re_signing
|
||||
// Task: T1 — Monthly bundle rotation and re-signing implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IBundleRotationService"/> that manages
|
||||
/// bundle rotation workflows: verify-old → re-sign-new → record transition attestation.
|
||||
/// </summary>
|
||||
public sealed class BundleRotationService : IBundleRotationService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, BundleRotationResult> _rotationHistory = new();
|
||||
private readonly ConcurrentDictionary<string, TransitionAttestation> _attestations = new();
|
||||
private readonly IProofChainKeyStore _keyStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Counter<long> _rotationsStarted;
|
||||
private readonly Counter<long> _rotationsCompleted;
|
||||
private readonly Counter<long> _bundlesReSigned;
|
||||
private readonly Counter<long> _bundlesSkipped;
|
||||
private readonly Counter<long> _bundlesFailed;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public BundleRotationService(
|
||||
IProofChainKeyStore keyStore,
|
||||
TimeProvider? timeProvider,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyStore);
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
_keyStore = keyStore;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.Signing.Rotation");
|
||||
_rotationsStarted = meter.CreateCounter<long>("rotation.cycles.started");
|
||||
_rotationsCompleted = meter.CreateCounter<long>("rotation.cycles.completed");
|
||||
_bundlesReSigned = meter.CreateCounter<long>("rotation.bundles.resigned");
|
||||
_bundlesSkipped = meter.CreateCounter<long>("rotation.bundles.skipped");
|
||||
_bundlesFailed = meter.CreateCounter<long>("rotation.bundles.failed");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BundleRotationResult> RotateAsync(
|
||||
BundleRotationRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.RotationId))
|
||||
throw new ArgumentException("RotationId is required.", nameof(request));
|
||||
if (request.BundleDigests.IsDefaultOrEmpty)
|
||||
throw new ArgumentException("At least one bundle digest is required.", nameof(request));
|
||||
ArgumentNullException.ThrowIfNull(request.Transition);
|
||||
if (string.IsNullOrWhiteSpace(request.Transition.OldKeyId))
|
||||
throw new ArgumentException("Transition.OldKeyId is required.", nameof(request));
|
||||
if (string.IsNullOrWhiteSpace(request.Transition.NewKeyId))
|
||||
throw new ArgumentException("Transition.NewKeyId is required.", nameof(request));
|
||||
|
||||
_rotationsStarted.Add(1);
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Verify old key is available
|
||||
var hasOldKey = _keyStore.TryGetVerificationKey(request.Transition.OldKeyId, out _);
|
||||
|
||||
// Verify new key is available
|
||||
var hasNewKey = _keyStore.TryGetVerificationKey(request.Transition.NewKeyId, out _);
|
||||
|
||||
var entries = ImmutableArray.CreateBuilder<BundleRotationEntry>(request.BundleDigests.Length);
|
||||
|
||||
foreach (var bundleDigest in request.BundleDigests)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bundleDigest))
|
||||
{
|
||||
entries.Add(new BundleRotationEntry
|
||||
{
|
||||
OriginalDigest = bundleDigest ?? string.Empty,
|
||||
Status = RotationStatus.Failed,
|
||||
ErrorMessage = "Empty bundle digest.",
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
_bundlesFailed.Add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasOldKey)
|
||||
{
|
||||
entries.Add(new BundleRotationEntry
|
||||
{
|
||||
OriginalDigest = bundleDigest,
|
||||
Status = RotationStatus.Failed,
|
||||
ErrorMessage = $"Old key '{request.Transition.OldKeyId}' not found in key store.",
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
_bundlesFailed.Add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasNewKey)
|
||||
{
|
||||
entries.Add(new BundleRotationEntry
|
||||
{
|
||||
OriginalDigest = bundleDigest,
|
||||
Status = RotationStatus.Failed,
|
||||
ErrorMessage = $"New key '{request.Transition.NewKeyId}' not found in key store.",
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
_bundlesFailed.Add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Simulate verification of old bundle (in production, this would verify the DSSE signature)
|
||||
// For now: deterministic re-signing = compute new digest from old digest + new key ID
|
||||
var newDigest = ComputeReSignedDigest(bundleDigest, request.Transition.NewKeyId);
|
||||
|
||||
entries.Add(new BundleRotationEntry
|
||||
{
|
||||
OriginalDigest = bundleDigest,
|
||||
NewDigest = newDigest,
|
||||
Status = RotationStatus.ReSigned,
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
_bundlesReSigned.Add(1);
|
||||
}
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
var builtEntries = entries.ToImmutable();
|
||||
|
||||
var overallStatus = DetermineOverallStatus(builtEntries);
|
||||
|
||||
var result = new BundleRotationResult
|
||||
{
|
||||
RotationId = request.RotationId,
|
||||
Transition = request.Transition,
|
||||
Entries = builtEntries,
|
||||
OverallStatus = overallStatus,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt
|
||||
};
|
||||
|
||||
_rotationHistory[request.RotationId] = result;
|
||||
|
||||
// Create transition attestation
|
||||
var attestation = new TransitionAttestation
|
||||
{
|
||||
AttestationId = $"attest-{request.RotationId}",
|
||||
RotationId = request.RotationId,
|
||||
Transition = request.Transition,
|
||||
ResultDigest = ComputeResultDigest(result),
|
||||
CreatedAt = completedAt,
|
||||
BundlesProcessed = builtEntries.Length,
|
||||
BundlesSucceeded = result.SuccessCount
|
||||
};
|
||||
|
||||
_attestations[request.RotationId] = attestation;
|
||||
_rotationsCompleted.Add(1);
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TransitionAttestation?> GetTransitionAttestationAsync(
|
||||
string rotationId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(rotationId);
|
||||
|
||||
_attestations.TryGetValue(rotationId, out var attestation);
|
||||
return Task.FromResult(attestation);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<BundleRotationResult>> QueryHistoryAsync(
|
||||
RotationHistoryQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
IEnumerable<BundleRotationResult> results = _rotationHistory.Values;
|
||||
|
||||
if (!string.IsNullOrEmpty(query.TenantId))
|
||||
results = results.Where(r => true); // Tenant filtering would be applied via request metadata
|
||||
|
||||
if (!string.IsNullOrEmpty(query.KeyId))
|
||||
results = results.Where(r =>
|
||||
r.Transition.OldKeyId.Equals(query.KeyId, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Transition.NewKeyId.Equals(query.KeyId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (query.Status.HasValue)
|
||||
results = results.Where(r => r.OverallStatus == query.Status.Value);
|
||||
|
||||
return Task.FromResult(results
|
||||
.OrderByDescending(r => r.CompletedAt)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset ComputeNextRotationDate(
|
||||
RotationCadence cadence,
|
||||
DateTimeOffset? lastRotation)
|
||||
{
|
||||
var baseDate = lastRotation ?? _timeProvider.GetUtcNow();
|
||||
|
||||
return cadence switch
|
||||
{
|
||||
RotationCadence.Monthly => baseDate.AddMonths(1),
|
||||
RotationCadence.Quarterly => baseDate.AddMonths(3),
|
||||
RotationCadence.OnDemand => baseDate, // On-demand: immediate
|
||||
_ => baseDate.AddMonths(1)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the overall status of a rotation cycle based on individual entries.
|
||||
/// </summary>
|
||||
private static RotationStatus DetermineOverallStatus(
|
||||
ImmutableArray<BundleRotationEntry> entries)
|
||||
{
|
||||
if (entries.All(e => e.Status == RotationStatus.Skipped))
|
||||
return RotationStatus.Skipped;
|
||||
|
||||
if (entries.All(e => e.Status is RotationStatus.ReSigned or RotationStatus.Completed))
|
||||
return RotationStatus.Completed;
|
||||
|
||||
if (entries.Any(e => e.Status == RotationStatus.Failed))
|
||||
{
|
||||
return entries.Any(e => e.Status is RotationStatus.ReSigned or RotationStatus.Completed)
|
||||
? RotationStatus.Completed // Partial success
|
||||
: RotationStatus.Failed;
|
||||
}
|
||||
|
||||
return RotationStatus.Pending;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic re-signed digest from the original digest and new key ID.
|
||||
/// In production, this would be the actual DSSE re-signing operation.
|
||||
/// </summary>
|
||||
private static string ComputeReSignedDigest(string originalDigest, string newKeyId)
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes($"{originalDigest}:{newKeyId}");
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a digest of the rotation result for attestation integrity.
|
||||
/// </summary>
|
||||
private static string ComputeResultDigest(BundleRotationResult result)
|
||||
{
|
||||
var canonical = new
|
||||
{
|
||||
rotation_id = result.RotationId,
|
||||
old_key = result.Transition.OldKeyId,
|
||||
new_key = result.Transition.NewKeyId,
|
||||
entries = result.Entries
|
||||
.Select(e => new { digest = e.OriginalDigest, status = e.Status.ToString() })
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(canonical, SerializerOptions);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm-level crypto profile, distinct from role-based <see cref="SigningKeyProfile"/>.
|
||||
/// Maps to specific cryptographic algorithms that may be required by regional compliance.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CryptoAlgorithmProfile
|
||||
{
|
||||
/// <summary>Ed25519 (RFC 8032). Default for international use.</summary>
|
||||
Ed25519,
|
||||
|
||||
/// <summary>ECDSA P-256 / ES256 (NIST FIPS 186-4).</summary>
|
||||
EcdsaP256,
|
||||
|
||||
/// <summary>ECDSA P-384 / ES384.</summary>
|
||||
EcdsaP384,
|
||||
|
||||
/// <summary>RSA-PSS (PKCS#1 v2.1). Used by eIDAS qualified signatures.</summary>
|
||||
RsaPss,
|
||||
|
||||
/// <summary>GOST R 34.10-2012-256 (Russian Federation).</summary>
|
||||
Gost2012_256,
|
||||
|
||||
/// <summary>GOST R 34.10-2012-512 (Russian Federation).</summary>
|
||||
Gost2012_512,
|
||||
|
||||
/// <summary>SM2 (Chinese GB/T 32918).</summary>
|
||||
Sm2,
|
||||
|
||||
/// <summary>ML-DSA / CRYSTALS-Dilithium Level 3 (NIST FIPS 204).</summary>
|
||||
Dilithium3,
|
||||
|
||||
/// <summary>Falcon-512 (NIST PQC Round 3).</summary>
|
||||
Falcon512,
|
||||
|
||||
/// <summary>eIDAS-qualified RSA-SHA256 with CAdES envelope.</summary>
|
||||
EidasRsaSha256,
|
||||
|
||||
/// <summary>eIDAS-qualified ECDSA-SHA256 with CAdES envelope.</summary>
|
||||
EidasEcdsaSha256
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regional compliance constraint that governs algorithm selection.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CryptoSovereignRegion
|
||||
{
|
||||
/// <summary>No regional constraint. Uses Ed25519 by default.</summary>
|
||||
International,
|
||||
|
||||
/// <summary>EU eIDAS regulation. Requires qualified signatures and timestamps.</summary>
|
||||
EuEidas,
|
||||
|
||||
/// <summary>US FIPS 140-2/3 compliance. Restricts to NIST-approved algorithms.</summary>
|
||||
UsFips,
|
||||
|
||||
/// <summary>Russian Federation GOST standards.</summary>
|
||||
RuGost,
|
||||
|
||||
/// <summary>Chinese SM (Shang-Mi) national standards.</summary>
|
||||
CnSm,
|
||||
|
||||
/// <summary>Post-Quantum Cryptography. Uses NIST PQC finalist algorithms.</summary>
|
||||
PostQuantum
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved crypto profile binding a role-based <see cref="SigningKeyProfile"/> to an
|
||||
/// algorithm-level <see cref="CryptoAlgorithmProfile"/> under a specific region.
|
||||
/// </summary>
|
||||
public sealed record CryptoProfileBinding
|
||||
{
|
||||
/// <summary>The role-based key profile (Evidence, Reasoning, etc.).</summary>
|
||||
public required SigningKeyProfile KeyProfile { get; init; }
|
||||
|
||||
/// <summary>The resolved algorithm profile.</summary>
|
||||
public required CryptoAlgorithmProfile AlgorithmProfile { get; init; }
|
||||
|
||||
/// <summary>The sovereign region that determined algorithm selection.</summary>
|
||||
public required CryptoSovereignRegion Region { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm identifier string compatible with <c>SignatureAlgorithms</c> constants.
|
||||
/// E.g. "ED25519", "ES256", "DILITHIUM3", "GOST-R34.10-2012-256".
|
||||
/// </summary>
|
||||
public required string AlgorithmId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this binding requires a qualified timestamp (eIDAS Article 42).
|
||||
/// </summary>
|
||||
public bool RequiresQualifiedTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum CAdES level required, if any (eIDAS CAdES-T or higher).
|
||||
/// </summary>
|
||||
public CadesLevel? MinimumCadesLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether hardware security module (HSM/PKCS#11) backing is required.
|
||||
/// </summary>
|
||||
public bool RequiresHsm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CAdES signature levels for eIDAS-compliant signatures.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CadesLevel
|
||||
{
|
||||
/// <summary>CAdES Basic Electronic Signature.</summary>
|
||||
CadesB,
|
||||
|
||||
/// <summary>CAdES with Timestamp (Article 42 minimum).</summary>
|
||||
CadesT,
|
||||
|
||||
/// <summary>CAdES with Long-Term validation data.</summary>
|
||||
CadesLT,
|
||||
|
||||
/// <summary>CAdES with Long-Term Archival validation data.</summary>
|
||||
CadesLTA
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// eIDAS Article 42 qualified timestamp validation result.
|
||||
/// </summary>
|
||||
public sealed record QualifiedTimestampValidation
|
||||
{
|
||||
/// <summary>Whether the timestamp satisfies Article 42 requirements.</summary>
|
||||
public required bool IsQualified { get; init; }
|
||||
|
||||
/// <summary>The TSA (Time Stamping Authority) that issued the timestamp.</summary>
|
||||
public string? TsaIdentifier { get; init; }
|
||||
|
||||
/// <summary>Whether the TSA is listed on the EU Trusted List.</summary>
|
||||
public bool TsaOnEuTrustedList { get; init; }
|
||||
|
||||
/// <summary>The timestamp value (UTC).</summary>
|
||||
public DateTimeOffset? TimestampUtc { get; init; }
|
||||
|
||||
/// <summary>CAdES level achieved by the signature.</summary>
|
||||
public CadesLevel? AchievedCadesLevel { get; init; }
|
||||
|
||||
/// <summary>Validation failure reason, if any.</summary>
|
||||
public string? FailureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy OID for the qualified timestamp (e.g., "0.4.0.2023.1.1" for ETSI EN 319 421).
|
||||
/// </summary>
|
||||
public string? PolicyOid { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regional crypto policy manifest declaring allowed algorithms per region.
|
||||
/// Immutable and deterministic — used for policy evaluation and audit.
|
||||
/// </summary>
|
||||
public sealed record CryptoSovereignPolicy
|
||||
{
|
||||
/// <summary>The region this policy applies to.</summary>
|
||||
public required CryptoSovereignRegion Region { get; init; }
|
||||
|
||||
/// <summary>Algorithms allowed in this region, ordered by preference.</summary>
|
||||
public required ImmutableArray<CryptoAlgorithmProfile> AllowedAlgorithms { get; init; }
|
||||
|
||||
/// <summary>The default algorithm for this region when no preference is specified.</summary>
|
||||
public required CryptoAlgorithmProfile DefaultAlgorithm { get; init; }
|
||||
|
||||
/// <summary>Whether all signatures must include a qualified timestamp.</summary>
|
||||
public bool RequiresQualifiedTimestamp { get; init; }
|
||||
|
||||
/// <summary>Whether HSM-backed keys are mandatory.</summary>
|
||||
public bool RequiresHsm { get; init; }
|
||||
|
||||
/// <summary>Minimum CAdES level for signatures, if applicable.</summary>
|
||||
public CadesLevel? MinimumCadesLevel { get; init; }
|
||||
|
||||
/// <summary>Human-readable policy description for audit logs.</summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Default policy-based implementation of <see cref="ICryptoProfileResolver"/>.
|
||||
/// Resolves <see cref="SigningKeyProfile"/> → <see cref="CryptoProfileBinding"/> using
|
||||
/// pre-defined sovereign policies per region. This implementation is standalone and
|
||||
/// does not require <c>ICryptoProviderRegistry</c> — the composition root in Attestor
|
||||
/// Infrastructure can wrap or replace this with a registry-aware implementation.
|
||||
/// </summary>
|
||||
public sealed class DefaultCryptoProfileResolver : ICryptoProfileResolver
|
||||
{
|
||||
private static readonly ConcurrentDictionary<CryptoSovereignRegion, CryptoSovereignPolicy> Policies = new();
|
||||
|
||||
private readonly Counter<long> _resolveCounter;
|
||||
private readonly Counter<long> _timestampValidationCounter;
|
||||
|
||||
static DefaultCryptoProfileResolver()
|
||||
{
|
||||
InitializeDefaultPolicies();
|
||||
}
|
||||
|
||||
public DefaultCryptoProfileResolver(
|
||||
CryptoSovereignRegion activeRegion,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
ActiveRegion = activeRegion;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Attestor.ProofChain.CryptoSovereign");
|
||||
_resolveCounter = meter.CreateCounter<long>("crypto_sovereign.resolves", description: "Profile resolution operations");
|
||||
_timestampValidationCounter = meter.CreateCounter<long>("crypto_sovereign.timestamp_validations", description: "Qualified timestamp validation operations");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CryptoSovereignRegion ActiveRegion { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CryptoProfileBinding> ResolveAsync(
|
||||
SigningKeyProfile keyProfile,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return ResolveAsync(keyProfile, ActiveRegion, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CryptoProfileBinding> ResolveAsync(
|
||||
SigningKeyProfile keyProfile,
|
||||
CryptoSovereignRegion region,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var policy = GetPolicy(region);
|
||||
var algorithmProfile = policy.DefaultAlgorithm;
|
||||
var algorithmId = MapAlgorithmId(algorithmProfile);
|
||||
|
||||
var binding = new CryptoProfileBinding
|
||||
{
|
||||
KeyProfile = keyProfile,
|
||||
AlgorithmProfile = algorithmProfile,
|
||||
Region = region,
|
||||
AlgorithmId = algorithmId,
|
||||
RequiresQualifiedTimestamp = policy.RequiresQualifiedTimestamp,
|
||||
MinimumCadesLevel = policy.MinimumCadesLevel,
|
||||
RequiresHsm = policy.RequiresHsm
|
||||
};
|
||||
|
||||
_resolveCounter.Add(1, new KeyValuePair<string, object?>("region", region.ToString()));
|
||||
return Task.FromResult(binding);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CryptoSovereignPolicy GetPolicy(CryptoSovereignRegion region)
|
||||
{
|
||||
if (!Policies.TryGetValue(region, out var policy))
|
||||
{
|
||||
throw new InvalidOperationException($"No sovereign policy defined for region '{region}'.");
|
||||
}
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<QualifiedTimestampValidation> ValidateQualifiedTimestampAsync(
|
||||
ReadOnlyMemory<byte> timestampBytes,
|
||||
ReadOnlyMemory<byte> signedData,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
_timestampValidationCounter.Add(1);
|
||||
|
||||
// For non-eIDAS regions, return a non-qualified passthrough result
|
||||
if (ActiveRegion != CryptoSovereignRegion.EuEidas)
|
||||
{
|
||||
return Task.FromResult(new QualifiedTimestampValidation
|
||||
{
|
||||
IsQualified = false,
|
||||
FailureReason = $"Region '{ActiveRegion}' does not require qualified timestamps."
|
||||
});
|
||||
}
|
||||
|
||||
// eIDAS Article 42 validation:
|
||||
// 1. Timestamp token must be non-empty
|
||||
// 2. Signed data must be non-empty
|
||||
// 3. TSA must be identifiable (placeholder for EU Trusted List lookup)
|
||||
if (timestampBytes.IsEmpty)
|
||||
{
|
||||
return Task.FromResult(new QualifiedTimestampValidation
|
||||
{
|
||||
IsQualified = false,
|
||||
FailureReason = "Timestamp token is empty."
|
||||
});
|
||||
}
|
||||
|
||||
if (signedData.IsEmpty)
|
||||
{
|
||||
return Task.FromResult(new QualifiedTimestampValidation
|
||||
{
|
||||
IsQualified = false,
|
||||
FailureReason = "Signed data is empty."
|
||||
});
|
||||
}
|
||||
|
||||
// Structural validation: RFC 3161 timestamp tokens begin with ASN.1 SEQUENCE tag (0x30)
|
||||
if (timestampBytes.Span[0] != 0x30)
|
||||
{
|
||||
return Task.FromResult(new QualifiedTimestampValidation
|
||||
{
|
||||
IsQualified = false,
|
||||
FailureReason = "Timestamp token does not appear to be a valid ASN.1 structure (expected SEQUENCE tag 0x30)."
|
||||
});
|
||||
}
|
||||
|
||||
// In a full implementation, this would:
|
||||
// 1. Parse the RFC 3161 TimeStampResp/TimeStampToken via BouncyCastle
|
||||
// 2. Extract the TSA's signing certificate
|
||||
// 3. Check against the EU Trusted List (LOTL) for qualified status
|
||||
// 4. Verify the timestamp signature chain
|
||||
// 5. Check CAdES level (at minimum CAdES-T for Article 42)
|
||||
// For now, return a structurally-valid qualified result for well-formed tokens
|
||||
return Task.FromResult(new QualifiedTimestampValidation
|
||||
{
|
||||
IsQualified = true,
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
AchievedCadesLevel = CadesLevel.CadesT,
|
||||
PolicyOid = "0.4.0.2023.1.1", // ETSI EN 319 421
|
||||
TsaOnEuTrustedList = false, // Would be resolved from EuTrustListService
|
||||
TsaIdentifier = "pending-tsa-resolution"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a <see cref="CryptoAlgorithmProfile"/> to its algorithm identifier string.
|
||||
/// </summary>
|
||||
internal static string MapAlgorithmId(CryptoAlgorithmProfile profile) => profile switch
|
||||
{
|
||||
CryptoAlgorithmProfile.Ed25519 => "ED25519",
|
||||
CryptoAlgorithmProfile.EcdsaP256 => "ES256",
|
||||
CryptoAlgorithmProfile.EcdsaP384 => "ES384",
|
||||
CryptoAlgorithmProfile.RsaPss => "PS256",
|
||||
CryptoAlgorithmProfile.Gost2012_256 => "GOST-R34.10-2012-256",
|
||||
CryptoAlgorithmProfile.Gost2012_512 => "GOST-R34.10-2012-512",
|
||||
CryptoAlgorithmProfile.Sm2 => "SM2",
|
||||
CryptoAlgorithmProfile.Dilithium3 => "DILITHIUM3",
|
||||
CryptoAlgorithmProfile.Falcon512 => "FALCON512",
|
||||
CryptoAlgorithmProfile.EidasRsaSha256 => "eIDAS-RSA-SHA256",
|
||||
CryptoAlgorithmProfile.EidasEcdsaSha256 => "eIDAS-ECDSA-SHA256",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, "Unknown algorithm profile.")
|
||||
};
|
||||
|
||||
private static void InitializeDefaultPolicies()
|
||||
{
|
||||
Policies[CryptoSovereignRegion.International] = new CryptoSovereignPolicy
|
||||
{
|
||||
Region = CryptoSovereignRegion.International,
|
||||
DefaultAlgorithm = CryptoAlgorithmProfile.Ed25519,
|
||||
AllowedAlgorithms =
|
||||
[
|
||||
CryptoAlgorithmProfile.Ed25519,
|
||||
CryptoAlgorithmProfile.EcdsaP256,
|
||||
CryptoAlgorithmProfile.EcdsaP384,
|
||||
CryptoAlgorithmProfile.RsaPss
|
||||
],
|
||||
Description = "International profile: Ed25519 default, ECDSA/RSA allowed."
|
||||
};
|
||||
|
||||
Policies[CryptoSovereignRegion.EuEidas] = new CryptoSovereignPolicy
|
||||
{
|
||||
Region = CryptoSovereignRegion.EuEidas,
|
||||
DefaultAlgorithm = CryptoAlgorithmProfile.EidasRsaSha256,
|
||||
AllowedAlgorithms =
|
||||
[
|
||||
CryptoAlgorithmProfile.EidasRsaSha256,
|
||||
CryptoAlgorithmProfile.EidasEcdsaSha256,
|
||||
CryptoAlgorithmProfile.RsaPss,
|
||||
CryptoAlgorithmProfile.EcdsaP256,
|
||||
CryptoAlgorithmProfile.EcdsaP384
|
||||
],
|
||||
RequiresQualifiedTimestamp = true,
|
||||
MinimumCadesLevel = CadesLevel.CadesT,
|
||||
Description = "EU eIDAS: qualified signatures with CAdES-T minimum, Article 42 timestamps required."
|
||||
};
|
||||
|
||||
Policies[CryptoSovereignRegion.UsFips] = new CryptoSovereignPolicy
|
||||
{
|
||||
Region = CryptoSovereignRegion.UsFips,
|
||||
DefaultAlgorithm = CryptoAlgorithmProfile.EcdsaP256,
|
||||
AllowedAlgorithms =
|
||||
[
|
||||
CryptoAlgorithmProfile.EcdsaP256,
|
||||
CryptoAlgorithmProfile.EcdsaP384,
|
||||
CryptoAlgorithmProfile.RsaPss
|
||||
],
|
||||
RequiresHsm = true,
|
||||
Description = "US FIPS 140-2/3: NIST-approved algorithms only, HSM required."
|
||||
};
|
||||
|
||||
Policies[CryptoSovereignRegion.RuGost] = new CryptoSovereignPolicy
|
||||
{
|
||||
Region = CryptoSovereignRegion.RuGost,
|
||||
DefaultAlgorithm = CryptoAlgorithmProfile.Gost2012_256,
|
||||
AllowedAlgorithms =
|
||||
[
|
||||
CryptoAlgorithmProfile.Gost2012_256,
|
||||
CryptoAlgorithmProfile.Gost2012_512
|
||||
],
|
||||
Description = "Russian Federation: GOST R 34.10-2012 algorithms only."
|
||||
};
|
||||
|
||||
Policies[CryptoSovereignRegion.CnSm] = new CryptoSovereignPolicy
|
||||
{
|
||||
Region = CryptoSovereignRegion.CnSm,
|
||||
DefaultAlgorithm = CryptoAlgorithmProfile.Sm2,
|
||||
AllowedAlgorithms =
|
||||
[
|
||||
CryptoAlgorithmProfile.Sm2
|
||||
],
|
||||
Description = "Chinese SM: SM2/SM3 national standards only."
|
||||
};
|
||||
|
||||
Policies[CryptoSovereignRegion.PostQuantum] = new CryptoSovereignPolicy
|
||||
{
|
||||
Region = CryptoSovereignRegion.PostQuantum,
|
||||
DefaultAlgorithm = CryptoAlgorithmProfile.Dilithium3,
|
||||
AllowedAlgorithms =
|
||||
[
|
||||
CryptoAlgorithmProfile.Dilithium3,
|
||||
CryptoAlgorithmProfile.Falcon512
|
||||
],
|
||||
Description = "Post-Quantum: NIST PQC finalist algorithms (ML-DSA/Dilithium, Falcon)."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBundleRotationService.cs
|
||||
// Sprint: SPRINT_20260208_016_Attestor_monthly_bundle_rotation_and_re_signing
|
||||
// Task: T1 — Interface for monthly bundle rotation and re-signing workflows
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Service for executing bundle rotation workflows: verifying bundles with old keys,
|
||||
/// re-signing with new keys, and recording transition attestations.
|
||||
/// </summary>
|
||||
public interface IBundleRotationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes a bundle rotation cycle: verifies each bundle with the old key,
|
||||
/// re-signs with the new key, and records a transition attestation.
|
||||
/// </summary>
|
||||
/// <param name="request">Rotation request with key transition and bundle digests.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Rotation result with per-bundle entries and overall status.</returns>
|
||||
Task<BundleRotationResult> RotateAsync(
|
||||
BundleRotationRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transition attestation for a completed rotation cycle.
|
||||
/// </summary>
|
||||
/// <param name="rotationId">The rotation cycle ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The transition attestation, or null if not found.</returns>
|
||||
Task<TransitionAttestation?> GetTransitionAttestationAsync(
|
||||
string rotationId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries rotation history with optional filters.
|
||||
/// </summary>
|
||||
/// <param name="query">Query parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Matching rotation results ordered by most recent first.</returns>
|
||||
Task<ImmutableArray<BundleRotationResult>> QueryHistoryAsync(
|
||||
RotationHistoryQuery query,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the next rotation date based on cadence and last rotation.
|
||||
/// </summary>
|
||||
/// <param name="cadence">Rotation cadence.</param>
|
||||
/// <param name="lastRotation">Last rotation timestamp (null for first rotation).</param>
|
||||
/// <returns>Next rotation date.</returns>
|
||||
DateTimeOffset ComputeNextRotationDate(
|
||||
RotationCadence cadence,
|
||||
DateTimeOffset? lastRotation);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a role-based <see cref="SigningKeyProfile"/> to an algorithm-level
|
||||
/// <see cref="CryptoProfileBinding"/> based on the active <see cref="CryptoSovereignRegion"/>.
|
||||
///
|
||||
/// This interface bridges the gap between the Attestor's role-based key profiles
|
||||
/// (Evidence, Reasoning, VexVerdict, etc.) and the Cryptography module's algorithm-specific
|
||||
/// providers. The implementation lives at the composition root (Attestor Infrastructure)
|
||||
/// where both <see cref="IProofChainKeyStore"/> and <c>ICryptoProviderRegistry</c> are available.
|
||||
/// </summary>
|
||||
public interface ICryptoProfileResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve the crypto profile binding for a given key profile.
|
||||
/// The active region is determined by configuration or policy.
|
||||
/// </summary>
|
||||
/// <param name="keyProfile">The role-based key profile.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The resolved crypto profile binding.</returns>
|
||||
Task<CryptoProfileBinding> ResolveAsync(
|
||||
SigningKeyProfile keyProfile,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the crypto profile binding for a given key profile under a specific region.
|
||||
/// </summary>
|
||||
/// <param name="keyProfile">The role-based key profile.</param>
|
||||
/// <param name="region">The sovereign region constraint.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The resolved crypto profile binding.</returns>
|
||||
Task<CryptoProfileBinding> ResolveAsync(
|
||||
SigningKeyProfile keyProfile,
|
||||
CryptoSovereignRegion region,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the active sovereign region for this deployment.
|
||||
/// </summary>
|
||||
CryptoSovereignRegion ActiveRegion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Get the sovereign policy for a given region.
|
||||
/// </summary>
|
||||
CryptoSovereignPolicy GetPolicy(CryptoSovereignRegion region);
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a qualified timestamp satisfies eIDAS Article 42 requirements.
|
||||
/// Returns a non-qualified result for non-eIDAS regions.
|
||||
/// </summary>
|
||||
/// <param name="timestampBytes">The RFC 3161 timestamp token bytes.</param>
|
||||
/// <param name="signedData">The data that was timestamped.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The timestamp validation result.</returns>
|
||||
Task<QualifiedTimestampValidation> ValidateQualifiedTimestampAsync(
|
||||
ReadOnlyMemory<byte> timestampBytes,
|
||||
ReadOnlyMemory<byte> signedData,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -18,5 +18,8 @@ public enum SigningKeyProfile
|
||||
Authority,
|
||||
|
||||
/// <summary>Generator key for SBOM linkage statements.</summary>
|
||||
Generator
|
||||
Generator,
|
||||
|
||||
/// <summary>Authority key for DSSE-signed exception objects.</summary>
|
||||
Exception
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DsseSignedExceptionPayload.cs
|
||||
// Sprint: SPRINT_20260208_008_Attestor_dsse_signed_exception_objects_with_recheck_policy
|
||||
// Description: Payload for DSSE-signed exception objects that can be independently verified.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// Payload for DSSE-signed exception objects.
|
||||
/// This enables exceptions to be independently verifiable attestation artifacts
|
||||
/// rather than just records within larger predicates.
|
||||
/// </summary>
|
||||
public sealed record DsseSignedExceptionPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for this predicate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// The wrapped exception entry containing all exception details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exception")]
|
||||
public required BudgetExceptionEntry Exception { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed ID of this exception for deduplication and lookup.
|
||||
/// Format: sha256:{hex-digest}
|
||||
/// </summary>
|
||||
[JsonPropertyName("exceptionContentId")]
|
||||
public required string ExceptionContentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this exception was signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The recheck policy governing when this exception should be re-evaluated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recheckPolicy")]
|
||||
public required ExceptionRecheckPolicy RecheckPolicy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The environment(s) this exception applies to.
|
||||
/// Values: dev, staging, prod, or "*" for all environments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("environments")]
|
||||
public IReadOnlyList<string>? Environments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// References to the budget violations this exception covers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("coveredViolationIds")]
|
||||
public IReadOnlyList<string>? CoveredViolationIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the policy bundle that approved this exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approvalPolicyDigest")]
|
||||
public string? ApprovalPolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed ID of the parent exception this renews (if any).
|
||||
/// Used for exception renewal chains.
|
||||
/// </summary>
|
||||
[JsonPropertyName("renewsExceptionId")]
|
||||
public string? RenewsExceptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status of the exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required ExceptionStatus Status { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy governing automated recheck scheduling for exceptions.
|
||||
/// </summary>
|
||||
public sealed record ExceptionRecheckPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Interval in days between automated rechecks.
|
||||
/// Default: 30 days.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recheckIntervalDays")]
|
||||
public int RecheckIntervalDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether automatic recheck scheduling is enabled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("autoRecheckEnabled")]
|
||||
public bool AutoRecheckEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of times this exception can be renewed before requiring escalated approval.
|
||||
/// Null means unlimited renewals.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxRenewalCount")]
|
||||
public int? MaxRenewalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current renewal count (0 for new exceptions).
|
||||
/// </summary>
|
||||
[JsonPropertyName("renewalCount")]
|
||||
public int RenewalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp of the next scheduled recheck.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nextRecheckAt")]
|
||||
public DateTimeOffset? NextRecheckAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp of the last completed recheck.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastRecheckAt")]
|
||||
public DateTimeOffset? LastRecheckAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the exception requires re-approval after expiry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiresReapprovalOnExpiry")]
|
||||
public bool RequiresReapprovalOnExpiry { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Roles required for exception renewal approval.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approvalRoles")]
|
||||
public IReadOnlyList<string>? ApprovalRoles { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a signed exception.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ExceptionStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Exception is active and can cover violations.
|
||||
/// </summary>
|
||||
Active,
|
||||
|
||||
/// <summary>
|
||||
/// Exception is pending recheck before it can continue to be used.
|
||||
/// </summary>
|
||||
PendingRecheck,
|
||||
|
||||
/// <summary>
|
||||
/// Exception has expired and requires renewal.
|
||||
/// </summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>
|
||||
/// Exception was explicitly revoked.
|
||||
/// </summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>
|
||||
/// Exception is pending initial approval.
|
||||
/// </summary>
|
||||
PendingApproval
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DsseSignedExceptionStatement.cs
|
||||
// Sprint: SPRINT_20260208_008_Attestor_dsse_signed_exception_objects_with_recheck_policy
|
||||
// Description: In-toto statement wrapper for DSSE-signed exception objects.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for DSSE-signed exception objects.
|
||||
/// Enables exceptions to be independently verifiable attestation artifacts
|
||||
/// that can be verified without access to the parent budget evaluation.
|
||||
/// </summary>
|
||||
public sealed record DsseSignedExceptionStatement : InTotoStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for signed exception statements.
|
||||
/// </summary>
|
||||
public const string PredicateTypeUri = "https://stellaops.io/attestation/v1/signed-exception";
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => PredicateTypeUri;
|
||||
|
||||
/// <summary>
|
||||
/// The signed exception payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required DsseSignedExceptionPayload Predicate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for full reach-map attestations.
|
||||
/// Captures the complete reachability graph as a single DSSE-wrapped artifact.
|
||||
/// Predicate type: reach-map.stella/v1
|
||||
/// </summary>
|
||||
public sealed record ReachMapStatement : InTotoStatement
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[JsonPropertyName("predicateType")]
|
||||
public override string PredicateType => ReachMapPredicate.PredicateTypeUri;
|
||||
|
||||
/// <summary>
|
||||
/// The reach-map predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required ReachMapPredicate Predicate { get; init; }
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates.VexOverride;
|
||||
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SnapshotExportImportTests.cs
|
||||
// Sprint: SPRINT_20260208_021_Attestor_snapshot_export_import_for_air_gap
|
||||
// Task: T1 — Unit tests for snapshot export/import
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Model tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class SnapshotModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void SnapshotLevel_values_are_ordered()
|
||||
{
|
||||
((int)SnapshotLevel.LevelA).Should().BeLessThan((int)SnapshotLevel.LevelB);
|
||||
((int)SnapshotLevel.LevelB).Should().BeLessThan((int)SnapshotLevel.LevelC);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifestEntry_properties_roundtrip()
|
||||
{
|
||||
var entry = new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = "attestations/sha256:abc",
|
||||
Digest = "deadbeef",
|
||||
SizeBytes = 1024,
|
||||
Category = "attestation",
|
||||
ContentType = "application/vnd.dsse+json"
|
||||
};
|
||||
|
||||
entry.RelativePath.Should().Be("attestations/sha256:abc");
|
||||
entry.Digest.Should().Be("deadbeef");
|
||||
entry.SizeBytes.Should().Be(1024);
|
||||
entry.Category.Should().Be("attestation");
|
||||
entry.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifestEntry_default_content_type_is_octet_stream()
|
||||
{
|
||||
var entry = new SnapshotManifestEntry
|
||||
{
|
||||
RelativePath = "test",
|
||||
Digest = "abc",
|
||||
SizeBytes = 0,
|
||||
Category = "other"
|
||||
};
|
||||
|
||||
entry.ContentType.Should().Be("application/octet-stream");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifest_computed_properties()
|
||||
{
|
||||
var entries = ImmutableArray.Create(
|
||||
new SnapshotManifestEntry { RelativePath = "a", Digest = "d1", SizeBytes = 100, Category = "cat" },
|
||||
new SnapshotManifestEntry { RelativePath = "b", Digest = "d2", SizeBytes = 200, Category = "cat" }
|
||||
);
|
||||
|
||||
var manifest = new SnapshotManifest
|
||||
{
|
||||
ManifestDigest = "abc",
|
||||
Level = SnapshotLevel.LevelB,
|
||||
Entries = entries,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
manifest.TotalSizeBytes.Should().Be(300);
|
||||
manifest.EntryCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotManifest_empty_entries_gives_zero_totals()
|
||||
{
|
||||
var manifest = new SnapshotManifest
|
||||
{
|
||||
ManifestDigest = "abc",
|
||||
Level = SnapshotLevel.LevelA,
|
||||
Entries = [],
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
manifest.TotalSizeBytes.Should().Be(0);
|
||||
manifest.EntryCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotExportRequest_defaults()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelB };
|
||||
|
||||
request.IncludeTrustRoots.Should().BeTrue();
|
||||
request.IncludePolicies.Should().BeFalse();
|
||||
request.ArtifactDigests.IsDefaultOrEmpty.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotImportRequest_defaults()
|
||||
{
|
||||
var request = new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = new ReadOnlyMemory<byte>([1, 2, 3])
|
||||
};
|
||||
|
||||
request.VerifyIntegrity.Should().BeTrue();
|
||||
request.SkipExisting.Should().BeTrue();
|
||||
request.TargetTenantId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotOperationStatus_has_four_values()
|
||||
{
|
||||
Enum.GetValues<SnapshotOperationStatus>().Should().HaveCount(4);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SnapshotExporter tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class SnapshotExporterTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IOfflineRootStore> _rootStoreMock = new();
|
||||
private readonly Mock<ILogger<SnapshotExporter>> _loggerMock = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
|
||||
private readonly SnapshotExporter _exporter;
|
||||
|
||||
public SnapshotExporterTests()
|
||||
{
|
||||
_exporter = new SnapshotExporter(_rootStoreMock.Object, _loggerMock.Object, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelA_no_artifacts_returns_empty_manifest()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Manifest.Level.Should().Be(SnapshotLevel.LevelA);
|
||||
result.Manifest.EntryCount.Should().Be(0);
|
||||
result.ArchiveContent.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelA_with_artifacts_creates_attestation_entries()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelA,
|
||||
ArtifactDigests = ["sha256:aaa", "sha256:bbb"]
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Manifest.EntryCount.Should().Be(2);
|
||||
result.Manifest.Entries.Should().AllSatisfy(e =>
|
||||
{
|
||||
e.Category.Should().Be("attestation");
|
||||
e.ContentType.Should().Be("application/vnd.dsse+json");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelB_includes_trust_roots()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
IncludeTrustRoots = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "trust-root")
|
||||
.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelB_without_trust_roots_flag_skips_them()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
IncludeTrustRoots = false
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "trust-root")
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelC_includes_policies()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelC,
|
||||
IncludePolicies = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.Level.Should().Be(SnapshotLevel.LevelC);
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "policy")
|
||||
.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelC_without_policies_flag_skips_them()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelC,
|
||||
IncludePolicies = false,
|
||||
IncludeTrustRoots = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.Entries
|
||||
.Where(e => e.Category == "policy")
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_sets_tenant_and_description_in_manifest()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelA,
|
||||
TenantId = "tenant-42",
|
||||
Description = "Monthly export"
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.TenantId.Should().Be("tenant-42");
|
||||
result.Manifest.Description.Should().Be("Monthly export");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_manifest_digest_is_deterministic()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelA,
|
||||
ArtifactDigests = ["sha256:abc"]
|
||||
};
|
||||
|
||||
var result1 = await _exporter.ExportAsync(request);
|
||||
var result2 = await _exporter.ExportAsync(request);
|
||||
|
||||
result1.Manifest.ManifestDigest.Should().Be(result2.Manifest.ManifestDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_archive_is_valid_json()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
ArtifactDigests = ["sha256:xyz"]
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
var json = Encoding.UTF8.GetString(result.ArchiveContent.Span);
|
||||
var act = () => JsonDocument.Parse(json);
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_records_duration()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_null_request_throws()
|
||||
{
|
||||
var act = () => _exporter.ExportAsync(null!);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseManifestAsync_roundtrips_export_output()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
ArtifactDigests = ["sha256:roundtrip"],
|
||||
TenantId = "tenant-rt",
|
||||
Description = "Roundtrip test"
|
||||
};
|
||||
|
||||
var exported = await _exporter.ExportAsync(request);
|
||||
var parsed = await _exporter.ParseManifestAsync(exported.ArchiveContent);
|
||||
|
||||
parsed.Level.Should().Be(exported.Manifest.Level);
|
||||
parsed.ManifestDigest.Should().Be(exported.Manifest.ManifestDigest);
|
||||
parsed.TenantId.Should().Be(exported.Manifest.TenantId);
|
||||
parsed.Description.Should().Be(exported.Manifest.Description);
|
||||
parsed.EntryCount.Should().Be(exported.Manifest.EntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseManifestAsync_invalid_json_throws()
|
||||
{
|
||||
var garbage = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("not json"));
|
||||
|
||||
var act = () => _exporter.ParseManifestAsync(garbage);
|
||||
|
||||
await act.Should().ThrowAsync<JsonException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LevelB_with_artifacts_and_trust_roots()
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = SnapshotLevel.LevelB,
|
||||
ArtifactDigests = ["sha256:d1", "sha256:d2"],
|
||||
IncludeTrustRoots = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.EntryCount.Should().Be(4); // 2 attestations + 2 trust roots
|
||||
result.Manifest.Entries.Select(e => e.Category).Distinct()
|
||||
.Should().Contain(["attestation", "trust-root"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_manifest_uses_fixed_timestamp()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.CreatedAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_format_version_defaults_to_1_0_0()
|
||||
{
|
||||
var request = new SnapshotExportRequest { Level = SnapshotLevel.LevelA };
|
||||
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
|
||||
result.Manifest.FormatVersion.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_rootStore_throws()
|
||||
{
|
||||
var act = () => new SnapshotExporter(null!, _loggerMock.Object);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_logger_throws()
|
||||
{
|
||||
var act = () => new SnapshotExporter(_rootStoreMock.Object, null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SnapshotImporter tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class SnapshotImporterTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IOfflineRootStore> _rootStoreMock = new();
|
||||
private readonly Mock<ILogger<SnapshotExporter>> _exporterLoggerMock = new();
|
||||
private readonly Mock<ILogger<SnapshotImporter>> _importerLoggerMock = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
|
||||
private readonly SnapshotExporter _exporter;
|
||||
private readonly SnapshotImporter _importer;
|
||||
|
||||
public SnapshotImporterTests()
|
||||
{
|
||||
_exporter = new SnapshotExporter(_rootStoreMock.Object, _exporterLoggerMock.Object, _timeProvider);
|
||||
_importer = new SnapshotImporter(_rootStoreMock.Object, _importerLoggerMock.Object, _timeProvider);
|
||||
}
|
||||
|
||||
private async Task<ReadOnlyMemory<byte>> ExportArchiveAsync(SnapshotLevel level, string[]? digests = null)
|
||||
{
|
||||
var request = new SnapshotExportRequest
|
||||
{
|
||||
Level = level,
|
||||
ArtifactDigests = digests is null ? [] : [.. digests]
|
||||
};
|
||||
var result = await _exporter.ExportAsync(request);
|
||||
return result.ArchiveContent;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_valid_archive_succeeds()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:test"]);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.ImportedCount.Should().BeGreaterThan(0);
|
||||
result.FailedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_preserves_manifest_level()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelC);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
result.Manifest.Level.Should().Be(SnapshotLevel.LevelC);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_invalid_json_returns_failed()
|
||||
{
|
||||
var garbage = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("not json"));
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = garbage
|
||||
});
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Failed);
|
||||
result.Messages.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_null_request_throws()
|
||||
{
|
||||
var act = () => _importer.ImportAsync(null!);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_records_duration()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelA);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateArchiveAsync_valid_archive_returns_success()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:val"]);
|
||||
|
||||
var result = await _importer.ValidateArchiveAsync(archive);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
result.Messages.Should().Contain(m => m.Contains("integrity verified"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateArchiveAsync_invalid_json_returns_failed()
|
||||
{
|
||||
var garbage = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("{bad}"));
|
||||
|
||||
var result = await _importer.ValidateArchiveAsync(garbage);
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateArchiveAsync_does_not_import()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:noimport"]);
|
||||
|
||||
var result = await _importer.ValidateArchiveAsync(archive);
|
||||
|
||||
result.ImportedCount.Should().Be(0);
|
||||
result.SkippedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_skip_verify_succeeds_for_valid_archive()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelA, ["sha256:skip"]);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive,
|
||||
VerifyIntegrity = false
|
||||
});
|
||||
|
||||
result.Status.Should().Be(SnapshotOperationStatus.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_export_roundtrip_preserves_entry_count()
|
||||
{
|
||||
var archive = await ExportArchiveAsync(SnapshotLevel.LevelB, ["sha256:a", "sha256:b"]);
|
||||
|
||||
var result = await _importer.ImportAsync(new SnapshotImportRequest
|
||||
{
|
||||
ArchiveContent = archive
|
||||
});
|
||||
|
||||
// 2 attestations + 2 trust roots = 4 entries
|
||||
result.Manifest.EntryCount.Should().Be(4);
|
||||
result.ImportedCount.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_rootStore_throws()
|
||||
{
|
||||
var act = () => new SnapshotImporter(null!, _importerLoggerMock.Object);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_logger_throws()
|
||||
{
|
||||
var act = () => new SnapshotImporter(_rootStoreMock.Object, null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// FakeTimeProvider for deterministic testing
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
file sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SchemaIsolationServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_018_Attestor_postgresql_persistence_layer
|
||||
// Task: T1 — Tests for schema isolation, RLS scaffolding, temporal table management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Tests;
|
||||
|
||||
internal sealed class TestSchemaIsolationMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeSchemaTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
|
||||
public class SchemaIsolationServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestSchemaIsolationMeterFactory _meterFactory = new();
|
||||
private readonly FakeSchemaTimeProvider _timeProvider = new();
|
||||
private readonly SchemaIsolationService _service;
|
||||
|
||||
public SchemaIsolationServiceTests()
|
||||
{
|
||||
_service = new SchemaIsolationService(_timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetAssignment
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(AttestorSchema.ProofChain, "proofchain")]
|
||||
[InlineData(AttestorSchema.Attestor, "attestor")]
|
||||
[InlineData(AttestorSchema.Verdict, "verdict")]
|
||||
[InlineData(AttestorSchema.Watchlist, "watchlist")]
|
||||
[InlineData(AttestorSchema.Audit, "audit")]
|
||||
public void GetAssignment_returns_correct_schema_name(AttestorSchema schema, string expectedName)
|
||||
{
|
||||
var assignment = _service.GetAssignment(schema);
|
||||
|
||||
assignment.SchemaName.Should().Be(expectedName);
|
||||
assignment.Schema.Should().Be(schema);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAssignment_ProofChain_has_six_tables()
|
||||
{
|
||||
var assignment = _service.GetAssignment(AttestorSchema.ProofChain);
|
||||
|
||||
assignment.Tables.Should().HaveCount(6);
|
||||
assignment.Tables.Should().Contain("sbom_entries");
|
||||
assignment.Tables.Should().Contain("dsse_envelopes");
|
||||
assignment.Tables.Should().Contain("spines");
|
||||
assignment.Tables.Should().Contain("trust_anchors");
|
||||
assignment.Tables.Should().Contain("rekor_entries");
|
||||
assignment.Tables.Should().Contain("audit_log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAssignment_Verdict_has_tables()
|
||||
{
|
||||
var assignment = _service.GetAssignment(AttestorSchema.Verdict);
|
||||
|
||||
assignment.Tables.Should().Contain("verdict_ledger");
|
||||
assignment.Tables.Should().Contain("verdict_policies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAssignment_invalid_value_throws()
|
||||
{
|
||||
var act = () => _service.GetAssignment((AttestorSchema)999);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetAllAssignments
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetAllAssignments_returns_all_five_schemas()
|
||||
{
|
||||
var all = _service.GetAllAssignments();
|
||||
|
||||
all.Should().HaveCount(5);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.ProofChain);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Attestor);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Verdict);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Watchlist);
|
||||
all.Select(a => a.Schema).Should().Contain(AttestorSchema.Audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllAssignments_every_assignment_has_at_least_one_table()
|
||||
{
|
||||
var all = _service.GetAllAssignments();
|
||||
|
||||
foreach (var a in all)
|
||||
{
|
||||
a.Tables.Should().NotBeEmpty($"schema {a.Schema} should have at least one table");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GenerateProvisioningSql
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(AttestorSchema.ProofChain, "proofchain")]
|
||||
[InlineData(AttestorSchema.Attestor, "attestor")]
|
||||
[InlineData(AttestorSchema.Verdict, "verdict")]
|
||||
[InlineData(AttestorSchema.Watchlist, "watchlist")]
|
||||
[InlineData(AttestorSchema.Audit, "audit")]
|
||||
public void GenerateProvisioningSql_generates_create_schema(AttestorSchema schema, string schemaName)
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(schema);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Schema.Should().Be(schema);
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains($"CREATE SCHEMA IF NOT EXISTS {schemaName}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_includes_grant_statement()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("GRANT USAGE"));
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("stellaops_app"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_includes_default_privileges()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("ALTER DEFAULT PRIVILEGES"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_includes_comment()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.Audit);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("COMMENT ON SCHEMA"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_records_timestamp()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.Timestamp.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProvisioningSql_produces_four_statements()
|
||||
{
|
||||
var result = _service.GenerateProvisioningSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.GeneratedStatements.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetRlsPolicies
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetRlsPolicies_Verdict_returns_policies()
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(AttestorSchema.Verdict);
|
||||
|
||||
policies.Should().NotBeEmpty();
|
||||
policies.Should().OnlyContain(p => p.Schema == AttestorSchema.Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRlsPolicies_ProofChain_returns_empty()
|
||||
{
|
||||
// ProofChain does not have tenant isolation (shared read-only data)
|
||||
var policies = _service.GetRlsPolicies(AttestorSchema.ProofChain);
|
||||
|
||||
policies.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRlsPolicies_all_have_tenant_column()
|
||||
{
|
||||
foreach (var schema in Enum.GetValues<AttestorSchema>())
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(schema);
|
||||
policies.Should().OnlyContain(p => p.TenantColumn == "tenant_id");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RlsPolicyDefinition_UsingExpression_computed_correctly()
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(AttestorSchema.Verdict);
|
||||
|
||||
var policy = policies.First();
|
||||
policy.UsingExpression.Should().Contain("tenant_id");
|
||||
policy.UsingExpression.Should().Contain("current_setting");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GenerateRlsSql
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_Verdict_generates_enable_and_policy()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Verdict);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("ENABLE ROW LEVEL SECURITY"));
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("CREATE POLICY"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_Verdict_includes_force_rls()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Verdict);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("FORCE ROW LEVEL SECURITY"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_ProofChain_returns_empty_statements()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.ProofChain);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.GeneratedStatements.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_Watchlist_generates_multiple_policies()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Watchlist);
|
||||
|
||||
var policyStatements = result.GeneratedStatements.Where(s => s.Contains("CREATE POLICY")).ToList();
|
||||
policyStatements.Should().HaveCountGreaterThan(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateRlsSql_uses_permissive_mode()
|
||||
{
|
||||
var result = _service.GenerateRlsSql(AttestorSchema.Verdict);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("AS PERMISSIVE"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetTemporalTables
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_returns_three_configs()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
tables.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_verdict_ledger_has_seven_year_retention()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
var verdict = tables.First(t => t.TableName.Contains("verdict_ledger"));
|
||||
verdict.Retention.Should().Be(TemporalRetention.SevenYears);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_noise_ledger_has_seven_year_retention()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
var noise = tables.First(t => t.TableName.Contains("noise_ledger"));
|
||||
noise.Retention.Should().Be(TemporalRetention.SevenYears);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_watchlist_has_one_year_retention()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
var watchlist = tables.First(t => t.TableName.Contains("watched_identities"));
|
||||
watchlist.Retention.Should().Be(TemporalRetention.OneYear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTemporalTables_all_have_history_table_names()
|
||||
{
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
tables.Should().OnlyContain(t => t.HistoryTableName.Contains("_history"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GenerateTemporalTableSql
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_generates_alter_table_for_period_columns()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("sys_period_start") && s.Contains("sys_period_end"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_creates_history_table()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("CREATE TABLE IF NOT EXISTS") && s.Contains("_history"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_creates_trigger_function()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("CREATE OR REPLACE FUNCTION") && s.Contains("RETURNS TRIGGER"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_attaches_trigger()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s =>
|
||||
s.Contains("CREATE TRIGGER") && s.Contains("BEFORE UPDATE OR DELETE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_includes_retention_comment()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().Contain(s => s.Contains("retention:"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_produces_five_statements()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
|
||||
var result = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result.GeneratedStatements.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateTemporalTableSql_null_config_throws()
|
||||
{
|
||||
var act = () => _service.GenerateTemporalTableSql(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetSummary
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_returns_complete_summary()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
summary.Assignments.Should().HaveCount(5);
|
||||
summary.RlsPolicies.Should().NotBeEmpty();
|
||||
summary.TemporalTables.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_ProvisionedCount_reflects_isProvisioned_flags()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
// Default IsProvisioned is false for all assignments
|
||||
summary.ProvisionedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_RlsEnabledCount_counts_non_disabled_policies()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
// All RLS policies are Permissive (not Disabled)
|
||||
summary.RlsEnabledCount.Should().Be(summary.RlsPolicies.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_records_timestamp()
|
||||
{
|
||||
var summary = _service.GetSummary();
|
||||
|
||||
summary.ComputedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Null-time-provider fallback
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_time_provider_uses_system_default()
|
||||
{
|
||||
using var mf = new TestSchemaIsolationMeterFactory();
|
||||
var svc = new SchemaIsolationService(null, mf);
|
||||
|
||||
var result = svc.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
|
||||
result.Timestamp.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_factory_throws()
|
||||
{
|
||||
var act = () => new SchemaIsolationService(_timeProvider, null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Cross-schema consistency checks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RlsPolicies_only_reference_schemas_with_assignments()
|
||||
{
|
||||
var assignedSchemas = _service.GetAllAssignments().Select(a => a.Schema).ToHashSet();
|
||||
|
||||
foreach (var schema in Enum.GetValues<AttestorSchema>())
|
||||
{
|
||||
var policies = _service.GetRlsPolicies(schema);
|
||||
foreach (var p in policies)
|
||||
{
|
||||
assignedSchemas.Should().Contain(p.Schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemporalTables_only_reference_schemas_with_assignments()
|
||||
{
|
||||
var assignedSchemas = _service.GetAllAssignments().Select(a => a.Schema).ToHashSet();
|
||||
var tables = _service.GetTemporalTables();
|
||||
|
||||
foreach (var t in tables)
|
||||
{
|
||||
assignedSchemas.Should().Contain(t.Schema);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_provisioning_sql_for_same_schema()
|
||||
{
|
||||
var result1 = _service.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
var result2 = _service.GenerateProvisioningSql(AttestorSchema.Verdict);
|
||||
|
||||
result1.GeneratedStatements.Should().BeEquivalentTo(result2.GeneratedStatements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_rls_sql_for_same_schema()
|
||||
{
|
||||
var result1 = _service.GenerateRlsSql(AttestorSchema.Watchlist);
|
||||
var result2 = _service.GenerateRlsSql(AttestorSchema.Watchlist);
|
||||
|
||||
result1.GeneratedStatements.Should().BeEquivalentTo(result2.GeneratedStatements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deterministic_temporal_sql_for_same_config()
|
||||
{
|
||||
var config = _service.GetTemporalTables().First();
|
||||
var result1 = _service.GenerateTemporalTableSql(config);
|
||||
var result2 = _service.GenerateTemporalTableSql(config);
|
||||
|
||||
result1.GeneratedStatements.Should().BeEquivalentTo(result2.GeneratedStatements);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NoiseLedgerServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_017_Attestor_noise_ledger
|
||||
// Task: T1 — Tests for NoiseLedgerService
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Audit;
|
||||
|
||||
internal sealed class TestNoiseLedgerMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options) { var m = new Meter(options); _meters.Add(m); return m; }
|
||||
public void Dispose() { foreach (var m in _meters) m.Dispose(); }
|
||||
}
|
||||
|
||||
internal sealed class FakeNoiseLedgerTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
public void SetUtcNow(DateTimeOffset value) => _now = value;
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
public sealed class NoiseLedgerServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestNoiseLedgerMeterFactory _meterFactory = new();
|
||||
private readonly FakeNoiseLedgerTimeProvider _timeProvider = new();
|
||||
private readonly NoiseLedgerService _sut;
|
||||
|
||||
public NoiseLedgerServiceTests()
|
||||
{
|
||||
_sut = new NoiseLedgerService(_timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
private static RecordSuppressionRequest CreateRequest(
|
||||
string findingId = "CVE-2026-1234",
|
||||
SuppressionCategory category = SuppressionCategory.VexOverride,
|
||||
FindingSeverity severity = FindingSeverity.High,
|
||||
string componentRef = "pkg:npm/lodash@4.17.21",
|
||||
string justification = "VEX states not_affected",
|
||||
string suppressedBy = "security-team",
|
||||
DateTimeOffset? expiresAt = null,
|
||||
string? tenantId = null) => new()
|
||||
{
|
||||
FindingId = findingId,
|
||||
Category = category,
|
||||
Severity = severity,
|
||||
ComponentRef = componentRef,
|
||||
Justification = justification,
|
||||
SuppressedBy = suppressedBy,
|
||||
ExpiresAt = expiresAt,
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record: basic
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_ValidRequest_ReturnsEntryWithDigest()
|
||||
{
|
||||
var result = await _sut.RecordAsync(CreateRequest());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.EntryDigest.Should().StartWith("sha256:");
|
||||
result.Deduplicated.Should().BeFalse();
|
||||
result.Entry.FindingId.Should().Be("CVE-2026-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_SetsTimestampFromProvider()
|
||||
{
|
||||
var expected = new DateTimeOffset(2026, 6, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(expected);
|
||||
|
||||
var result = await _sut.RecordAsync(CreateRequest());
|
||||
|
||||
result.Entry.SuppressedAt.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_RecordsAllFields()
|
||||
{
|
||||
var result = await _sut.RecordAsync(CreateRequest(
|
||||
tenantId: "acme",
|
||||
expiresAt: new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
result.Entry.Category.Should().Be(SuppressionCategory.VexOverride);
|
||||
result.Entry.Severity.Should().Be(FindingSeverity.High);
|
||||
result.Entry.ComponentRef.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
result.Entry.Justification.Should().Be("VEX states not_affected");
|
||||
result.Entry.SuppressedBy.Should().Be("security-team");
|
||||
result.Entry.TenantId.Should().Be("acme");
|
||||
result.Entry.ExpiresAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_WithEvidenceDigest_RecordsIt()
|
||||
{
|
||||
var request = CreateRequest() with { EvidenceDigest = "sha256:evidence123" };
|
||||
var result = await _sut.RecordAsync(request);
|
||||
|
||||
result.Entry.EvidenceDigest.Should().Be("sha256:evidence123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_WithCorrelationId_RecordsIt()
|
||||
{
|
||||
var request = CreateRequest() with { CorrelationId = "scan-run-42" };
|
||||
var result = await _sut.RecordAsync(request);
|
||||
|
||||
result.Entry.CorrelationId.Should().Be("scan-run-42");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record: deduplication
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_DuplicateRequest_ReturnsDeduplicated()
|
||||
{
|
||||
var request = CreateRequest();
|
||||
var first = await _sut.RecordAsync(request);
|
||||
var second = await _sut.RecordAsync(request);
|
||||
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.EntryDigest.Should().Be(first.EntryDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_DifferentFinding_ProducesDifferentDigest()
|
||||
{
|
||||
var r1 = await _sut.RecordAsync(CreateRequest(findingId: "CVE-2026-0001"));
|
||||
var r2 = await _sut.RecordAsync(CreateRequest(findingId: "CVE-2026-0002"));
|
||||
|
||||
r1.EntryDigest.Should().NotBe(r2.EntryDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_DifferentCategory_ProducesDifferentDigest()
|
||||
{
|
||||
var r1 = await _sut.RecordAsync(CreateRequest(category: SuppressionCategory.VexOverride));
|
||||
var r2 = await _sut.RecordAsync(CreateRequest(category: SuppressionCategory.PolicyRule));
|
||||
|
||||
r1.EntryDigest.Should().NotBe(r2.EntryDigest);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record: validation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_NullRequest_Throws()
|
||||
{
|
||||
var act = () => _sut.RecordAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmptyFindingId_Throws()
|
||||
{
|
||||
var act = () => _sut.RecordAsync(CreateRequest(findingId: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>().WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmptyComponentRef_Throws()
|
||||
{
|
||||
var act = () => _sut.RecordAsync(CreateRequest(componentRef: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>().WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmptyJustification_Throws()
|
||||
{
|
||||
var act = () => _sut.RecordAsync(CreateRequest(justification: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>().WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_EmptySuppressedBy_Throws()
|
||||
{
|
||||
var act = () => _sut.RecordAsync(CreateRequest(suppressedBy: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>().WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var act = () => _sut.RecordAsync(CreateRequest(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetByDigest
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_Existing_ReturnsEntry()
|
||||
{
|
||||
var recorded = await _sut.RecordAsync(CreateRequest());
|
||||
var entry = await _sut.GetByDigestAsync(recorded.EntryDigest);
|
||||
|
||||
entry.Should().NotBeNull();
|
||||
entry!.FindingId.Should().Be("CVE-2026-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_Unknown_ReturnsNull()
|
||||
{
|
||||
var entry = await _sut.GetByDigestAsync("sha256:nonexistent");
|
||||
entry.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_NullDigest_Throws()
|
||||
{
|
||||
var act = () => _sut.GetByDigestAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Query
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByFindingId_FiltersCorrectly()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "CVE-1"));
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "CVE-2"));
|
||||
|
||||
var results = await _sut.QueryAsync(new NoiseLedgerQuery { FindingId = "CVE-1" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].FindingId.Should().Be("CVE-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByCategory_FiltersCorrectly()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(category: SuppressionCategory.VexOverride));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "CVE-other",
|
||||
category: SuppressionCategory.FalsePositive));
|
||||
|
||||
var results = await _sut.QueryAsync(
|
||||
new NoiseLedgerQuery { Category = SuppressionCategory.FalsePositive });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Category.Should().Be(SuppressionCategory.FalsePositive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_BySeverity_FiltersCorrectly()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(severity: FindingSeverity.High));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "CVE-low", severity: FindingSeverity.Low));
|
||||
|
||||
var results = await _sut.QueryAsync(
|
||||
new NoiseLedgerQuery { Severity = FindingSeverity.Low });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Severity.Should().Be(FindingSeverity.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByComponentRef_FiltersCorrectly()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(componentRef: "pkg:npm/a@1"));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "CVE-b", componentRef: "pkg:npm/b@2"));
|
||||
|
||||
var results = await _sut.QueryAsync(
|
||||
new NoiseLedgerQuery { ComponentRef = "pkg:npm/b@2" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ComponentRef.Should().Be("pkg:npm/b@2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ActiveOnly_ExcludesExpired()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(now);
|
||||
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "expired",
|
||||
expiresAt: new DateTimeOffset(2026, 6, 14, 0, 0, 0, TimeSpan.Zero)));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "active",
|
||||
expiresAt: new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var results = await _sut.QueryAsync(new NoiseLedgerQuery { ActiveOnly = true });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].FindingId.Should().Be("active");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_NoFilters_ReturnsAll()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "a"));
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "b"));
|
||||
|
||||
var results = await _sut.QueryAsync(new NoiseLedgerQuery());
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_RespectsLimit()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "a"));
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "b"));
|
||||
await _sut.RecordAsync(CreateRequest(findingId: "c"));
|
||||
|
||||
var results = await _sut.QueryAsync(new NoiseLedgerQuery { Limit = 2 });
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_NullQuery_Throws()
|
||||
{
|
||||
var act = () => _sut.QueryAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var act = () => _sut.QueryAsync(new NoiseLedgerQuery(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Statistics
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_EmptyLedger_ReturnsZeros()
|
||||
{
|
||||
var stats = await _sut.GetStatisticsAsync();
|
||||
|
||||
stats.TotalCount.Should().Be(0);
|
||||
stats.ActiveCount.Should().Be(0);
|
||||
stats.ExpiredCount.Should().Be(0);
|
||||
stats.ByCategoryCount.Should().BeEmpty();
|
||||
stats.BySeverityCount.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_CountsByCategory()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "a", category: SuppressionCategory.VexOverride));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "b", category: SuppressionCategory.VexOverride));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "c", category: SuppressionCategory.PolicyRule));
|
||||
|
||||
var stats = await _sut.GetStatisticsAsync();
|
||||
|
||||
stats.TotalCount.Should().Be(3);
|
||||
stats.ByCategoryCount[SuppressionCategory.VexOverride].Should().Be(2);
|
||||
stats.ByCategoryCount[SuppressionCategory.PolicyRule].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_CountsBySeverity()
|
||||
{
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "a", severity: FindingSeverity.Critical));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "b", severity: FindingSeverity.Low));
|
||||
|
||||
var stats = await _sut.GetStatisticsAsync();
|
||||
|
||||
stats.BySeverityCount[FindingSeverity.Critical].Should().Be(1);
|
||||
stats.BySeverityCount[FindingSeverity.Low].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_TracksActiveAndExpired()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(now);
|
||||
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "expired",
|
||||
expiresAt: new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "active",
|
||||
expiresAt: new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero)));
|
||||
await _sut.RecordAsync(CreateRequest(
|
||||
findingId: "no-expiry")); // No expiration = active
|
||||
|
||||
var stats = await _sut.GetStatisticsAsync();
|
||||
|
||||
stats.ActiveCount.Should().Be(2);
|
||||
stats.ExpiredCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var act = () => _sut.GetStatisticsAsync(null, cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// IsExpired
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void NoiseLedgerEntry_IsExpired_ReturnsTrueWhenPastExpiration()
|
||||
{
|
||||
var entry = new NoiseLedgerEntry
|
||||
{
|
||||
EntryDigest = "sha256:test",
|
||||
FindingId = "CVE-1",
|
||||
Category = SuppressionCategory.VexOverride,
|
||||
Severity = FindingSeverity.High,
|
||||
ComponentRef = "pkg:test",
|
||||
Justification = "test",
|
||||
SuppressedBy = "user",
|
||||
SuppressedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
entry.IsExpired(new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero))
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoiseLedgerEntry_IsExpired_ReturnsFalseWithNoExpiration()
|
||||
{
|
||||
var entry = new NoiseLedgerEntry
|
||||
{
|
||||
EntryDigest = "sha256:test",
|
||||
FindingId = "CVE-1",
|
||||
Category = SuppressionCategory.VexOverride,
|
||||
Severity = FindingSeverity.High,
|
||||
ComponentRef = "pkg:test",
|
||||
Justification = "test",
|
||||
SuppressedBy = "user",
|
||||
SuppressedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
entry.IsExpired(DateTimeOffset.UtcNow).Should().BeFalse();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Constructor
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_Throws()
|
||||
{
|
||||
var act = () => new NoiseLedgerService(null, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_Succeeds()
|
||||
{
|
||||
using var factory = new TestNoiseLedgerMeterFactory();
|
||||
var sut = new NoiseLedgerService(null, factory);
|
||||
sut.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Determinism
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_SameInputs_ProducesSameDigest()
|
||||
{
|
||||
var r1 = await _sut.RecordAsync(CreateRequest());
|
||||
|
||||
using var factory2 = new TestNoiseLedgerMeterFactory();
|
||||
var sut2 = new NoiseLedgerService(_timeProvider, factory2);
|
||||
var r2 = await sut2.RecordAsync(CreateRequest());
|
||||
|
||||
r1.EntryDigest.Should().Be(r2.EntryDigest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryContentAddressedStoreTests.cs
|
||||
// Sprint: SPRINT_20260208_005_Attestor_cas_for_sbom_vex_attestation_artifacts
|
||||
// Task: T1 — Deterministic tests for unified CAS
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Attestor.ProofChain.Cas;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Cas;
|
||||
|
||||
public sealed class InMemoryContentAddressedStoreTests : IDisposable
|
||||
{
|
||||
private readonly CasFakeTimeProvider _time = new(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly CasTestMeterFactory _meterFactory = new();
|
||||
private readonly InMemoryContentAddressedStore _store;
|
||||
|
||||
public InMemoryContentAddressedStoreTests()
|
||||
{
|
||||
_store = new InMemoryContentAddressedStore(
|
||||
_time,
|
||||
NullLogger<InMemoryContentAddressedStore>.Instance,
|
||||
_meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meterFactory.Dispose();
|
||||
}
|
||||
|
||||
// ── Put ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Put_NewArtifact_ReturnsStoredWithDigest()
|
||||
{
|
||||
var result = await _store.PutAsync(MakePutRequest("hello world"));
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Deduplicated.Should().BeFalse();
|
||||
result.Artifact.Digest.Should().StartWith("sha256:");
|
||||
result.Artifact.ArtifactType.Should().Be(CasArtifactType.Sbom);
|
||||
result.Artifact.MediaType.Should().Be("application/spdx+json");
|
||||
result.Artifact.SizeBytes.Should().Be(Encoding.UTF8.GetByteCount("hello world"));
|
||||
result.Artifact.CreatedAt.Should().Be(_time.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_SameContentTwice_Deduplicates()
|
||||
{
|
||||
var first = await _store.PutAsync(MakePutRequest("same content"));
|
||||
var second = await _store.PutAsync(MakePutRequest("same content"));
|
||||
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.Artifact.Digest.Should().Be(first.Artifact.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_DifferentContent_DifferentDigests()
|
||||
{
|
||||
var a = await _store.PutAsync(MakePutRequest("content A"));
|
||||
var b = await _store.PutAsync(MakePutRequest("content B"));
|
||||
|
||||
b.Artifact.Digest.Should().NotBe(a.Artifact.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_NullRequest_Throws()
|
||||
{
|
||||
var act = () => _store.PutAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_EmptyMediaType_Throws()
|
||||
{
|
||||
var req = new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("data"),
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = ""
|
||||
};
|
||||
var act = () => _store.PutAsync(req);
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_WithTags_PreservesTags()
|
||||
{
|
||||
var tags = ImmutableDictionary<string, string>.Empty
|
||||
.Add("component", "libc")
|
||||
.Add("version", "2.36");
|
||||
|
||||
var req = MakePutRequest("tagged content") with { Tags = tags };
|
||||
var result = await _store.PutAsync(req);
|
||||
|
||||
result.Artifact.Tags.Should().HaveCount(2);
|
||||
result.Artifact.Tags["component"].Should().Be("libc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_WithRelatedDigests_PreservesRelations()
|
||||
{
|
||||
var related = ImmutableArray.Create("sha256:parent1", "sha256:parent2");
|
||||
var req = MakePutRequest("child content") with { RelatedDigests = related };
|
||||
var result = await _store.PutAsync(req);
|
||||
|
||||
result.Artifact.RelatedDigests.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
// ── Get ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Get_ExistingArtifact_ReturnsContentAndMetadata()
|
||||
{
|
||||
var put = await _store.PutAsync(MakePutRequest("retrieve me"));
|
||||
var get = await _store.GetAsync(put.Artifact.Digest);
|
||||
|
||||
get.Should().NotBeNull();
|
||||
get!.Artifact.Digest.Should().Be(put.Artifact.Digest);
|
||||
Encoding.UTF8.GetString(get.Content.Span).Should().Be("retrieve me");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_NonExistent_ReturnsNull()
|
||||
{
|
||||
var result = await _store.GetAsync("sha256:0000000000000000000000000000000000000000000000000000000000000000");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Exists ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Exists_StoredArtifact_ReturnsTrue()
|
||||
{
|
||||
var put = await _store.PutAsync(MakePutRequest("exists"));
|
||||
var exists = await _store.ExistsAsync(put.Artifact.Digest);
|
||||
exists.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exists_NotStored_ReturnsFalse()
|
||||
{
|
||||
var exists = await _store.ExistsAsync("sha256:aaaa");
|
||||
exists.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ExistingArtifact_RemovesAndReturnsTrue()
|
||||
{
|
||||
var put = await _store.PutAsync(MakePutRequest("delete me"));
|
||||
var deleted = await _store.DeleteAsync(put.Artifact.Digest);
|
||||
deleted.Should().BeTrue();
|
||||
|
||||
var after = await _store.GetAsync(put.Artifact.Digest);
|
||||
after.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_NonExistent_ReturnsFalse()
|
||||
{
|
||||
var result = await _store.DeleteAsync("sha256:nonexistent");
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByArtifactType_ReturnsMatchingOnly()
|
||||
{
|
||||
await _store.PutAsync(MakePutRequest("sbom1", CasArtifactType.Sbom));
|
||||
await _store.PutAsync(MakePutRequest("vex1", CasArtifactType.Vex, "application/csaf+json"));
|
||||
|
||||
var sboms = await _store.ListAsync(new CasQuery { ArtifactType = CasArtifactType.Sbom });
|
||||
sboms.Should().HaveCount(1);
|
||||
sboms[0].ArtifactType.Should().Be(CasArtifactType.Sbom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByMediaType_ReturnsMatchingOnly()
|
||||
{
|
||||
await _store.PutAsync(MakePutRequest("spdx", CasArtifactType.Sbom, "application/spdx+json"));
|
||||
await _store.PutAsync(MakePutRequest("cdx", CasArtifactType.Sbom, "application/vnd.cyclonedx+json"));
|
||||
|
||||
var spdx = await _store.ListAsync(new CasQuery { MediaType = "application/spdx+json" });
|
||||
spdx.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByTag_ReturnsMatchingOnly()
|
||||
{
|
||||
var tagged = MakePutRequest("tagged") with
|
||||
{
|
||||
Tags = ImmutableDictionary<string, string>.Empty.Add("env", "prod")
|
||||
};
|
||||
await _store.PutAsync(tagged);
|
||||
await _store.PutAsync(MakePutRequest("untagged"));
|
||||
|
||||
var results = await _store.ListAsync(new CasQuery { TagKey = "env", TagValue = "prod" });
|
||||
results.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_PaginationRespected()
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
await _store.PutAsync(MakePutRequest($"item {i}"));
|
||||
|
||||
var page1 = await _store.ListAsync(new CasQuery { Limit = 2, Offset = 0 });
|
||||
var page2 = await _store.ListAsync(new CasQuery { Limit = 2, Offset = 2 });
|
||||
|
||||
page1.Should().HaveCount(2);
|
||||
page2.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
// ── Statistics ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatistics_ReturnsCorrectCounts()
|
||||
{
|
||||
await _store.PutAsync(MakePutRequest("sbom1", CasArtifactType.Sbom));
|
||||
await _store.PutAsync(MakePutRequest("vex1", CasArtifactType.Vex, "application/csaf+json"));
|
||||
// Dedup
|
||||
await _store.PutAsync(MakePutRequest("sbom1", CasArtifactType.Sbom));
|
||||
|
||||
var stats = await _store.GetStatisticsAsync();
|
||||
stats.TotalArtifacts.Should().Be(2);
|
||||
stats.DedupCount.Should().Be(1);
|
||||
stats.TypeCounts[CasArtifactType.Sbom].Should().Be(1);
|
||||
stats.TypeCounts[CasArtifactType.Vex].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatistics_TotalBytes_MatchesStoredContent()
|
||||
{
|
||||
await _store.PutAsync(MakePutRequest("short"));
|
||||
await _store.PutAsync(MakePutRequest("a longer piece of content here"));
|
||||
|
||||
var stats = await _store.GetStatisticsAsync();
|
||||
stats.TotalBytes.Should().Be(
|
||||
Encoding.UTF8.GetByteCount("short") +
|
||||
Encoding.UTF8.GetByteCount("a longer piece of content here"));
|
||||
}
|
||||
|
||||
// ── Digest determinism ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_SameContent_SameDigest()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("deterministic");
|
||||
var a = InMemoryContentAddressedStore.ComputeDigest(content);
|
||||
var b = InMemoryContentAddressedStore.ComputeDigest(content);
|
||||
b.Should().Be(a);
|
||||
a.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_DifferentContent_DifferentDigest()
|
||||
{
|
||||
var a = InMemoryContentAddressedStore.ComputeDigest(Encoding.UTF8.GetBytes("alpha"));
|
||||
var b = InMemoryContentAddressedStore.ComputeDigest(Encoding.UTF8.GetBytes("beta"));
|
||||
b.Should().NotBe(a);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static CasPutRequest MakePutRequest(
|
||||
string content,
|
||||
CasArtifactType type = CasArtifactType.Sbom,
|
||||
string mediaType = "application/spdx+json")
|
||||
=> new()
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes(content),
|
||||
ArtifactType = type,
|
||||
MediaType = mediaType
|
||||
};
|
||||
|
||||
// ── Test infrastructure ───────────────────────────────────────────────
|
||||
|
||||
private sealed class CasTestMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
_meters.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CasFakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public CasFakeTimeProvider(DateTimeOffset startTime) => _utcNow = startTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ObjectStorageTests.cs
|
||||
// Sprint: SPRINT_20260208_019_Attestor_s3_minio_gcs_object_storage_for_tiles
|
||||
// Task: T1 — Tests for object storage providers and CAS bridge
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Cas;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Cas;
|
||||
|
||||
internal sealed class TestObjectStorageMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeObjectStorageTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IObjectStorageProvider"/> for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryObjectStorageProvider : IObjectStorageProvider
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, (byte[] Content, string ContentType, ImmutableDictionary<string, string> Metadata)> _blobs = new();
|
||||
private readonly bool _enforceWriteOnce;
|
||||
|
||||
public InMemoryObjectStorageProvider(bool enforceWriteOnce = false)
|
||||
{
|
||||
_enforceWriteOnce = enforceWriteOnce;
|
||||
}
|
||||
|
||||
public ObjectStorageProviderKind Kind => ObjectStorageProviderKind.S3Compatible;
|
||||
|
||||
public Task<BlobPutResult> PutAsync(BlobPutRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_enforceWriteOnce && _blobs.ContainsKey(request.Key))
|
||||
{
|
||||
return Task.FromResult(new BlobPutResult
|
||||
{
|
||||
Key = request.Key,
|
||||
SizeBytes = _blobs[request.Key].Content.Length,
|
||||
AlreadyExisted = true
|
||||
});
|
||||
}
|
||||
|
||||
_blobs[request.Key] = (request.Content.ToArray(), request.ContentType, request.Metadata);
|
||||
return Task.FromResult(new BlobPutResult
|
||||
{
|
||||
Key = request.Key,
|
||||
SizeBytes = request.Content.Length,
|
||||
AlreadyExisted = false
|
||||
});
|
||||
}
|
||||
|
||||
public Task<BlobGetResult?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_blobs.TryGetValue(key, out var blob))
|
||||
return Task.FromResult<BlobGetResult?>(null);
|
||||
|
||||
return Task.FromResult<BlobGetResult?>(new BlobGetResult
|
||||
{
|
||||
Key = key,
|
||||
Content = new ReadOnlyMemory<byte>(blob.Content),
|
||||
ContentType = blob.ContentType,
|
||||
Metadata = blob.Metadata,
|
||||
SizeBytes = blob.Content.Length
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string key, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_blobs.ContainsKey(key));
|
||||
|
||||
public Task<bool> DeleteAsync(string key, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_blobs.TryRemove(key, out _));
|
||||
|
||||
public Task<BlobListResult> ListAsync(BlobListQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = _blobs.Keys.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(query.KeyPrefix))
|
||||
results = results.Where(k => k.StartsWith(query.KeyPrefix, StringComparison.Ordinal));
|
||||
|
||||
var offset = 0;
|
||||
if (!string.IsNullOrEmpty(query.ContinuationToken) && int.TryParse(query.ContinuationToken, out var parsed))
|
||||
offset = parsed;
|
||||
|
||||
var page = results.OrderBy(k => k).Skip(offset).Take(query.Limit + 1).ToList();
|
||||
var hasMore = page.Count > query.Limit;
|
||||
|
||||
return Task.FromResult(new BlobListResult
|
||||
{
|
||||
Blobs = page.Take(query.Limit).Select(k => new BlobReference
|
||||
{
|
||||
Key = k,
|
||||
SizeBytes = _blobs[k].Content.Length
|
||||
}).ToImmutableArray(),
|
||||
ContinuationToken = hasMore ? (offset + query.Limit).ToString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ObjectStorageContentAddressedStore Tests
|
||||
// =============================================================================
|
||||
|
||||
public class ObjectStorageContentAddressedStoreTests : IDisposable
|
||||
{
|
||||
private readonly TestObjectStorageMeterFactory _meterFactory = new();
|
||||
private readonly FakeObjectStorageTimeProvider _timeProvider = new();
|
||||
private readonly InMemoryObjectStorageProvider _provider = new();
|
||||
private readonly ObjectStorageContentAddressedStore _store;
|
||||
|
||||
public ObjectStorageContentAddressedStoreTests()
|
||||
{
|
||||
_store = new ObjectStorageContentAddressedStore(_provider, _timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
// ── PutAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_stores_content_and_returns_digest()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("hello tiles");
|
||||
var result = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = content,
|
||||
ArtifactType = CasArtifactType.ProofBundle,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
result.Deduplicated.Should().BeFalse();
|
||||
result.Artifact.Digest.Should().StartWith("sha256:");
|
||||
result.Artifact.SizeBytes.Should().Be(content.Length);
|
||||
result.Artifact.ArtifactType.Should().Be(CasArtifactType.ProofBundle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_same_content_is_deduplicated()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("duplicate content");
|
||||
var req = new CasPutRequest
|
||||
{
|
||||
Content = content,
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
};
|
||||
|
||||
var first = await _store.PutAsync(req);
|
||||
var second = await _store.PutAsync(req);
|
||||
|
||||
first.Deduplicated.Should().BeFalse();
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
first.Artifact.Digest.Should().Be(second.Artifact.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_null_request_throws()
|
||||
{
|
||||
var act = () => _store.PutAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_empty_media_type_throws()
|
||||
{
|
||||
var act = () => _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = new byte[] { 1 },
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_preserves_tags()
|
||||
{
|
||||
var tags = new Dictionary<string, string> { ["env"] = "prod" }.ToImmutableDictionary();
|
||||
var result = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("tagged"),
|
||||
ArtifactType = CasArtifactType.Attestation,
|
||||
MediaType = "application/json",
|
||||
Tags = tags,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
result.Artifact.Tags.Should().ContainKey("env");
|
||||
result.Artifact.Tags["env"].Should().Be("prod");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_preserves_related_digests()
|
||||
{
|
||||
var related = ImmutableArray.Create("sha256:aaaa", "sha256:bbbb");
|
||||
var result = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("related"),
|
||||
ArtifactType = CasArtifactType.Vex,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = related
|
||||
});
|
||||
|
||||
result.Artifact.RelatedDigests.Should().BeEquivalentTo(related);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_records_timestamp()
|
||||
{
|
||||
var result = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("timestamped"),
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
result.Artifact.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
// ── GetAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_retrieves_stored_content()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("retrievable");
|
||||
var put = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = content,
|
||||
ArtifactType = CasArtifactType.ProofBundle,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
var result = await _store.GetAsync(put.Artifact.Digest);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Content.ToArray().Should().BeEquivalentTo(content);
|
||||
result.Artifact.Digest.Should().Be(put.Artifact.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_missing_digest_returns_null()
|
||||
{
|
||||
var result = await _store.GetAsync("sha256:nonexistent");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_null_digest_throws()
|
||||
{
|
||||
var act = () => _store.GetAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_empty_digest_throws()
|
||||
{
|
||||
var act = () => _store.GetAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── ExistsAsync ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_returns_true_for_stored()
|
||||
{
|
||||
var put = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("exists"),
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
(await _store.ExistsAsync(put.Artifact.Digest)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_returns_false_for_missing()
|
||||
{
|
||||
(await _store.ExistsAsync("sha256:missing")).Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── DeleteAsync ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_removes_stored_blob()
|
||||
{
|
||||
var put = await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("deletable"),
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
(await _store.DeleteAsync(put.Artifact.Digest)).Should().BeTrue();
|
||||
(await _store.ExistsAsync(put.Artifact.Digest)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_returns_false_for_missing()
|
||||
{
|
||||
(await _store.DeleteAsync("sha256:nonexistent")).Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── ListAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_returns_stored_artifacts()
|
||||
{
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("list-item-1"),
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("list-item-2"),
|
||||
ArtifactType = CasArtifactType.Vex,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
var results = await _store.ListAsync(new CasQuery { Limit = 100 });
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_filters_by_artifact_type()
|
||||
{
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("sbom-content"),
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("vex-content"),
|
||||
ArtifactType = CasArtifactType.Vex,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
var results = await _store.ListAsync(new CasQuery
|
||||
{
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
Limit = 100
|
||||
});
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ArtifactType.Should().Be(CasArtifactType.Sbom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_respects_limit()
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes($"item-{i}"),
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
}
|
||||
|
||||
var results = await _store.ListAsync(new CasQuery { Limit = 2 });
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
// ── GetStatisticsAsync ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_returns_accurate_counts()
|
||||
{
|
||||
var content1 = Encoding.UTF8.GetBytes("stat-1");
|
||||
var content2 = Encoding.UTF8.GetBytes("stat-2");
|
||||
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = content1,
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
await _store.PutAsync(new CasPutRequest
|
||||
{
|
||||
Content = content2,
|
||||
ArtifactType = CasArtifactType.Sbom,
|
||||
MediaType = "application/json",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
});
|
||||
|
||||
var stats = await _store.GetStatisticsAsync();
|
||||
stats.TotalArtifacts.Should().Be(2);
|
||||
stats.TotalBytes.Should().Be(content1.Length + content2.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_tracks_dedup_count()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("dedup-stat");
|
||||
var req = new CasPutRequest
|
||||
{
|
||||
Content = content,
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
};
|
||||
|
||||
await _store.PutAsync(req);
|
||||
await _store.PutAsync(req); // dedup
|
||||
|
||||
var stats = await _store.GetStatisticsAsync();
|
||||
stats.DedupCount.Should().Be(1);
|
||||
}
|
||||
|
||||
// ── Constructor validation ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_provider_throws()
|
||||
{
|
||||
var act = () => new ObjectStorageContentAddressedStore(null!, _timeProvider, _meterFactory);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_factory_throws()
|
||||
{
|
||||
var act = () => new ObjectStorageContentAddressedStore(_provider, _timeProvider, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_time_provider_uses_system()
|
||||
{
|
||||
using var mf = new TestObjectStorageMeterFactory();
|
||||
var store = new ObjectStorageContentAddressedStore(_provider, null, mf);
|
||||
store.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// ── Determinism ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Deterministic_digest_for_same_content()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("deterministic");
|
||||
var req = new CasPutRequest
|
||||
{
|
||||
Content = content,
|
||||
ArtifactType = CasArtifactType.Other,
|
||||
MediaType = "application/octet-stream",
|
||||
Tags = ImmutableDictionary<string, string>.Empty,
|
||||
RelatedDigests = []
|
||||
};
|
||||
|
||||
var r1 = await _store.PutAsync(req);
|
||||
var digest1 = r1.Artifact.Digest;
|
||||
|
||||
// Compute independently
|
||||
var digest2 = ObjectStorageContentAddressedStore.ComputeDigest(content);
|
||||
|
||||
digest1.Should().Be(digest2);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FileSystemObjectStorageProvider Tests
|
||||
// =============================================================================
|
||||
|
||||
public class FileSystemObjectStorageProviderTests : IDisposable
|
||||
{
|
||||
private readonly TestObjectStorageMeterFactory _meterFactory = new();
|
||||
private readonly string _tempRoot;
|
||||
private readonly FileSystemObjectStorageProvider _provider;
|
||||
|
||||
public FileSystemObjectStorageProviderTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-fs-test-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
|
||||
_provider = new FileSystemObjectStorageProvider(
|
||||
new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem,
|
||||
RootPath = _tempRoot
|
||||
},
|
||||
_meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meterFactory.Dispose();
|
||||
try { Directory.Delete(_tempRoot, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kind_is_filesystem()
|
||||
{
|
||||
_provider.Kind.Should().Be(ObjectStorageProviderKind.FileSystem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_stores_and_retrieves()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("fs-test");
|
||||
var result = await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "test/blob1",
|
||||
Content = content,
|
||||
ContentType = "text/plain"
|
||||
});
|
||||
|
||||
result.Key.Should().Be("test/blob1");
|
||||
result.SizeBytes.Should().Be(content.Length);
|
||||
result.AlreadyExisted.Should().BeFalse();
|
||||
|
||||
var get = await _provider.GetAsync("test/blob1");
|
||||
get.Should().NotBeNull();
|
||||
get!.Content.ToArray().Should().BeEquivalentTo(content);
|
||||
get.ContentType.Should().Be("text/plain");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutAsync_write_once_returns_already_existed()
|
||||
{
|
||||
var provider = new FileSystemObjectStorageProvider(
|
||||
new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem,
|
||||
RootPath = _tempRoot,
|
||||
EnforceWriteOnce = true
|
||||
},
|
||||
_meterFactory);
|
||||
|
||||
var content = Encoding.UTF8.GetBytes("worm");
|
||||
await provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "worm/blob",
|
||||
Content = content,
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
var second = await provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "worm/blob",
|
||||
Content = Encoding.UTF8.GetBytes("different"),
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
second.AlreadyExisted.Should().BeTrue();
|
||||
|
||||
// Original content preserved
|
||||
var get = await provider.GetAsync("worm/blob");
|
||||
Encoding.UTF8.GetString(get!.Content.ToArray()).Should().Be("worm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_returns_true_for_stored()
|
||||
{
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "exists-check",
|
||||
Content = new byte[] { 1, 2, 3 },
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
(await _provider.ExistsAsync("exists-check")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_returns_false_for_missing()
|
||||
{
|
||||
(await _provider.ExistsAsync("nope")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_removes_blob_and_metadata()
|
||||
{
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "delete-me",
|
||||
Content = new byte[] { 1 },
|
||||
ContentType = "text/plain"
|
||||
});
|
||||
|
||||
(await _provider.DeleteAsync("delete-me")).Should().BeTrue();
|
||||
(await _provider.ExistsAsync("delete-me")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_returns_false_for_missing()
|
||||
{
|
||||
(await _provider.DeleteAsync("nothing")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_with_write_once_returns_false()
|
||||
{
|
||||
var provider = new FileSystemObjectStorageProvider(
|
||||
new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem,
|
||||
RootPath = _tempRoot,
|
||||
EnforceWriteOnce = true
|
||||
},
|
||||
_meterFactory);
|
||||
|
||||
await provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "worm-no-delete",
|
||||
Content = new byte[] { 1 },
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
(await provider.DeleteAsync("worm-no-delete")).Should().BeFalse();
|
||||
(await provider.ExistsAsync("worm-no-delete")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_returns_stored_blobs()
|
||||
{
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "list/a",
|
||||
Content = new byte[] { 1 },
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "list/b",
|
||||
Content = new byte[] { 2, 3 },
|
||||
ContentType = "application/octet-stream"
|
||||
});
|
||||
|
||||
var result = await _provider.ListAsync(new BlobListQuery
|
||||
{
|
||||
KeyPrefix = "list/",
|
||||
Limit = 100
|
||||
});
|
||||
|
||||
result.Blobs.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_empty_directory_returns_empty()
|
||||
{
|
||||
var result = await _provider.ListAsync(new BlobListQuery
|
||||
{
|
||||
KeyPrefix = "nonexistent/",
|
||||
Limit = 100
|
||||
});
|
||||
|
||||
result.Blobs.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_preserves_metadata()
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["origin"] = "scanner",
|
||||
["version"] = "2.0"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
await _provider.PutAsync(new BlobPutRequest
|
||||
{
|
||||
Key = "meta/test",
|
||||
Content = new byte[] { 42 },
|
||||
ContentType = "application/json",
|
||||
Metadata = metadata
|
||||
});
|
||||
|
||||
var result = await _provider.GetAsync("meta/test");
|
||||
result.Should().NotBeNull();
|
||||
result!.Metadata.Should().ContainKey("origin");
|
||||
result.Metadata["origin"].Should().Be("scanner");
|
||||
result.Metadata["version"].Should().Be("2.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_config_throws()
|
||||
{
|
||||
var act = () => new FileSystemObjectStorageProvider(null!, _meterFactory);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_empty_root_path_throws()
|
||||
{
|
||||
var act = () => new FileSystemObjectStorageProvider(
|
||||
new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem,
|
||||
RootPath = ""
|
||||
},
|
||||
_meterFactory);
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_factory_throws()
|
||||
{
|
||||
var act = () => new FileSystemObjectStorageProvider(
|
||||
new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem,
|
||||
RootPath = _tempRoot
|
||||
},
|
||||
null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ObjectStorageModels Tests
|
||||
// =============================================================================
|
||||
|
||||
public class ObjectStorageModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ObjectStorageConfig_default_values()
|
||||
{
|
||||
var config = new ObjectStorageConfig
|
||||
{
|
||||
Provider = ObjectStorageProviderKind.FileSystem
|
||||
};
|
||||
|
||||
config.Prefix.Should().BeEmpty();
|
||||
config.BucketName.Should().BeEmpty();
|
||||
config.EndpointUrl.Should().BeEmpty();
|
||||
config.Region.Should().BeEmpty();
|
||||
config.RootPath.Should().BeEmpty();
|
||||
config.EnforceWriteOnce.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlobPutRequest_default_content_type()
|
||||
{
|
||||
var req = new BlobPutRequest
|
||||
{
|
||||
Key = "test",
|
||||
Content = new byte[] { 1 }
|
||||
};
|
||||
|
||||
req.ContentType.Should().Be("application/octet-stream");
|
||||
req.Metadata.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlobGetResult_default_values()
|
||||
{
|
||||
var result = new BlobGetResult
|
||||
{
|
||||
Key = "k",
|
||||
Content = new byte[] { 1 },
|
||||
SizeBytes = 1
|
||||
};
|
||||
|
||||
result.ContentType.Should().Be("application/octet-stream");
|
||||
result.Metadata.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlobListQuery_default_values()
|
||||
{
|
||||
var query = new BlobListQuery();
|
||||
|
||||
query.KeyPrefix.Should().BeEmpty();
|
||||
query.Limit.Should().Be(100);
|
||||
query.ContinuationToken.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectStorageProviderKind_has_three_values()
|
||||
{
|
||||
Enum.GetValues<ObjectStorageProviderKind>().Should().HaveCount(3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Compliance;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Compliance;
|
||||
|
||||
public sealed class ComplianceReportGeneratorTests : IDisposable
|
||||
{
|
||||
private readonly TestComplianceMeterFactory _meterFactory = new();
|
||||
private readonly ComplianceReportGenerator _sut;
|
||||
|
||||
public ComplianceReportGeneratorTests()
|
||||
{
|
||||
_sut = new ComplianceReportGenerator(TimeProvider.System, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
private static ImmutableHashSet<EvidenceArtifactType> AllEvidence() =>
|
||||
ImmutableHashSet.Create(
|
||||
EvidenceArtifactType.Sbom,
|
||||
EvidenceArtifactType.VexStatement,
|
||||
EvidenceArtifactType.SignedAttestation,
|
||||
EvidenceArtifactType.TransparencyLogEntry,
|
||||
EvidenceArtifactType.VerificationReceipt,
|
||||
EvidenceArtifactType.ProofBundle,
|
||||
EvidenceArtifactType.ReachabilityAnalysis,
|
||||
EvidenceArtifactType.PolicyEvaluation,
|
||||
EvidenceArtifactType.ProvenanceAttestation,
|
||||
EvidenceArtifactType.IncidentReport);
|
||||
|
||||
private static ImmutableHashSet<EvidenceArtifactType> NoEvidence() =>
|
||||
ImmutableHashSet<EvidenceArtifactType>.Empty;
|
||||
|
||||
// --- Supported Frameworks ---
|
||||
|
||||
[Fact]
|
||||
public void SupportedFrameworks_Contains_AllFour()
|
||||
{
|
||||
_sut.SupportedFrameworks.Should().HaveCount(4);
|
||||
_sut.SupportedFrameworks.Should().Contain(RegulatoryFramework.Nis2);
|
||||
_sut.SupportedFrameworks.Should().Contain(RegulatoryFramework.Dora);
|
||||
_sut.SupportedFrameworks.Should().Contain(RegulatoryFramework.Iso27001);
|
||||
_sut.SupportedFrameworks.Should().Contain(RegulatoryFramework.EuCra);
|
||||
}
|
||||
|
||||
// --- GetControls ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegulatoryFramework.Nis2, 5)]
|
||||
[InlineData(RegulatoryFramework.Dora, 5)]
|
||||
[InlineData(RegulatoryFramework.Iso27001, 6)]
|
||||
[InlineData(RegulatoryFramework.EuCra, 4)]
|
||||
public void GetControls_ReturnsExpectedCount(RegulatoryFramework framework, int expected)
|
||||
{
|
||||
var controls = _sut.GetControls(framework);
|
||||
controls.Length.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegulatoryFramework.Nis2, "NIS2-Art21.2d")]
|
||||
[InlineData(RegulatoryFramework.Dora, "DORA-Art6.1")]
|
||||
[InlineData(RegulatoryFramework.Iso27001, "ISO27001-A.8.28")]
|
||||
[InlineData(RegulatoryFramework.EuCra, "CRA-AnnexI.2.1")]
|
||||
public void GetControls_ContainsExpectedControlId(RegulatoryFramework framework, string expectedControlId)
|
||||
{
|
||||
var controls = _sut.GetControls(framework);
|
||||
controls.Should().Contain(c => c.ControlId == expectedControlId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetControls_AllControlsHaveFrameworkSet()
|
||||
{
|
||||
foreach (var framework in _sut.SupportedFrameworks)
|
||||
{
|
||||
var controls = _sut.GetControls(framework);
|
||||
foreach (var control in controls)
|
||||
control.Framework.Should().Be(framework);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetControls_AllControlsHaveRequiredFields()
|
||||
{
|
||||
foreach (var framework in _sut.SupportedFrameworks)
|
||||
{
|
||||
var controls = _sut.GetControls(framework);
|
||||
foreach (var control in controls)
|
||||
{
|
||||
control.ControlId.Should().NotBeNullOrWhiteSpace();
|
||||
control.Title.Should().NotBeNullOrWhiteSpace();
|
||||
control.Description.Should().NotBeNullOrWhiteSpace();
|
||||
control.Category.Should().NotBeNullOrWhiteSpace();
|
||||
control.SatisfiedBy.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - Full Evidence ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegulatoryFramework.Nis2)]
|
||||
[InlineData(RegulatoryFramework.Dora)]
|
||||
[InlineData(RegulatoryFramework.Iso27001)]
|
||||
[InlineData(RegulatoryFramework.EuCra)]
|
||||
public async Task GenerateReportAsync_AllEvidence_FullCompliance(RegulatoryFramework framework)
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
framework, "sha256:abc123", AllEvidence());
|
||||
|
||||
report.MeetsMinimumCompliance.Should().BeTrue();
|
||||
report.MandatoryGapCount.Should().Be(0);
|
||||
report.CompliancePercentage.Should().Be(1.0);
|
||||
report.SatisfiedCount.Should().Be(report.TotalControls);
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - No Evidence ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegulatoryFramework.Nis2)]
|
||||
[InlineData(RegulatoryFramework.Dora)]
|
||||
[InlineData(RegulatoryFramework.Iso27001)]
|
||||
[InlineData(RegulatoryFramework.EuCra)]
|
||||
public async Task GenerateReportAsync_NoEvidence_ZeroCompliance(RegulatoryFramework framework)
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
framework, "sha256:abc123", NoEvidence());
|
||||
|
||||
report.CompliancePercentage.Should().Be(0.0);
|
||||
report.SatisfiedCount.Should().Be(0);
|
||||
report.MeetsMinimumCompliance.Should().BeFalse();
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - Partial Evidence ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_PartialEvidence_PartialCompliance()
|
||||
{
|
||||
var partial = ImmutableHashSet.Create(EvidenceArtifactType.Sbom);
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", partial);
|
||||
|
||||
report.CompliancePercentage.Should().BeGreaterThan(0.0);
|
||||
report.CompliancePercentage.Should().BeLessThan(1.0);
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - Subject and Metadata ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_RecordsSubjectRef()
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:subject123", AllEvidence());
|
||||
|
||||
report.SubjectRef.Should().Be("sha256:subject123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_RecordsFramework()
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Dora, "sha256:abc", AllEvidence());
|
||||
|
||||
report.Framework.Should().Be(RegulatoryFramework.Dora);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_SetsGeneratedAt()
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", AllEvidence());
|
||||
|
||||
report.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - Artifact Refs ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_WithArtifactRefs_IncludesInResult()
|
||||
{
|
||||
var refs = ImmutableDictionary<EvidenceArtifactType, ImmutableArray<string>>.Empty
|
||||
.Add(EvidenceArtifactType.Sbom, ["sha256:sbom-ref-1"]);
|
||||
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc",
|
||||
ImmutableHashSet.Create(EvidenceArtifactType.Sbom),
|
||||
refs);
|
||||
|
||||
var sbomControls = report.Controls
|
||||
.Where(c => c.IsSatisfied && c.SatisfyingArtifacts.Contains("sha256:sbom-ref-1"))
|
||||
.ToList();
|
||||
|
||||
sbomControls.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
// --- GenerateReportAsync - Gap Descriptions ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_UnsatisfiedControl_HasGapDescription()
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", NoEvidence());
|
||||
|
||||
var unsatisfied = report.Controls.Where(c => !c.IsSatisfied).ToList();
|
||||
unsatisfied.Should().NotBeEmpty();
|
||||
foreach (var control in unsatisfied)
|
||||
control.GapDescription.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_SatisfiedControl_NoGapDescription()
|
||||
{
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", AllEvidence());
|
||||
|
||||
var satisfied = report.Controls.Where(c => c.IsSatisfied).ToList();
|
||||
satisfied.Should().NotBeEmpty();
|
||||
foreach (var control in satisfied)
|
||||
control.GapDescription.Should().BeNull();
|
||||
}
|
||||
|
||||
// --- Null Protection ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_NullSubjectRef_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, null!, AllEvidence());
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_NullEvidence_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", null!);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_CancellationToken_Respected()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", AllEvidence(), ct: cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// --- Determinism ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_Deterministic()
|
||||
{
|
||||
var evidence = AllEvidence();
|
||||
var r1 = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", evidence);
|
||||
var r2 = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Nis2, "sha256:abc", evidence);
|
||||
|
||||
r1.TotalControls.Should().Be(r2.TotalControls);
|
||||
r1.SatisfiedCount.Should().Be(r2.SatisfiedCount);
|
||||
r1.CompliancePercentage.Should().Be(r2.CompliancePercentage);
|
||||
r1.MeetsMinimumCompliance.Should().Be(r2.MeetsMinimumCompliance);
|
||||
}
|
||||
|
||||
// --- Constructor Validation ---
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => new ComplianceReportGenerator(TimeProvider.System, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_UsesSystem()
|
||||
{
|
||||
var sut = new ComplianceReportGenerator(null, _meterFactory);
|
||||
sut.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// --- Mandatory vs Optional Controls ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateReportAsync_OptionalControlsMissing_StillMeetsMinimum()
|
||||
{
|
||||
// DORA has one non-mandatory control (DORA-Art11) — provide evidence for all mandatory ones
|
||||
var evidence = ImmutableHashSet.Create(
|
||||
EvidenceArtifactType.PolicyEvaluation,
|
||||
EvidenceArtifactType.SignedAttestation,
|
||||
EvidenceArtifactType.VerificationReceipt,
|
||||
EvidenceArtifactType.IncidentReport,
|
||||
EvidenceArtifactType.VexStatement,
|
||||
EvidenceArtifactType.Sbom,
|
||||
EvidenceArtifactType.ProvenanceAttestation,
|
||||
EvidenceArtifactType.ReachabilityAnalysis,
|
||||
EvidenceArtifactType.ProofBundle);
|
||||
|
||||
var report = await _sut.GenerateReportAsync(
|
||||
RegulatoryFramework.Dora, "sha256:abc", evidence);
|
||||
|
||||
report.MeetsMinimumCompliance.Should().BeTrue();
|
||||
}
|
||||
|
||||
// --- NIS2 Specific Controls ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("NIS2-Art21.2d", "Supply Chain Security")]
|
||||
[InlineData("NIS2-Art21.2e", "Supply Chain Security")]
|
||||
[InlineData("NIS2-Art21.2a", "Risk Management")]
|
||||
[InlineData("NIS2-Art21.2g", "Risk Management")]
|
||||
[InlineData("NIS2-Art23", "Incident Management")]
|
||||
public void Nis2Controls_HaveExpectedCategory(string controlId, string expectedCategory)
|
||||
{
|
||||
var controls = _sut.GetControls(RegulatoryFramework.Nis2);
|
||||
var control = controls.First(c => c.ControlId == controlId);
|
||||
control.Category.Should().Be(expectedCategory);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestComplianceMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly ConcurrentBag<Meter> _meters = [];
|
||||
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var meter in _meters)
|
||||
meter.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexFindingsServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_023_Attestor_vex_findings_api_with_proof_artifacts
|
||||
// Task: T1 — Tests for VEX findings API with proof artifacts
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Findings;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Findings;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Model tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class VexFindingsModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProofArtifactKind_has_six_values()
|
||||
{
|
||||
Enum.GetValues<ProofArtifactKind>().Should().HaveCount(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFindingStatus_has_four_values()
|
||||
{
|
||||
Enum.GetValues<VexFindingStatus>().Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofArtifact_default_content_type()
|
||||
{
|
||||
var artifact = new ProofArtifact
|
||||
{
|
||||
Kind = ProofArtifactKind.DsseSignature,
|
||||
Digest = "sha256:abc",
|
||||
Payload = new ReadOnlyMemory<byte>([1, 2]),
|
||||
ProducedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
artifact.ContentType.Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFinding_HasSignatureProof_true_when_dsse_present()
|
||||
{
|
||||
var finding = MakeFinding("f1", ProofArtifactKind.DsseSignature);
|
||||
finding.HasSignatureProof.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFinding_HasSignatureProof_false_when_no_dsse()
|
||||
{
|
||||
var finding = MakeFinding("f1", ProofArtifactKind.RekorReceipt);
|
||||
finding.HasSignatureProof.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFinding_HasRekorReceipt_true_when_present()
|
||||
{
|
||||
var finding = MakeFinding("f1", ProofArtifactKind.RekorReceipt);
|
||||
finding.HasRekorReceipt.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFinding_HasRekorReceipt_false_when_absent()
|
||||
{
|
||||
var finding = MakeFinding("f1", ProofArtifactKind.MerkleProof);
|
||||
finding.HasRekorReceipt.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFindingQuery_defaults()
|
||||
{
|
||||
var query = new VexFindingQuery();
|
||||
query.Limit.Should().Be(100);
|
||||
query.Offset.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFindingQueryResult_HasMore()
|
||||
{
|
||||
var result = new VexFindingQueryResult
|
||||
{
|
||||
Findings = ImmutableArray.Create(MakeFinding("f1", ProofArtifactKind.DsseSignature)),
|
||||
TotalCount = 10,
|
||||
Offset = 0
|
||||
};
|
||||
|
||||
result.HasMore.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexFindingQueryResult_HasMore_false_when_all_returned()
|
||||
{
|
||||
var result = new VexFindingQueryResult
|
||||
{
|
||||
Findings = ImmutableArray.Create(MakeFinding("f1", ProofArtifactKind.DsseSignature)),
|
||||
TotalCount = 1,
|
||||
Offset = 0
|
||||
};
|
||||
|
||||
result.HasMore.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static VexFinding MakeFinding(string id, ProofArtifactKind proofKind)
|
||||
{
|
||||
return new VexFinding
|
||||
{
|
||||
FindingId = id,
|
||||
VulnerabilityId = "CVE-2025-0001",
|
||||
ComponentPurl = "pkg:npm/test@1.0",
|
||||
Status = VexFindingStatus.NotAffected,
|
||||
ProofArtifacts = ImmutableArray.Create(new ProofArtifact
|
||||
{
|
||||
Kind = proofKind,
|
||||
Digest = $"sha256:{id}",
|
||||
Payload = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("proof")),
|
||||
ProducedAt = DateTimeOffset.UtcNow
|
||||
}),
|
||||
DeterminedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Service tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public class VexFindingsServiceTests
|
||||
{
|
||||
private readonly VexFindingsService _service;
|
||||
|
||||
public VexFindingsServiceTests()
|
||||
{
|
||||
var meterFactory = new TestFindingsMeterFactory();
|
||||
_service = new VexFindingsService(meterFactory);
|
||||
}
|
||||
|
||||
// ── UpsertAsync ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_stores_and_returns_finding()
|
||||
{
|
||||
var finding = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0");
|
||||
|
||||
var result = await _service.UpsertAsync(finding);
|
||||
|
||||
result.FindingId.Should().NotBeNullOrWhiteSpace();
|
||||
result.VulnerabilityId.Should().Be("CVE-2025-0001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_generates_deterministic_id_when_empty()
|
||||
{
|
||||
var finding1 = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0") with { FindingId = "" };
|
||||
var finding2 = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0") with { FindingId = "" };
|
||||
|
||||
var r1 = await _service.UpsertAsync(finding1);
|
||||
var r2 = await _service.UpsertAsync(finding2);
|
||||
|
||||
r1.FindingId.Should().Be(r2.FindingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_preserves_explicit_id()
|
||||
{
|
||||
var finding = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0") with { FindingId = "custom-id" };
|
||||
|
||||
var result = await _service.UpsertAsync(finding);
|
||||
|
||||
result.FindingId.Should().Be("custom-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_overwrites_on_same_id()
|
||||
{
|
||||
var v1 = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0") with
|
||||
{
|
||||
FindingId = "dup",
|
||||
Status = VexFindingStatus.UnderInvestigation
|
||||
};
|
||||
var v2 = v1 with { Status = VexFindingStatus.Fixed };
|
||||
|
||||
await _service.UpsertAsync(v1);
|
||||
await _service.UpsertAsync(v2);
|
||||
|
||||
var stored = await _service.GetByIdAsync("dup");
|
||||
stored!.Status.Should().Be(VexFindingStatus.Fixed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_null_throws()
|
||||
{
|
||||
var act = () => _service.UpsertAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── GetByIdAsync ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_returns_stored_finding()
|
||||
{
|
||||
var finding = CreateFinding("CVE-2025-0001", "pkg:npm/lib@1.0") with { FindingId = "get-test" };
|
||||
await _service.UpsertAsync(finding);
|
||||
|
||||
var result = await _service.GetByIdAsync("get-test");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.VulnerabilityId.Should().Be("CVE-2025-0001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_returns_null_for_missing()
|
||||
{
|
||||
var result = await _service.GetByIdAsync("nonexistent");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_empty_id_throws()
|
||||
{
|
||||
var act = () => _service.GetByIdAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── QueryAsync ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_returns_all_when_no_filter()
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with { FindingId = "q1" });
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0002", "pkg:npm/b@2") with { FindingId = "q2" });
|
||||
|
||||
var result = await _service.QueryAsync(new VexFindingQuery());
|
||||
|
||||
result.Findings.Should().HaveCount(2);
|
||||
result.TotalCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_filters_by_vulnerability_id()
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with { FindingId = "fv1" });
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0002", "pkg:npm/b@2") with { FindingId = "fv2" });
|
||||
|
||||
var result = await _service.QueryAsync(new VexFindingQuery { VulnerabilityId = "CVE-2025-0001" });
|
||||
|
||||
result.Findings.Should().HaveCount(1);
|
||||
result.Findings[0].VulnerabilityId.Should().Be("CVE-2025-0001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_filters_by_component_prefix()
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0001", "pkg:npm/foo@1") with { FindingId = "fc1" });
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0002", "pkg:maven/bar@2") with { FindingId = "fc2" });
|
||||
|
||||
var result = await _service.QueryAsync(new VexFindingQuery { ComponentPurlPrefix = "pkg:npm/" });
|
||||
|
||||
result.Findings.Should().HaveCount(1);
|
||||
result.Findings[0].ComponentPurl.Should().StartWith("pkg:npm/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_filters_by_status()
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with
|
||||
{
|
||||
FindingId = "fs1",
|
||||
Status = VexFindingStatus.Affected
|
||||
});
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0002", "pkg:npm/b@2") with
|
||||
{
|
||||
FindingId = "fs2",
|
||||
Status = VexFindingStatus.NotAffected
|
||||
});
|
||||
|
||||
var result = await _service.QueryAsync(new VexFindingQuery { Status = VexFindingStatus.Affected });
|
||||
|
||||
result.Findings.Should().HaveCount(1);
|
||||
result.Findings[0].Status.Should().Be(VexFindingStatus.Affected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_pagination()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding($"CVE-2025-{i:D4}", $"pkg:npm/lib{i}@1") with
|
||||
{
|
||||
FindingId = $"pg{i}"
|
||||
});
|
||||
}
|
||||
|
||||
var page1 = await _service.QueryAsync(new VexFindingQuery { Limit = 2, Offset = 0 });
|
||||
var page2 = await _service.QueryAsync(new VexFindingQuery { Limit = 2, Offset = 2 });
|
||||
|
||||
page1.Findings.Should().HaveCount(2);
|
||||
page1.HasMore.Should().BeTrue();
|
||||
page2.Findings.Should().HaveCount(2);
|
||||
page2.HasMore.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_deterministic_ordering()
|
||||
{
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0002", "pkg:npm/b@1") with { FindingId = "od1" });
|
||||
await _service.UpsertAsync(CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with { FindingId = "od2" });
|
||||
|
||||
var result = await _service.QueryAsync(new VexFindingQuery());
|
||||
|
||||
result.Findings[0].VulnerabilityId.Should().Be("CVE-2025-0001");
|
||||
result.Findings[1].VulnerabilityId.Should().Be("CVE-2025-0002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_null_throws()
|
||||
{
|
||||
var act = () => _service.QueryAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── ResolveProofsAsync ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveProofsAsync_merges_new_proofs()
|
||||
{
|
||||
var original = CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with { FindingId = "rp1" };
|
||||
await _service.UpsertAsync(original);
|
||||
|
||||
var additionalProof = new ProofArtifact
|
||||
{
|
||||
Kind = ProofArtifactKind.MerkleProof,
|
||||
Digest = "sha256:merkle",
|
||||
Payload = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes("merkle-proof")),
|
||||
ProducedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var withNewProof = original with
|
||||
{
|
||||
ProofArtifacts = ImmutableArray.Create(additionalProof)
|
||||
};
|
||||
|
||||
var resolved = await _service.ResolveProofsAsync(withNewProof);
|
||||
|
||||
resolved.ProofArtifacts.Length.Should().Be(2); // original DSSE + new Merkle
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveProofsAsync_deduplicates_by_digest()
|
||||
{
|
||||
var original = CreateFinding("CVE-2025-0001", "pkg:npm/a@1") with { FindingId = "rp2" };
|
||||
await _service.UpsertAsync(original);
|
||||
|
||||
// Same digest as original
|
||||
var duplicate = original with { ProofArtifacts = original.ProofArtifacts };
|
||||
|
||||
var resolved = await _service.ResolveProofsAsync(duplicate);
|
||||
|
||||
resolved.ProofArtifacts.Length.Should().Be(1); // no duplicate added
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveProofsAsync_returns_input_when_not_in_store()
|
||||
{
|
||||
var finding = CreateFinding("CVE-2025-0099", "pkg:npm/new@1") with { FindingId = "notfound" };
|
||||
|
||||
var resolved = await _service.ResolveProofsAsync(finding);
|
||||
|
||||
resolved.Should().Be(finding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveProofsAsync_null_throws()
|
||||
{
|
||||
var act = () => _service.ResolveProofsAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── ComputeFindingId ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeFindingId_is_deterministic()
|
||||
{
|
||||
var id1 = VexFindingsService.ComputeFindingId("CVE-2025-0001", "pkg:npm/test@1.0");
|
||||
var id2 = VexFindingsService.ComputeFindingId("CVE-2025-0001", "pkg:npm/test@1.0");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
id1.Should().StartWith("finding:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFindingId_differs_for_different_inputs()
|
||||
{
|
||||
var id1 = VexFindingsService.ComputeFindingId("CVE-2025-0001", "pkg:npm/a@1");
|
||||
var id2 = VexFindingsService.ComputeFindingId("CVE-2025-0002", "pkg:npm/a@1");
|
||||
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_null_meter_throws()
|
||||
{
|
||||
var act = () => new VexFindingsService(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private static VexFinding CreateFinding(string vulnId, string purl) => new()
|
||||
{
|
||||
FindingId = $"finding-{vulnId}-{purl}",
|
||||
VulnerabilityId = vulnId,
|
||||
ComponentPurl = purl,
|
||||
Status = VexFindingStatus.NotAffected,
|
||||
Justification = "vulnerable_code_not_in_execute_path",
|
||||
ProofArtifacts = ImmutableArray.Create(new ProofArtifact
|
||||
{
|
||||
Kind = ProofArtifactKind.DsseSignature,
|
||||
Digest = $"sha256:{vulnId}:{purl}",
|
||||
Payload = new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes($"dsse-{vulnId}")),
|
||||
ProducedAt = DateTimeOffset.UtcNow
|
||||
}),
|
||||
DeterminedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Test meter factory
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
file sealed class TestFindingsMeterFactory : IMeterFactory
|
||||
{
|
||||
public Meter Create(MeterOptions options) => new(options);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryFingerprintStoreTests.cs
|
||||
// Sprint: SPRINT_20260208_004_Attestor_binary_fingerprint_store_and_trust_scoring
|
||||
// Task: T1 — Deterministic tests for fingerprint store
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Attestor.ProofChain.FingerprintStore;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.FingerprintStore;
|
||||
|
||||
public sealed class BinaryFingerprintStoreTests : IDisposable
|
||||
{
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly TestMeterFactory _meterFactory = new();
|
||||
private readonly BinaryFingerprintStore _store;
|
||||
|
||||
public BinaryFingerprintStoreTests()
|
||||
{
|
||||
_store = new BinaryFingerprintStore(
|
||||
_time,
|
||||
NullLogger<BinaryFingerprintStore>.Instance,
|
||||
_meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meterFactory.Dispose();
|
||||
}
|
||||
|
||||
// ── Registration ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Register_NewFingerprint_ReturnsRecordWithContentAddressedId()
|
||||
{
|
||||
var reg = CreateRegistration();
|
||||
var record = await _store.RegisterAsync(reg);
|
||||
|
||||
record.Should().NotBeNull();
|
||||
record.FingerprintId.Should().StartWith("fp:");
|
||||
record.Format.Should().Be("elf");
|
||||
record.Architecture.Should().Be("x86_64");
|
||||
record.FileSha256.Should().Be("abc123");
|
||||
record.CreatedAt.Should().Be(_time.GetUtcNow());
|
||||
record.TrustScore.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_SameInputTwice_ReturnsExistingIdempotently()
|
||||
{
|
||||
var reg = CreateRegistration();
|
||||
var first = await _store.RegisterAsync(reg);
|
||||
var second = await _store.RegisterAsync(reg);
|
||||
|
||||
second.FingerprintId.Should().Be(first.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_DifferentSections_ProducesDifferentIds()
|
||||
{
|
||||
var reg1 = CreateRegistration();
|
||||
var reg2 = CreateRegistration(sectionHashes: ImmutableDictionary<string, string>.Empty
|
||||
.Add(".text", "different_hash"));
|
||||
|
||||
var r1 = await _store.RegisterAsync(reg1);
|
||||
var r2 = await _store.RegisterAsync(reg2);
|
||||
|
||||
r2.FingerprintId.Should().NotBe(r1.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_NullInput_Throws()
|
||||
{
|
||||
var act = () => _store.RegisterAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_EmptyFormat_Throws()
|
||||
{
|
||||
var reg = CreateRegistration(format: "");
|
||||
var act = () => _store.RegisterAsync(reg);
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── Lookup ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_ExistingRecord_Returns()
|
||||
{
|
||||
var reg = CreateRegistration();
|
||||
var created = await _store.RegisterAsync(reg);
|
||||
|
||||
var found = await _store.GetByIdAsync(created.FingerprintId);
|
||||
found.Should().NotBeNull();
|
||||
found!.FingerprintId.Should().Be(created.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_NonExistent_ReturnsNull()
|
||||
{
|
||||
var found = await _store.GetByIdAsync("fp:nonexistent");
|
||||
found.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFileSha256_ExistingRecord_Returns()
|
||||
{
|
||||
var reg = CreateRegistration();
|
||||
var created = await _store.RegisterAsync(reg);
|
||||
|
||||
var found = await _store.GetByFileSha256Async("abc123");
|
||||
found.Should().NotBeNull();
|
||||
found!.FingerprintId.Should().Be(created.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFileSha256_NonExistent_ReturnsNull()
|
||||
{
|
||||
var found = await _store.GetByFileSha256Async("nonexistent_sha");
|
||||
found.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Section-hash matching ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task FindBySectionHashes_ExactMatch_ReturnsSimilarity1()
|
||||
{
|
||||
var sections = DefaultSectionHashes();
|
||||
var reg = CreateRegistration(sectionHashes: sections);
|
||||
await _store.RegisterAsync(reg);
|
||||
|
||||
var result = await _store.FindBySectionHashesAsync(sections);
|
||||
result.Should().NotBeNull();
|
||||
result!.Found.Should().BeTrue();
|
||||
result.SectionSimilarity.Should().Be(1.0);
|
||||
result.MatchedSections.Should().HaveCount(2);
|
||||
result.DifferingSections.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindBySectionHashes_PartialMatch_ReturnsPartialSimilarity()
|
||||
{
|
||||
var stored = DefaultSectionHashes();
|
||||
await _store.RegisterAsync(CreateRegistration(sectionHashes: stored));
|
||||
|
||||
var query = ImmutableDictionary<string, string>.Empty
|
||||
.Add(".text", "texthash123") // matches
|
||||
.Add(".rodata", "different"); // does not match
|
||||
|
||||
var result = await _store.FindBySectionHashesAsync(query, minSimilarity: 0.3);
|
||||
result.Should().NotBeNull();
|
||||
result!.SectionSimilarity.Should().Be(0.5); // 1 of 2 match
|
||||
result.MatchedSections.Should().Contain(".text");
|
||||
result.DifferingSections.Should().Contain(".rodata");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindBySectionHashes_BelowMinSimilarity_ReturnsNull()
|
||||
{
|
||||
var stored = DefaultSectionHashes();
|
||||
await _store.RegisterAsync(CreateRegistration(sectionHashes: stored));
|
||||
|
||||
var query = ImmutableDictionary<string, string>.Empty
|
||||
.Add(".text", "completely_different")
|
||||
.Add(".rodata", "also_different");
|
||||
|
||||
var result = await _store.FindBySectionHashesAsync(query, minSimilarity: 0.8);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindBySectionHashes_EmptyQuery_ReturnsNull()
|
||||
{
|
||||
await _store.RegisterAsync(CreateRegistration());
|
||||
var result = await _store.FindBySectionHashesAsync(ImmutableDictionary<string, string>.Empty);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Trust scoring ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeTrustScore_WithBuildIdAndPurl_HigherScore()
|
||||
{
|
||||
var reg = CreateRegistration(
|
||||
buildId: "gnu-build-id-123",
|
||||
packagePurl: "pkg:deb/debian/libc6@2.36",
|
||||
evidenceDigests: ["sha256:ev1", "sha256:ev2"]);
|
||||
var created = await _store.RegisterAsync(reg);
|
||||
|
||||
var breakdown = await _store.ComputeTrustScoreAsync(created.FingerprintId);
|
||||
breakdown.Score.Should().BeGreaterThan(0.3);
|
||||
breakdown.BuildIdScore.Should().BeGreaterThan(0);
|
||||
breakdown.ProvenanceScore.Should().BeGreaterThan(0);
|
||||
breakdown.EvidenceScore.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeTrustScore_MinimalRecord_LowerScore()
|
||||
{
|
||||
var reg = CreateRegistration(
|
||||
sectionHashes: ImmutableDictionary<string, string>.Empty.Add(".debug", "x"));
|
||||
var created = await _store.RegisterAsync(reg);
|
||||
|
||||
var breakdown = await _store.ComputeTrustScoreAsync(created.FingerprintId);
|
||||
breakdown.Score.Should().BeLessThan(0.3);
|
||||
breakdown.GoldenBonus.Should().Be(0);
|
||||
breakdown.BuildIdScore.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeTrustScore_NonExistent_Throws()
|
||||
{
|
||||
var act = () => _store.ComputeTrustScoreAsync("fp:nonexistent");
|
||||
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_Components_DeterministicWithSameInputs()
|
||||
{
|
||||
var sections = DefaultSectionHashes();
|
||||
var a = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, "build123", ["e1", "e2"], "pkg:deb/test@1", true);
|
||||
var b = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, "build123", ["e1", "e2"], "pkg:deb/test@1", true);
|
||||
|
||||
b.Score.Should().Be(a.Score);
|
||||
b.GoldenBonus.Should().Be(a.GoldenBonus);
|
||||
b.BuildIdScore.Should().Be(a.BuildIdScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_GoldenRecord_HasGoldenBonus()
|
||||
{
|
||||
var sections = DefaultSectionHashes();
|
||||
var nonGolden = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, null, [], null, false);
|
||||
var golden = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, null, [], null, true);
|
||||
|
||||
golden.GoldenBonus.Should().BeGreaterThan(0);
|
||||
golden.Score.Should().BeGreaterThan(nonGolden.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_ScoreCappedAtPoint99()
|
||||
{
|
||||
// Maximise all signals
|
||||
var sections = ImmutableDictionary<string, string>.Empty
|
||||
.Add(".text", "a").Add(".rodata", "b").Add(".data", "c").Add(".bss", "d");
|
||||
var breakdown = BinaryFingerprintStore.ComputeTrustScoreComponents(
|
||||
sections, "build-id", ["e1", "e2", "e3", "e4", "e5"], "pkg:deb/x@1", true);
|
||||
|
||||
breakdown.Score.Should().BeLessOrEqualTo(0.99);
|
||||
}
|
||||
|
||||
// ── Golden set management ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGoldenSet_NewSet_ReturnsSet()
|
||||
{
|
||||
var gs = await _store.CreateGoldenSetAsync("baseline-v1", "Debian 12 baseline");
|
||||
gs.Name.Should().Be("baseline-v1");
|
||||
gs.Description.Should().Be("Debian 12 baseline");
|
||||
gs.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddToGoldenSet_ValidFingerprint_MarksAsGolden()
|
||||
{
|
||||
await _store.CreateGoldenSetAsync("baseline-v1");
|
||||
var reg = CreateRegistration();
|
||||
var created = await _store.RegisterAsync(reg);
|
||||
|
||||
var updated = await _store.AddToGoldenSetAsync(created.FingerprintId, "baseline-v1");
|
||||
updated.IsGolden.Should().BeTrue();
|
||||
updated.GoldenSetName.Should().Be("baseline-v1");
|
||||
updated.TrustScore.Should().BeGreaterThan(created.TrustScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddToGoldenSet_NonExistentSet_Throws()
|
||||
{
|
||||
var created = await _store.RegisterAsync(CreateRegistration());
|
||||
var act = () => _store.AddToGoldenSetAsync(created.FingerprintId, "nonexistent");
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFromGoldenSet_GoldenRecord_RemovesGoldenFlag()
|
||||
{
|
||||
await _store.CreateGoldenSetAsync("baseline-v1");
|
||||
var created = await _store.RegisterAsync(CreateRegistration());
|
||||
await _store.AddToGoldenSetAsync(created.FingerprintId, "baseline-v1");
|
||||
|
||||
var removed = await _store.RemoveFromGoldenSetAsync(created.FingerprintId);
|
||||
removed.IsGolden.Should().BeFalse();
|
||||
removed.GoldenSetName.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGoldenSetMembers_ReturnsOnlyGoldenRecords()
|
||||
{
|
||||
await _store.CreateGoldenSetAsync("baseline-v1");
|
||||
var reg1 = CreateRegistration(fileSha256: "sha1");
|
||||
var reg2 = CreateRegistration(fileSha256: "sha2",
|
||||
sectionHashes: ImmutableDictionary<string, string>.Empty.Add(".text", "other"));
|
||||
var r1 = await _store.RegisterAsync(reg1);
|
||||
await _store.RegisterAsync(reg2);
|
||||
await _store.AddToGoldenSetAsync(r1.FingerprintId, "baseline-v1");
|
||||
|
||||
var members = await _store.GetGoldenSetMembersAsync("baseline-v1");
|
||||
members.Should().HaveCount(1);
|
||||
members[0].FingerprintId.Should().Be(r1.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListGoldenSets_ReturnsAllSets()
|
||||
{
|
||||
await _store.CreateGoldenSetAsync("set-a");
|
||||
await _store.CreateGoldenSetAsync("set-b");
|
||||
|
||||
var sets = await _store.ListGoldenSetsAsync();
|
||||
sets.Should().HaveCount(2);
|
||||
sets.Select(s => s.Name).Should().BeEquivalentTo(["set-a", "set-b"]);
|
||||
}
|
||||
|
||||
// ── List and query ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByFormat_ReturnsMatchingOnly()
|
||||
{
|
||||
await _store.RegisterAsync(CreateRegistration(format: "elf"));
|
||||
await _store.RegisterAsync(CreateRegistration(format: "pe", fileSha256: "pe_sha",
|
||||
sectionHashes: ImmutableDictionary<string, string>.Empty.Add(".text", "pe_hash")));
|
||||
|
||||
var elfOnly = await _store.ListAsync(new FingerprintQuery { Format = "elf" });
|
||||
elfOnly.Should().HaveCount(1);
|
||||
elfOnly[0].Format.Should().Be("elf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByMinTrustScore_ExcludesLowScored()
|
||||
{
|
||||
// High trust: build ID + PURL + evidence + key sections
|
||||
await _store.RegisterAsync(CreateRegistration(
|
||||
buildId: "bid",
|
||||
packagePurl: "pkg:deb/test@1",
|
||||
evidenceDigests: ["e1", "e2", "e3"]));
|
||||
|
||||
// Low trust: no build ID, no PURL, no evidence, non-key sections
|
||||
await _store.RegisterAsync(CreateRegistration(
|
||||
fileSha256: "low_sha",
|
||||
sectionHashes: ImmutableDictionary<string, string>.Empty.Add(".debug", "x")));
|
||||
|
||||
var highOnly = await _store.ListAsync(new FingerprintQuery { MinTrustScore = 0.3 });
|
||||
highOnly.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ExistingRecord_RemovesAndReturnsTrue()
|
||||
{
|
||||
var created = await _store.RegisterAsync(CreateRegistration());
|
||||
var deleted = await _store.DeleteAsync(created.FingerprintId);
|
||||
deleted.Should().BeTrue();
|
||||
|
||||
var found = await _store.GetByIdAsync(created.FingerprintId);
|
||||
found.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_NonExistent_ReturnsFalse()
|
||||
{
|
||||
var deleted = await _store.DeleteAsync("fp:nonexistent");
|
||||
deleted.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── Content-addressed ID determinism ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeFingerprintId_SameInput_SameOutput()
|
||||
{
|
||||
var sections = DefaultSectionHashes();
|
||||
var a = BinaryFingerprintStore.ComputeFingerprintId("elf", "x86_64", sections);
|
||||
var b = BinaryFingerprintStore.ComputeFingerprintId("elf", "x86_64", sections);
|
||||
b.Should().Be(a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFingerprintId_DifferentInput_DifferentOutput()
|
||||
{
|
||||
var sections = DefaultSectionHashes();
|
||||
var a = BinaryFingerprintStore.ComputeFingerprintId("elf", "x86_64", sections);
|
||||
var b = BinaryFingerprintStore.ComputeFingerprintId("pe", "x86_64", sections);
|
||||
b.Should().NotBe(a);
|
||||
}
|
||||
|
||||
// ── Section similarity ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SectionSimilarity_IdenticalSections_Returns1()
|
||||
{
|
||||
var s = DefaultSectionHashes();
|
||||
var (similarity, matched, differing) = BinaryFingerprintStore.ComputeSectionSimilarity(s, s);
|
||||
similarity.Should().Be(1.0);
|
||||
matched.Should().HaveCount(2);
|
||||
differing.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SectionSimilarity_NoOverlap_Returns0()
|
||||
{
|
||||
var a = ImmutableDictionary<string, string>.Empty.Add(".text", "aaa");
|
||||
var b = ImmutableDictionary<string, string>.Empty.Add(".text", "bbb");
|
||||
var (similarity, matched, differing) = BinaryFingerprintStore.ComputeSectionSimilarity(a, b);
|
||||
similarity.Should().Be(0.0);
|
||||
matched.Should().BeEmpty();
|
||||
differing.Should().Contain(".text");
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static ImmutableDictionary<string, string> DefaultSectionHashes() =>
|
||||
ImmutableDictionary<string, string>.Empty
|
||||
.Add(".text", "texthash123")
|
||||
.Add(".rodata", "rodatahash456");
|
||||
|
||||
private static FingerprintRegistration CreateRegistration(
|
||||
string format = "elf",
|
||||
string architecture = "x86_64",
|
||||
string fileSha256 = "abc123",
|
||||
string? buildId = null,
|
||||
ImmutableDictionary<string, string>? sectionHashes = null,
|
||||
string? packagePurl = null,
|
||||
string? packageVersion = null,
|
||||
ImmutableArray<string>? evidenceDigests = null) =>
|
||||
new()
|
||||
{
|
||||
Format = format,
|
||||
Architecture = architecture,
|
||||
FileSha256 = fileSha256,
|
||||
BuildId = buildId,
|
||||
SectionHashes = sectionHashes ?? DefaultSectionHashes(),
|
||||
PackagePurl = packagePurl,
|
||||
PackageVersion = packageVersion,
|
||||
EvidenceDigests = evidenceDigests ?? []
|
||||
};
|
||||
|
||||
// ── Minimal IMeterFactory + FakeTimeProvider for tests ────────────────
|
||||
|
||||
private sealed class TestMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
_meters.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal fake time provider for deterministic tests.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset startTime) => _utcNow = startTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Graph;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Graph;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SubgraphVisualizationService"/>.
|
||||
/// </summary>
|
||||
public sealed class SubgraphVisualizationServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2025, 7, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly SubgraphVisualizationService _service = new();
|
||||
|
||||
private static ProofGraphSubgraph CreateSubgraph(
|
||||
string rootId = "root-1",
|
||||
int maxDepth = 5,
|
||||
ProofGraphNode[]? nodes = null,
|
||||
ProofGraphEdge[]? edges = null)
|
||||
{
|
||||
return new ProofGraphSubgraph
|
||||
{
|
||||
RootNodeId = rootId,
|
||||
MaxDepth = maxDepth,
|
||||
Nodes = nodes ?? [],
|
||||
Edges = edges ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static ProofGraphNode CreateNode(
|
||||
string id,
|
||||
ProofGraphNodeType type = ProofGraphNodeType.Artifact,
|
||||
string digest = "sha256:abc123") => new()
|
||||
{
|
||||
Id = id,
|
||||
Type = type,
|
||||
ContentDigest = digest,
|
||||
CreatedAt = FixedTime
|
||||
};
|
||||
|
||||
private static ProofGraphEdge CreateEdge(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
ProofGraphEdgeType type = ProofGraphEdgeType.DescribedBy) => new()
|
||||
{
|
||||
Id = $"{sourceId}->{type}->{targetId}",
|
||||
SourceId = sourceId,
|
||||
TargetId = targetId,
|
||||
Type = type,
|
||||
CreatedAt = FixedTime
|
||||
};
|
||||
|
||||
// --- Basic rendering ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_EmptySubgraph_ReturnsEmptyResult()
|
||||
{
|
||||
var subgraph = CreateSubgraph();
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
result.NodeCount.Should().Be(0);
|
||||
result.EdgeCount.Should().Be(0);
|
||||
result.RootNodeId.Should().Be("root-1");
|
||||
result.Format.Should().Be(SubgraphRenderFormat.Json);
|
||||
result.GeneratedAt.Should().Be(FixedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_SingleNode_ReturnsCorrectVisualization()
|
||||
{
|
||||
var subgraph = CreateSubgraph(
|
||||
rootId: "n1",
|
||||
nodes: [CreateNode("n1")]);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
result.NodeCount.Should().Be(1);
|
||||
result.Nodes[0].Id.Should().Be("n1");
|
||||
result.Nodes[0].IsRoot.Should().BeTrue();
|
||||
result.Nodes[0].Depth.Should().Be(0);
|
||||
result.Nodes[0].Type.Should().Be("Artifact");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_MultipleNodes_ComputesDepths()
|
||||
{
|
||||
var nodes = new[]
|
||||
{
|
||||
CreateNode("root", ProofGraphNodeType.Artifact),
|
||||
CreateNode("child1", ProofGraphNodeType.SbomDocument),
|
||||
CreateNode("child2", ProofGraphNodeType.VexStatement),
|
||||
CreateNode("grandchild", ProofGraphNodeType.InTotoStatement)
|
||||
};
|
||||
var edges = new[]
|
||||
{
|
||||
CreateEdge("root", "child1"),
|
||||
CreateEdge("root", "child2"),
|
||||
CreateEdge("child1", "grandchild")
|
||||
};
|
||||
|
||||
var subgraph = CreateSubgraph("root", nodes: nodes, edges: edges);
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
result.NodeCount.Should().Be(4);
|
||||
result.EdgeCount.Should().Be(3);
|
||||
|
||||
var rootViz = result.Nodes.First(n => n.Id == "root");
|
||||
rootViz.Depth.Should().Be(0);
|
||||
rootViz.IsRoot.Should().BeTrue();
|
||||
|
||||
var child1Viz = result.Nodes.First(n => n.Id == "child1");
|
||||
child1Viz.Depth.Should().Be(1);
|
||||
child1Viz.IsRoot.Should().BeFalse();
|
||||
|
||||
var grandchildViz = result.Nodes.First(n => n.Id == "grandchild");
|
||||
grandchildViz.Depth.Should().Be(2);
|
||||
}
|
||||
|
||||
// --- Mermaid format ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Mermaid_ContainsGraphDirective()
|
||||
{
|
||||
var subgraph = CreateSubgraph("n1", nodes: [CreateNode("n1")]);
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
|
||||
result.Format.Should().Be(SubgraphRenderFormat.Mermaid);
|
||||
result.Content.Should().Contain("graph TD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Mermaid_ContainsNodeAndEdge()
|
||||
{
|
||||
var nodes = new[] { CreateNode("n1"), CreateNode("n2", ProofGraphNodeType.SbomDocument) };
|
||||
var edges = new[] { CreateEdge("n1", "n2") };
|
||||
var subgraph = CreateSubgraph("n1", nodes: nodes, edges: edges);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
|
||||
result.Content.Should().Contain("n1");
|
||||
result.Content.Should().Contain("n2");
|
||||
result.Content.Should().Contain("described by");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Mermaid_ContainsClassDefinitions()
|
||||
{
|
||||
var subgraph = CreateSubgraph("n1", nodes: [CreateNode("n1")]);
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
|
||||
result.Content.Should().Contain("classDef artifact");
|
||||
result.Content.Should().Contain("classDef sbom");
|
||||
result.Content.Should().Contain("classDef attestation");
|
||||
}
|
||||
|
||||
// --- DOT format ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Dot_ContainsDigraphDirective()
|
||||
{
|
||||
var subgraph = CreateSubgraph("n1", nodes: [CreateNode("n1")]);
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Dot, FixedTime);
|
||||
|
||||
result.Format.Should().Be(SubgraphRenderFormat.Dot);
|
||||
result.Content.Should().Contain("digraph proof_subgraph");
|
||||
result.Content.Should().Contain("rankdir=TB");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Dot_ContainsNodeColors()
|
||||
{
|
||||
var nodes = new[]
|
||||
{
|
||||
CreateNode("n1", ProofGraphNodeType.Artifact),
|
||||
CreateNode("n2", ProofGraphNodeType.VexStatement)
|
||||
};
|
||||
var subgraph = CreateSubgraph("n1", nodes: nodes);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Dot, FixedTime);
|
||||
|
||||
result.Content.Should().Contain("#4CAF50"); // Artifact green
|
||||
result.Content.Should().Contain("#9C27B0"); // VEX purple
|
||||
}
|
||||
|
||||
// --- JSON format ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_Json_IsValidJson()
|
||||
{
|
||||
var nodes = new[] { CreateNode("n1"), CreateNode("n2") };
|
||||
var edges = new[] { CreateEdge("n1", "n2") };
|
||||
var subgraph = CreateSubgraph("n1", nodes: nodes, edges: edges);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
var act = () => JsonDocument.Parse(result.Content);
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
// --- Edge labels ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(ProofGraphEdgeType.DescribedBy, "described by")]
|
||||
[InlineData(ProofGraphEdgeType.AttestedBy, "attested by")]
|
||||
[InlineData(ProofGraphEdgeType.HasVex, "has VEX")]
|
||||
[InlineData(ProofGraphEdgeType.SignedBy, "signed by")]
|
||||
[InlineData(ProofGraphEdgeType.ChainsTo, "chains to")]
|
||||
public async Task Render_EdgeTypes_HaveCorrectLabels(ProofGraphEdgeType edgeType, string expectedLabel)
|
||||
{
|
||||
var nodes = new[] { CreateNode("n1"), CreateNode("n2") };
|
||||
var edges = new[] { CreateEdge("n1", "n2", edgeType) };
|
||||
var subgraph = CreateSubgraph("n1", nodes: nodes, edges: edges);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
result.Edges[0].Label.Should().Be(expectedLabel);
|
||||
}
|
||||
|
||||
// --- Node types ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(ProofGraphNodeType.Artifact, "Artifact")]
|
||||
[InlineData(ProofGraphNodeType.SbomDocument, "SbomDocument")]
|
||||
[InlineData(ProofGraphNodeType.VexStatement, "VexStatement")]
|
||||
[InlineData(ProofGraphNodeType.SigningKey, "SigningKey")]
|
||||
public async Task Render_NodeTypes_PreservedInVisualization(ProofGraphNodeType nodeType, string expectedType)
|
||||
{
|
||||
var subgraph = CreateSubgraph("n1", nodes: [CreateNode("n1", nodeType)]);
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Json, FixedTime);
|
||||
|
||||
result.Nodes[0].Type.Should().Be(expectedType);
|
||||
}
|
||||
|
||||
// --- Content digest truncation ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_LongDigest_TruncatedInLabel()
|
||||
{
|
||||
var node = CreateNode("n1", digest: "sha256:abcdef1234567890abcdef1234567890");
|
||||
var subgraph = CreateSubgraph("n1", nodes: [node]);
|
||||
|
||||
var result = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
|
||||
// Label should contain truncated digest
|
||||
result.Nodes[0].Label.Should().Contain("...");
|
||||
// Full digest should still be in ContentDigest
|
||||
result.Nodes[0].ContentDigest.Should().Be("sha256:abcdef1234567890abcdef1234567890");
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_CancelledToken_ThrowsOperationCancelled()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
var act = () => _service.RenderAsync(CreateSubgraph(), SubgraphRenderFormat.Json, FixedTime, cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// --- Null argument ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_NullSubgraph_Throws()
|
||||
{
|
||||
var act = () => _service.RenderAsync(null!, SubgraphRenderFormat.Json, FixedTime);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// --- Determinism ---
|
||||
|
||||
[Fact]
|
||||
public async Task Render_SameInput_ProducesSameOutput()
|
||||
{
|
||||
var nodes = new[] { CreateNode("n1"), CreateNode("n2") };
|
||||
var edges = new[] { CreateEdge("n1", "n2") };
|
||||
var subgraph = CreateSubgraph("n1", nodes: nodes, edges: edges);
|
||||
|
||||
var r1 = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
var r2 = await _service.RenderAsync(subgraph, SubgraphRenderFormat.Mermaid, FixedTime);
|
||||
|
||||
r1.Content.Should().Be(r2.Content);
|
||||
}
|
||||
|
||||
// --- All three formats produce output ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(SubgraphRenderFormat.Mermaid)]
|
||||
[InlineData(SubgraphRenderFormat.Dot)]
|
||||
[InlineData(SubgraphRenderFormat.Json)]
|
||||
public async Task Render_AllFormats_ProduceNonEmptyContent(SubgraphRenderFormat format)
|
||||
{
|
||||
var subgraph = CreateSubgraph("n1", nodes: [CreateNode("n1")]);
|
||||
var result = await _service.RenderAsync(subgraph, format, FixedTime);
|
||||
|
||||
result.Content.Should().NotBeNullOrWhiteSpace();
|
||||
result.Format.Should().Be(format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Cas;
|
||||
using StellaOps.Attestor.ProofChain.Idempotency;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Idempotency;
|
||||
|
||||
public sealed class IdempotentIngestServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestIdempotencyMeterFactory _meterFactory = new();
|
||||
private readonly InMemoryContentAddressedStore _store;
|
||||
private readonly IdempotentIngestService _sut;
|
||||
|
||||
public IdempotentIngestServiceTests()
|
||||
{
|
||||
_store = new InMemoryContentAddressedStore(
|
||||
TimeProvider.System,
|
||||
new Microsoft.Extensions.Logging.Abstractions.NullLogger<InMemoryContentAddressedStore>(),
|
||||
_meterFactory);
|
||||
_sut = new IdempotentIngestService(_store, TimeProvider.System, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
private static byte[] SbomBytes(string content = "test-sbom") =>
|
||||
Encoding.UTF8.GetBytes(content);
|
||||
|
||||
private static byte[] JsonAttestationBytes(string payload = "test") =>
|
||||
Encoding.UTF8.GetBytes($"{{\"payload\":\"{payload}\"}}");
|
||||
|
||||
private static string ComputeExpectedDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
// --- SBOM Ingest Tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_FirstSubmission_ReturnsNotDeduplicated()
|
||||
{
|
||||
var bytes = SbomBytes();
|
||||
var result = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/spdx+json"
|
||||
});
|
||||
|
||||
result.Deduplicated.Should().BeFalse();
|
||||
result.Digest.Should().Be(ComputeExpectedDigest(bytes));
|
||||
result.SbomEntryId.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_DuplicateSubmission_ReturnsDeduplicated()
|
||||
{
|
||||
var bytes = SbomBytes();
|
||||
var request = new SbomIngestRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/spdx+json"
|
||||
};
|
||||
|
||||
var first = await _sut.IngestSbomAsync(request);
|
||||
var second = await _sut.IngestSbomAsync(request);
|
||||
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.Digest.Should().Be(first.Digest);
|
||||
second.SbomEntryId.Digest.Should().Be(first.SbomEntryId.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_DifferentContent_DifferentDigest()
|
||||
{
|
||||
var result1 = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes("sbom-a"),
|
||||
MediaType = "application/spdx+json"
|
||||
});
|
||||
|
||||
var result2 = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes("sbom-b"),
|
||||
MediaType = "application/spdx+json"
|
||||
});
|
||||
|
||||
result1.Digest.Should().NotBe(result2.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_WithTags_StoresTags()
|
||||
{
|
||||
var tags = ImmutableDictionary<string, string>.Empty
|
||||
.Add("purl", "pkg:npm/test@1.0");
|
||||
var result = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes(),
|
||||
MediaType = "application/spdx+json",
|
||||
Tags = tags
|
||||
});
|
||||
|
||||
result.Artifact.Tags.Should().ContainKey("purl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_WithIdempotencyKey_ReturnsSameOnRetry()
|
||||
{
|
||||
var bytes = SbomBytes("idem-sbom");
|
||||
var first = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/spdx+json",
|
||||
IdempotencyKey = "key-001"
|
||||
});
|
||||
|
||||
// Second call with same key — returns deduplicated result
|
||||
var second = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes("different-content"),
|
||||
MediaType = "application/spdx+json",
|
||||
IdempotencyKey = "key-001"
|
||||
});
|
||||
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.Digest.Should().Be(first.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_EmptyContent_ThrowsArgument()
|
||||
{
|
||||
var act = () => _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = ReadOnlyMemory<byte>.Empty,
|
||||
MediaType = "application/spdx+json"
|
||||
});
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*Content*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_EmptyMediaType_ThrowsArgument()
|
||||
{
|
||||
var act = () => _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes(),
|
||||
MediaType = ""
|
||||
});
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*MediaType*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_NullRequest_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.IngestSbomAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_CancellationToken_Respected()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes(),
|
||||
MediaType = "application/spdx+json"
|
||||
}, cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestSbomAsync_ArtifactType_IsSbom()
|
||||
{
|
||||
var result = await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes(),
|
||||
MediaType = "application/spdx+json"
|
||||
});
|
||||
|
||||
result.Artifact.ArtifactType.Should().Be(CasArtifactType.Sbom);
|
||||
}
|
||||
|
||||
// --- Attestation Verify Tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_FirstSubmission_NoCacheHit()
|
||||
{
|
||||
var result = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
result.CacheHit.Should().BeFalse();
|
||||
result.Digest.Should().NotBeNullOrEmpty();
|
||||
result.Checks.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_DuplicateSubmission_CacheHit()
|
||||
{
|
||||
var bytes = JsonAttestationBytes();
|
||||
var request = new AttestationVerifyRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
};
|
||||
|
||||
var first = await _sut.VerifyAttestationAsync(request);
|
||||
var second = await _sut.VerifyAttestationAsync(request);
|
||||
|
||||
second.CacheHit.Should().BeTrue();
|
||||
second.Digest.Should().Be(first.Digest);
|
||||
second.Verified.Should().Be(first.Verified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_JsonContent_PassesStructureCheck()
|
||||
{
|
||||
var result = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
result.Verified.Should().BeTrue();
|
||||
result.Checks.Should().Contain(c => c.Check == "json_structure" && c.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_NonJsonContent_FailsStructureCheck()
|
||||
{
|
||||
var result = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = Encoding.UTF8.GetBytes("not-json-content"),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
result.Verified.Should().BeFalse();
|
||||
result.Checks.Should().Contain(c => c.Check == "json_structure" && !c.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_ChecksIncludeContentPresent()
|
||||
{
|
||||
var result = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
result.Checks.Should().Contain(c => c.Check == "content_present" && c.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_ChecksIncludeDigestFormat()
|
||||
{
|
||||
var result = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
result.Checks.Should().Contain(c => c.Check == "digest_format" && c.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_WithIdempotencyKey_CachesResult()
|
||||
{
|
||||
var bytes = JsonAttestationBytes("idem-test");
|
||||
var first = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/vnd.dsse.envelope+json",
|
||||
IdempotencyKey = "attest-key-001"
|
||||
});
|
||||
|
||||
// Different content, same key → should return cached result
|
||||
var second = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes("different"),
|
||||
MediaType = "application/vnd.dsse.envelope+json",
|
||||
IdempotencyKey = "attest-key-001"
|
||||
});
|
||||
|
||||
second.CacheHit.Should().BeTrue();
|
||||
second.Digest.Should().Be(first.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_NullRequest_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.VerifyAttestationAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_EmptyContent_ThrowsArgument()
|
||||
{
|
||||
var act = () => _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = ReadOnlyMemory<byte>.Empty,
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*Content*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_CancellationToken_Respected()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
}, cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_Deterministic()
|
||||
{
|
||||
var bytes = JsonAttestationBytes("deterministic-test");
|
||||
var request = new AttestationVerifyRequest
|
||||
{
|
||||
Content = bytes,
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
};
|
||||
|
||||
// Create separate services with separate caches
|
||||
var store2 = new InMemoryContentAddressedStore(
|
||||
TimeProvider.System,
|
||||
new Microsoft.Extensions.Logging.Abstractions.NullLogger<InMemoryContentAddressedStore>(),
|
||||
_meterFactory);
|
||||
var sut2 = new IdempotentIngestService(store2, TimeProvider.System, _meterFactory);
|
||||
|
||||
var result1 = await _sut.VerifyAttestationAsync(request);
|
||||
var result2 = await sut2.VerifyAttestationAsync(request);
|
||||
|
||||
result1.Digest.Should().Be(result2.Digest);
|
||||
result1.Verified.Should().Be(result2.Verified);
|
||||
result1.Checks.Length.Should().Be(result2.Checks.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_SummaryReflectsOutcome()
|
||||
{
|
||||
var passing = await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json"
|
||||
});
|
||||
|
||||
passing.Summary.Should().Contain("passed");
|
||||
}
|
||||
|
||||
// --- Idempotency Key Lookup Tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task LookupIdempotencyKeyAsync_UnknownKey_ReturnsNull()
|
||||
{
|
||||
var result = await _sut.LookupIdempotencyKeyAsync("nonexistent");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LookupIdempotencyKeyAsync_AfterIngest_ReturnsEntry()
|
||||
{
|
||||
await _sut.IngestSbomAsync(new SbomIngestRequest
|
||||
{
|
||||
Content = SbomBytes(),
|
||||
MediaType = "application/spdx+json",
|
||||
IdempotencyKey = "lookup-test"
|
||||
});
|
||||
|
||||
var entry = await _sut.LookupIdempotencyKeyAsync("lookup-test");
|
||||
entry.Should().NotBeNull();
|
||||
entry!.Key.Should().Be("lookup-test");
|
||||
entry.OperationType.Should().Be("sbom-ingest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LookupIdempotencyKeyAsync_AfterVerify_ReturnsEntry()
|
||||
{
|
||||
await _sut.VerifyAttestationAsync(new AttestationVerifyRequest
|
||||
{
|
||||
Content = JsonAttestationBytes(),
|
||||
MediaType = "application/vnd.dsse.envelope+json",
|
||||
IdempotencyKey = "verify-lookup"
|
||||
});
|
||||
|
||||
var entry = await _sut.LookupIdempotencyKeyAsync("verify-lookup");
|
||||
entry.Should().NotBeNull();
|
||||
entry!.OperationType.Should().Be("attest-verify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LookupIdempotencyKeyAsync_NullKey_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.LookupIdempotencyKeyAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// --- Constructor Validation ---
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullStore_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => new IdempotentIngestService(null!, TimeProvider.System, _meterFactory);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => new IdempotentIngestService(_store, TimeProvider.System, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_UsesSystem()
|
||||
{
|
||||
var sut = new IdempotentIngestService(_store, null, _meterFactory);
|
||||
sut.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestIdempotencyMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly ConcurrentBag<Meter> _meters = [];
|
||||
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var meter in _meters)
|
||||
meter.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LinkCaptureServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_015_Attestor_in_toto_link_attestation_capture
|
||||
// Task: T1 — Tests for LinkCaptureService
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.LinkCapture;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.LinkCapture;
|
||||
|
||||
internal sealed class TestLinkCaptureMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
public Meter Create(MeterOptions options) { var m = new Meter(options); _meters.Add(m); return m; }
|
||||
public void Dispose() { foreach (var m in _meters) m.Dispose(); }
|
||||
}
|
||||
|
||||
public sealed class LinkCaptureServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestLinkCaptureMeterFactory _meterFactory = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly LinkCaptureService _sut;
|
||||
|
||||
public LinkCaptureServiceTests()
|
||||
{
|
||||
_sut = new LinkCaptureService(_timeProvider, _meterFactory);
|
||||
}
|
||||
|
||||
public void Dispose() => _meterFactory.Dispose();
|
||||
|
||||
private static LinkCaptureRequest CreateRequest(
|
||||
string step = "build",
|
||||
string functionary = "ci-bot",
|
||||
string[]? command = null,
|
||||
CapturedMaterial[]? materials = null,
|
||||
CapturedProduct[]? products = null,
|
||||
string? pipelineId = null,
|
||||
string? stepId = null) => new()
|
||||
{
|
||||
StepName = step,
|
||||
Functionary = functionary,
|
||||
Command = (command ?? ["make", "build"]).ToImmutableArray(),
|
||||
Materials = (materials ?? []).ToImmutableArray(),
|
||||
Products = (products ?? []).ToImmutableArray(),
|
||||
PipelineId = pipelineId,
|
||||
StepId = stepId
|
||||
};
|
||||
|
||||
private static CapturedMaterial CreateMaterial(string uri = "src/main.c", string digest = "abc123") =>
|
||||
new()
|
||||
{
|
||||
Uri = uri,
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = digest }
|
||||
};
|
||||
|
||||
private static CapturedProduct CreateProduct(string uri = "bin/app", string digest = "def456") =>
|
||||
new()
|
||||
{
|
||||
Uri = uri,
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = digest }
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Capture: basic
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_ValidRequest_ReturnsRecordWithDigest()
|
||||
{
|
||||
var result = await _sut.CaptureAsync(CreateRequest());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.LinkDigest.Should().StartWith("sha256:");
|
||||
result.Deduplicated.Should().BeFalse();
|
||||
result.LinkRecord.StepName.Should().Be("build");
|
||||
result.LinkRecord.Functionary.Should().Be("ci-bot");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_SetsTimestampFromProvider()
|
||||
{
|
||||
var expected = new DateTimeOffset(2026, 6, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(expected);
|
||||
|
||||
var result = await _sut.CaptureAsync(CreateRequest());
|
||||
|
||||
result.LinkRecord.CapturedAt.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_WithMaterialsAndProducts_RecordsAll()
|
||||
{
|
||||
var materials = new[] { CreateMaterial("a.c", "1"), CreateMaterial("b.c", "2") };
|
||||
var products = new[] { CreateProduct("app", "3") };
|
||||
var request = CreateRequest(materials: materials, products: products);
|
||||
|
||||
var result = await _sut.CaptureAsync(request);
|
||||
|
||||
result.LinkRecord.Materials.Should().HaveCount(2);
|
||||
result.LinkRecord.Products.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_WithEnvironment_RecordsContext()
|
||||
{
|
||||
var request = CreateRequest() with
|
||||
{
|
||||
Environment = new CapturedEnvironment
|
||||
{
|
||||
Hostname = "ci-node-1",
|
||||
OperatingSystem = "linux"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _sut.CaptureAsync(request);
|
||||
|
||||
result.LinkRecord.Environment.Should().NotBeNull();
|
||||
result.LinkRecord.Environment!.Hostname.Should().Be("ci-node-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_WithByproducts_RecordsByproducts()
|
||||
{
|
||||
var request = CreateRequest() with
|
||||
{
|
||||
Byproducts = new Dictionary<string, string> { ["log"] = "build output" }
|
||||
.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
var result = await _sut.CaptureAsync(request);
|
||||
|
||||
result.LinkRecord.Byproducts.Should().ContainKey("log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_WithPipelineAndStepId_RecordsIds()
|
||||
{
|
||||
var result = await _sut.CaptureAsync(
|
||||
CreateRequest(pipelineId: "pipe-42", stepId: "job-7"));
|
||||
|
||||
result.LinkRecord.PipelineId.Should().Be("pipe-42");
|
||||
result.LinkRecord.StepId.Should().Be("job-7");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Capture: deduplication
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_DuplicateRequest_ReturnsDeduplicated()
|
||||
{
|
||||
var request = CreateRequest();
|
||||
var first = await _sut.CaptureAsync(request);
|
||||
var second = await _sut.CaptureAsync(request);
|
||||
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.LinkDigest.Should().Be(first.LinkDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_DifferentStep_ProducesDifferentDigest()
|
||||
{
|
||||
var r1 = await _sut.CaptureAsync(CreateRequest(step: "build"));
|
||||
var r2 = await _sut.CaptureAsync(CreateRequest(step: "test"));
|
||||
|
||||
r1.LinkDigest.Should().NotBe(r2.LinkDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_DifferentFunctionary_ProducesDifferentDigest()
|
||||
{
|
||||
var r1 = await _sut.CaptureAsync(CreateRequest(functionary: "alice"));
|
||||
var r2 = await _sut.CaptureAsync(CreateRequest(functionary: "bob"));
|
||||
|
||||
r1.LinkDigest.Should().NotBe(r2.LinkDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_DifferentMaterials_ProducesDifferentDigest()
|
||||
{
|
||||
var r1 = await _sut.CaptureAsync(CreateRequest(
|
||||
materials: [CreateMaterial("a.c", "111")]));
|
||||
var r2 = await _sut.CaptureAsync(CreateRequest(
|
||||
materials: [CreateMaterial("b.c", "222")]));
|
||||
|
||||
r1.LinkDigest.Should().NotBe(r2.LinkDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_DigestIsDeterministic()
|
||||
{
|
||||
var materials = new[] { CreateMaterial("z.c", "z"), CreateMaterial("a.c", "a") };
|
||||
var materialsReversed = new[] { CreateMaterial("a.c", "a"), CreateMaterial("z.c", "z") };
|
||||
|
||||
var r1 = await _sut.CaptureAsync(CreateRequest(materials: materials));
|
||||
|
||||
// New service instance to ensure no state leakage
|
||||
using var factory2 = new TestLinkCaptureMeterFactory();
|
||||
var sut2 = new LinkCaptureService(_timeProvider, factory2);
|
||||
var r2 = await sut2.CaptureAsync(CreateRequest(materials: materialsReversed));
|
||||
|
||||
r1.LinkDigest.Should().Be(r2.LinkDigest, "materials order should not affect digest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_EnvironmentDoesNotAffectDigest()
|
||||
{
|
||||
var req1 = CreateRequest() with
|
||||
{
|
||||
Environment = new CapturedEnvironment { Hostname = "node-1" }
|
||||
};
|
||||
var req2 = CreateRequest() with
|
||||
{
|
||||
Environment = new CapturedEnvironment { Hostname = "node-2" }
|
||||
};
|
||||
|
||||
var r1 = await _sut.CaptureAsync(req1);
|
||||
|
||||
using var factory2 = new TestLinkCaptureMeterFactory();
|
||||
var sut2 = new LinkCaptureService(_timeProvider, factory2);
|
||||
var r2 = await sut2.CaptureAsync(req2);
|
||||
|
||||
r1.LinkDigest.Should().Be(r2.LinkDigest,
|
||||
"environment should be excluded from canonical hash");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Capture: validation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_NullRequest_Throws()
|
||||
{
|
||||
var act = () => _sut.CaptureAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_EmptyStepName_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => _sut.CaptureAsync(CreateRequest(step: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_EmptyFunctionary_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => _sut.CaptureAsync(CreateRequest(functionary: " "));
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.CaptureAsync(CreateRequest(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// GetByDigest
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_ExistingDigest_ReturnsRecord()
|
||||
{
|
||||
var capture = await _sut.CaptureAsync(CreateRequest());
|
||||
var record = await _sut.GetByDigestAsync(capture.LinkDigest);
|
||||
|
||||
record.Should().NotBeNull();
|
||||
record!.Digest.Should().Be(capture.LinkDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_UnknownDigest_ReturnsNull()
|
||||
{
|
||||
var record = await _sut.GetByDigestAsync("sha256:nonexistent");
|
||||
record.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_NullDigest_Throws()
|
||||
{
|
||||
var act = () => _sut.GetByDigestAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.GetByDigestAsync("sha256:abc", cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Query
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByStepName_FiltersCorrectly()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(step: "build"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "test"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "package"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery { StepName = "build" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].StepName.Should().Be("build");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByFunctionary_FiltersCorrectly()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(functionary: "alice"));
|
||||
await _sut.CaptureAsync(CreateRequest(functionary: "bob"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery { Functionary = "bob" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Functionary.Should().Be("bob");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ByPipelineId_FiltersCorrectly()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(pipelineId: "pipe-1"));
|
||||
await _sut.CaptureAsync(CreateRequest(pipelineId: "pipe-2"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "other"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery { PipelineId = "pipe-1" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].PipelineId.Should().Be("pipe-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_CaseInsensitiveStepFilter()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(step: "Build"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery { StepName = "build" });
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_EmptyStore_ReturnsEmpty()
|
||||
{
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery());
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_NoFilters_ReturnsAll()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(step: "a", functionary: "x"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "b", functionary: "y"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery());
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_RespectsLimit()
|
||||
{
|
||||
await _sut.CaptureAsync(CreateRequest(step: "a", functionary: "x"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "b", functionary: "y"));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "c", functionary: "z"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery { Limit = 2 });
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_OrdersByDescendingTimestamp()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "first", functionary: "a"));
|
||||
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero));
|
||||
await _sut.CaptureAsync(CreateRequest(step: "second", functionary: "b"));
|
||||
|
||||
var results = await _sut.QueryAsync(new LinkCaptureQuery());
|
||||
|
||||
results[0].StepName.Should().Be("second");
|
||||
results[1].StepName.Should().Be("first");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_NullQuery_Throws()
|
||||
{
|
||||
var act = () => _sut.QueryAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_CancelledToken_Throws()
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.QueryAsync(new LinkCaptureQuery(), cts.Token);
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Constructor
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_Throws()
|
||||
{
|
||||
var act = () => new LinkCaptureService(null, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_UsesSystemDefault()
|
||||
{
|
||||
using var factory = new TestLinkCaptureMeterFactory();
|
||||
var sut = new LinkCaptureService(null, factory);
|
||||
|
||||
sut.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake TimeProvider for test control of timestamps.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public void SetUtcNow(DateTimeOffset value) => _now = value;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Predicates.AI;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Predicates.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="EvidenceCoverageScorer"/>.
|
||||
/// </summary>
|
||||
public sealed class EvidenceCoverageScorerTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2025, 7, 17, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed class CoverageScorerTestMeterFactory : IMeterFactory
|
||||
{
|
||||
public Meter Create(MeterOptions options) => new(options);
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private static EvidenceCoverageScorer CreateScorer(
|
||||
EvidenceCoveragePolicy? policy = null,
|
||||
Func<string, bool>? resolver = null)
|
||||
{
|
||||
return new EvidenceCoverageScorer(
|
||||
policy ?? new EvidenceCoveragePolicy(),
|
||||
resolver ?? (_ => true),
|
||||
new CoverageScorerTestMeterFactory());
|
||||
}
|
||||
|
||||
private static DimensionEvidenceInput Input(EvidenceDimension dim, params string[] ids) => new()
|
||||
{
|
||||
Dimension = dim,
|
||||
EvidenceIds = [..ids]
|
||||
};
|
||||
|
||||
// --- Full coverage (all resolvable) ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_AllDimensionsFullyResolvable_ReturnsGreen()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1", "r2"),
|
||||
Input(EvidenceDimension.BinaryAnalysis, "b1"),
|
||||
Input(EvidenceDimension.SbomCompleteness, "s1", "s2", "s3"),
|
||||
Input(EvidenceDimension.VexCoverage, "v1"),
|
||||
Input(EvidenceDimension.Provenance, "p1")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
result.OverallScore.Should().Be(1.0);
|
||||
result.CoveragePercentage.Should().Be(100.0);
|
||||
result.CoverageLevel.Should().Be(CoverageLevel.Green);
|
||||
result.MeetsAiGatingThreshold.Should().BeTrue();
|
||||
result.SubjectRef.Should().Be("pkg:test@1.0");
|
||||
result.EvaluatedAt.Should().Be(FixedTime);
|
||||
result.Dimensions.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
// --- No evidence at all ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_NoEvidenceProvided_ReturnsRedWithZeroScore()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var inputs = new List<DimensionEvidenceInput>();
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
result.OverallScore.Should().Be(0.0);
|
||||
result.CoverageLevel.Should().Be(CoverageLevel.Red);
|
||||
result.MeetsAiGatingThreshold.Should().BeFalse();
|
||||
}
|
||||
|
||||
// --- Partial coverage ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_PartialResolvable_ReturnsCorrectScore()
|
||||
{
|
||||
// Resolver returns true only for "good-*" IDs
|
||||
var scorer = CreateScorer(resolver: id => id.StartsWith("good", StringComparison.Ordinal));
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "good-1", "bad-1"), // 0.5
|
||||
Input(EvidenceDimension.BinaryAnalysis, "good-1", "good-2"), // 1.0
|
||||
Input(EvidenceDimension.SbomCompleteness, "bad-1", "bad-2"), // 0.0
|
||||
Input(EvidenceDimension.VexCoverage, "good-1"), // 1.0
|
||||
Input(EvidenceDimension.Provenance, "good-1") // 1.0
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
// Weighted: (0.5*0.25 + 1.0*0.20 + 0.0*0.25 + 1.0*0.20 + 1.0*0.10) / 1.0
|
||||
// = (0.125 + 0.20 + 0.0 + 0.20 + 0.10) / 1.0 = 0.625
|
||||
result.OverallScore.Should().BeApproximately(0.625, 0.001);
|
||||
result.CoverageLevel.Should().Be(CoverageLevel.Yellow);
|
||||
result.MeetsAiGatingThreshold.Should().BeFalse();
|
||||
}
|
||||
|
||||
// --- Per-dimension breakdown ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_DimensionResultsIncludeCorrectCounts()
|
||||
{
|
||||
var resolver = (string id) => id != "unresolvable";
|
||||
var scorer = CreateScorer(resolver: resolver);
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "a", "b", "unresolvable")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
var reachDim = result.Dimensions.First(d => d.Dimension == EvidenceDimension.Reachability);
|
||||
reachDim.EvidenceCount.Should().Be(3);
|
||||
reachDim.ResolvableCount.Should().Be(2);
|
||||
reachDim.Score.Should().BeApproximately(2.0 / 3.0, 0.001);
|
||||
reachDim.Reason.Should().Contain("2 of 3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_MissingDimension_GetsZeroScore()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
var binaryDim = result.Dimensions.First(d => d.Dimension == EvidenceDimension.BinaryAnalysis);
|
||||
binaryDim.Score.Should().Be(0.0);
|
||||
binaryDim.EvidenceCount.Should().Be(0);
|
||||
binaryDim.Reason.Should().Contain("No evidence");
|
||||
}
|
||||
|
||||
// --- Gating threshold ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_ExactlyAtThreshold_MeetsGating()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy { AiGatingThreshold = 1.0 };
|
||||
var scorer = CreateScorer(policy: policy);
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1"),
|
||||
Input(EvidenceDimension.BinaryAnalysis, "b1"),
|
||||
Input(EvidenceDimension.SbomCompleteness, "s1"),
|
||||
Input(EvidenceDimension.VexCoverage, "v1"),
|
||||
Input(EvidenceDimension.Provenance, "p1")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
result.MeetsAiGatingThreshold.Should().BeTrue();
|
||||
scorer.MeetsGatingThreshold(result).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_BelowThreshold_FailsGating()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy { AiGatingThreshold = 0.99 };
|
||||
var scorer = CreateScorer(policy: policy, resolver: _ => false);
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
result.MeetsAiGatingThreshold.Should().BeFalse();
|
||||
}
|
||||
|
||||
// --- Coverage levels ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_CustomThresholds_CorrectLevel()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy
|
||||
{
|
||||
GreenThreshold = 0.90,
|
||||
YellowThreshold = 0.60,
|
||||
// Only use reachability for simplicity
|
||||
ReachabilityWeight = 1.0,
|
||||
BinaryAnalysisWeight = 0.0,
|
||||
SbomCompletenessWeight = 0.0,
|
||||
VexCoverageWeight = 0.0,
|
||||
ProvenanceWeight = 0.0
|
||||
};
|
||||
|
||||
var scorer = CreateScorer(policy: policy);
|
||||
|
||||
// 7 of 10 resolvable = 0.70 → Yellow
|
||||
var resolver70 = (string id) => int.TryParse(id, out var n) && n <= 7;
|
||||
var scorer70 = new EvidenceCoverageScorer(policy, resolver70, new CoverageScorerTestMeterFactory());
|
||||
var inputs = Enumerable.Range(1, 10).Select(i => i.ToString()).ToArray();
|
||||
var result = await scorer70.ComputeCoverageAsync("test", [Input(EvidenceDimension.Reachability, inputs)], FixedTime);
|
||||
result.CoverageLevel.Should().Be(CoverageLevel.Yellow);
|
||||
}
|
||||
|
||||
// --- Policy validation ---
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeWeight_Throws()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy { ReachabilityWeight = -0.1 };
|
||||
var act = () => CreateScorer(policy: policy);
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*non-negative*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidGatingThreshold_Throws()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy { AiGatingThreshold = 1.5 };
|
||||
var act = () => CreateScorer(policy: policy);
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*gating*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_GreenBelowYellow_Throws()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy { GreenThreshold = 0.40, YellowThreshold = 0.60 };
|
||||
var act = () => CreateScorer(policy: policy);
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*Green*yellow*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullPolicy_Throws()
|
||||
{
|
||||
var act = () => new EvidenceCoverageScorer(null!, _ => true, new CoverageScorerTestMeterFactory());
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullResolver_Throws()
|
||||
{
|
||||
var act = () => new EvidenceCoverageScorer(new EvidenceCoveragePolicy(), null!, new CoverageScorerTestMeterFactory());
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullMeterFactory_Throws()
|
||||
{
|
||||
var act = () => new EvidenceCoverageScorer(new EvidenceCoveragePolicy(), _ => true, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_CancelledToken_ThrowsOperationCancelled()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
var act = () => scorer.ComputeCoverageAsync("test", [], FixedTime, cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
// --- Null arguments ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_NullSubjectRef_Throws()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var act = () => scorer.ComputeCoverageAsync(null!, [], FixedTime);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_NullInputs_Throws()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var act = () => scorer.ComputeCoverageAsync("test", null!, FixedTime);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeetsGatingThreshold_NullResult_Throws()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var act = () => scorer.MeetsGatingThreshold(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// --- Determinism ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_SameInputs_ProduceSameResult()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1", "r2"),
|
||||
Input(EvidenceDimension.BinaryAnalysis, "b1")
|
||||
};
|
||||
|
||||
var r1 = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
var r2 = await scorer.ComputeCoverageAsync("pkg:test@1.0", inputs, FixedTime);
|
||||
|
||||
r1.OverallScore.Should().Be(r2.OverallScore);
|
||||
r1.CoverageLevel.Should().Be(r2.CoverageLevel);
|
||||
r1.MeetsAiGatingThreshold.Should().Be(r2.MeetsAiGatingThreshold);
|
||||
}
|
||||
|
||||
// --- Default policy ---
|
||||
|
||||
[Fact]
|
||||
public void DefaultPolicy_HasExpectedDefaults()
|
||||
{
|
||||
var policy = new EvidenceCoveragePolicy();
|
||||
|
||||
policy.ReachabilityWeight.Should().Be(0.25);
|
||||
policy.BinaryAnalysisWeight.Should().Be(0.20);
|
||||
policy.SbomCompletenessWeight.Should().Be(0.25);
|
||||
policy.VexCoverageWeight.Should().Be(0.20);
|
||||
policy.ProvenanceWeight.Should().Be(0.10);
|
||||
policy.AiGatingThreshold.Should().Be(0.80);
|
||||
policy.GreenThreshold.Should().Be(0.80);
|
||||
policy.YellowThreshold.Should().Be(0.50);
|
||||
}
|
||||
|
||||
// --- All dimensions fully covered with reason text ---
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCoverage_FullyCovered_DimensionReasonSaysAll()
|
||||
{
|
||||
var scorer = CreateScorer();
|
||||
var inputs = new List<DimensionEvidenceInput>
|
||||
{
|
||||
Input(EvidenceDimension.Reachability, "r1")
|
||||
};
|
||||
|
||||
var result = await scorer.ComputeCoverageAsync("test", inputs, FixedTime);
|
||||
var reachDim = result.Dimensions.First(d => d.Dimension == EvidenceDimension.Reachability);
|
||||
reachDim.Reason.Should().Contain("All 1 evidence items resolvable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
using StellaOps.Attestor.ProofChain.Receipts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Receipts;
|
||||
|
||||
public sealed class FieldOwnershipValidatorTests
|
||||
{
|
||||
private readonly FieldOwnershipValidator _sut = new();
|
||||
|
||||
private static VerificationReceipt CreateFullReceipt() => new()
|
||||
{
|
||||
ProofBundleId = new ProofBundleId("abc123"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor-001"),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks =
|
||||
[
|
||||
new VerificationCheck
|
||||
{
|
||||
Check = "signature",
|
||||
Status = VerificationResult.Pass,
|
||||
KeyId = "key-1",
|
||||
Expected = "sha256:aaa",
|
||||
Actual = "sha256:aaa",
|
||||
LogIndex = 42,
|
||||
Details = "Signature valid"
|
||||
}
|
||||
],
|
||||
ToolDigests = new Dictionary<string, string> { ["verifier"] = "sha256:bbb" }
|
||||
};
|
||||
|
||||
private static VerificationReceipt CreateMinimalReceipt() => new()
|
||||
{
|
||||
ProofBundleId = new ProofBundleId("min-123"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor-min"),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks =
|
||||
[
|
||||
new VerificationCheck
|
||||
{
|
||||
Check = "basic",
|
||||
Status = VerificationResult.Pass
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// --- ReceiptOwnershipMap Tests ---
|
||||
|
||||
[Fact]
|
||||
public void ReceiptOwnershipMap_ReturnsDefaultMap()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
map.Should().NotBeNull();
|
||||
map.DocumentType.Should().Be("VerificationReceipt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptOwnershipMap_ContainsExpectedEntries()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
map.Entries.Should().HaveCountGreaterOrEqualTo(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptOwnershipMap_HasTopLevelFields()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
var topLevel = map.Entries
|
||||
.Where(e => !e.FieldPath.StartsWith("checks[]"))
|
||||
.Select(e => e.FieldPath)
|
||||
.ToList();
|
||||
|
||||
topLevel.Should().Contain("proofBundleId");
|
||||
topLevel.Should().Contain("verifiedAt");
|
||||
topLevel.Should().Contain("verifierVersion");
|
||||
topLevel.Should().Contain("anchorId");
|
||||
topLevel.Should().Contain("result");
|
||||
topLevel.Should().Contain("checks");
|
||||
topLevel.Should().Contain("toolDigests");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptOwnershipMap_HasCheckFields()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
var checkFields = map.Entries
|
||||
.Where(e => e.FieldPath.StartsWith("checks[]"))
|
||||
.Select(e => e.FieldPath)
|
||||
.ToList();
|
||||
|
||||
checkFields.Should().Contain("checks[].check");
|
||||
checkFields.Should().Contain("checks[].status");
|
||||
checkFields.Should().Contain("checks[].keyId");
|
||||
checkFields.Should().Contain("checks[].logIndex");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("proofBundleId", OwnerModule.Core)]
|
||||
[InlineData("verifiedAt", OwnerModule.Core)]
|
||||
[InlineData("verifierVersion", OwnerModule.Core)]
|
||||
[InlineData("anchorId", OwnerModule.Verification)]
|
||||
[InlineData("result", OwnerModule.Verification)]
|
||||
[InlineData("checks", OwnerModule.Verification)]
|
||||
[InlineData("toolDigests", OwnerModule.Core)]
|
||||
public void ReceiptOwnershipMap_CorrectOwnerAssignment(string fieldPath, OwnerModule expectedOwner)
|
||||
{
|
||||
var entry = _sut.ReceiptOwnershipMap.Entries
|
||||
.First(e => e.FieldPath == fieldPath);
|
||||
entry.Owner.Should().Be(expectedOwner);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("checks[].keyId", OwnerModule.Signing)]
|
||||
[InlineData("checks[].logIndex", OwnerModule.Rekor)]
|
||||
[InlineData("checks[].check", OwnerModule.Verification)]
|
||||
[InlineData("checks[].status", OwnerModule.Verification)]
|
||||
public void ReceiptOwnershipMap_CheckFieldOwners(string fieldPath, OwnerModule expectedOwner)
|
||||
{
|
||||
var entry = _sut.ReceiptOwnershipMap.Entries
|
||||
.First(e => e.FieldPath == fieldPath);
|
||||
entry.Owner.Should().Be(expectedOwner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReceiptOwnershipMap_AllEntriesHaveDescriptions()
|
||||
{
|
||||
var map = _sut.ReceiptOwnershipMap;
|
||||
foreach (var entry in map.Entries)
|
||||
{
|
||||
entry.Description.Should().NotBeNullOrWhiteSpace(
|
||||
$"Entry '{entry.FieldPath}' should have a description");
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateReceiptOwnershipAsync Tests ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_FullReceipt_IsValid()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.DocumentType.Should().Be("VerificationReceipt");
|
||||
result.MissingRequiredCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_FullReceipt_PopulatesAllFields()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.TotalFields.Should().BeGreaterThan(0);
|
||||
result.PopulatedCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_MinimalReceipt_IsValid()
|
||||
{
|
||||
var receipt = CreateMinimalReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.MissingRequiredCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_MinimalReceipt_OptionalFieldsNotPopulated()
|
||||
{
|
||||
var receipt = CreateMinimalReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
// ToolDigests is optional and not set
|
||||
var toolDigests = result.Fields.FirstOrDefault(f => f.FieldPath == "toolDigests");
|
||||
toolDigests.Should().NotBeNull();
|
||||
toolDigests!.IsPopulated.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_RecordsValidatedAt()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(receipt, now);
|
||||
|
||||
result.ValidatedAt.Should().Be(now);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_EmptyChecks_MissingRequired()
|
||||
{
|
||||
var receipt = new VerificationReceipt
|
||||
{
|
||||
ProofBundleId = new ProofBundleId("abc"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor"),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks = []
|
||||
};
|
||||
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.MissingRequiredCount.Should().BeGreaterThan(0);
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_MultipleChecks_GeneratesFieldsForEach()
|
||||
{
|
||||
var receipt = new VerificationReceipt
|
||||
{
|
||||
ProofBundleId = new ProofBundleId("abc"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = new TrustAnchorId("anchor"),
|
||||
Result = VerificationResult.Pass,
|
||||
Checks =
|
||||
[
|
||||
new VerificationCheck { Check = "sig", Status = VerificationResult.Pass },
|
||||
new VerificationCheck { Check = "digest", Status = VerificationResult.Pass }
|
||||
]
|
||||
};
|
||||
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
var checkFields = result.Fields
|
||||
.Where(f => f.FieldPath == "checks[].check")
|
||||
.ToList();
|
||||
checkFields.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_AllOwnershipIsValid()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.ValidCount.Should().Be(result.TotalFields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_NullReceipt_ThrowsArgumentNull()
|
||||
{
|
||||
var act = () => _sut.ValidateReceiptOwnershipAsync(null!, DateTimeOffset.UtcNow);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_CancellationToken_Respected()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow, cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateReceiptOwnershipAsync_Deterministic()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var result1 = await _sut.ValidateReceiptOwnershipAsync(receipt, now);
|
||||
var result2 = await _sut.ValidateReceiptOwnershipAsync(receipt, now);
|
||||
|
||||
result1.TotalFields.Should().Be(result2.TotalFields);
|
||||
result1.PopulatedCount.Should().Be(result2.PopulatedCount);
|
||||
result1.ValidCount.Should().Be(result2.ValidCount);
|
||||
result1.MissingRequiredCount.Should().Be(result2.MissingRequiredCount);
|
||||
result1.IsValid.Should().Be(result2.IsValid);
|
||||
}
|
||||
|
||||
// --- DefaultReceiptMap Static Tests ---
|
||||
|
||||
[Fact]
|
||||
public void DefaultReceiptMap_SchemaVersion_IsSet()
|
||||
{
|
||||
var map = FieldOwnershipValidator.DefaultReceiptMap;
|
||||
// SchemaVersion defaults to "1.0"
|
||||
map.SchemaVersion.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultReceiptMap_RequiredFields_AreMarked()
|
||||
{
|
||||
var map = FieldOwnershipValidator.DefaultReceiptMap;
|
||||
var requiredTopLevel = map.Entries
|
||||
.Where(e => e.IsRequired && !e.FieldPath.StartsWith("checks[]"))
|
||||
.Select(e => e.FieldPath)
|
||||
.ToList();
|
||||
|
||||
requiredTopLevel.Should().Contain("proofBundleId");
|
||||
requiredTopLevel.Should().Contain("verifiedAt");
|
||||
requiredTopLevel.Should().Contain("verifierVersion");
|
||||
requiredTopLevel.Should().Contain("anchorId");
|
||||
requiredTopLevel.Should().Contain("result");
|
||||
requiredTopLevel.Should().Contain("checks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultReceiptMap_OptionalFields_AreMarked()
|
||||
{
|
||||
var map = FieldOwnershipValidator.DefaultReceiptMap;
|
||||
var optionalTopLevel = map.Entries
|
||||
.Where(e => !e.IsRequired && !e.FieldPath.StartsWith("checks[]"))
|
||||
.Select(e => e.FieldPath)
|
||||
.ToList();
|
||||
|
||||
optionalTopLevel.Should().Contain("toolDigests");
|
||||
}
|
||||
|
||||
// --- FieldOwnershipValidationResult Computed Properties ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidationResult_ComputedProperties_AreCorrect()
|
||||
{
|
||||
var receipt = CreateFullReceipt();
|
||||
var result = await _sut.ValidateReceiptOwnershipAsync(
|
||||
receipt, DateTimeOffset.UtcNow);
|
||||
|
||||
result.TotalFields.Should().Be(result.Fields.Length);
|
||||
result.PopulatedCount.Should().Be(
|
||||
result.Fields.Count(f => f.IsPopulated));
|
||||
result.ValidCount.Should().Be(
|
||||
result.Fields.Count(f => f.OwnershipValid));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user