feat(zastava): add evidence locker plan and schema examples
- Introduced README.md for Zastava Evidence Locker Plan detailing artifacts to sign and post-signing steps. - Added example JSON schemas for observer events and webhook admissions. - Updated implementor guidelines with checklist for CI linting, determinism, secrets management, and schema control. - Created alert rules for Vuln Explorer to monitor API latency and projection errors. - Developed analytics ingestion plan for Vuln Explorer, focusing on telemetry and PII guardrails. - Implemented Grafana dashboard configuration for Vuln Explorer metrics visualization. - Added expected projection SHA256 for vulnerability events. - Created k6 load testing script for Vuln Explorer API. - Added sample projection and replay event data for testing. - Implemented ReplayInputsLock for deterministic replay inputs management. - Developed tests for ReplayInputsLock to ensure stable hash computation. - Created SurfaceManifestDeterminismVerifier to validate manifest determinism and integrity. - Added unit tests for SurfaceManifestDeterminismVerifier to ensure correct functionality. - Implemented Angular tests for VulnerabilityHttpClient and VulnerabilityDetailComponent to verify API interactions and UI rendering.
This commit is contained in:
@@ -104,6 +104,16 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
|
||||
artifacts.Add(artifact);
|
||||
}
|
||||
|
||||
var compositionRecipe = artifacts.FirstOrDefault(a => string.Equals(a.Kind, "composition.recipe", StringComparison.Ordinal));
|
||||
var determinismMetadata = string.IsNullOrWhiteSpace(request.DeterminismMerkleRoot) && compositionRecipe is null
|
||||
? null
|
||||
: new SurfaceDeterminismMetadata
|
||||
{
|
||||
MerkleRoot = request.DeterminismMerkleRoot ?? string.Empty,
|
||||
RecipeDigest = compositionRecipe?.Digest,
|
||||
CompositionRecipeUri = compositionRecipe?.Uri
|
||||
};
|
||||
|
||||
var manifestDocument = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = tenant,
|
||||
@@ -119,6 +129,7 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
|
||||
},
|
||||
Artifacts = AttachAttestations(artifacts).ToImmutableArray(),
|
||||
DeterminismMerkleRoot = request.DeterminismMerkleRoot,
|
||||
Determinism = determinismMetadata,
|
||||
ReplayBundle = string.IsNullOrWhiteSpace(request.ReplayBundleUri)
|
||||
? null
|
||||
: new ReplayBundleReference
|
||||
|
||||
@@ -104,7 +104,7 @@ public sealed class FileSurfaceManifestStore :
|
||||
normalized.Tenant,
|
||||
digest);
|
||||
|
||||
return new SurfaceManifestPublishResult(digest, uri, artifactId, normalized, null);
|
||||
return new SurfaceManifestPublishResult(digest, uri, artifactId, normalized, normalized.DeterminismMerkleRoot);
|
||||
}
|
||||
|
||||
public async Task<SurfaceManifestDocument?> TryGetByDigestAsync(
|
||||
@@ -173,6 +173,25 @@ public sealed class FileSurfaceManifestStore :
|
||||
? DateTimeOffset.MinValue
|
||||
: document.GeneratedAt.ToUniversalTime();
|
||||
|
||||
var merkleRoot = string.IsNullOrWhiteSpace(document.DeterminismMerkleRoot)
|
||||
? null
|
||||
: document.DeterminismMerkleRoot.Trim().ToLowerInvariant();
|
||||
|
||||
var determinism = document.Determinism is null && merkleRoot is not null
|
||||
? new SurfaceDeterminismMetadata { MerkleRoot = merkleRoot! }
|
||||
: document.Determinism is null
|
||||
? null
|
||||
: document.Determinism with
|
||||
{
|
||||
MerkleRoot = document.Determinism.MerkleRoot.Trim().ToLowerInvariant(),
|
||||
RecipeDigest = string.IsNullOrWhiteSpace(document.Determinism.RecipeDigest)
|
||||
? null
|
||||
: EnsureShaPrefix(document.Determinism.RecipeDigest!),
|
||||
CompositionRecipeUri = string.IsNullOrWhiteSpace(document.Determinism.CompositionRecipeUri)
|
||||
? null
|
||||
: document.Determinism.CompositionRecipeUri.Trim()
|
||||
};
|
||||
|
||||
var artifacts = document.Artifacts
|
||||
.Select(NormalizeArtifact)
|
||||
.OrderBy(static a => a.Kind, StringComparer.Ordinal)
|
||||
@@ -182,7 +201,9 @@ public sealed class FileSurfaceManifestStore :
|
||||
return document with
|
||||
{
|
||||
GeneratedAt = generatedAt,
|
||||
Artifacts = artifacts
|
||||
Artifacts = artifacts,
|
||||
DeterminismMerkleRoot = merkleRoot ?? document.DeterminismMerkleRoot,
|
||||
Determinism = determinism
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,16 +217,37 @@ public sealed class FileSurfaceManifestStore :
|
||||
{
|
||||
if (artifact.Metadata is null || artifact.Metadata.Count == 0)
|
||||
{
|
||||
return artifact;
|
||||
return NormalizeAttestations(artifact);
|
||||
}
|
||||
|
||||
var sorted = artifact.Metadata
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
|
||||
|
||||
return artifact with { Metadata = sorted };
|
||||
return NormalizeAttestations(artifact with { Metadata = sorted });
|
||||
}
|
||||
|
||||
private static SurfaceManifestArtifact NormalizeAttestations(SurfaceManifestArtifact artifact)
|
||||
{
|
||||
if (artifact.Attestations is null || artifact.Attestations.Count == 0)
|
||||
{
|
||||
return artifact;
|
||||
}
|
||||
|
||||
var att = artifact.Attestations
|
||||
.OrderBy(a => a.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Uri, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return artifact with { Attestations = att };
|
||||
}
|
||||
|
||||
private static string EnsureShaPrefix(string digest)
|
||||
=> digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? digest
|
||||
: $"sha256:{digest}";
|
||||
|
||||
private static IEnumerable<string> EnumerateTenantDirectories(string rootDirectory)
|
||||
{
|
||||
if (!Directory.Exists(rootDirectory))
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies determinism metadata on a Surface manifest by checking composition recipe,
|
||||
/// layer fragment attestations, and DSSE payload integrity.
|
||||
/// </summary>
|
||||
public sealed class SurfaceManifestDeterminismVerifier
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public async Task<SurfaceDeterminismVerificationResult> VerifyAsync(
|
||||
SurfaceManifestDocument manifest,
|
||||
Func<SurfaceManifestArtifact, Task<ReadOnlyMemory<byte>>> artifactLoader,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(manifest));
|
||||
}
|
||||
|
||||
if (artifactLoader is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(artifactLoader));
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
var merkleRoot = (manifest.DeterminismMerkleRoot ?? manifest.Determinism?.MerkleRoot)?.Trim().ToLowerInvariant();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(merkleRoot))
|
||||
{
|
||||
errors.Add("determinism.merkleRoot missing from manifest.");
|
||||
}
|
||||
|
||||
var artifactsByDigest = manifest.Artifacts.ToDictionary(a => a.Digest, StringComparer.OrdinalIgnoreCase);
|
||||
var artifactsByUri = manifest.Artifacts.Where(a => !string.IsNullOrWhiteSpace(a.Uri))
|
||||
.ToDictionary(a => a.Uri, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Validate composition recipe first; it anchors the Merkle root.
|
||||
var recipe = manifest.Artifacts.FirstOrDefault(a => string.Equals(a.Kind, "composition.recipe", StringComparison.Ordinal));
|
||||
if (recipe is null)
|
||||
{
|
||||
errors.Add("composition.recipe artifact missing.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var recipeBytes = await LoadAndValidateDigestAsync(recipe, artifactLoader, errors, cancellationToken).ConfigureAwait(false);
|
||||
if (recipeBytes.Length > 0)
|
||||
{
|
||||
var computedRoot = ComputeSha256Hex(recipeBytes.Span);
|
||||
if (string.IsNullOrWhiteSpace(merkleRoot))
|
||||
{
|
||||
merkleRoot = computedRoot;
|
||||
}
|
||||
else if (!string.Equals(merkleRoot, computedRoot, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add($"determinism.merkleRoot mismatch: manifest={merkleRoot}, recipe={computedRoot}.");
|
||||
}
|
||||
|
||||
await VerifyAttestationAsync(
|
||||
recipe,
|
||||
recipeBytes,
|
||||
expectedPayloadType: recipe.MediaType,
|
||||
artifactsByDigest,
|
||||
artifactsByUri,
|
||||
artifactLoader,
|
||||
errors,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each layer fragment and its DSSE.
|
||||
foreach (var fragment in manifest.Artifacts.Where(a => string.Equals(a.Kind, "layer.fragments", StringComparison.Ordinal)))
|
||||
{
|
||||
var fragmentBytes = await LoadAndValidateDigestAsync(fragment, artifactLoader, errors, cancellationToken).ConfigureAwait(false);
|
||||
if (fragmentBytes.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await VerifyAttestationAsync(
|
||||
fragment,
|
||||
fragmentBytes,
|
||||
expectedPayloadType: fragment.MediaType,
|
||||
artifactsByDigest,
|
||||
artifactsByUri,
|
||||
artifactLoader,
|
||||
errors,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new SurfaceDeterminismVerificationResult(errors.Count == 0, merkleRoot, errors);
|
||||
}
|
||||
|
||||
private static async Task<ReadOnlyMemory<byte>> LoadAndValidateDigestAsync(
|
||||
SurfaceManifestArtifact artifact,
|
||||
Func<SurfaceManifestArtifact, Task<ReadOnlyMemory<byte>>> loader,
|
||||
List<string> errors,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var bytes = await loader(artifact).ConfigureAwait(false);
|
||||
if (bytes.Length == 0)
|
||||
{
|
||||
errors.Add($"artifact:{artifact.Kind} ({artifact.Digest}) content missing.");
|
||||
return ReadOnlyMemory<byte>.Empty;
|
||||
}
|
||||
|
||||
var computedDigest = $"sha256:{ComputeSha256Hex(bytes.Span)}";
|
||||
if (!string.Equals(computedDigest, artifact.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"artifact:{artifact.Kind} digest mismatch (manifest={artifact.Digest}, computed={computedDigest}).");
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"artifact:{artifact.Kind} load failed: {ex.Message}");
|
||||
return ReadOnlyMemory<byte>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task VerifyAttestationAsync(
|
||||
SurfaceManifestArtifact target,
|
||||
ReadOnlyMemory<byte> targetContent,
|
||||
string expectedPayloadType,
|
||||
IReadOnlyDictionary<string, SurfaceManifestArtifact> artifactsByDigest,
|
||||
IReadOnlyDictionary<string, SurfaceManifestArtifact> artifactsByUri,
|
||||
Func<SurfaceManifestArtifact, Task<ReadOnlyMemory<byte>>> loader,
|
||||
List<string> errors,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (target.Attestations is null || target.Attestations.Count == 0)
|
||||
{
|
||||
errors.Add($"artifact:{target.Kind} missing dsse attestation.");
|
||||
return;
|
||||
}
|
||||
|
||||
var attestation = target.Attestations.FirstOrDefault(a => string.Equals(a.Kind, "dsse", StringComparison.Ordinal));
|
||||
if (attestation is null)
|
||||
{
|
||||
errors.Add($"artifact:{target.Kind} missing dsse attestation.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!artifactsByDigest.TryGetValue(attestation.Digest, out var dsseArtifact) &&
|
||||
(!string.IsNullOrWhiteSpace(attestation.Uri) && !artifactsByUri.TryGetValue(attestation.Uri, out dsseArtifact)))
|
||||
{
|
||||
errors.Add($"artifact:{target.Kind} attestation not found in manifest (digest={attestation.Digest}).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dsseArtifact is null)
|
||||
{
|
||||
errors.Add($"artifact:{target.Kind} attestation lookup returned null instance.");
|
||||
return;
|
||||
}
|
||||
|
||||
var dsseBytes = await LoadAndValidateDigestAsync(dsseArtifact, loader, errors, cancellationToken).ConfigureAwait(false);
|
||||
if (dsseBytes.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(dsseBytes.ToArray(), new JsonDocumentOptions { AllowTrailingCommas = false });
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("payloadType", out var payloadTypeProp))
|
||||
{
|
||||
errors.Add($"artifact:{target.Kind} attestation payloadType missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
var payloadType = payloadTypeProp.GetString() ?? string.Empty;
|
||||
if (!string.Equals(payloadType, expectedPayloadType, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add($"artifact:{target.Kind} attestation payloadType mismatch (expected={expectedPayloadType}, actual={payloadType}).");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("payload", out var payloadProp))
|
||||
{
|
||||
errors.Add($"artifact:{target.Kind} attestation payload missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = DecodeBase64Url(payloadProp.GetString());
|
||||
if (!payload.Span.SequenceEqual(targetContent.Span))
|
||||
{
|
||||
errors.Add($"artifact:{target.Kind} attestation payload does not match artifact content.");
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("signatures", out var sigArray) &&
|
||||
sigArray.ValueKind == JsonValueKind.Array &&
|
||||
sigArray.GetArrayLength() > 0)
|
||||
{
|
||||
var sigNode = sigArray[0];
|
||||
if (sigNode.TryGetProperty("sig", out var sigValue))
|
||||
{
|
||||
var sigBytes = DecodeBase64Url(sigValue.GetString());
|
||||
var sigText = Encoding.UTF8.GetString(sigBytes.Span);
|
||||
var expectedSig = ComputeSha256Hex(targetContent.Span);
|
||||
if (!string.Equals(sigText, expectedSig, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"artifact:{target.Kind} attestation signature mismatch.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"artifact:{target.Kind} attestation parse failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static ReadOnlyMemory<byte> DecodeBase64Url(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return ReadOnlyMemory<byte>.Empty;
|
||||
}
|
||||
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
switch (padded.Length % 4)
|
||||
{
|
||||
case 2: padded += "=="; break;
|
||||
case 3: padded += "="; break;
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SurfaceDeterminismVerificationResult(
|
||||
bool Success,
|
||||
string? MerkleRoot,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public bool IsDeterministic => Success;
|
||||
}
|
||||
@@ -46,12 +46,36 @@ public sealed record SurfaceManifestDocument
|
||||
public string? DeterminismMerkleRoot { get; init; }
|
||||
= null;
|
||||
|
||||
[JsonPropertyName("determinism")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public SurfaceDeterminismMetadata? Determinism { get; init; }
|
||||
= null;
|
||||
|
||||
[JsonPropertyName("replayBundle")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ReplayBundleReference? ReplayBundle { get; init; }
|
||||
= null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determinism metadata for offline replay and verification.
|
||||
/// </summary>
|
||||
public sealed record SurfaceDeterminismMetadata
|
||||
{
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
public string MerkleRoot { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("recipeDigest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RecipeDigest { get; init; }
|
||||
= null;
|
||||
|
||||
[JsonPropertyName("compositionRecipeUri")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CompositionRecipeUri { get; init; }
|
||||
= null;
|
||||
}
|
||||
|
||||
public sealed record ReplayBundleReference
|
||||
{
|
||||
[JsonPropertyName("uri")]
|
||||
|
||||
@@ -101,6 +101,71 @@ public sealed class FileSurfaceManifestStoreTests : IAsyncDisposable
|
||||
Assert.Equal("scan-123", retrieved.ScanId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_NormalizesDeterminismMetadataAndAttestations()
|
||||
{
|
||||
var doc = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = "acme",
|
||||
DeterminismMerkleRoot = "ABCDEF",
|
||||
Determinism = new SurfaceDeterminismMetadata
|
||||
{
|
||||
MerkleRoot = "ABCDEF",
|
||||
RecipeDigest = "1234",
|
||||
CompositionRecipeUri = " cas://bucket/recipe.json "
|
||||
},
|
||||
Artifacts = new[]
|
||||
{
|
||||
new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = "layer.fragments",
|
||||
Uri = "cas://bucket/fragments.json",
|
||||
Digest = "sha256:bbbb",
|
||||
MediaType = "application/json",
|
||||
Format = "json",
|
||||
Attestations = new[]
|
||||
{
|
||||
new SurfaceManifestAttestation
|
||||
{
|
||||
Kind = "dsse",
|
||||
Digest = "sha256:dddd",
|
||||
Uri = "cas://attest/dsse.json"
|
||||
},
|
||||
new SurfaceManifestAttestation
|
||||
{
|
||||
Kind = "dsse",
|
||||
Digest = "sha256:cccc",
|
||||
Uri = "cas://attest/other.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = "composition.recipe",
|
||||
Uri = "cas://bucket/recipe.json",
|
||||
Digest = "sha256:1234",
|
||||
MediaType = "application/json",
|
||||
Format = "composition.recipe"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _store.PublishAsync(doc);
|
||||
|
||||
Assert.Equal("abcdef", result.Document.DeterminismMerkleRoot);
|
||||
Assert.Equal("sha256:1234", result.Document.Determinism!.RecipeDigest);
|
||||
Assert.Equal("cas://bucket/recipe.json", result.Document.Determinism!.CompositionRecipeUri);
|
||||
|
||||
var attestationOrder = result.Document.Artifacts
|
||||
.Single(a => a.Kind == "layer.fragments")
|
||||
.Attestations!
|
||||
.Select(a => a.Digest)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(new[] { "sha256:cccc", "sha256:dddd" }, attestationOrder);
|
||||
Assert.Equal(result.Document.DeterminismMerkleRoot, result.DeterminismMerkleRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetByDigestAsync_ReturnsManifestAcrossTenants()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS.Tests;
|
||||
|
||||
public sealed class SurfaceManifestDeterminismVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Succeeds_WhenRecipeAndFragmentsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var fragmentContent = Encoding.UTF8.GetBytes("{\"layers\":1}");
|
||||
var fragmentDigest = Sha("layer.fragments", fragmentContent);
|
||||
|
||||
var recipeBytes = Encoding.UTF8.GetBytes("{\"schema\":\"stellaops.composition.recipe@1\",\"artifacts\":{\"layer.fragments\":\"" + fragmentDigest + "\"}}");
|
||||
var recipeDigest = $"sha256:{ShaHex(recipeBytes)}";
|
||||
var merkleRoot = ShaHex(recipeBytes);
|
||||
|
||||
var recipeDsseBytes = BuildDeterministicDsse("application/vnd.stellaops.composition.recipe+json", recipeBytes);
|
||||
var recipeDsseDigest = $"sha256:{ShaHex(recipeDsseBytes)}";
|
||||
|
||||
var fragmentDsseBytes = BuildDeterministicDsse("application/json", fragmentContent);
|
||||
var fragmentDsseDigest = $"sha256:{ShaHex(fragmentDsseBytes)}";
|
||||
|
||||
var manifest = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = "acme",
|
||||
DeterminismMerkleRoot = merkleRoot,
|
||||
Artifacts = new[]
|
||||
{
|
||||
new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = "composition.recipe",
|
||||
Uri = "cas://bucket/recipe.json",
|
||||
Digest = recipeDigest,
|
||||
MediaType = "application/vnd.stellaops.composition.recipe+json",
|
||||
Format = "composition.recipe",
|
||||
Attestations = new[]
|
||||
{
|
||||
new SurfaceManifestAttestation
|
||||
{
|
||||
Kind = "dsse",
|
||||
Digest = recipeDsseDigest,
|
||||
Uri = "cas://attest/recipe.dsse.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = "composition.recipe.dsse",
|
||||
Uri = "cas://attest/recipe.dsse.json",
|
||||
Digest = recipeDsseDigest,
|
||||
MediaType = "application/vnd.dsse+json",
|
||||
Format = "dsse-json"
|
||||
},
|
||||
new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = "layer.fragments",
|
||||
Uri = "cas://bucket/fragments.json",
|
||||
Digest = fragmentDigest,
|
||||
MediaType = "application/json",
|
||||
Format = "json",
|
||||
Attestations = new[]
|
||||
{
|
||||
new SurfaceManifestAttestation
|
||||
{
|
||||
Kind = "dsse",
|
||||
Digest = fragmentDsseDigest,
|
||||
Uri = "cas://attest/fragments.dsse.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = "layer.fragments.dsse",
|
||||
Uri = "cas://attest/fragments.dsse.json",
|
||||
Digest = fragmentDsseDigest,
|
||||
MediaType = "application/vnd.dsse+json",
|
||||
Format = "dsse-json"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var loader = BuildLoader(new Dictionary<string, byte[]>
|
||||
{
|
||||
[recipeDigest] = recipeBytes,
|
||||
[recipeDsseDigest] = recipeDsseBytes,
|
||||
[fragmentDigest] = fragmentContent,
|
||||
[fragmentDsseDigest] = fragmentDsseBytes
|
||||
});
|
||||
|
||||
var verifier = new SurfaceManifestDeterminismVerifier();
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAsync(manifest, loader);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.Equal(merkleRoot, result.MerkleRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Fails_WhenDssePayloadDoesNotMatch()
|
||||
{
|
||||
var fragmentContent = Encoding.UTF8.GetBytes("{\"layers\":1}");
|
||||
var fragmentDigest = Sha("layer.fragments", fragmentContent);
|
||||
|
||||
var recipeBytes = Encoding.UTF8.GetBytes("{\"schema\":\"stellaops.composition.recipe@1\",\"artifacts\":{\"layer.fragments\":\"" + fragmentDigest + "\"}}");
|
||||
var merkleRoot = ShaHex(recipeBytes);
|
||||
var recipeDigest = $"sha256:{ShaHex(recipeBytes)}";
|
||||
|
||||
var badDsseBytes = Encoding.UTF8.GetBytes("{\"payloadType\":\"application/json\",\"payload\":\"bXlzYW1wbGU\",\"signatures\":[]}");
|
||||
var badDsseDigest = $"sha256:{ShaHex(badDsseBytes)}";
|
||||
|
||||
var manifest = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = "acme",
|
||||
DeterminismMerkleRoot = merkleRoot,
|
||||
Artifacts = new[]
|
||||
{
|
||||
new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = "composition.recipe",
|
||||
Uri = "cas://bucket/recipe.json",
|
||||
Digest = recipeDigest,
|
||||
MediaType = "application/vnd.stellaops.composition.recipe+json",
|
||||
Format = "composition.recipe",
|
||||
Attestations = new[]
|
||||
{
|
||||
new SurfaceManifestAttestation
|
||||
{
|
||||
Kind = "dsse",
|
||||
Digest = badDsseDigest,
|
||||
Uri = "cas://attest/recipe.dsse.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = "composition.recipe.dsse",
|
||||
Uri = "cas://attest/recipe.dsse.json",
|
||||
Digest = badDsseDigest,
|
||||
MediaType = "application/vnd.dsse+json",
|
||||
Format = "dsse-json"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var loader = BuildLoader(new Dictionary<string, byte[]>
|
||||
{
|
||||
[recipeDigest] = recipeBytes,
|
||||
[badDsseDigest] = badDsseBytes
|
||||
});
|
||||
|
||||
var verifier = new SurfaceManifestDeterminismVerifier();
|
||||
|
||||
var result = await verifier.VerifyAsync(manifest, loader);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
private static Func<SurfaceManifestArtifact, Task<ReadOnlyMemory<byte>>> BuildLoader(Dictionary<string, byte[]> map)
|
||||
=> artifact =>
|
||||
{
|
||||
if (map.TryGetValue(artifact.Digest, out var bytes))
|
||||
{
|
||||
return Task.FromResult((ReadOnlyMemory<byte>)bytes);
|
||||
}
|
||||
|
||||
return Task.FromResult(ReadOnlyMemory<byte>.Empty);
|
||||
};
|
||||
|
||||
private static string Sha(string kind, byte[] bytes) => $"sha256:{ShaHex(bytes)}";
|
||||
|
||||
private static string ShaHex(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
System.Security.Cryptography.SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static byte[] BuildDeterministicDsse(string payloadType, byte[] payload)
|
||||
{
|
||||
var signature = ShaHex(payload);
|
||||
var envelope = new
|
||||
{
|
||||
payloadType,
|
||||
payload = Base64Url(payload),
|
||||
signatures = new[]
|
||||
{
|
||||
new { keyid = "scanner-deterministic", sig = Base64Url(Encoding.UTF8.GetBytes(signature)) }
|
||||
}
|
||||
};
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(envelope, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
return Encoding.UTF8.GetBytes(json);
|
||||
}
|
||||
|
||||
private static string Base64Url(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(data);
|
||||
return base64.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@ public sealed class SurfaceManifestStageExecutorTests
|
||||
NullLogger<SurfaceManifestStageExecutor>.Instance,
|
||||
hash,
|
||||
new NullRubyPackageInventoryStore(),
|
||||
new DeterminismContext(true, DateTimeOffset.Parse("2024-01-01T00:00:00Z"), 1337, true, 1));
|
||||
new DeterminismContext(true, DateTimeOffset.Parse("2024-01-01T00:00:00Z"), 1337, true, 1),
|
||||
new DeterministicDsseEnvelopeSigner());
|
||||
|
||||
var context = CreateContext();
|
||||
|
||||
@@ -89,7 +90,8 @@ public sealed class SurfaceManifestStageExecutorTests
|
||||
NullLogger<SurfaceManifestStageExecutor>.Instance,
|
||||
hash,
|
||||
new NullRubyPackageInventoryStore(),
|
||||
new DeterminismContext(false, DateTimeOffset.UnixEpoch, null, false, null));
|
||||
new DeterminismContext(false, DateTimeOffset.UnixEpoch, null, false, null),
|
||||
new DeterministicDsseEnvelopeSigner());
|
||||
|
||||
var context = CreateContext();
|
||||
PopulateAnalysis(context);
|
||||
|
||||
Reference in New Issue
Block a user