- 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.
263 lines
9.7 KiB
C#
263 lines
9.7 KiB
C#
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;
|
|
}
|