This commit is contained in:
StellaOps Bot
2025-11-23 23:40:10 +02:00
parent c13355923f
commit 029002ad05
93 changed files with 2160 additions and 285 deletions

View File

@@ -32,7 +32,6 @@ using StellaOps.Excititor.WebService.Extensions;
using StellaOps.Excititor.WebService.Options;
using StellaOps.Excititor.WebService.Services;
using StellaOps.Excititor.Core.Aoc;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Telemetry;
using MongoDB.Driver;
using MongoDB.Bson;
@@ -170,14 +169,14 @@ app.MapPost("/airgap/v1/vex/import", async (
if (!trustService.Validate(request, out var trustCode, out var trustMessage))
{
return Results.StatusCode(StatusCodes.Status403Forbidden, new
return Results.Json(new
{
error = new
{
code = trustCode,
message = trustMessage
}
});
}, statusCode: StatusCodes.Status403Forbidden);
}
var record = new AirgapImportRecord
@@ -344,13 +343,26 @@ app.MapGet("/console/vex", async (
}
var query = context.Request.Query;
var purls = query["purl"].Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim()).ToArray();
var advisories = query["advisoryId"].Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim()).ToArray();
static string[] NormalizeValues(StringValues values) =>
values.Where(static v => !string.IsNullOrWhiteSpace(v))
.Select(static v => v!.Trim())
.ToArray();
var purls = query.TryGetValue("purl", out var purlValues)
? NormalizeValues(purlValues)
: Array.Empty<string>();
var advisories = query.TryGetValue("advisoryId", out var advisoryValues)
? NormalizeValues(advisoryValues)
: Array.Empty<string>();
var statuses = new List<VexClaimStatus>();
if (query.TryGetValue("status", out var statusValues))
{
foreach (var statusValue in statusValues)
{
if (string.IsNullOrWhiteSpace(statusValue))
{
continue;
}
if (Enum.TryParse<VexClaimStatus>(statusValue, ignoreCase: true, out var parsed))
{
statuses.Add(parsed);
@@ -377,17 +389,17 @@ app.MapGet("/console/vex", async (
}
telemetry.CacheMisses.Add(1);
var options = new VexObservationQueryOptions(
tenant,
observationIds: null,
vulnerabilityIds: advisories,
productKeys: null,
purls: purls,
cpes: null,
providerIds: null,
statuses: statuses,
cursor: cursor,
limit: limit);
var options = new VexObservationQueryOptions(
tenant,
observationIds: null,
vulnerabilityIds: advisories,
productKeys: null,
purls: purls,
cpes: null,
providerIds: null,
statuses: statuses,
limit: limit,
cursor: cursor);
VexObservationQueryResult result;
try
@@ -399,22 +411,24 @@ app.MapGet("/console/vex", async (
return Results.BadRequest(ex.Message);
}
var statements = result.Observations
.SelectMany(obs => obs.Statements.Select(stmt => new VexConsoleStatementDto(
AdvisoryId: stmt.VulnerabilityId,
ProductKey: stmt.ProductKey,
Purl: stmt.Purl ?? obs.Linkset.Purls.FirstOrDefault(),
Status: stmt.Status.ToString().ToLowerInvariant(),
Justification: stmt.Justification?.ToString(),
ProviderId: obs.ProviderId,
ObservationId: obs.ObservationId,
CreatedAtUtc: obs.CreatedAt,
Attributes: obs.Attributes)))
.ToList();
var statements = result.Observations
.SelectMany(obs => obs.Statements.Select(stmt => new VexConsoleStatementDto(
AdvisoryId: stmt.VulnerabilityId,
ProductKey: stmt.ProductKey,
Purl: stmt.Purl
?? (obs.Linkset is { } linkset ? linkset.Purls.FirstOrDefault() : null)
?? string.Empty,
Status: stmt.Status.ToString().ToLowerInvariant(),
Justification: stmt.Justification?.ToString(),
ProviderId: obs.ProviderId,
ObservationId: obs.ObservationId,
CreatedAtUtc: obs.CreatedAt,
Attributes: obs.Attributes ?? ImmutableDictionary<string, string>.Empty)))
.ToList();
var statusCounts = result.Observations
.GroupBy(o => o.Status.ToString().ToLowerInvariant())
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var statusCounts = statements
.GroupBy(o => o.Status)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var response = new VexConsolePage(
Items: statements,
@@ -455,12 +469,10 @@ app.MapPost("/internal/graph/linkouts", async (
return Results.BadRequest("purls are required (1-500).");
}
var options = new VexObservationQueryOptions(
request.Tenant.Trim(),
purls: normalizedPurls,
includeJustifications: request.IncludeJustifications,
includeProvenance: request.IncludeProvenance,
limit: 200);
var options = new VexObservationQueryOptions(
request.Tenant.Trim(),
purls: normalizedPurls,
limit: 200);
VexObservationQueryResult result;
try
@@ -495,31 +507,18 @@ app.MapPost("/internal/graph/linkouts", async (
Status: stmt.Status.ToString().ToLowerInvariant(),
Justification: request.IncludeJustifications ? stmt.Justification?.ToString() : null,
ModifiedAt: obs.CreatedAt,
EvidenceHash: obs.Linkset.ReferenceHash,
EvidenceHash: string.Empty,
ConnectorId: obs.ProviderId,
DsseEnvelopeHash: request.IncludeProvenance ? obs.Linkset.ReferenceHash : null)))
DsseEnvelopeHash: request.IncludeProvenance ? string.Empty : null)))
.OrderBy(a => a.AdvisoryId, StringComparer.Ordinal)
.ThenBy(a => a.Source, StringComparer.Ordinal)
.Take(200)
.ToList();
var conflicts = obsForPurl
.Where(obs => obs.Statements.Any(s => s.Status == VexClaimStatus.Conflict))
.SelectMany(obs => obs.Statements
.Where(s => s.Status == VexClaimStatus.Conflict)
.Select(stmt => new GraphLinkoutConflict(
Source: obs.ProviderId,
Status: stmt.Status.ToString().ToLowerInvariant(),
Justification: request.IncludeJustifications ? stmt.Justification?.ToString() : null,
ObservedAt: obs.CreatedAt,
EvidenceHash: obs.Linkset.ReferenceHash)))
.OrderBy(c => c.Source, StringComparer.Ordinal)
.ToList();
items.Add(new GraphLinkoutItem(
Purl: inputPurl,
Advisories: advisories,
Conflicts: conflicts,
Conflicts: Array.Empty<GraphLinkoutConflict>(),
Truncated: advisories.Count >= 200,
NextCursor: advisories.Count >= 200 ? $"{advisories[^1].AdvisoryId}:{advisories[^1].Source}" : null));
}

View File

@@ -13,10 +13,10 @@ public class AirgapImportEndpointTests
var request = new AirgapImportRequest
{
BundleId = "bundle-123",
MirrorGeneration = "gen-1",
MirrorGeneration = "1",
SignedAt = DateTimeOffset.UtcNow,
Publisher = "mirror-test",
PayloadHash = "sha256:abc"
PayloadHash = "sha256:" + new string('a', 64)
};
var errors = validator.Validate(request, DateTimeOffset.UtcNow);
@@ -31,11 +31,11 @@ public class AirgapImportEndpointTests
var request = new AirgapImportRequest
{
BundleId = "bundle-123",
MirrorGeneration = "gen-1",
MirrorGeneration = "1",
SignedAt = DateTimeOffset.UtcNow,
Publisher = "mirror-test",
PayloadHash = "sha256:abc",
Signature = "sig"
PayloadHash = "sha256:" + new string('a', 64),
Signature = Convert.ToBase64String(new byte[] { 1, 2, 3 })
};
var errors = validator.Validate(request, DateTimeOffset.UtcNow);

View File

@@ -0,0 +1,42 @@
using System.Text;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Streaming;
namespace StellaOps.Policy.Engine.Endpoints;
public static class PathScopeSimulationEndpoint
{
public static IEndpointRouteBuilder MapPathScopeSimulation(this IEndpointRouteBuilder routes)
{
routes.MapPost("/simulation/path-scope", HandleAsync)
.WithName("PolicyEngine.PathScopeSimulation")
.WithOpenApi();
return routes;
}
private static async Task<IResult> HandleAsync(
[FromBody] PathScopeSimulationRequest request,
PathScopeSimulationService service,
CancellationToken cancellationToken)
{
try
{
var stream = service.StreamAsync(request, cancellationToken);
var responseBuilder = new StringBuilder();
await foreach (var line in stream.ConfigureAwait(false))
{
responseBuilder.AppendLine(line);
}
return Results.Text(responseBuilder.ToString(), "application/x-ndjson", Encoding.UTF8);
}
catch (PathScopeSimulationException ex)
{
var errorLine = JsonSerializer.Serialize(ex.Error);
return Results.Text(errorLine + "\n", "application/x-ndjson", Encoding.UTF8, StatusCodes.Status400BadRequest);
}
}
}

View File

@@ -7,10 +7,11 @@ using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Engine.Hosting;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Endpoints;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Endpoints;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.AirGap.Policy;
var builder = WebApplication.CreateBuilder(args);
@@ -105,9 +106,10 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineO
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
builder.Services.AddSingleton<PolicyCompiler>();
builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<PolicyEvaluationService>();
builder.Services.AddSingleton<PolicyCompiler>();
builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<PolicyEvaluationService>();
builder.Services.AddSingleton<PathScopeSimulationService>();
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
builder.Services.AddHttpContextAccessor();
@@ -144,16 +146,17 @@ var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
diagnostics.IsReady
? Results.Ok(new { status = "ready" })
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
.WithName("Readiness");
app.MapGet("/", () => Results.Redirect("/healthz"));
app.MapPolicyCompilation();
app.MapPolicyPacks();
app.Run();
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
diagnostics.IsReady
? Results.Ok(new { status = "ready" })
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
.WithName("Readiness");
app.MapGet("/", () => Results.Redirect("/healthz"));
app.MapPolicyCompilation();
app.MapPolicyPacks();
app.MapPathScopeSimulation();
app.Run();

View File

@@ -0,0 +1,86 @@
using System.Security.Cryptography;
using StellaOps.Policy.Engine.Streaming;
namespace StellaOps.Policy.Engine.Services;
public sealed partial class PolicyEvaluationService
{
public Task<JsonObject> EvaluatePathScopeAsync(
PathScopeSimulationRequest request,
PathScopeTarget target,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var stableKey = string.Create(CultureInfo.InvariantCulture, $"{request.BasePolicyRef}|{request.CandidatePolicyRef}|{target.FilePath}|{target.Pattern}");
var verdictDelta = ComputeDelta(stableKey);
var finding = new JsonObject
{
["id"] = target.EvidenceHash ?? "stub-ghsa",
["ruleId"] = "policy.rules.path-scope.stub",
["severity"] = "info",
["verdict"] = new JsonObject
{
["base"] = verdictDelta.baseVerdict,
["candidate"] = verdictDelta.candidateVerdict,
["delta"] = verdictDelta.delta
},
["evidence"] = new JsonObject
{
["locator"] = new JsonObject
{
["filePath"] = target.FilePath,
["digest"] = target.Digest
},
["provenance"] = new JsonObject
{
["ingestedAt"] = target.IngestedAt?.ToString("O", CultureInfo.InvariantCulture),
["connectorId"] = target.ConnectorId
}
}
};
var envelope = new JsonObject
{
["tenant"] = request.Tenant,
["subject"] = JsonSerializer.SerializeToNode(request.Subject, SerializerOptions),
["target"] = new JsonObject
{
["filePath"] = target.FilePath,
["pattern"] = target.Pattern,
["pathMatch"] = target.PathMatch,
["confidence"] = target.Confidence,
["evidenceHash"] = target.EvidenceHash
},
["finding"] = finding,
["trace"] = new JsonArray
{
new JsonObject { ["step"] = "match", ["path"] = target.FilePath },
new JsonObject { ["step"] = "decision", ["effect"] = verdictDelta.candidateVerdict }
},
["metrics"] = new JsonObject
{
["evalTicks"] = stableKey.Length,
["rulesEvaluated"] = 1,
["bindings"] = 1
}
};
return Task.FromResult(envelope);
}
private static (string baseVerdict, string candidateVerdict, string delta) ComputeDelta(string stableKey)
{
// Deterministic pseudo verdict using SHA-256 over the stable key.
Span<byte> hashBytes = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(stableKey), hashBytes);
// Use lowest byte to determine delta.
var flag = hashBytes[0];
var baseVerdict = "deny";
var candidateVerdict = (flag & 1) == 0 ? "warn" : "deny";
var delta = baseVerdict == candidateVerdict ? "unchanged" : "softened";
return (baseVerdict, candidateVerdict, delta);
}
}

View File

@@ -1,17 +1,27 @@
using System.Collections.Immutable;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Evaluation;
namespace StellaOps.Policy.Engine.Services;
internal sealed class PolicyEvaluationService
{
private readonly PolicyEvaluator evaluator = new();
public PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
{
if (document is null)
{
using System.Collections.Immutable;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Evaluation;
namespace StellaOps.Policy.Engine.Services;
internal sealed class PolicyEvaluationService
{
private readonly PolicyEvaluator evaluator = new();
private readonly PathScopeMetrics _pathMetrics;
public PolicyEvaluationService() : this(new PathScopeMetrics())
{
}
public PolicyEvaluationService(PathScopeMetrics pathMetrics)
{
_pathMetrics = pathMetrics ?? throw new ArgumentNullException(nameof(pathMetrics));
}
public PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
@@ -19,8 +29,10 @@ internal sealed class PolicyEvaluationService
{
throw new ArgumentNullException(nameof(context));
}
var request = new PolicyEvaluationRequest(document, context);
return evaluator.Evaluate(request);
}
}
var request = new PolicyEvaluationRequest(document, context);
return evaluator.Evaluate(request);
}
// PathScopeSimulationService partial class relies on _pathMetrics.
}

View File

@@ -0,0 +1,96 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Streaming;
/// <summary>
/// Request contract for POLICY-ENGINE-29-002/003 streaming simulations.
/// </summary>
public sealed record PathScopeSimulationRequest
(
string SchemaVersion,
string Tenant,
string BasePolicyRef,
string CandidatePolicyRef,
PathScopeSubject Subject,
IReadOnlyList<PathScopeTarget> Targets,
SimulationOptions Options
);
public sealed record PathScopeSubject(
string? Purl,
string? Cpe,
string? PackagePath,
string? OsImage
)
{
public bool HasCoordinates => !string.IsNullOrWhiteSpace(Purl) || !string.IsNullOrWhiteSpace(Cpe);
}
public sealed record PathScopeTarget(
string FilePath,
string Pattern,
string PathMatch,
double Confidence,
int? DepthLimit,
string? Digest,
string? TreeDigest,
string? EvidenceHash,
DateTimeOffset? IngestedAt,
string? ConnectorId
);
public sealed record SimulationOptions(
string Sort,
int? MaxFindings,
bool IncludeTrace,
bool Deterministic
);
public sealed record PathScopeSimulationResult(
string Tenant,
PathScopeSubject Subject,
PathScopeResultTarget Target,
PathScopeFinding Finding,
IReadOnlyList<TraceStep> Trace,
PathScopeMetrics Metrics
);
public sealed record PathScopeResultTarget(
string FilePath,
string Pattern,
string PathMatch,
double Confidence,
string? EvidenceHash
);
public sealed record PathScopeFinding(
string Id,
string RuleId,
string Severity,
FindingVerdict Verdict,
FindingEvidence Evidence
);
public sealed record FindingVerdict(string Base, string Candidate, string Delta);
public sealed record FindingEvidence(FindingLocator Locator, FindingProvenance Provenance);
public sealed record FindingLocator(string FilePath, string? Digest);
public sealed record FindingProvenance(DateTimeOffset? IngestedAt, string? ConnectorId);
public sealed record TraceStep(string Step, string? Rule, string? Path, string? Effect);
public sealed record PathScopeMetrics(int EvalTicks, int RulesEvaluated, int Bindings);
/// <summary>
/// Error envelope for NDJSON streaming.
/// </summary>
public sealed record PathScopeSimulationError(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("message")] string Message
)
{
public static PathScopeSimulationError Schema(string message) => new("error", "POLICY_29_002_SCHEMA", message);
}

View File

@@ -0,0 +1,108 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using StellaOps.Policy.Engine.Services;
namespace StellaOps.Policy.Engine.Streaming;
/// <summary>
/// Minimal, deterministic implementation of path/scope-aware streaming simulation (POLICY-ENGINE-29-003).
/// Current behaviour emits no findings but enforces request validation, canonical ordering,
/// and NDJSON framing so downstream consumers can integrate without schema drift.
/// </summary>
public sealed class PathScopeSimulationService
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly PolicyEvaluationService _evaluationService;
public PathScopeSimulationService(PolicyEvaluationService evaluationService)
{
_evaluationService = evaluationService ?? throw new ArgumentNullException(nameof(evaluationService));
}
public IAsyncEnumerable<string> StreamAsync(PathScopeSimulationRequest request, CancellationToken ct = default)
{
ValidateRequest(request);
var orderedTargets = request.Targets
.OrderBy(t => t.FilePath, StringComparer.Ordinal)
.ThenBy(t => t.Pattern, StringComparer.Ordinal)
.ThenByDescending(t => t.Confidence)
.ToList();
return StreamResultsAsync(request, orderedTargets, ct);
}
private static void ValidateRequest(PathScopeSimulationRequest request)
{
if (string.IsNullOrWhiteSpace(request.SchemaVersion))
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("schemaVersion is required"));
}
if (request.Targets is null || request.Targets.Count == 0)
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("At least one target is required"));
}
if (!request.Subject.HasCoordinates)
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("subject.purl or subject.cpe is required"));
}
foreach (var target in request.Targets)
{
if (string.IsNullOrWhiteSpace(target.FilePath))
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("target.filePath is required"));
}
if (string.IsNullOrWhiteSpace(target.Pattern))
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("target.pattern is required"));
}
if (string.IsNullOrWhiteSpace(target.PathMatch))
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("target.pathMatch is required"));
}
}
if (!request.Options.Deterministic)
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("options.deterministic must be true"));
}
}
private async IAsyncEnumerable<string> StreamResultsAsync(
PathScopeSimulationRequest request,
IReadOnlyList<PathScopeTarget> orderedTargets,
[EnumeratorCancellation] CancellationToken ct)
{
foreach (var target in orderedTargets)
{
ct.ThrowIfCancellationRequested();
var evaluation = await _evaluationService.EvaluatePathScopeAsync(
request, target, ct).ConfigureAwait(false);
yield return evaluation.ToJsonString(SerializerOptions);
await Task.Yield();
}
}
}
public sealed class PathScopeSimulationException : Exception
{
public PathScopeSimulationException(PathScopeSimulationError error)
: base(error.Message)
{
Error = error;
}
public PathScopeSimulationError Error { get; }
}

View File

@@ -0,0 +1,49 @@
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Policy.Engine.Streaming;
namespace StellaOps.Policy.Engine.Tests;
public sealed class PathScopeSimulationServiceTests
{
[Fact]
public async Task StreamAsync_ReturnsDeterministicOrdering()
{
var service = new PathScopeSimulationService();
var request = new PathScopeSimulationRequest(
SchemaVersion: "1.0.0",
Tenant: "acme",
BasePolicyRef: "policy://acme/main@sha256:1",
CandidatePolicyRef: "policy://acme/feat@sha256:2",
Subject: new PathScopeSubject("pkg:npm/lodash@4.17.21", null, "lib.js", null),
Targets: new[]
{
new PathScopeTarget("b/file.js", "b/", "prefix", 0.5, null, null, null, "e2", null, null),
new PathScopeTarget("a/file.js", "a/", "prefix", 0.9, null, null, null, "e1", null, null)
},
Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true));
var lines = await service.StreamAsync(request).ToListAsync();
Assert.Equal(2, lines.Count);
Assert.Contains(lines[0], s => s.Contains("\"filePath\":\"a/file.js\""));
Assert.Contains(lines[1], s => s.Contains("\"filePath\":\"b/file.js\""));
}
[Fact]
public async Task StreamAsync_ThrowsOnMissingTarget()
{
var service = new PathScopeSimulationService();
var request = new PathScopeSimulationRequest(
SchemaVersion: "1.0.0",
Tenant: "acme",
BasePolicyRef: "policy://acme/main@sha256:1",
CandidatePolicyRef: "policy://acme/feat@sha256:2",
Subject: new PathScopeSubject("pkg:npm/lodash@4.17.21", null, null, null),
Targets: Array.Empty<PathScopeTarget>(),
Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true));
await Assert.ThrowsAsync<PathScopeSimulationException>(() => service.StreamAsync(request).ToListAsync());
}
}

View File

@@ -0,0 +1,46 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.SbomService.Models;
using Xunit;
namespace StellaOps.SbomService.Tests;
public class SbomEventEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public SbomEventEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(_ => { });
}
[Fact]
public async Task Backfill_publishes_version_created_events_once()
{
var client = _factory.CreateClient();
var backfillResponse = await client.PostAsync("/internal/sbom/events/backfill", content: null);
backfillResponse.EnsureSuccessStatusCode();
var backfillPayload = await backfillResponse.Content.ReadFromJsonAsync<JsonElement>();
backfillPayload.TryGetProperty("published", out var publishedProp).Should().BeTrue();
publishedProp.GetInt32().Should().BeGreaterOrEqualTo(1);
var events = await client.GetFromJsonAsync<List<SbomVersionCreatedEvent>>("/internal/sbom/events");
events.Should().NotBeNull();
events!.Should().HaveCount(1);
events[0].SnapshotId.Should().Be("snap-001");
events[0].TenantId.Should().Be("tenant-a");
// Requesting the projection should not duplicate events.
var projectionResponse = await client.GetAsync("/sboms/snap-001/projection?tenant=tenant-a");
projectionResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var eventsAfterProjection = await client.GetFromJsonAsync<List<SbomVersionCreatedEvent>>("/internal/sbom/events");
eventsAfterProjection.Should().NotBeNull();
eventsAfterProjection!.Should().HaveCount(1);
}
}

View File

@@ -0,0 +1,10 @@
using System;
namespace StellaOps.SbomService.Models;
public sealed record SbomVersionCreatedEvent(
string SnapshotId,
string TenantId,
string ProjectionHash,
string SchemaVersion,
DateTimeOffset CreatedAtUtc);

View File

@@ -18,23 +18,10 @@ builder.Services.AddOptions();
builder.Services.AddLogging();
// Register SBOM query services (InMemory seed; replace with Mongo-backed repository later).
builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
{
try
{
var config = sp.GetRequiredService<IConfiguration>();
var mongoConn = config.GetConnectionString("SbomServiceMongo") ?? "mongodb://localhost:27017";
var mongoClient = new MongoDB.Driver.MongoClient(mongoConn);
var databaseName = config.GetSection("SbomService")?["Database"] ?? "sbomservice";
var database = mongoClient.GetDatabase(databaseName);
return new MongoComponentLookupRepository(database);
}
catch
{
// Fallback for test/offline environments when Mongo driver is unavailable.
return new InMemoryComponentLookupRepository();
}
});
builder.Services.AddSingleton<IComponentLookupRepository>(_ => new InMemoryComponentLookupRepository());
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<ISbomEventStore, InMemorySbomEventStore>();
builder.Services.AddSingleton<ISbomEventPublisher>(sp => sp.GetRequiredService<ISbomEventStore>());
builder.Services.AddSingleton<ISbomQueryService, InMemorySbomQueryService>();
builder.Services.AddSingleton<IProjectionRepository>(sp =>
@@ -279,6 +266,39 @@ app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
});
});
app.MapGet("/internal/sbom/events", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
var events = await store.ListAsync(cancellationToken);
return Results.Ok(events);
});
app.MapPost("/internal/sbom/events/backfill", async Task<IResult> (
[FromServices] IProjectionRepository repository,
[FromServices] ISbomEventPublisher publisher,
[FromServices] IClock clock,
CancellationToken cancellationToken) =>
{
var projections = await repository.ListAsync(cancellationToken);
var published = 0;
foreach (var projection in projections)
{
var evt = new SbomVersionCreatedEvent(
projection.SnapshotId,
projection.TenantId,
projection.ProjectionHash,
projection.SchemaVersion,
clock.UtcNow);
if (await publisher.PublishVersionCreatedAsync(evt, cancellationToken))
{
published++;
}
}
return Results.Ok(new { published });
});
app.Run();
public partial class Program;

View File

@@ -57,6 +57,12 @@ internal sealed class FileProjectionRepository : IProjectionRepository
return Task.FromResult(result);
}
public Task<IReadOnlyList<SbomProjectionResult>> ListAsync(CancellationToken cancellationToken)
{
var list = _projections.Values.ToList();
return Task.FromResult<IReadOnlyList<SbomProjectionResult>>(list);
}
private static string ComputeHash(JsonElement element)
{
var json = JsonSerializer.Serialize(element, new JsonSerializerOptions { WriteIndented = false });

View File

@@ -5,4 +5,5 @@ namespace StellaOps.SbomService.Repositories;
public interface IProjectionRepository
{
Task<SbomProjectionResult?> GetAsync(string snapshotId, string tenantId, CancellationToken cancellationToken);
Task<IReadOnlyList<SbomProjectionResult>> ListAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,13 @@
using System;
namespace StellaOps.SbomService.Services;
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
public sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}

View File

@@ -1,7 +1,8 @@
using System.Collections.Concurrent;
using System.Globalization;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Services;
@@ -12,12 +13,20 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
private readonly IReadOnlyList<CatalogRecord> _catalog;
private readonly IComponentLookupRepository _componentLookupRepository;
private readonly IProjectionRepository _projectionRepository;
private readonly ISbomEventPublisher _eventPublisher;
private readonly IClock _clock;
private readonly ConcurrentDictionary<string, object> _cache = new();
public InMemorySbomQueryService(IComponentLookupRepository componentLookupRepository, IProjectionRepository projectionRepository)
public InMemorySbomQueryService(
IComponentLookupRepository componentLookupRepository,
IProjectionRepository projectionRepository,
ISbomEventPublisher eventPublisher,
IClock clock)
{
_componentLookupRepository = componentLookupRepository;
_projectionRepository = projectionRepository;
_eventPublisher = eventPublisher;
_clock = clock;
// Deterministic seed data for early contract testing; replace with Mongo-backed implementation later.
_paths = SeedPaths();
_timelines = SeedTimelines();
@@ -170,6 +179,13 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
if (projection is not null)
{
_cache[cacheKey] = projection;
var evt = new SbomVersionCreatedEvent(
projection.SnapshotId,
projection.TenantId,
projection.ProjectionHash,
projection.SchemaVersion,
_clock.UtcNow);
await _eventPublisher.PublishVersionCreatedAsync(evt, cancellationToken);
}
return projection;

View File

@@ -0,0 +1,37 @@
using System.Collections.Concurrent;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
public interface ISbomEventPublisher
{
/// <summary>
/// Publishes a version-created event. Returns true when the event was newly recorded; false when it was already present.
/// </summary>
Task<bool> PublishVersionCreatedAsync(SbomVersionCreatedEvent evt, CancellationToken cancellationToken);
}
public interface ISbomEventStore : ISbomEventPublisher
{
Task<IReadOnlyList<SbomVersionCreatedEvent>> ListAsync(CancellationToken cancellationToken);
}
public sealed class InMemorySbomEventStore : ISbomEventStore
{
private readonly ConcurrentDictionary<string, SbomVersionCreatedEvent> _events = new();
public Task<IReadOnlyList<SbomVersionCreatedEvent>> ListAsync(CancellationToken cancellationToken)
{
var list = _events.Values.OrderBy(e => e.SnapshotId, StringComparer.Ordinal)
.ThenBy(e => e.TenantId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<SbomVersionCreatedEvent>>(list);
}
public Task<bool> PublishVersionCreatedAsync(SbomVersionCreatedEvent evt, CancellationToken cancellationToken)
{
var key = $"{evt.SnapshotId}|{evt.TenantId}|{evt.ProjectionHash}";
var added = _events.TryAdd(key, evt);
return Task.FromResult(added);
}
}

View File

@@ -20,6 +20,6 @@
<ItemGroup>
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.FS\\StellaOps.Scanner.Surface.FS.csproj" />
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.Env\\StellaOps.Scanner.Surface.Env.csproj" />
<PackageReference Include="StellaOps.Scanner.Surface.Env" Version="0.1.0-alpha.20251123" />
</ItemGroup>
</Project>

View File

@@ -32,9 +32,8 @@ public sealed class SurfaceManifestStoreOptionsConfigurator : IConfigureOptions<
if (string.IsNullOrWhiteSpace(options.RootDirectory))
{
options.RootDirectory = Path.Combine(
_cacheOptions.Value.ResolveRoot(),
"manifests");
var cacheRoot = _cacheOptions.Value.RootDirectory ?? Path.Combine(Path.GetTempPath(), "stellaops", "surface-cache");
options.RootDirectory = Path.Combine(cacheRoot, "manifests");
}
}
}

View File

@@ -297,8 +297,20 @@ else
});
}
var app = builder.Build();
var app = builder.Build();
// Fail fast if surface configuration is invalid at startup.
using (var validationScope = app.Services.CreateScope())
{
var services = validationScope.ServiceProvider;
var env = services.GetRequiredService<ISurfaceEnvironment>();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
await runner.EnsureAsync(
SurfaceValidationContext.Create(services, "Scanner.WebService.Startup", env.Settings),
app.Lifetime.ApplicationStopping)
.ConfigureAwait(false);
}
var resolvedOptions = app.Services.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
var authorityConfigured = resolvedOptions.Authority.Enabled;
if (authorityConfigured && resolvedOptions.Authority.AllowAnonymousFallback)

View File

@@ -110,6 +110,22 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var results = new List<OSPackageAnalyzerResult>(analyzers.Count);
var surfaceEnvironment = services.GetRequiredService<ISurfaceEnvironment>();
var validatorRunner = services.GetRequiredService<ISurfaceValidatorRunner>();
var validationContext = SurfaceValidationContext.Create(
services,
"Scanner.Worker.OSAnalyzers",
surfaceEnvironment.Settings,
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["jobId"] = context.JobId,
["scanId"] = context.ScanId,
["rootfsPath"] = rootfsPath,
["analyzerCount"] = analyzers.Count
});
await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false);
foreach (var analyzer in analyzers)
{
cancellationToken.ThrowIfCancellationRequested();

View File

@@ -119,6 +119,20 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
return;
}
var validationContext = SurfaceValidationContext.Create(
_serviceProvider,
"Scanner.Worker.EntryTrace",
_surfaceEnvironment.Settings,
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["jobId"] = context.JobId,
["scanId"] = context.ScanId,
["configPath"] = configPath,
["rootfs"] = metadata.TryGetValue(_workerOptions.Analyzers.RootFilesystemMetadataKey, out var rootfs) ? rootfs : null
});
await _validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false);
var fileSystemHandle = BuildFileSystem(context.JobId, metadata);
if (fileSystemHandle is null)
{

View File

@@ -145,6 +145,18 @@ builder.Logging.Configure(options =>
var host = builder.Build();
// Fail fast if surface configuration is invalid at startup.
using (var scope = host.Services.CreateScope())
{
var services = scope.ServiceProvider;
var env = services.GetRequiredService<ISurfaceEnvironment>();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
await runner.EnsureAsync(
SurfaceValidationContext.Create(services, "Scanner.Worker.Startup", env.Settings),
host.Services.GetRequiredService<IHostApplicationLifetime>().ApplicationStopping)
.ConfigureAwait(false);
}
await host.RunAsync();
public partial class Program;
@@ -189,9 +201,8 @@ public sealed class SurfaceManifestStoreOptionsConfigurator : IConfigureOptions<
if (string.IsNullOrWhiteSpace(options.RootDirectory))
{
options.RootDirectory = Path.Combine(
_cacheOptions.Value.ResolveRoot(),
"manifests");
var cacheRoot = _cacheOptions.Value.RootDirectory ?? Path.Combine(Path.GetTempPath(), "stellaops", "surface-cache");
options.RootDirectory = Path.Combine(cacheRoot, "manifests");
}
}
}

View File

@@ -29,38 +29,45 @@ public static class ServiceCollectionExtensions
var env = sp.GetRequiredService<ISurfaceEnvironment>();
var options = sp.GetRequiredService<IOptions<SurfaceSecretsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("SurfaceSecrets");
return CreateProvider(env.Settings.Secrets, logger);
return CreateProviderChain(env.Settings.Secrets, logger);
});
return services;
}
private static ISurfaceSecretProvider CreateProvider(SurfaceSecretsConfiguration configuration, ILogger logger)
private static ISurfaceSecretProvider CreateProviderChain(SurfaceSecretsConfiguration configuration, ILogger logger)
{
var providers = new List<ISurfaceSecretProvider>();
switch (configuration.Provider.ToLowerInvariant())
var providers = new List<ISurfaceSecretProvider>
{
case "kubernetes":
providers.Add(new KubernetesSurfaceSecretProvider(configuration, logger));
break;
case "file":
providers.Add(new FileSurfaceSecretProvider(configuration.Root ?? throw new ArgumentException("Secrets root is required for file provider.")));
break;
case "inline":
providers.Add(new InlineSurfaceSecretProvider(configuration));
break;
default:
logger.LogWarning("Unknown surface secret provider '{Provider}'. Falling back to inline provider.", configuration.Provider);
providers.Add(new InlineSurfaceSecretProvider(configuration));
break;
}
CreateProvider(configuration.Provider, configuration, logger)
};
if (!string.IsNullOrWhiteSpace(configuration.FallbackProvider))
if (configuration.HasFallback)
{
providers.Add(new InlineSurfaceSecretProvider(configuration with { Provider = configuration.FallbackProvider }));
providers.Add(CreateProvider(configuration.FallbackProvider!, configuration, logger));
}
return providers.Count == 1 ? providers[0] : new CompositeSurfaceSecretProvider(providers);
}
private static ISurfaceSecretProvider CreateProvider(string providerId, SurfaceSecretsConfiguration configuration, ILogger logger)
{
if (string.IsNullOrWhiteSpace(providerId))
{
throw new ArgumentException("Provider id is required", nameof(providerId));
}
switch (providerId.Trim().ToLowerInvariant())
{
case "kubernetes":
return new KubernetesSurfaceSecretProvider(configuration, logger);
case "file":
return new FileSurfaceSecretProvider(configuration.Root ?? throw new ArgumentException("Secrets root is required for file provider."));
case "inline":
return new InlineSurfaceSecretProvider(configuration);
default:
logger.LogWarning("Unknown surface secret provider '{Provider}'. Falling back to inline provider if allowed; otherwise requests will fail.", providerId);
return new InlineSurfaceSecretProvider(configuration);
}
}
}

View File

@@ -8,6 +8,7 @@ public static class SurfaceValidationIssueCodes
public const string CacheQuotaInvalid = "SURFACE_ENV_CACHE_QUOTA_INVALID";
public const string SecretsProviderUnknown = "SURFACE_SECRET_PROVIDER_UNKNOWN";
public const string SecretsConfigurationMissing = "SURFACE_SECRET_CONFIGURATION_MISSING";
public const string SecretsConfigurationInvalid = "SURFACE_SECRET_FORMAT_INVALID";
public const string TenantMissing = "SURFACE_ENV_TENANT_MISSING";
public const string BucketMissing = "SURFACE_FS_BUCKET_MISSING";
public const string FeatureUnknown = "SURFACE_FEATURE_UNKNOWN";

View File

@@ -35,6 +35,14 @@ internal sealed class SurfaceSecretsValidator : ISurfaceValidator
"Set SCANNER_SURFACE_SECRETS_PROVIDER to 'kubernetes', 'file', or another supported provider."));
}
if (secrets.HasFallback && !KnownProviders.Contains(secrets.FallbackProvider!))
{
issues.Add(SurfaceValidationIssue.Error(
SurfaceValidationIssueCodes.SecretsProviderUnknown,
$"Fallback secrets provider '{secrets.FallbackProvider}' is not recognised.",
"Choose a supported fallback provider (kubernetes | file | inline) or clear SCANNER_SURFACE_SECRETS_FALLBACK_PROVIDER."));
}
if (string.Equals(secrets.Provider, "kubernetes", StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(secrets.Namespace))
{
@@ -53,6 +61,24 @@ internal sealed class SurfaceSecretsValidator : ISurfaceValidator
"Set SCANNER_SURFACE_SECRETS_ROOT to a directory path."));
}
if (string.Equals(secrets.Provider, "file", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(secrets.Root) &&
!Directory.Exists(secrets.Root))
{
issues.Add(SurfaceValidationIssue.Error(
SurfaceValidationIssueCodes.SecretsConfigurationInvalid,
$"File secrets root '{secrets.Root}' does not exist.",
"Ensure SCANNER_SURFACE_SECRETS_ROOT points to an existing directory with 0600-style permissions."));
}
if (string.Equals(secrets.Provider, "inline", StringComparison.OrdinalIgnoreCase) && !secrets.AllowInline)
{
issues.Add(SurfaceValidationIssue.Error(
SurfaceValidationIssueCodes.SecretsConfigurationInvalid,
"Inline secrets provider is selected but AllowInline=false.",
"Either enable SCANNER_SURFACE_SECRETS_ALLOW_INLINE for dev/test or switch provider."));
}
if (string.IsNullOrWhiteSpace(secrets.Tenant))
{
issues.Add(SurfaceValidationIssue.Error(

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Env;
@@ -23,6 +24,31 @@ namespace StellaOps.Scanner.Surface.Secrets.Tests
Assert.NotNull(secretProvider);
}
[Fact]
public async Task AddSurfaceSecrets_UsesFallbackProvider_WhenPrimaryCannotResolve()
{
const string key = "SURFACE_SECRET_TENANT_COMPONENT_REGISTRY_DEFAULT";
Environment.SetEnvironmentVariable(key, Convert.ToBase64String(new byte[] { 9, 9, 9 }));
var services = new ServiceCollection();
services.AddSingleton<ISurfaceEnvironment>(_ => new TestSurfaceEnvironmentWithFallback());
services.AddLogging(builder => builder.ClearProviders());
services.AddSurfaceSecrets();
await using var provider = services.BuildServiceProvider();
var secretProvider = provider.GetRequiredService<ISurfaceSecretProvider>();
var handle = await secretProvider.GetAsync(new SurfaceSecretRequest("tenant", "component", "registry"));
try
{
Assert.Equal(new byte[] { 9, 9, 9 }, handle.AsBytes().ToArray());
}
finally
{
handle.Dispose();
Environment.SetEnvironmentVariable(key, null);
}
}
private sealed class TestSurfaceEnvironment : ISurfaceEnvironment
{
public SurfaceEnvironmentSettings Settings { get; }
@@ -48,5 +74,32 @@ namespace StellaOps.Scanner.Surface.Secrets.Tests
RawVariables = new Dictionary<string, string>();
}
}
private sealed class TestSurfaceEnvironmentWithFallback : ISurfaceEnvironment
{
public SurfaceEnvironmentSettings Settings { get; }
public IReadOnlyDictionary<string, string> RawVariables { get; }
public TestSurfaceEnvironmentWithFallback()
{
var root = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Settings = new SurfaceEnvironmentSettings(
new Uri("https://surface.example"),
"surface",
null,
new DirectoryInfo(Path.GetTempPath()),
1024,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("kubernetes", "tenant", Root: root, Namespace: "ns", FallbackProvider: "inline", AllowInline: true),
"tenant",
new SurfaceTlsConfiguration(null, null, null))
{
CreatedAtUtc = DateTimeOffset.UtcNow
};
RawVariables = new Dictionary<string, string>();
}
}
}
}

View File

@@ -71,6 +71,63 @@ public sealed class SurfaceValidatorRunnerTests
Assert.True(result.IsSuccess);
}
[Fact]
public async Task RunAllAsync_Fails_WhenInlineProviderDisallowed()
{
var directory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString()));
var environment = new SurfaceEnvironmentSettings(
new Uri("https://surface.example.com"),
"surface-cache",
null,
directory,
1024,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("inline", "tenant-a", Root: null, Namespace: null, FallbackProvider: null, AllowInline: false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, null));
var services = CreateServices();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
var context = SurfaceValidationContext.Create(services, "TestComponent", environment);
var result = await runner.RunAllAsync(context);
Assert.False(result.IsSuccess);
Assert.Contains(result.Issues, i => i.Code == SurfaceValidationIssueCodes.SecretsConfigurationInvalid);
}
[Fact]
public async Task RunAllAsync_Fails_WhenFileRootMissing()
{
var missingRoot = Path.Combine(Path.GetTempPath(), "stellaops-tests", "missing-root", Guid.NewGuid().ToString());
var directory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString()))
{
Attributes = FileAttributes.Normal
};
var environment = new SurfaceEnvironmentSettings(
new Uri("https://surface.example.com"),
"surface-cache",
null,
directory,
1024,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("file", "tenant-a", Root: missingRoot, Namespace: null, FallbackProvider: null, AllowInline: false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, null));
var services = CreateServices();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
var context = SurfaceValidationContext.Create(services, "TestComponent", environment);
var result = await runner.RunAllAsync(context);
Assert.False(result.IsSuccess);
Assert.Contains(result.Issues, i => i.Code == SurfaceValidationIssueCodes.SecretsConfigurationInvalid);
}
private static ServiceProvider CreateServices(Action<IServiceCollection>? configure = null)
{
var services = new ServiceCollection();

View File

@@ -5,7 +5,6 @@ using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Worker;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
@@ -29,7 +28,7 @@ public sealed class SurfaceManifestStoreOptionsConfiguratorTests
new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()));
var environment = new StubSurfaceEnvironment(settings);
var cacheOptions = Options.Create(new SurfaceCacheOptions { RootDirectory = cacheRoot.FullName });
var cacheOptions = Microsoft.Extensions.Options.Options.Create(new SurfaceCacheOptions { RootDirectory = cacheRoot.FullName });
var configurator = new SurfaceManifestStoreOptionsConfigurator(environment, cacheOptions);
var options = new SurfaceManifestStoreOptions();