work
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.SbomService.Models;
|
||||
|
||||
public sealed record SbomVersionCreatedEvent(
|
||||
string SnapshotId,
|
||||
string TenantId,
|
||||
string ProjectionHash,
|
||||
string SchemaVersion,
|
||||
DateTimeOffset CreatedAtUtc);
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
13
src/SbomService/StellaOps.SbomService/Services/Clock.cs
Normal file
13
src/SbomService/StellaOps.SbomService/Services/Clock.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
37
src/SbomService/StellaOps.SbomService/Services/SbomEvents.cs
Normal file
37
src/SbomService/StellaOps.SbomService/Services/SbomEvents.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user