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:
StellaOps Bot
2025-12-02 09:27:31 +02:00
parent 885ce86af4
commit 2d08f52715
74 changed files with 1690 additions and 131 deletions

View File

@@ -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))

View File

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

View File

@@ -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")]