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