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

@@ -9,7 +9,7 @@ namespace StellaOps.Excititor.WebService.Tests;
public sealed class PolicyEndpointsTests
{
[Fact(Skip = "Skipped in CI: WebApplicationFactory binding blocked in test environment; functional coverage retained in core + contract tests.")]
[Fact]
public async Task VexLookup_ReturnsStatements_ForAdvisoryAndPurl()
{
var claims = CreateSampleClaims();
@@ -17,6 +17,7 @@ public sealed class PolicyEndpointsTests
using var factory = new TestWebApplicationFactory(
configureServices: services =>
{
TestServiceOverrides.Apply(services);
services.RemoveAll<IVexClaimStore>();
services.AddSingleton<IVexClaimStore>(new StubClaimStore(claims));
services.AddTestAuthentication();

View File

@@ -42,7 +42,6 @@
<Compile Include="GraphTooltipFactoryTests.cs" />
<Compile Include="AttestationVerifyEndpointTests.cs" />
<Compile Include="OpenApiDiscoveryEndpointTests.cs" />
<!-- PolicyEndpointsTests excluded: flakey host binding in this runner; coverage retained via core/unit tests -->
<!-- <Compile Include="PolicyEndpointsTests.cs" /> -->
<Compile Include="PolicyEndpointsTests.cs" />
</ItemGroup>
</Project>

View File

@@ -93,20 +93,21 @@ public sealed record AuditEntry(
var occurredAt = DateTimeOffset.UtcNow;
// Compute canonical hash from immutable content
// Use the same property names and fields as VerifyIntegrity to keep the hash stable.
var contentHash = CanonicalJsonHasher.ComputeCanonicalSha256(new
{
entryId,
tenantId,
eventType,
resourceType,
resourceId,
actorId,
actorType,
description,
oldState,
newState,
occurredAt,
sequenceNumber
EntryId = entryId,
TenantId = tenantId,
EventType = eventType,
ResourceType = resourceType,
ResourceId = resourceId,
ActorId = actorId,
ActorType = actorType,
Description = description,
OldState = oldState,
NewState = newState,
OccurredAt = occurredAt,
SequenceNumber = sequenceNumber
});
return new AuditEntry(

View File

@@ -2,6 +2,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Orchestrator.Core.Hashing;
namespace StellaOps.Orchestrator.Core.Domain.Events;
@@ -180,8 +181,8 @@ public sealed record EventEnvelope(
/// <summary>Computes a digest of the envelope for signing.</summary>
public string ComputeDigest()
{
var json = ToJson();
var bytes = Encoding.UTF8.GetBytes(json);
var canonicalJson = CanonicalJsonHasher.ToCanonicalJson(new { envelope = this });
var bytes = Encoding.UTF8.GetBytes(canonicalJson);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}

View File

@@ -82,7 +82,10 @@ public sealed record EventPublishOptions(
bool CompressLargePayloads,
/// <summary>Threshold for payload compression (bytes).</summary>
int CompressionThreshold)
int CompressionThreshold,
/// <summary>Maximum number of events to fan out in a single batch to avoid backpressure.</summary>
int MaxBatchSize)
{
/// <summary>Default publishing options.</summary>
public static EventPublishOptions Default => new(
@@ -92,7 +95,8 @@ public sealed record EventPublishOptions(
IdempotencyTtl: TimeSpan.FromHours(24),
IncludeProvenance: true,
CompressLargePayloads: true,
CompressionThreshold: 64 * 1024);
CompressionThreshold: 64 * 1024,
MaxBatchSize: 500);
}
/// <summary>

View File

@@ -26,6 +26,12 @@ public sealed record PackRunLog(
/// <summary>Log message content.</summary>
string Message,
/// <summary>Canonical SHA-256 digest of the log payload (message+data+metadata).</summary>
string Digest,
/// <summary>Size of the log payload in bytes (UTF-8).</summary>
long SizeBytes,
/// <summary>When the log entry was created.</summary>
DateTimeOffset Timestamp,
@@ -45,6 +51,8 @@ public sealed record PackRunLog(
string? data = null,
DateTimeOffset? timestamp = null)
{
var (digest, sizeBytes) = ComputeDigest(message, data, tenantId, packRunId, sequence, level, source);
return new PackRunLog(
LogId: Guid.NewGuid(),
TenantId: tenantId,
@@ -53,6 +61,8 @@ public sealed record PackRunLog(
Level: level,
Source: source,
Message: message,
Digest: digest,
SizeBytes: sizeBytes,
Timestamp: timestamp ?? DateTimeOffset.UtcNow,
Data: data);
}
@@ -188,4 +198,19 @@ public sealed record PackRunLogCursor(
/// Advances the cursor to a new sequence.
/// </summary>
public PackRunLogCursor Advance(long newSequence) => this with { LastSequence = newSequence };
private static (string Digest, long SizeBytes) ComputeDigest(
string message,
string? data,
string tenantId,
Guid packRunId,
long sequence,
LogLevel level,
string source)
{
var payload = $"{tenantId}|{packRunId}|{sequence}|{level}|{source}|{message}|{data}";
var bytes = System.Text.Encoding.UTF8.GetBytes(payload);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return (Convert.ToHexString(hash).ToLowerInvariant(), bytes.LongLength);
}
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
using StellaOps.Orchestrator.Core.Hashing;
namespace StellaOps.Orchestrator.Core.Domain.Replay;
/// <summary>
/// Immutable lock record that captures the exact replay inputs (tooling, policy/graph hashes, seeds, env)
/// and ties them to a specific replay manifest hash. Used to ensure deterministic replays.
/// </summary>
public sealed record ReplayInputsLock(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("manifestHash")] string ManifestHash,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("inputs")] ReplayInputs Inputs,
[property: JsonPropertyName("notes")] string? Notes = null)
{
public const string DefaultSchemaVersion = "orch.replay.lock.v1";
public static ReplayInputsLock Create(
ReplayManifest manifest,
string? notes = null,
DateTimeOffset? createdAt = null,
string schemaVersion = DefaultSchemaVersion)
{
ArgumentNullException.ThrowIfNull(manifest);
return new ReplayInputsLock(
SchemaVersion: schemaVersion,
ManifestHash: manifest.ComputeHash(),
CreatedAt: createdAt ?? DateTimeOffset.UtcNow,
Inputs: manifest.Inputs,
Notes: string.IsNullOrWhiteSpace(notes) ? null : notes);
}
/// <summary>
/// Canonical hash of the lock content.
/// </summary>
public string ComputeHash() => CanonicalJsonHasher.ComputeCanonicalSha256(this);
}

View File

@@ -27,7 +27,7 @@ public static class CanonicalJsonHasher
{
var node = JsonSerializer.SerializeToNode(value, SerializerOptions) ?? new JsonObject();
// Work on a detached copy to avoid parent conflicts.
var ordered = OrderNode(node.Clone());
var ordered = OrderNode(node.DeepClone());
return ordered.ToJsonString(SerializerOptions);
}
@@ -50,18 +50,18 @@ public static class CanonicalJsonHasher
var orderedObj = new JsonObject();
foreach (var kvp in obj.OrderBy(x => x.Key, StringComparer.Ordinal))
{
orderedObj.Add(kvp.Key, kvp.Value is null ? null : OrderNode(kvp.Value.Clone()));
orderedObj.Add(kvp.Key, kvp.Value is null ? null : OrderNode(kvp.Value.DeepClone()));
}
return orderedObj;
case JsonArray arr:
var orderedArr = new JsonArray();
foreach (var item in arr)
{
orderedArr.Add(item is null ? null : OrderNode(item.Clone()));
orderedArr.Add(item is null ? null : OrderNode(item.DeepClone()));
}
return orderedArr;
default:
return node.Clone(); // primitives stay as-is
return node.DeepClone(); // primitives stay as-is
}
}
}

View File

@@ -79,20 +79,42 @@ public sealed class OrchestratorEventPublisher : IEventPublisher
var failed = 0;
var errors = new List<string>();
foreach (var envelope in envelopes)
// Stable ordering + pre-deduplication to enforce deterministic fan-out and reduce backpressure.
var ordered = envelopes
.OrderBy(e => e.OccurredAt)
.ThenBy(e => e.EventId, StringComparer.Ordinal)
.ToList();
var seenKeys = new HashSet<string>(StringComparer.Ordinal);
var workItems = new List<EventEnvelope>();
foreach (var envelope in ordered)
{
try
if (!seenKeys.Add(envelope.IdempotencyKey))
{
var result = await PublishAsync(envelope, cancellationToken);
if (result)
published++;
else
deduplicated++;
deduplicated++;
continue;
}
catch (Exception ex)
workItems.Add(envelope);
}
foreach (var chunk in workItems.Chunk(_options.MaxBatchSize))
{
foreach (var envelope in chunk)
{
failed++;
errors.Add($"{envelope.EventId}: {ex.Message}");
try
{
var result = await PublishAsync(envelope, cancellationToken);
if (result)
published++;
else
deduplicated++;
}
catch (Exception ex)
{
failed++;
errors.Add($"{envelope.EventId}: {ex.Message}");
}
}
}

View File

@@ -824,6 +824,66 @@ public class EventPublishingTests
Assert.Equal(1, result2.Deduplicated);
}
[Fact]
public async Task OrchestratorEventPublisher_PublishBatch_OrdersAndDeduplicatesBeforeSend()
{
var bus = NullNotifierBus.Instance;
bus.Clear();
var store = new InMemoryIdempotencyStore();
var options = Options.Create(EventPublishOptions.Default with
{
SignWithDsse = false,
MaxBatchSize = 2
});
var publisher = new OrchestratorEventPublisher(
store, bus, options, NullLogger<OrchestratorEventPublisher>.Instance);
var actor = EventActor.Service("test");
var baseEnvelope = EventEnvelope.Create(
eventType: OrchestratorEventType.JobCreated,
tenantId: "tenant-1",
actor: actor);
var earliest = baseEnvelope with
{
EventId = "urn:orch:event:earliest",
OccurredAt = new DateTimeOffset(2025, 1, 1, 0, 0, 5, TimeSpan.Zero),
IdempotencyKey = "dup-key"
};
var laterDuplicate = baseEnvelope with
{
EventId = "urn:orch:event:later-duplicate",
OccurredAt = new DateTimeOffset(2025, 1, 1, 0, 0, 10, TimeSpan.Zero),
IdempotencyKey = "dup-key"
};
var latest = baseEnvelope with
{
EventId = "urn:orch:event:latest",
OccurredAt = new DateTimeOffset(2025, 1, 1, 0, 0, 20, TimeSpan.Zero),
IdempotencyKey = "unique-key"
};
var result = await publisher.PublishBatchAsync(
new[] { laterDuplicate, latest, earliest },
CT);
Assert.Equal(2, result.Published);
Assert.Equal(1, result.Deduplicated);
var messages = bus.GetMessages("orch.jobs");
Assert.Equal(2, messages.Count);
var deserialized = messages
.Select(EventEnvelope.FromJson)
.Where(e => e is not null)
.ToList();
Assert.Equal("urn:orch:event:earliest", deserialized[0]!.EventId);
Assert.Equal("urn:orch:event:latest", deserialized[1]!.EventId);
}
#endregion
#region BatchPublishResult Tests
@@ -905,6 +965,7 @@ public class EventPublishingTests
Assert.True(options.IncludeProvenance);
Assert.True(options.CompressLargePayloads);
Assert.Equal(64 * 1024, options.CompressionThreshold);
Assert.Equal(500, options.MaxBatchSize);
}
#endregion

View File

@@ -29,6 +29,8 @@ public sealed class PackRunLogTests
Assert.Equal(LogLevel.Info, log.Level);
Assert.Equal("stdout", log.Source);
Assert.Equal("Test message", log.Message);
Assert.False(string.IsNullOrWhiteSpace(log.Digest));
Assert.True(log.SizeBytes > 0);
Assert.Equal(now, log.Timestamp);
Assert.Equal("{\"key\":\"value\"}", log.Data);
}

View File

@@ -0,0 +1,49 @@
using StellaOps.Orchestrator.Core.Domain.Replay;
namespace StellaOps.Orchestrator.Tests;
public class ReplayInputsLockTests
{
[Fact]
public void ReplayInputsLock_ComputesStableHash()
{
var manifest = ReplayManifest.Create(
jobId: "job-1",
replayOf: "orig-1",
inputs: new ReplayInputs(
PolicyHash: "sha256:policy",
GraphRevisionId: "graph-1",
LatticeHash: "sha256:lattice",
ToolImages: new[] { "img:v1", "img:v2" }.ToImmutableArray(),
Seeds: new ReplaySeeds(Rng: 42, Sampling: 5),
TimeSource: ReplayTimeSource.monotonic,
Env: new Dictionary<string, string> { { "TZ", "UTC" } }.ToImmutableDictionary()),
artifacts: null,
createdAt: new DateTimeOffset(2025, 01, 01, 0, 0, 0, TimeSpan.Zero));
var lock1 = ReplayInputsLock.Create(manifest, createdAt: new DateTimeOffset(2025, 01, 01, 0, 0, 5, TimeSpan.Zero));
var lock2 = ReplayInputsLock.Create(manifest, createdAt: new DateTimeOffset(2025, 01, 01, 0, 0, 5, TimeSpan.Zero));
Assert.Equal(lock1.ComputeHash(), lock2.ComputeHash());
}
[Fact]
public void ReplayInputsLock_TracksManifestHash()
{
var manifest = ReplayManifest.Create(
jobId: "job-1",
replayOf: "orig-1",
inputs: new ReplayInputs(
PolicyHash: "sha256:policy",
GraphRevisionId: "graph-1",
LatticeHash: null,
ToolImages: new[] { "img:v1" }.ToImmutableArray(),
Seeds: new ReplaySeeds(Rng: null, Sampling: null),
TimeSource: ReplayTimeSource.wall,
Env: ImmutableDictionary<string, string>.Empty));
var inputsLock = ReplayInputsLock.Create(manifest);
Assert.Equal(manifest.ComputeHash(), inputsLock.ManifestHash);
}
}

View File

@@ -0,0 +1 @@
{"baseVector":"CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H","computedAt":"2025-12-01T00:00:00Z","evidence":["cas://evidence/sha256:abc"],"policyId":"pol-v4-001","tenantId":"tenant-a","vulnId":"CVE-2025-0001"}

View File

@@ -0,0 +1 @@
4de79d5af28ec27a7754e6be6acdb99c36d1fe5792984a7fdb67e98934097142 example-receipt-input.json

View File

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

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

View File

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

View File

@@ -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('=');
}
}

View File

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

View File

@@ -5,7 +5,7 @@
| WEB-AOC-19-002 | DONE (2025-11-30) | Added provenance builder, checksum utilities, and DSSE/CMS signature verification helpers with unit tests. |
| WEB-AOC-19-003 | DONE (2025-11-30) | Added client-side guard validator (forbidden/derived/unknown fields, provenance/signature checks) with unit fixtures. |
| WEB-CONSOLE-23-002 | DOING (2025-12-01) | Console status polling + SSE run stream client/store/UI added; tests pending once env fixed. |
| WEB-RISK-66-001 | DOING (2025-12-02) | Added risk gateway HTTP client (trace-id headers), store, `/risk` dashboard with filters and vuln link, auth guard; added `/vulnerabilities/:vulnId` detail; risk/vuln providers switch via quickstart; awaiting gateway endpoints/test harness. |
| WEB-RISK-66-001 | DOING (2025-12-02) | Added risk gateway HTTP client (trace-id headers), store, `/risk` dashboard with filters, empty state, vuln link, auth guard; added `/vulnerabilities/:vulnId` detail + specs; risk/vuln providers switch via quickstart; awaiting gateway endpoints/test harness. |
| WEB-EXC-25-001 | TODO | Exceptions workflow CRUD pending policy scopes. |
| WEB-TEN-47-CONTRACT | DONE (2025-12-01) | Gateway tenant auth/ABAC contract doc v1.0 published (`docs/api/gateway/tenant-auth.md`). |
| WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). |

View File

@@ -18,7 +18,7 @@ export class RiskHttpClient implements RiskApi {
list(options: RiskQueryOptions): Observable<RiskResultPage> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? this.generateTraceId();
const traceId = options.traceId ?? crypto.randomUUID?.() ?? this.generateTraceId();
const headers = this.buildHeaders(tenant, options.projectId, traceId);
let params = new HttpParams();
@@ -40,7 +40,7 @@ export class RiskHttpClient implements RiskApi {
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? this.generateTraceId();
const traceId = options.traceId ?? crypto.randomUUID?.() ?? this.generateTraceId();
const headers = this.buildHeaders(tenant, options.projectId, traceId);
return this.http

View File

@@ -0,0 +1,53 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { VulnerabilityHttpClient, VULNERABILITY_API_BASE_URL } from './vulnerability-http.client';
import { VulnerabilitiesResponse } from './vulnerability.models';
class MockAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-dev';
}
}
describe('VulnerabilityHttpClient', () => {
let client: VulnerabilityHttpClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
VulnerabilityHttpClient,
{ provide: VULNERABILITY_API_BASE_URL, useValue: 'https://api.example.local' },
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
],
});
client = TestBed.inject(VulnerabilityHttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('adds tenant header when listing vulnerabilities', () => {
const stub: VulnerabilitiesResponse = { items: [], total: 0, page: 1, pageSize: 20 };
client.listVulnerabilities({ page: 1, pageSize: 5 }).subscribe((resp) => {
expect(resp.page).toBe(1);
});
const req = httpMock.expectOne('https://api.example.local/vuln?page=1&pageSize=5');
expect(req.request.headers.get('X-Stella-Tenant')).toBe('tenant-dev');
req.flush(stub);
});
it('adds project header when provided', () => {
client.listVulnerabilities({ page: 1, projectId: 'proj-ops' }).subscribe();
const req = httpMock.expectOne('https://api.example.local/vuln?page=1');
expect(req.request.headers.get('X-Stella-Project')).toBe('proj-ops');
req.flush({ items: [], total: 0, page: 1, pageSize: 20 });
});
});

View File

@@ -40,7 +40,7 @@
<button type="button" (click)="applyFilters()">Refresh</button>
</section>
<section class="risk-dashboard__table" *ngIf="list() as page">
<section class="risk-dashboard__table" *ngIf="list() as page; else riskEmpty">
<table>
<thead>
<tr>
@@ -67,4 +67,11 @@
</table>
<p class="meta">Showing {{ page.items.length }} of {{ page.total }} risks.</p>
</section>
<ng-template #riskEmpty>
<div class="empty" *ngIf="!loading(); else riskLoading">No risks found for current filters.</div>
<ng-template #riskLoading>
<div class="empty">Loading risks…</div>
</ng-template>
</ng-template>
</section>

View File

@@ -156,6 +156,13 @@ tr:last-child td {
color: #6b7280;
}
.empty {
padding: 1rem;
border: 1px dashed #d1d5db;
border-radius: 0.75rem;
color: #6b7280;
}
@media (max-width: 768px) {
.risk-dashboard__header { flex-direction: column; align-items: flex-start; }
table { display: block; overflow-x: auto; }

View File

@@ -0,0 +1,55 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { VULNERABILITY_API } from '../../core/api/vulnerability.client';
import { Vulnerability } from '../../core/api/vulnerability.models';
import { VulnerabilityDetailComponent } from './vulnerability-detail.component';
const STUB_VULN: Vulnerability = {
vulnId: 'vuln-001',
cveId: 'CVE-2021-44228',
title: 'Log4Shell',
description: 'Test description',
severity: 'critical',
cvssScore: 10,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'open',
publishedAt: '2021-12-10T00:00:00Z',
modifiedAt: '2024-06-27T00:00:00Z',
affectedComponents: [],
references: [],
hasException: false,
};
class MockVulnApi {
getVulnerability() {
return of(STUB_VULN);
}
}
describe('VulnerabilityDetailComponent', () => {
let fixture: ComponentFixture<VulnerabilityDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VulnerabilityDetailComponent],
providers: [
{ provide: VULNERABILITY_API, useClass: MockVulnApi },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: new Map([['vulnId', 'vuln-001']]) } },
},
],
}).compileComponents();
fixture = TestBed.createComponent(VulnerabilityDetailComponent);
fixture.detectChanges();
});
it('renders vulnerability data', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Log4Shell');
expect(compiled.textContent).toContain('CVE-2021-44228');
});
});

View File

@@ -10,12 +10,12 @@ import {
import { firstValueFrom } from 'rxjs';
import { VULNERABILITY_API, VulnerabilityApi } from '../../core/api/vulnerability.client';
import {
Vulnerability,
VulnerabilitySeverity,
VulnerabilityStats,
VulnerabilityStatus,
} from '../../core/api/vulnerability.models';
import {
Vulnerability,
VulnerabilitySeverity,
VulnerabilityStats,
VulnerabilityStatus,
} from '../../core/api/vulnerability.models';
import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,