feat: Add UI benchmark driver and scenarios for graph interactions
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled

- Introduced `ui_bench_driver.mjs` to read scenarios and fixture manifest, generating a deterministic run plan.
- Created `ui_bench_plan.md` outlining the purpose, scope, and next steps for the benchmark.
- Added `ui_bench_scenarios.json` containing various scenarios for graph UI interactions.
- Implemented tests for CLI commands, ensuring bundle verification and telemetry defaults.
- Developed schemas for orchestrator components, including replay manifests and event envelopes.
- Added mock API for risk management, including listing and statistics functionalities.
- Implemented models for risk profiles and query options to support the new API.
This commit is contained in:
StellaOps Bot
2025-12-02 01:28:17 +02:00
parent 909d9b6220
commit 44171930ff
94 changed files with 3606 additions and 271 deletions

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Repo root is four levels up from Graph/
REPO_ROOT="$(cd "${ROOT}/../../../.." && pwd)"
FIXTURES_ROOT="${FIXTURES_ROOT:-${REPO_ROOT}/samples/graph/interim}"
OUT_DIR="${OUT_DIR:-$ROOT/results}"
SAMPLES="${SAMPLES:-100}"
mkdir -p "${OUT_DIR}"
run_one() {
local fixture="$1"
local name
name="$(basename "${fixture}")"
local out_file="${OUT_DIR}/${name}.json"
python "${ROOT}/graph_bench.py" --fixture "${fixture}" --output "${out_file}" --samples "${SAMPLES}"
}
run_one "${FIXTURES_ROOT}/graph-50k"
run_one "${FIXTURES_ROOT}/graph-100k"
echo "Graph bench complete. Results in ${OUT_DIR}"

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* ui_bench_driver.mjs
*
* Reads scenarios and fixture manifest, and emits a deterministic run plan.
* This is browser-free; intended to be wrapped by Playwright later.
*/
import fs from "fs";
import path from "path";
function readJson(p) {
return JSON.parse(fs.readFileSync(p, "utf-8"));
}
function buildPlan(scenarios, manifest, fixtureName) {
const now = new Date().toISOString();
return {
version: "1.0.0",
fixture: fixtureName,
manifestHash: manifest?.hashes || {},
timestamp: now,
steps: scenarios.map((s, idx) => ({
order: idx + 1,
id: s.id,
name: s.name,
actions: s.steps,
})),
};
}
function main() {
const fixtureDir = process.argv[2];
const scenariosPath = process.argv[3];
const outputPath = process.argv[4];
if (!fixtureDir || !scenariosPath || !outputPath) {
console.error("usage: ui_bench_driver.mjs <fixture_dir> <scenarios.json> <output.json>");
process.exit(1);
}
const manifestPath = path.join(fixtureDir, "manifest.json");
const manifest = fs.existsSync(manifestPath) ? readJson(manifestPath) : {};
const scenarios = readJson(scenariosPath).scenarios || [];
const plan = buildPlan(scenarios, manifest, path.basename(fixtureDir));
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(plan, null, 2));
console.log(`Wrote plan to ${outputPath}`);
}
main();

View File

@@ -0,0 +1,30 @@
# Graph UI Bench Plan (BENCH-GRAPH-21-002)
Purpose: provide a deterministic, headless flow for measuring graph UI interactions over large fixtures (50k/100k nodes).
## Scope
- Use synthetic fixtures under `samples/graph/interim/` until canonical SAMPLES-GRAPH-24-003 lands.
- Drive a deterministic sequence of interactions:
1) Load graph canvas with specified fixture.
2) Pan to node `pkg-000001`.
3) Zoom in 2×, zoom out 1×.
4) Apply filter `name contains "package-0001"`.
5) Select node, expand neighbors (depth 1), collapse.
6) Toggle overlay layer (once available).
- Capture timings: initial render, filter apply, expand/collapse, overlay toggle.
## Determinism rules
- Fixed seed for any randomized layouts (seed `424242`).
- Disable animations/transitions where possible; otherwise measure after `requestAnimationFrame` settle.
- No network calls; fixtures loaded from local file served by test harness.
- Stable viewport (width=1280, height=720), device scale factor 1.
## Artifacts
- `ui_bench_scenarios.json` — canonical scenario list with step ids and notes.
- `ui_bench_driver.mjs` — helper that reads scenario + fixture manifest and emits a run plan (no browser dependency). Intended to be wrapped by a Playwright script later.
- Results format (proposed): NDJSON with `{stepId, name, durationMs, fixture, timestamp}`.
## Next steps
- Bind `ui_bench_driver.mjs` into a Playwright harness when Graph UI build/serve target is available.
- Swap fixtures to SAMPLES-GRAPH-24-003 + overlay once schema finalized; keep scenario ids stable.
- Add CI slice to run the driver and validate scenario/fixture bindings (no browser) to keep determinism checked in commits.

View File

@@ -0,0 +1,35 @@
{
"version": "1.0.0",
"scenarios": [
{
"id": "load",
"name": "Load graph canvas",
"steps": ["navigate", "waitForRender"]
},
{
"id": "pan-start-node",
"name": "Pan to pkg-000001",
"steps": ["panTo:pkg-000001"]
},
{
"id": "zoom-in-out",
"name": "Zoom in twice, out once",
"steps": ["zoomIn", "zoomIn", "zoomOut"]
},
{
"id": "filter-name",
"name": "Filter name contains package-0001",
"steps": ["setFilter:name=package-0001", "waitForRender"]
},
{
"id": "expand-collapse",
"name": "Expand neighbors then collapse",
"steps": ["select:pkg-000001", "expandDepth:1", "collapseSelection"]
},
{
"id": "overlay-toggle",
"name": "Toggle overlay layer",
"steps": ["toggleOverlay:on", "toggleOverlay:off"]
}
]
}

View File

@@ -4,3 +4,4 @@
| --- | --- | --- | --- | --- |
| BENCH-DETERMINISM-401-057 | DONE (2025-11-26) | SPRINT_0512_0001_0001_bench | Determinism harness and mock scanner added under `src/Bench/StellaOps.Bench/Determinism`; manifests + sample inputs included. | `src/Bench/StellaOps.Bench/Determinism/results` (generated) |
| BENCH-GRAPH-21-001 | DOING (2025-12-01) | SPRINT_0512_0001_0001_bench | Added interim graph bench harness (`Graph/graph_bench.py`) using synthetic 50k/100k fixtures; measures adjacency build + depth-3 reach; pending overlay schema for final fixture integration. | `src/Bench/StellaOps.Bench/Graph` |
| BENCH-GRAPH-21-002 | DOING (2025-12-01) | SPRINT_0512_0001_0001_bench | Added Graph UI bench scaffold (scenarios JSON + driver + plan) using interim fixtures; awaits overlay schema/UI target for Playwright binding and timing collection. | `src/Bench/StellaOps.Bench/Graph` |

View File

@@ -48,17 +48,43 @@ namespace StellaOps.Cli.Commands;
internal static class CommandHandlers
{
private const string KmsPassphraseEnvironmentVariable = "STELLAOPS_KMS_PASSPHRASE";
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
{
// Simple SHA256 check using sidecar .sha256 file if present; fail closed on mismatch.
var shaPath = path + ".sha256";
if (!File.Exists(shaPath))
{
logger.LogError("Checksum file missing for bundle {Bundle}. Expected sidecar {Sidecar}.", path, shaPath);
Environment.ExitCode = 21;
throw new InvalidOperationException("Checksum file missing");
}
var expected = (await File.ReadAllTextAsync(shaPath, cancellationToken).ConfigureAwait(false)).Trim();
using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actual = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
{
logger.LogError("Checksum mismatch for {Bundle}. Expected {Expected} but found {Actual}", path, expected, actual);
Environment.ExitCode = 22;
throw new InvalidOperationException("Checksum verification failed");
}
logger.LogInformation("Checksum verified for {Bundle}", path);
}
public static async Task HandleScannerDownloadAsync(
IServiceProvider services,
string channel,
string? output,
bool overwrite,
bool install,
public static async Task HandleScannerDownloadAsync(
IServiceProvider services,
string channel,
string? output,
bool overwrite,
bool install,
bool verbose,
CancellationToken cancellationToken)
{
@@ -88,24 +114,29 @@ internal static class CommandHandlers
CliMetrics.RecordScannerDownload(channel, result.FromCache);
if (install)
{
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
CliMetrics.RecordScannerInstall(channel);
}
if (install)
{
await VerifyBundleAsync(result.Path, logger, cancellationToken).ConfigureAwait(false);
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
CliMetrics.RecordScannerInstall(channel);
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download scanner bundle.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download scanner bundle.");
if (Environment.ExitCode == 0)
{
Environment.ExitCode = 1;
}
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleTaskRunnerSimulateAsync(

View File

@@ -107,9 +107,9 @@ public sealed class CliProfileStore
public Dictionary<string, CliProfile> Profiles { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Default telemetry opt-in status.
/// Default telemetry opt-in status. Defaults to false for privacy.
/// </summary>
public bool? TelemetryEnabled { get; set; }
public bool TelemetryEnabled { get; set; } = false;
}
/// <summary>
@@ -225,7 +225,7 @@ public sealed class CliProfileManager
public async Task SetTelemetryEnabledAsync(bool? enabled, CancellationToken cancellationToken = default)
{
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
store.TelemetryEnabled = enabled;
store.TelemetryEnabled = enabled ?? false;
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
}
@@ -247,7 +247,18 @@ public sealed class CliProfileManager
{
await using var stream = File.OpenRead(_profilesFilePath);
var store = await JsonSerializer.DeserializeAsync<CliProfileStore>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
return store ?? new CliProfileStore();
if (store is null)
{
return new CliProfileStore();
}
// Ensure default-off if older files had telemetryEnabled missing/null.
if (!store.TelemetryEnabled)
{
store.TelemetryEnabled = false;
}
return store;
}
catch (JsonException)
{

View File

@@ -4512,11 +4512,11 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
// CLI-SDK-64-001: SDK update operations
public async Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
public async Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
OfflineModeGuard.ThrowIfOffline("sdk update");
var queryParams = new List<string>();
@@ -4554,9 +4554,9 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
};
}
var result = await response.Content.ReadFromJsonAsync<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
}
var result = await response.Content.ReadFromJsonAsync<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
}
public async Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken)
{

View File

@@ -0,0 +1,60 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cli.Commands;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
public sealed class ScannerDownloadVerifyTests
{
[Fact]
public async Task VerifyBundleAsync_Succeeds_WhenHashMatches()
{
var tmp = Path.Combine(Path.GetTempPath(), $"stellaops-cli-{Guid.NewGuid():N}");
Directory.CreateDirectory(tmp);
var bundle = Path.Combine(tmp, "scanner.tgz");
await File.WriteAllTextAsync(bundle, "hello");
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(bundle))).ToLowerInvariant();
await File.WriteAllTextAsync(bundle + ".sha256", hash);
await CommandHandlersTestShim.VerifyBundlePublicAsync(bundle, NullLogger.Instance, CancellationToken.None);
}
[Fact]
public async Task VerifyBundleAsync_Throws_WhenHashMismatch()
{
var tmp = Path.Combine(Path.GetTempPath(), $"stellaops-cli-{Guid.NewGuid():N}");
Directory.CreateDirectory(tmp);
var bundle = Path.Combine(tmp, "scanner.tgz");
await File.WriteAllTextAsync(bundle, "hello");
await File.WriteAllTextAsync(bundle + ".sha256", "deadbeef");
await Assert.ThrowsAsync<InvalidOperationException>(() =>
CommandHandlersTestShim.VerifyBundlePublicAsync(bundle, NullLogger.Instance, CancellationToken.None));
}
[Fact]
public async Task VerifyBundleAsync_Throws_WhenChecksumMissing()
{
var tmp = Path.Combine(Path.GetTempPath(), $"stellaops-cli-{Guid.NewGuid():N}");
Directory.CreateDirectory(tmp);
var bundle = Path.Combine(tmp, "scanner.tgz");
await File.WriteAllTextAsync(bundle, "hello");
await Assert.ThrowsAsync<InvalidOperationException>(() =>
CommandHandlersTestShim.VerifyBundlePublicAsync(bundle, NullLogger.Instance, CancellationToken.None));
}
}
internal static class CommandHandlersTestShim
{
public static Task VerifyBundlePublicAsync(string path, ILogger logger, CancellationToken token)
=> typeof(CommandHandlers)
.GetMethod(\"VerifyBundleAsync\", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!
.Invoke(null, new object[] { path, logger, token }) as Task
?? Task.CompletedTask;
}

View File

@@ -0,0 +1,35 @@
using System.IO;
using System.Threading.Tasks;
using StellaOps.Cli.Configuration;
using Xunit;
namespace StellaOps.Cli.Tests.Configuration;
public sealed class TelemetryDefaultsTests
{
[Fact]
public async Task NewStore_DefaultsTelemetryToOff()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-cli-telemetry-{Path.GetRandomFileName()}");
Directory.CreateDirectory(tempDir);
var manager = new CliProfileManager(tempDir);
var store = await manager.GetStoreAsync();
Assert.False(store.TelemetryEnabled);
}
[Fact]
public async Task SetTelemetryEnabled_PersistsValue()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-cli-telemetry-{Path.GetRandomFileName()}");
Directory.CreateDirectory(tempDir);
var manager = new CliProfileManager(tempDir);
await manager.SetTelemetryEnabledAsync(true);
var store = await manager.GetStoreAsync();
Assert.True(store.TelemetryEnabled);
}
}

View File

@@ -0,0 +1,41 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Xunit;
namespace StellaOps.Cli.Tests.Contracts;
public sealed class CliSpecTests
{
private static readonly string SpecPath = Path.Combine("docs", "modules", "cli", "contracts", "cli-spec-v1.yaml");
[Fact]
public async Task Spec_Exists_And_Has_PrivacyDefaults()
{
Assert.True(File.Exists(SpecPath), $"Spec file missing: {SpecPath}");
var text = await File.ReadAllTextAsync(SpecPath);
Assert.Contains("defaultEnabled: false", text);
Assert.Contains("checksumRequired: true", text);
Assert.Contains("cosignVerifyDefault: true", text);
}
[Fact]
public async Task Spec_Has_Pinned_Buildx_Digest()
{
var text = await File.ReadAllLinesAsync(SpecPath);
var digestLine = text.FirstOrDefault(l => l.TrimStart().StartsWith("imageDigest:"));
Assert.False(string.IsNullOrWhiteSpace(digestLine));
Assert.DoesNotContain("TO-BE-PINNED", digestLine);
}
[Fact]
public async Task Spec_Declares_Install_ExitCodes()
{
var text = await File.ReadAllTextAsync(SpecPath);
Assert.Contains("21: checksum-file-missing", text);
Assert.Contains("22: checksum-mismatch", text);
}
}

View File

@@ -1,3 +1,5 @@
using StellaOps.Orchestrator.Core.Hashing;
namespace StellaOps.Orchestrator.Core.Domain;
/// <summary>
@@ -90,9 +92,22 @@ public sealed record AuditEntry(
var entryId = Guid.NewGuid();
var occurredAt = DateTimeOffset.UtcNow;
// Compute content hash from entry data
var contentToHash = $"{entryId}|{tenantId}|{eventType}|{resourceType}|{resourceId}|{actorId}|{actorType}|{description}|{oldState}|{newState}|{occurredAt:O}|{sequenceNumber}";
var contentHash = ComputeSha256(contentToHash);
// Compute canonical hash from immutable content
var contentHash = CanonicalJsonHasher.ComputeCanonicalSha256(new
{
entryId,
tenantId,
eventType,
resourceType,
resourceId,
actorId,
actorType,
description,
oldState,
newState,
occurredAt,
sequenceNumber
});
return new AuditEntry(
EntryId: entryId,
@@ -122,8 +137,21 @@ public sealed record AuditEntry(
/// </summary>
public bool VerifyIntegrity()
{
var contentToHash = $"{EntryId}|{TenantId}|{EventType}|{ResourceType}|{ResourceId}|{ActorId}|{ActorType}|{Description}|{OldState}|{NewState}|{OccurredAt:O}|{SequenceNumber}";
var computed = ComputeSha256(contentToHash);
var computed = CanonicalJsonHasher.ComputeCanonicalSha256(new
{
EntryId,
TenantId,
EventType,
ResourceType,
ResourceId,
ActorId,
ActorType,
Description,
OldState,
NewState,
OccurredAt,
SequenceNumber
});
return string.Equals(ContentHash, computed, StringComparison.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,72 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Orchestrator.Core.Hashing;
namespace StellaOps.Orchestrator.Core.Domain.Replay;
/// <summary>
/// Deterministic replay manifest that captures all inputs required to faithfully re-run a job.
/// Aligns with replay-manifest.schema.json and is hashed via canonical JSON.
/// </summary>
public sealed record ReplayManifest(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("jobId")] string JobId,
[property: JsonPropertyName("replayOf")] string ReplayOf,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("reason")] string? Reason,
[property: JsonPropertyName("inputs")] ReplayInputs Inputs,
[property: JsonPropertyName("artifacts")] ImmutableArray<ReplayArtifact> Artifacts)
{
public static ReplayManifest Create(
string jobId,
string replayOf,
ReplayInputs inputs,
IEnumerable<ReplayArtifact>? artifacts = null,
string schemaVersion = "orch.replay.v1",
string? reason = null,
DateTimeOffset? createdAt = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
ArgumentException.ThrowIfNullOrWhiteSpace(replayOf);
ArgumentNullException.ThrowIfNull(inputs);
return new ReplayManifest(
SchemaVersion: schemaVersion,
JobId: jobId,
ReplayOf: replayOf,
CreatedAt: createdAt ?? DateTimeOffset.UtcNow,
Reason: string.IsNullOrWhiteSpace(reason) ? null : reason,
Inputs: inputs,
Artifacts: artifacts is null ? ImmutableArray<ReplayArtifact>.Empty : ImmutableArray.CreateRange(artifacts));
}
/// <summary>
/// Deterministic SHA-256 over canonical JSON representation of the manifest.
/// </summary>
public string ComputeHash() => CanonicalJsonHasher.ComputeCanonicalSha256(this);
}
public sealed record ReplayInputs(
[property: JsonPropertyName("policyHash")] string PolicyHash,
[property: JsonPropertyName("graphRevisionId")] string GraphRevisionId,
[property: JsonPropertyName("latticeHash")] string? LatticeHash,
[property: JsonPropertyName("toolImages")] ImmutableArray<string> ToolImages,
[property: JsonPropertyName("seeds")] ReplaySeeds Seeds,
[property: JsonPropertyName("timeSource")] ReplayTimeSource TimeSource,
[property: JsonPropertyName("env")] ImmutableDictionary<string, string> Env);
public sealed record ReplaySeeds(
[property: JsonPropertyName("rng")] int? Rng,
[property: JsonPropertyName("sampling")] int? Sampling);
public sealed record ReplayArtifact(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("mediaType")] string? MediaType);
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ReplayTimeSource
{
monotonic,
wall
}

View File

@@ -0,0 +1,66 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace StellaOps.Orchestrator.Core.Hashing;
/// <summary>
/// Produces deterministic, canonical JSON and hashes for orchestrator payloads (events, audit, manifests).
/// Keys are sorted lexicographically; arrays preserve order; nulls are retained; timestamps remain ISO 8601 with offsets.
/// </summary>
public static class CanonicalJsonHasher
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
WriteIndented = false,
PropertyNamingPolicy = null,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Serialize the value to canonical JSON (sorted object keys, stable formatting).
/// </summary>
public static string ToCanonicalJson<T>(T value)
{
var node = JsonSerializer.SerializeToNode(value, SerializerOptions) ?? new JsonObject();
var ordered = OrderNode(node);
return ordered.ToJsonString(SerializerOptions);
}
/// <summary>
/// Compute SHA-256 over canonical JSON (lowercase hex).
/// </summary>
public static string ComputeCanonicalSha256<T>(T value)
{
var canonicalJson = ToCanonicalJson(value);
var bytes = Encoding.UTF8.GetBytes(canonicalJson);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static JsonNode OrderNode(JsonNode node)
{
switch (node)
{
case JsonObject obj:
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));
}
return orderedObj;
case JsonArray arr:
var orderedArr = new JsonArray();
foreach (var item in arr)
{
orderedArr.Add(item is null ? null : OrderNode(item));
}
return orderedArr;
default:
return node; // primitives stay as-is
}
}
}

View File

@@ -0,0 +1,51 @@
{
"$id": "https://stellaops.dev/orchestrator/schemas/audit-bundle.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Orchestrator Audit Bundle",
"type": "object",
"additionalProperties": false,
"required": ["manifestVersion", "bundleId", "createdAt", "entries"],
"properties": {
"manifestVersion": { "type": "string", "pattern": "^orch\.audit\.v[0-9]+$" },
"bundleId": { "type": "string", "minLength": 1 },
"createdAt": { "type": "string", "format": "date-time" },
"tenantId": { "type": "string" },
"hashAlgorithm": { "type": "string", "enum": ["sha256"] },
"entries": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["entryId", "contentHash", "sequenceNumber", "occurredAt"],
"properties": {
"entryId": { "type": "string" },
"contentHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"previousEntryHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"sequenceNumber": { "type": "integer", "minimum": 1 },
"occurredAt": { "type": "string", "format": "date-time" },
"resourceType": { "type": "string" },
"resourceId": { "type": "string" },
"eventType": { "type": "string" },
"actorId": { "type": "string" },
"actorType": { "type": "string" },
"oldState": { "type": "string" },
"newState": { "type": "string" },
"metadata": { "type": "string" }
}
}
},
"attachments": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["uri", "digest"],
"properties": {
"uri": { "type": "string" },
"digest": { "type": "string", "pattern": "^[a-z0-9]+:[A-Fa-f0-9]+$" },
"mediaType": { "type": "string" }
}
}
}
}
}

View File

@@ -0,0 +1,95 @@
{
"$id": "https://stellaops.dev/orchestrator/schemas/event-envelope.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Orchestrator Event Envelope",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"eventId",
"eventType",
"occurredAt",
"idempotencyKey",
"tenantId",
"actor",
"job"
],
"properties": {
"schemaVersion": { "type": "string", "pattern": "^orch\.event\.v[0-9]+$" },
"eventId": { "type": "string", "minLength": 1 },
"eventType": { "type": "string", "minLength": 1 },
"occurredAt": { "type": "string", "format": "date-time" },
"idempotencyKey": { "type": "string", "minLength": 1 },
"correlationId": { "type": "string" },
"tenantId": { "type": "string", "minLength": 1 },
"projectId": { "type": "string" },
"actor": {
"type": "object",
"additionalProperties": false,
"required": ["subject", "scopes"],
"properties": {
"subject": { "type": "string", "minLength": 1 },
"scopes": { "type": "array", "items": { "type": "string" } }
}
},
"job": {
"type": "object",
"additionalProperties": false,
"required": ["id", "type", "attempt", "status"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"type": { "type": "string", "minLength": 1 },
"runId": { "type": "string" },
"attempt": { "type": "integer", "minimum": 0 },
"leaseId": { "type": "string" },
"taskRunnerId": { "type": "string" },
"status": { "type": "string" },
"reason": { "type": "string" },
"payloadDigest": { "type": "string" },
"artifacts": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["uri", "digest"],
"properties": {
"uri": { "type": "string" },
"digest": { "type": "string" },
"mime": { "type": "string" }
}
}
},
"provenance": {
"type": "object",
"additionalProperties": { "type": "string" }
}
}
},
"metrics": {
"type": "object",
"additionalProperties": false,
"properties": {
"durationSeconds": { "type": "number", "minimum": 0 },
"logStreamLagSeconds": { "type": "number", "minimum": 0 },
"backoffSeconds": { "type": "number", "minimum": 0 }
}
},
"notifier": {
"type": "object",
"additionalProperties": false,
"properties": {
"channel": { "type": "string" },
"delivery": { "type": "string" },
"replay": {
"type": "object",
"additionalProperties": false,
"required": ["ordinal", "total"],
"properties": {
"ordinal": { "type": "integer", "minimum": 0 },
"total": { "type": "integer", "minimum": 1 }
}
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
{
"$id": "https://stellaops.dev/orchestrator/schemas/replay-manifest.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Replay Manifest",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "jobId", "replayOf", "inputs"],
"properties": {
"schemaVersion": { "type": "string", "pattern": "^orch\.replay\.v[0-9]+$" },
"jobId": { "type": "string" },
"replayOf": { "type": "string" },
"createdAt": { "type": "string", "format": "date-time" },
"reason": { "type": "string" },
"inputs": {
"type": "object",
"additionalProperties": false,
"required": ["policyHash", "graphRevisionId", "toolImages", "seeds"],
"properties": {
"policyHash": { "type": "string" },
"graphRevisionId": { "type": "string" },
"latticeHash": { "type": "string" },
"toolImages": {
"type": "array",
"items": { "type": "string" }
},
"seeds": {
"type": "object",
"additionalProperties": false,
"properties": {
"rng": { "type": "integer", "minimum": 0 },
"sampling": { "type": "integer", "minimum": 0 }
}
},
"timeSource": { "type": "string", "enum": ["monotonic", "wall"] },
"env": {
"type": "object",
"additionalProperties": { "type": "string" }
}
}
},
"artifacts": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "digest"],
"properties": {
"name": { "type": "string" },
"digest": { "type": "string" },
"mediaType": { "type": "string" }
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
{
"$id": "https://stellaops.dev/orchestrator/schemas/taskrunner-integrity.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "TaskRunner Integrity Capsule",
"type": "object",
"additionalProperties": false,
"required": ["jobId", "runId", "attempt", "artifacts", "logs"],
"properties": {
"jobId": { "type": "string" },
"runId": { "type": "string" },
"attempt": { "type": "integer", "minimum": 0 },
"workerImage": { "type": "string" },
"configHash": { "type": "string" },
"artifacts": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "digest", "sizeBytes"],
"properties": {
"name": { "type": "string" },
"digest": { "type": "string", "pattern": "^[a-z0-9]+:[A-Fa-f0-9]+$" },
"sizeBytes": { "type": "integer", "minimum": 0 },
"mediaType": { "type": "string" }
}
}
},
"logs": {
"type": "object",
"additionalProperties": false,
"required": ["digest", "sizeBytes"],
"properties": {
"digest": { "type": "string", "pattern": "^[a-z0-9]+:[A-Fa-f0-9]+$" },
"sizeBytes": { "type": "integer", "minimum": 0 }
}
},
"timeline": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["type", "occurredAt"],
"properties": {
"type": { "type": "string" },
"occurredAt": { "type": "string", "format": "date-time" },
"traceId": { "type": "string" }
}
}
}
}
}

View File

@@ -0,0 +1,54 @@
using System.Text.Json;
using StellaOps.Orchestrator.Core.Domain;
using StellaOps.Orchestrator.Core.Hashing;
namespace StellaOps.Orchestrator.Tests;
public class CanonicalJsonHasherTests
{
[Fact]
public void ProducesStableHash_WhenObjectPropertyOrderDiffers()
{
var first = new { b = 1, a = 2 };
var second = new { a = 2, b = 1 };
var firstHash = CanonicalJsonHasher.ComputeCanonicalSha256(first);
var secondHash = CanonicalJsonHasher.ComputeCanonicalSha256(second);
Assert.Equal(firstHash, secondHash);
}
[Fact]
public void CanonicalJson_SortsKeysAndPreservesArrays()
{
var value = new
{
meta = new { z = 1, a = 2 },
items = new[] { "first", "second" }
};
var json = CanonicalJsonHasher.ToCanonicalJson(value);
// keys sorted inside meta
Assert.Equal("{\"items\":[\"first\",\"second\"],\"meta\":{\"a\":2,\"z\":1}}", json);
}
[Fact]
public void AuditEntry_UsesCanonicalHash()
{
var entry = AuditEntry.Create(
tenantId: "tenant-1",
eventType: AuditEventType.JobCreated,
resourceType: "job",
resourceId: Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
actorId: "user-1",
actorType: ActorType.User,
description: "created job");
Assert.True(entry.VerifyIntegrity());
// Changing description should invalidate hash
var tampered = entry with { Description = "tampered" };
Assert.False(tampered.VerifyIntegrity());
}
}

View File

@@ -203,7 +203,6 @@ public sealed class PackRunStreamCoordinatorTests
public override void Dispose()
{
_state = WebSocketState.Closed;
base.Dispose();
}
public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken)

View File

@@ -0,0 +1,39 @@
using System.Collections.Immutable;
using StellaOps.Orchestrator.Core.Domain.Replay;
namespace StellaOps.Orchestrator.Tests;
public class ReplayManifestTests
{
[Fact]
public void ComputeHash_IsStableWithCanonicalOrdering()
{
var inputs = new ReplayInputs(
PolicyHash: "sha256:policy",
GraphRevisionId: "graph-42",
LatticeHash: "sha256:lattice",
ToolImages: ImmutableArray.Create("img:1", "img:2"),
Seeds: new ReplaySeeds(Rng: 1234, Sampling: 99),
TimeSource: ReplayTimeSource.monotonic,
Env: ImmutableDictionary.Create<string, string>().Add("TZ", "UTC"));
var manifestA = ReplayManifest.Create(
jobId: "job-123",
replayOf: "job-original",
inputs: inputs,
artifacts: new[] { new ReplayArtifact("ledger.ndjson", "sha256:abc", "application/x-ndjson") },
createdAt: new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero));
var manifestB = ReplayManifest.Create(
jobId: "job-123",
replayOf: "job-original",
inputs: inputs,
artifacts: new[] { new ReplayArtifact("ledger.ndjson", "sha256:abc", "application/x-ndjson") },
createdAt: new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero));
var hashA = manifestA.ComputeHash();
var hashB = manifestB.ComputeHash();
Assert.Equal(hashA, hashB);
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Orchestrator.Core;
using StellaOps.Orchestrator.Core.Hashing;
namespace StellaOps.Orchestrator.Tests;
public class SchemaSmokeTests
{
[Theory]
[InlineData("event-envelope.schema.json")]
[InlineData("audit-bundle.schema.json")]
[InlineData("replay-manifest.schema.json")]
[InlineData("taskrunner-integrity.schema.json")]
public void Schemas_AreWellFormedJson(string schemaFile)
{
var path = Path.Combine(AppContext.BaseDirectory, "../../../StellaOps.Orchestrator.Core/Schemas", schemaFile);
Assert.True(File.Exists(path), $"Schema missing: {schemaFile}");
var text = File.ReadAllText(path);
using var doc = JsonDocument.Parse(text);
Assert.True(doc.RootElement.TryGetProperty("$id", out _));
Assert.True(doc.RootElement.TryGetProperty("title", out _));
}
[Fact]
public void CanonicalHash_For_EventEnvelope_IsStable()
{
var envelope = EventEnvelope.Create(
eventType: "job.completed",
tenantId: "tenant-a",
job: new EventJob(
Id: "job-123",
Type: "pack-run",
RunId: "run-1",
Attempt: 1,
LeaseId: "lease-9",
TaskRunnerId: "tr-1",
Status: "completed",
Reason: null,
PayloadDigest: "sha256:abc",
Artifacts: ImmutableArray.Create<EventArtifact>(),
Provenance: ImmutableDictionary<string, string>.Empty),
actor: new EventActor("worker-go", ImmutableArray.Create("orch:job")),
occurredAt: new DateTimeOffset(2025, 12, 1, 12, 0, 0, TimeSpan.Zero),
eventId: "evt-1",
idempotencyKey: "fixed");
var hash1 = CanonicalJsonHasher.ComputeCanonicalSha256(envelope);
var hash2 = CanonicalJsonHasher.ComputeCanonicalSha256(envelope);
Assert.Equal(hash1, hash2);
Assert.Equal(64, hash1.Length);
}
}

View File

@@ -257,6 +257,7 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
ArtifactDocumentFormat.ComponentFragmentJson => "layer.fragments",
ArtifactDocumentFormat.ObservationJson => "observation.json",
ArtifactDocumentFormat.SurfaceManifestJson => "surface.manifest",
ArtifactDocumentFormat.CompositionRecipeJson => "composition.recipe",
ArtifactDocumentFormat.CycloneDxJson => "cdx-json",
ArtifactDocumentFormat.CycloneDxProtobuf => "cdx-protobuf",
ArtifactDocumentFormat.SpdxJson => "spdx-json",

View File

@@ -265,8 +265,8 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
pins["policy"] = policy;
}
var (artifactHashes, merkle) = ComputeDeterminismHashes(payloads);
merkleRoot = merkle;
var (artifactHashes, recipeBytes, recipeSha256) = BuildCompositionRecipe(payloads);
merkleRoot = recipeSha256;
var report = new
{
@@ -277,12 +277,26 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
concurrencyLimit = _determinism.ConcurrencyLimit,
pins = pins,
artifacts = artifactHashes,
merkleRoot = merkle
merkleRoot = recipeSha256
};
var evidence = new Determinism.DeterminismEvidence(artifactHashes, merkle);
var evidence = new Determinism.DeterminismEvidence(artifactHashes, recipeSha256);
context.Analysis.Set(ScanAnalysisKeys.DeterminismEvidence, evidence);
// Publish composition recipe as a manifest artifact for offline replay.
payloads = payloads.ToList();
((List<SurfaceManifestPayload>)payloads).Add(new SurfaceManifestPayload(
ArtifactDocumentType.CompositionRecipe,
ArtifactDocumentFormat.CompositionRecipeJson,
Kind: "composition.recipe",
MediaType: "application/vnd.stellaops.composition.recipe+json",
Content: recipeBytes,
Metadata: new Dictionary<string, string>
{
["schema"] = "stellaops.composition.recipe@1",
["merkleRoot"] = recipeSha256,
}));
var json = JsonSerializer.Serialize(report, JsonOptions);
return new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
@@ -293,9 +307,9 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
View: "replay");
}
private static (Dictionary<string, string> Hashes, string MerkleRoot) ComputeDeterminismHashes(IEnumerable<SurfaceManifestPayload> payloads)
private static (Dictionary<string, string> Hashes, byte[] RecipeBytes, string RecipeSha256) BuildCompositionRecipe(IEnumerable<SurfaceManifestPayload> payloads)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var map = new SortedDictionary<string, string>(StringComparer.Ordinal);
using var sha = SHA256.Create();
foreach (var payload in payloads.OrderBy(p => p.Kind, StringComparer.Ordinal))
@@ -304,18 +318,18 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
map[payload.Kind] = digest;
}
// Build Merkle-like root by hashing the ordered list of kind:digest lines.
var builder = new StringBuilder();
foreach (var kvp in map.OrderBy(kv => kv.Key, StringComparer.Ordinal))
var recipe = new
{
builder.Append(kvp.Key).Append(':').Append(kvp.Value).Append('\n');
}
schema = "stellaops.composition.recipe@1",
artifacts = map, // already sorted
};
var rootBytes = Encoding.UTF8.GetBytes(builder.ToString());
var rootHash = sha.ComputeHash(rootBytes);
var recipeJson = JsonSerializer.Serialize(recipe, JsonOptions);
var recipeBytes = Encoding.UTF8.GetBytes(recipeJson);
var rootHash = sha.ComputeHash(recipeBytes);
var merkleRoot = Convert.ToHexString(rootHash).ToLowerInvariant();
return (map, merkleRoot);
return (new Dictionary<string, string>(map, StringComparer.OrdinalIgnoreCase), recipeBytes, merkleRoot);
}
private static string? GetReplayBundleUri(ScanJobContext context)

View File

@@ -24,11 +24,11 @@ public sealed class CycloneDxComposer
private const string InventoryMediaTypeProtobuf = "application/vnd.cyclonedx+protobuf; version=1.6";
private const string UsageMediaTypeProtobuf = "application/vnd.cyclonedx+protobuf; version=1.6; view=usage";
public SbomCompositionResult Compose(SbomCompositionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.LayerFragments.IsDefaultOrEmpty)
{
public SbomCompositionResult Compose(SbomCompositionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.LayerFragments.IsDefaultOrEmpty)
{
throw new ArgumentException("At least one layer fragment is required.", nameof(request));
}
@@ -48,9 +48,9 @@ public sealed class CycloneDxComposer
.Where(static component => component.Usage.UsedByEntrypoint)
.ToImmutableArray();
CycloneDxArtifact? usageArtifact = null;
if (!usageComponents.IsEmpty)
{
CycloneDxArtifact? usageArtifact = null;
if (!usageComponents.IsEmpty)
{
usageArtifact = BuildArtifact(
request,
graph,
@@ -59,15 +59,36 @@ public sealed class CycloneDxComposer
generatedAt,
UsageMediaTypeJson,
UsageMediaTypeProtobuf);
}
return new SbomCompositionResult
{
Inventory = inventoryArtifact,
Usage = usageArtifact,
Graph = graph,
};
}
}
var compositionRecipeJson = BuildCompositionRecipeJson(graph, generatedAt);
var compositionRecipeSha = ComputeSha256(compositionRecipeJson);
var compositionRecipeUri = $"cas://sbom/composition/{compositionRecipeSha}.json";
inventoryArtifact = inventoryArtifact with
{
MerkleRoot = compositionRecipeSha,
CompositionRecipeUri = compositionRecipeUri,
};
if (usageArtifact is not null)
{
usageArtifact = usageArtifact with
{
MerkleRoot = compositionRecipeSha,
CompositionRecipeUri = compositionRecipeUri,
};
}
return new SbomCompositionResult
{
Inventory = inventoryArtifact,
Usage = usageArtifact,
Graph = graph,
CompositionRecipeJson = compositionRecipeJson,
CompositionRecipeSha256 = compositionRecipeSha,
};
}
private CycloneDxArtifact BuildArtifact(
SbomCompositionRequest request,
@@ -92,6 +113,7 @@ public sealed class CycloneDxComposer
: null;
request.AdditionalProperties?.TryGetValue("stellaops:composition.manifest", out var compositionUri);
request.AdditionalProperties?.TryGetValue("stellaops:composition.recipe", out var compositionRecipeUri);
return new CycloneDxArtifact
{
@@ -104,12 +126,38 @@ public sealed class CycloneDxComposer
ContentHash = jsonHash,
MerkleRoot = merkleRoot,
CompositionUri = compositionUri,
CompositionRecipeUri = compositionRecipeUri,
JsonMediaType = jsonMediaType,
ProtobufBytes = protobufBytes,
ProtobufSha256 = protobufHash,
ProtobufMediaType = protobufMediaType,
};
}
private static byte[] BuildCompositionRecipeJson(ComponentGraph graph, DateTimeOffset generatedAt)
{
var recipe = new
{
schema = "stellaops.composition.recipe@1",
generatedAt = ScannerTimestamps.ToIso8601(generatedAt),
layers = graph.Layers.Select(layer => new
{
layer.LayerDigest,
components = layer.Components
.Select(component => component.Identity.Key)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray(),
}).OrderBy(entry => entry.LayerDigest, StringComparer.Ordinal).ToArray(),
};
var json = JsonSerializer.Serialize(recipe, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
});
return Encoding.UTF8.GetBytes(json);
}
private Bom BuildBom(
SbomCompositionRequest request,

View File

@@ -33,6 +33,11 @@ public sealed record CycloneDxArtifact
/// </summary>
public string? CompositionUri { get; init; }
/// <summary>
/// CAS URI of the layer composition recipe (_composition.json) if emitted.
/// </summary>
public string? CompositionRecipeUri { get; init; }
public required string JsonMediaType { get; init; }
public required byte[] ProtobufBytes { get; init; }
@@ -42,11 +47,21 @@ public sealed record CycloneDxArtifact
public required string ProtobufMediaType { get; init; }
}
public sealed record SbomCompositionResult
{
public required CycloneDxArtifact Inventory { get; init; }
public CycloneDxArtifact? Usage { get; init; }
public required ComponentGraph Graph { get; init; }
}
public sealed record SbomCompositionResult
{
public required CycloneDxArtifact Inventory { get; init; }
public CycloneDxArtifact? Usage { get; init; }
public required ComponentGraph Graph { get; init; }
/// <summary>
/// Composition recipe JSON bytes (canonical) capturing fragment ordering and hashes.
/// </summary>
public required byte[] CompositionRecipeJson { get; init; }
/// <summary>
/// SHA256 hex of the composition recipe JSON.
/// </summary>
public required string CompositionRecipeSha256 { get; init; }
}

View File

@@ -88,18 +88,26 @@ public sealed class ScannerArtifactPackageBuilder
descriptors.Add(CreateDescriptor(ArtifactDocumentType.ImageBom, ArtifactDocumentFormat.CycloneDxProtobuf, composition.Usage.ProtobufMediaType, composition.Usage.ProtobufBytes, composition.Usage.ProtobufSha256, SbomView.Usage));
}
descriptors.Add(CreateDescriptor(ArtifactDocumentType.Index, ArtifactDocumentFormat.BomIndex, "application/vnd.stellaops.bom-index.v1+binary", bomIndex.Bytes, bomIndex.Sha256, null));
descriptors.Add(CreateDescriptor(ArtifactDocumentType.Index, ArtifactDocumentFormat.BomIndex, "application/vnd.stellaops.bom-index.v1+binary", bomIndex.Bytes, bomIndex.Sha256, null));
descriptors.Add(CreateDescriptor(
ArtifactDocumentType.CompositionRecipe,
ArtifactDocumentFormat.CompositionRecipeJson,
"application/vnd.stellaops.composition.recipe+json",
composition.CompositionRecipeJson,
composition.CompositionRecipeSha256,
null));
var manifest = new ScannerArtifactManifest
{
ImageDigest = imageDigest.Trim(),
GeneratedAt = generatedAt,
Artifacts = descriptors
.Select(ToManifestEntry)
.OrderBy(entry => entry.Kind, StringComparer.Ordinal)
.ThenBy(entry => entry.Format)
.ToImmutableArray(),
};
var manifest = new ScannerArtifactManifest
{
ImageDigest = imageDigest.Trim(),
GeneratedAt = generatedAt,
Artifacts = descriptors
.Select(ToManifestEntry)
.OrderBy(entry => entry.Kind, StringComparer.Ordinal)
.ThenBy(entry => entry.Format)
.ToImmutableArray(),
};
return new ScannerArtifactPackage
{
@@ -136,9 +144,10 @@ public sealed class ScannerArtifactPackageBuilder
ArtifactDocumentType.ImageBom => "sbom-inventory",
ArtifactDocumentType.LayerBom => "layer-sbom",
ArtifactDocumentType.Diff => "diff",
ArtifactDocumentType.Attestation => "attestation",
_ => descriptor.Type.ToString().ToLowerInvariant(),
};
ArtifactDocumentType.Attestation => "attestation",
ArtifactDocumentType.CompositionRecipe => "composition-recipe",
_ => descriptor.Type.ToString().ToLowerInvariant(),
};
return new ScannerArtifactManifestEntry
{

View File

@@ -12,7 +12,8 @@ public enum ArtifactDocumentType
SurfaceManifest,
SurfaceEntryTrace,
SurfaceLayerFragment,
SurfaceObservation
SurfaceObservation,
CompositionRecipe
}
public enum ArtifactDocumentFormat
@@ -26,7 +27,8 @@ public enum ArtifactDocumentFormat
EntryTraceNdjson,
EntryTraceGraphJson,
ComponentFragmentJson,
ObservationJson
ObservationJson,
CompositionRecipeJson
}
[BsonIgnoreExtraElements]

View File

@@ -8,6 +8,8 @@
<IsPackable>false</IsPackable>
<!-- Stay scoped: disable implicit restore sources beyond local nugets -->
<RestoreSources>$(StellaOpsLocalNuGetSource)</RestoreSources>
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
<RestoreNoCache>true</RestoreNoCache>
</PropertyGroup>
<ItemGroup>

View File

@@ -79,8 +79,9 @@ public sealed class CycloneDxComposerTests
Assert.Equal(first.Inventory.ContentHash, first.Inventory.JsonSha256);
Assert.Equal(first.Inventory.ProtobufSha256, second.Inventory.ProtobufSha256);
Assert.Equal(first.Inventory.SerialNumber, second.Inventory.SerialNumber);
Assert.Null(first.Inventory.MerkleRoot);
Assert.False(string.IsNullOrWhiteSpace(first.Inventory.MerkleRoot));
Assert.Null(first.Inventory.CompositionUri);
Assert.Null(first.Inventory.CompositionRecipeUri);
Assert.NotNull(first.Usage);
Assert.NotNull(second.Usage);
@@ -88,8 +89,15 @@ public sealed class CycloneDxComposerTests
Assert.Equal(first.Usage.ContentHash, first.Usage.JsonSha256);
Assert.Equal(first.Usage.ProtobufSha256, second.Usage.ProtobufSha256);
Assert.Equal(first.Usage.SerialNumber, second.Usage.SerialNumber);
Assert.Null(first.Usage.MerkleRoot);
Assert.False(string.IsNullOrWhiteSpace(first.Usage.MerkleRoot));
Assert.Null(first.Usage.CompositionUri);
Assert.Null(first.Usage.CompositionRecipeUri);
Assert.Equal(first.Inventory.MerkleRoot, first.Usage.MerkleRoot);
Assert.Equal(first.Inventory.MerkleRoot, result.CompositionRecipeSha256);
Assert.Equal(first.Inventory.ContentHash.Length, first.Inventory.MerkleRoot!.Length);
Assert.Equal(result.CompositionRecipeSha256.Length, 64);
Assert.NotEmpty(result.CompositionRecipeJson);
}
private static SbomCompositionRequest BuildRequest()

View File

@@ -64,16 +64,16 @@ public sealed class ScannerArtifactPackageBuilderTests
var packageBuilder = new ScannerArtifactPackageBuilder();
var package = packageBuilder.Build(request.Image.ImageDigest, request.GeneratedAt, composition, bomIndex);
Assert.Equal(5, package.Artifacts.Length); // inventory JSON+PB, usage JSON+PB, index
var kinds = package.Manifest.Artifacts.Select(entry => entry.Kind).ToArray();
Assert.Equal(new[] { "bom-index", "sbom-inventory", "sbom-inventory", "sbom-usage", "sbom-usage" }, kinds);
var manifestJson = package.Manifest.ToJsonBytes();
using var document = JsonDocument.Parse(manifestJson);
var root = document.RootElement;
Assert.Equal("sha256:image", root.GetProperty("imageDigest").GetString());
Assert.Equal(5, root.GetProperty("artifacts").GetArrayLength());
Assert.Equal(6, package.Artifacts.Length); // inventory JSON+PB, usage JSON+PB, index, composition recipe
var kinds = package.Manifest.Artifacts.Select(entry => entry.Kind).ToArray();
Assert.Equal(new[] { "bom-index", "composition-recipe", "sbom-inventory", "sbom-inventory", "sbom-usage", "sbom-usage" }, kinds);
var manifestJson = package.Manifest.ToJsonBytes();
using var document = JsonDocument.Parse(manifestJson);
var root = document.RootElement;
Assert.Equal("sha256:image", root.GetProperty("imageDigest").GetString());
Assert.Equal(6, root.GetProperty("artifacts").GetArrayLength());
var usageEntry = root.GetProperty("artifacts").EnumerateArray().First(element => element.GetProperty("kind").GetString() == "sbom-usage");
Assert.Equal("application/vnd.cyclonedx+json; version=1.6; view=usage", usageEntry.GetProperty("mediaType").GetString());

View File

@@ -102,10 +102,12 @@ public sealed class SurfaceManifestStageExecutorTests
Assert.Equal(publisher.LastManifestDigest, result!.ManifestDigest);
Assert.Equal(result.DeterminismMerkleRoot, publisher.LastRequest!.DeterminismMerkleRoot);
Assert.Equal(4, cache.Entries.Count);
Assert.Equal(6, cache.Entries.Count);
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.entrytrace.graph" && key.Tenant == "tenant-a");
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.entrytrace.ndjson" && key.Tenant == "tenant-a");
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.layer.fragments" && key.Tenant == "tenant-a");
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.determinism.json" && key.Tenant == "tenant-a");
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.composition.recipe" && key.Tenant == "tenant-a");
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.manifests" && key.Tenant == "tenant-a");
var publishedMetrics = listener.Measurements
@@ -114,7 +116,7 @@ public sealed class SurfaceManifestStageExecutorTests
Assert.Single(publishedMetrics);
Assert.Equal(1, publishedMetrics[0].Value);
Assert.Equal("published", publishedMetrics[0]["surface.result"]);
Assert.Equal(3, Convert.ToInt32(publishedMetrics[0]["surface.payload_count"]));
Assert.Equal(5, Convert.ToInt32(publishedMetrics[0]["surface.payload_count"]));
var payloadMetrics = listener.Measurements
.Where(m => m.InstrumentName == "scanner_worker_surface_payload_persisted_total")
@@ -608,7 +610,8 @@ public sealed class SurfaceManifestStageExecutorTests
WorkerInstance = request.WorkerInstance,
Attempt = request.Attempt
},
Artifacts = artifacts
Artifacts = artifacts,
DeterminismMerkleRoot = request.DeterminismMerkleRoot
};
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(document, _options);

View File

@@ -5,4 +5,8 @@
| 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-01) | Added risk gateway mock client/models + tests; wire to real gateway once endpoints land. |
| 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`). |
| WEB-RISK-68-NOTIFY-DOC | DONE (2025-12-01) | Notifications severity transition event schema v1.0 published (`docs/api/gateway/notifications-severity.md`). |

View File

@@ -20,6 +20,8 @@ import {
NOTIFY_TENANT_ID,
} from './core/api/notify.client';
import { CONSOLE_API_BASE_URL } from './core/api/console-status.client';
import { RISK_API } from './core/api/risk.client';
import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client';
import { AppConfigService } from './core/config/app-config.service';
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
@@ -65,11 +67,31 @@ export const appConfig: ApplicationConfig = {
}
},
},
AuthorityConsoleApiHttpClient,
{
provide: AUTHORITY_CONSOLE_API,
useExisting: AuthorityConsoleApiHttpClient,
},
AuthorityConsoleApiHttpClient,
{
provide: AUTHORITY_CONSOLE_API,
useExisting: AuthorityConsoleApiHttpClient,
},
{
provide: RISK_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const authorityBase = config.config.apiBaseUrls.authority;
try {
return new URL('/risk', authorityBase).toString();
} catch {
const normalized = authorityBase.endsWith('/')
? authorityBase.slice(0, -1)
: authorityBase;
return `${normalized}/risk`;
}
},
},
RiskHttpClient,
{
provide: RISK_API,
useExisting: RiskHttpClient,
},
{
provide: NOTIFY_API_BASE_URL,
useValue: '/api/v1/notify',

View File

@@ -0,0 +1,62 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, map } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { RiskApi } from './risk.client';
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
export const RISK_API_BASE_URL = new InjectionToken<string>('RISK_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class RiskHttpClient implements RiskApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
@Inject(RISK_API_BASE_URL) private readonly baseUrl: string
) {}
list(options: RiskQueryOptions): Observable<RiskResultPage> {
const tenant = this.resolveTenant(options.tenantId);
const headers = this.buildHeaders(tenant, options.projectId, options.traceId);
let params = new HttpParams();
if (options.page) params = params.set('page', options.page);
if (options.pageSize) params = params.set('pageSize', options.pageSize);
if (options.severity) params = params.set('severity', options.severity);
if (options.search) params = params.set('search', options.search);
return this.http
.get<RiskResultPage>(`${this.baseUrl}/risk`, { headers, params })
.pipe(map((page) => ({ ...page, page: page.page ?? 1, pageSize: page.pageSize ?? 20 })));
}
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats> {
const tenant = this.resolveTenant(options.tenantId);
const headers = this.buildHeaders(tenant, options.projectId, options.traceId);
return this.http
.get<RiskStats>(`${this.baseUrl}/risk/status`, { headers })
.pipe(
map((stats) => ({
countsBySeverity: stats.countsBySeverity,
lastComputation: stats.lastComputation ?? '1970-01-01T00:00:00Z',
}))
);
}
private buildHeaders(tenantId: string, projectId?: string, traceId?: string): HttpHeaders {
let headers = new HttpHeaders({ 'X-Stella-Tenant': tenantId });
if (projectId) headers = headers.set('X-Stella-Project', projectId);
if (traceId) headers = headers.set('X-Stella-Trace-Id', traceId);
return headers;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('RiskHttpClient requires an active tenant identifier.');
}
return tenant;
}
}

View File

@@ -0,0 +1,40 @@
import { MockRiskApi } from './risk.client';
describe('MockRiskApi', () => {
let api: MockRiskApi;
beforeEach(() => {
api = new MockRiskApi();
});
it('requires tenantId for list', () => {
expect(() => api.list({ tenantId: '' })).toThrow('tenantId is required');
});
it('returns deterministic ordering by score then id', (done) => {
api.list({ tenantId: 'acme-tenant', pageSize: 10 }).subscribe((page) => {
const scores = page.items.map((r) => r.score);
expect(scores).toEqual([...scores].sort((a, b) => b - a));
done();
});
});
it('filters by project and severity', (done) => {
api
.list({ tenantId: 'acme-tenant', projectId: 'proj-ops', severity: 'high' })
.subscribe((page) => {
expect(page.items.every((r) => r.projectId === 'proj-ops')).toBeTrue();
expect(page.items.every((r) => r.severity === 'high')).toBeTrue();
done();
});
});
it('computes stats with zeroed severities present', (done) => {
api.stats({ tenantId: 'acme-tenant' }).subscribe((stats) => {
expect(stats.countsBySeverity.none).toBe(0);
expect(stats.countsBySeverity.critical).toBeGreaterThan(0);
expect(stats.lastComputation).toMatch(/T/);
done();
});
});
});

View File

@@ -0,0 +1,112 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, delay, map, of } from 'rxjs';
import { RiskProfile, RiskQueryOptions, RiskResultPage, RiskStats, RiskSeverity } from './risk.models';
export interface RiskApi {
list(options: RiskQueryOptions): Observable<RiskResultPage>;
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats>;
}
export const RISK_API = new InjectionToken<RiskApi>('RISK_API');
const MOCK_RISKS: RiskProfile[] = [
{
id: 'risk-001',
title: 'RCE on internet-facing API',
description: 'Critical RCE on public API gateway impacting tenants acme, globally exposed.',
severity: 'critical',
score: 97,
lastEvaluatedAt: '2025-11-30T12:00:00Z',
tenantId: 'acme-tenant',
},
{
id: 'risk-002',
title: 'Expired token audience',
description: 'Tokens minted without correct audience allow cross-tenant reuse.',
severity: 'high',
score: 81,
lastEvaluatedAt: '2025-11-30T11:00:00Z',
tenantId: 'acme-tenant',
projectId: 'proj-ops',
},
{
id: 'risk-003',
title: 'Missing SBOM attestation',
description: 'Builds lack SBOM attestations; export blocked in sealed mode.',
severity: 'medium',
score: 55,
lastEvaluatedAt: '2025-11-29T19:30:00Z',
tenantId: 'acme-tenant',
},
];
@Injectable({ providedIn: 'root' })
export class MockRiskApi implements RiskApi {
list(options: RiskQueryOptions): Observable<RiskResultPage> {
if (!options.tenantId) {
throw new Error('tenantId is required');
}
const page = options.page ?? 1;
const pageSize = options.pageSize ?? 20;
const filtered = MOCK_RISKS.filter((r) => {
if (r.tenantId !== options.tenantId) {
return false;
}
if (options.projectId && r.projectId !== options.projectId) {
return false;
}
if (options.severity && r.severity !== options.severity) {
return false;
}
if (options.search && !r.title.toLowerCase().includes(options.search.toLowerCase())) {
return false;
}
return true;
});
const start = (page - 1) * pageSize;
const items = filtered
.slice()
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
.slice(start, start + pageSize);
const response: RiskResultPage = {
items,
total: filtered.length,
page,
pageSize,
};
return of(response).pipe(delay(50));
}
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats> {
if (!options.tenantId) {
throw new Error('tenantId is required');
}
const relevant = MOCK_RISKS.filter((r) => r.tenantId === options.tenantId);
const emptyCounts: Record<RiskSeverity, number> = {
none: 0,
info: 0,
low: 0,
medium: 0,
high: 0,
critical: 0,
};
const counts = relevant.reduce((acc, curr) => {
acc[curr.severity] = (acc[curr.severity] ?? 0) + 1;
return acc;
}, { ...emptyCounts });
const lastEvaluatedAt = relevant
.map((r) => r.lastEvaluatedAt)
.sort()
.reverse()[0] ?? '1970-01-01T00:00:00Z';
return of({ countsBySeverity: counts, lastComputation: lastEvaluatedAt }).pipe(delay(25));
}
}

View File

@@ -0,0 +1,34 @@
export type RiskSeverity = 'none' | 'info' | 'low' | 'medium' | 'high' | 'critical';
export interface RiskProfile {
id: string;
title: string;
description: string;
severity: RiskSeverity;
score: number;
lastEvaluatedAt: string; // UTC ISO-8601
tenantId: string;
projectId?: string;
}
export interface RiskResultPage {
items: RiskProfile[];
total: number;
page: number;
pageSize: number;
}
export interface RiskQueryOptions {
tenantId: string;
projectId?: string;
page?: number;
pageSize?: number;
severity?: RiskSeverity;
search?: string;
traceId?: string;
}
export interface RiskStats {
countsBySeverity: Record<RiskSeverity, number>;
lastComputation: string; // UTC ISO-8601
}