feat: Implement Policy Engine Evaluation Service and Cache with unit tests
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Temp commit to debug
This commit is contained in:
@@ -55,9 +55,13 @@ public sealed record ReportDocumentDto
|
||||
[JsonPropertyOrder(6)]
|
||||
public IReadOnlyList<PolicyPreviewVerdictDto> Verdicts { get; init; } = Array.Empty<PolicyPreviewVerdictDto>();
|
||||
|
||||
[JsonPropertyName("issues")]
|
||||
[JsonPropertyOrder(7)]
|
||||
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
|
||||
[JsonPropertyName("issues")]
|
||||
[JsonPropertyOrder(7)]
|
||||
public IReadOnlyList<PolicyPreviewIssueDto> Issues { get; init; } = Array.Empty<PolicyPreviewIssueDto>();
|
||||
|
||||
[JsonPropertyName("surface")]
|
||||
[JsonPropertyOrder(8)]
|
||||
public SurfacePointersDto? Surface { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReportPolicyDto
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record ScanStatusResponse(
|
||||
string ScanId,
|
||||
string Status,
|
||||
ScanStatusTarget Image,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? FailureReason);
|
||||
|
||||
public sealed record ScanStatusTarget(
|
||||
string? Reference,
|
||||
string? Digest);
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record ScanStatusResponse(
|
||||
string ScanId,
|
||||
string Status,
|
||||
ScanStatusTarget Image,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? FailureReason,
|
||||
SurfacePointersDto? Surface);
|
||||
|
||||
public sealed record ScanStatusTarget(
|
||||
string? Reference,
|
||||
string? Digest);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record SurfacePointersDto
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
= DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("manifestDigest")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public string ManifestDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifestUri")]
|
||||
[JsonPropertyOrder(3)]
|
||||
public string? ManifestUri { get; init; }
|
||||
= null;
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
[JsonPropertyOrder(4)]
|
||||
public SurfaceManifestDocument Manifest { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record SurfaceManifestDocument
|
||||
{
|
||||
[JsonPropertyName("schema")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public string Schema { get; init; } = "stellaops.surface.manifest@1";
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
[JsonPropertyOrder(3)]
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
[JsonPropertyOrder(4)]
|
||||
public IReadOnlyList<SurfaceManifestArtifact> Artifacts { get; init; } = Array.Empty<SurfaceManifestArtifact>();
|
||||
}
|
||||
|
||||
public sealed record SurfaceManifestArtifact
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("mediaType")]
|
||||
[JsonPropertyOrder(3)]
|
||||
public string MediaType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
[JsonPropertyOrder(4)]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
[JsonPropertyOrder(5)]
|
||||
public long SizeBytes { get; init; }
|
||||
= 0;
|
||||
|
||||
[JsonPropertyName("view")]
|
||||
[JsonPropertyOrder(6)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? View { get; init; }
|
||||
= null;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
using System.Diagnostics;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
@@ -56,27 +60,69 @@ internal static class HealthEndpoints
|
||||
return Json(document, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleReady(
|
||||
ServiceStatus status,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
await Task.CompletedTask;
|
||||
|
||||
status.RecordReadyCheck(success: true, latency: TimeSpan.Zero, error: null);
|
||||
var snapshot = status.CreateSnapshot();
|
||||
var ready = snapshot.Ready;
|
||||
|
||||
var document = new ReadyDocument(
|
||||
Status: ready.IsReady ? "ready" : "unready",
|
||||
CheckedAt: ready.CheckedAt,
|
||||
LatencyMs: ready.Latency?.TotalMilliseconds,
|
||||
Error: ready.Error);
|
||||
|
||||
return Json(document, StatusCodes.Status200OK);
|
||||
}
|
||||
private static async Task<IResult> HandleReady(
|
||||
ServiceStatus status,
|
||||
ISurfaceValidatorRunner validatorRunner,
|
||||
ISurfaceEnvironment surfaceEnvironment,
|
||||
ILoggerFactory loggerFactory,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
|
||||
var logger = loggerFactory.CreateLogger("Scanner.WebService.Health");
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var success = true;
|
||||
string? error = null;
|
||||
|
||||
try
|
||||
{
|
||||
var validationContext = SurfaceValidationContext.Create(
|
||||
context.RequestServices,
|
||||
"Scanner.WebService.ReadyCheck",
|
||||
surfaceEnvironment.Settings,
|
||||
properties: new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = context.Request.Path.ToString()
|
||||
});
|
||||
|
||||
await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (SurfaceValidationException ex)
|
||||
{
|
||||
success = false;
|
||||
error = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
success = false;
|
||||
error = ex.Message;
|
||||
logger.LogError(ex, "Surface validation failed during ready check.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
}
|
||||
|
||||
status.RecordReadyCheck(success, stopwatch.Elapsed, error);
|
||||
var snapshot = status.CreateSnapshot();
|
||||
var ready = snapshot.Ready;
|
||||
|
||||
var document = new ReadyDocument(
|
||||
Status: ready.IsReady ? "ready" : "unready",
|
||||
CheckedAt: ready.CheckedAt,
|
||||
LatencyMs: ready.Latency?.TotalMilliseconds,
|
||||
Error: ready.Error);
|
||||
|
||||
var statusCode = success ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
|
||||
return Json(document, statusCode);
|
||||
}
|
||||
|
||||
private static void ApplyNoCache(HttpResponse response)
|
||||
{
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
@@ -49,25 +50,30 @@ internal static class ReportEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreateReportAsync(
|
||||
ReportRequestDto request,
|
||||
PolicyPreviewService previewService,
|
||||
IReportSigner signer,
|
||||
TimeProvider timeProvider,
|
||||
IReportEventDispatcher eventDispatcher,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(previewService);
|
||||
ArgumentNullException.ThrowIfNull(signer);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(eventDispatcher);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ImageDigest))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
private static async Task<IResult> HandleCreateReportAsync(
|
||||
ReportRequestDto request,
|
||||
PolicyPreviewService previewService,
|
||||
IReportSigner signer,
|
||||
TimeProvider timeProvider,
|
||||
IReportEventDispatcher eventDispatcher,
|
||||
ISurfacePointerService surfacePointerService,
|
||||
ILoggerFactory loggerFactory,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(previewService);
|
||||
ArgumentNullException.ThrowIfNull(signer);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(eventDispatcher);
|
||||
ArgumentNullException.ThrowIfNull(surfacePointerService);
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
var logger = loggerFactory.CreateLogger("Scanner.WebService.Reports");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ImageDigest))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid report request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
@@ -127,26 +133,46 @@ internal static class ReportEndpoints
|
||||
.ToArray();
|
||||
|
||||
var issuesDto = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
|
||||
var summary = BuildSummary(projectedVerdicts);
|
||||
var verdict = ComputeVerdict(projectedVerdicts);
|
||||
var reportId = CreateReportId(request.ImageDigest!, preview.PolicyDigest);
|
||||
var generatedAt = timeProvider.GetUtcNow();
|
||||
|
||||
var document = new ReportDocumentDto
|
||||
{
|
||||
ReportId = reportId,
|
||||
ImageDigest = request.ImageDigest!,
|
||||
GeneratedAt = generatedAt,
|
||||
Verdict = verdict,
|
||||
Policy = new ReportPolicyDto
|
||||
{
|
||||
RevisionId = preview.RevisionId,
|
||||
Digest = preview.PolicyDigest
|
||||
},
|
||||
Summary = summary,
|
||||
Verdicts = projectedVerdicts,
|
||||
Issues = issuesDto
|
||||
};
|
||||
var summary = BuildSummary(projectedVerdicts);
|
||||
var verdict = ComputeVerdict(projectedVerdicts);
|
||||
var reportId = CreateReportId(request.ImageDigest!, preview.PolicyDigest);
|
||||
var generatedAt = timeProvider.GetUtcNow();
|
||||
SurfacePointersDto? surfacePointers = null;
|
||||
|
||||
try
|
||||
{
|
||||
surfacePointers = await surfacePointerService
|
||||
.TryBuildAsync(request.ImageDigest!, context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
logger.LogDebug(ex, "Failed to build surface pointers for digest {Digest}.", request.ImageDigest);
|
||||
}
|
||||
}
|
||||
|
||||
var document = new ReportDocumentDto
|
||||
{
|
||||
ReportId = reportId,
|
||||
ImageDigest = request.ImageDigest!,
|
||||
GeneratedAt = generatedAt,
|
||||
Verdict = verdict,
|
||||
Policy = new ReportPolicyDto
|
||||
{
|
||||
RevisionId = preview.RevisionId,
|
||||
Digest = preview.PolicyDigest
|
||||
},
|
||||
Summary = summary,
|
||||
Verdicts = projectedVerdicts,
|
||||
Issues = issuesDto,
|
||||
Surface = surfacePointers
|
||||
};
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions);
|
||||
var signature = signer.Sign(payloadBytes);
|
||||
@@ -169,11 +195,11 @@ internal static class ReportEndpoints
|
||||
};
|
||||
}
|
||||
|
||||
var response = new ReportResponseDto
|
||||
{
|
||||
Report = document,
|
||||
Dsse = envelope
|
||||
};
|
||||
var response = new ReportResponseDto
|
||||
{
|
||||
Report = document,
|
||||
Dsse = envelope
|
||||
};
|
||||
|
||||
await eventDispatcher
|
||||
.PublishAsync(request, preview, document, envelope, context, cancellationToken)
|
||||
|
||||
@@ -140,10 +140,12 @@ internal static class ScanEndpoints
|
||||
private static async Task<IResult> HandleStatusAsync(
|
||||
string scanId,
|
||||
IScanCoordinator coordinator,
|
||||
ISurfacePointerService surfacePointerService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
ArgumentNullException.ThrowIfNull(surfacePointerService);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
@@ -163,7 +165,23 @@ internal static class ScanEndpoints
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
SurfacePointersDto? surfacePointers = null;
|
||||
var digest = snapshot.Target.Digest;
|
||||
if (!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
try
|
||||
{
|
||||
surfacePointers = await surfacePointerService
|
||||
.TryBuildAsync(digest!, context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
var response = new ScanStatusResponse(
|
||||
@@ -172,7 +190,8 @@ internal static class ScanEndpoints
|
||||
Image: new ScanStatusTarget(snapshot.Target.Reference, snapshot.Target.Digest),
|
||||
CreatedAt: snapshot.CreatedAt,
|
||||
UpdatedAt: snapshot.UpdatedAt,
|
||||
FailureReason: snapshot.FailureReason);
|
||||
FailureReason: snapshot.FailureReason,
|
||||
Surface: surfacePointers);
|
||||
|
||||
return Json(response, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Scanner.Storage;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Options;
|
||||
|
||||
@@ -129,6 +130,8 @@ public sealed class ScannerWebServiceOptions
|
||||
|
||||
public int ObjectLockRetentionDays { get; set; } = 30;
|
||||
|
||||
public string RootPrefix { get; set; } = ScannerStorageDefaults.DefaultRootPrefix;
|
||||
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
public string ApiKeyHeader { get; set; } = string.Empty;
|
||||
|
||||
@@ -19,6 +19,10 @@ using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Cache;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using StellaOps.Scanner.WebService.Extensions;
|
||||
@@ -80,11 +84,20 @@ builder.Services.AddSingleton<IScanProgressReader>(sp => sp.GetRequiredService<S
|
||||
builder.Services.AddSingleton<IScanCoordinator, InMemoryScanCoordinator>();
|
||||
builder.Services.AddSingleton<IPolicySnapshotRepository, InMemoryPolicySnapshotRepository>();
|
||||
builder.Services.AddSingleton<IPolicyAuditRepository, InMemoryPolicyAuditRepository>();
|
||||
builder.Services.AddSingleton<PolicySnapshotStore>();
|
||||
builder.Services.AddSingleton<PolicyPreviewService>();
|
||||
builder.Services.AddStellaOpsCrypto();
|
||||
builder.Services.AddBouncyCastleEd25519Provider();
|
||||
builder.Services.AddSingleton<PolicySnapshotStore>();
|
||||
builder.Services.AddSingleton<PolicyPreviewService>();
|
||||
builder.Services.AddStellaOpsCrypto();
|
||||
builder.Services.AddBouncyCastleEd25519Provider();
|
||||
builder.Services.AddSingleton<IReportSigner, ReportSigner>();
|
||||
builder.Services.AddSurfaceEnvironment(options =>
|
||||
{
|
||||
options.ComponentName = "Scanner.WebService";
|
||||
options.AddPrefix("SCANNER");
|
||||
});
|
||||
builder.Services.AddSurfaceValidation();
|
||||
builder.Services.AddSurfaceFileCache();
|
||||
builder.Services.AddSurfaceSecrets();
|
||||
builder.Services.AddSingleton<ISurfacePointerService, SurfacePointerService>();
|
||||
builder.Services.AddSingleton<IRedisConnectionFactory, RedisConnectionFactory>();
|
||||
if (bootstrapOptions.Events is { Enabled: true } eventsOptions
|
||||
&& string.Equals(eventsOptions.Driver, "redis", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -119,6 +132,11 @@ builder.Services.AddScannerStorage(storageOptions =>
|
||||
storageOptions.ObjectStore.BucketName = bootstrapOptions.ArtifactStore.Bucket;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.RootPrefix))
|
||||
{
|
||||
storageOptions.ObjectStore.RootPrefix = bootstrapOptions.ArtifactStore.RootPrefix;
|
||||
}
|
||||
|
||||
var artifactDriver = bootstrapOptions.ArtifactStore.Driver?.Trim() ?? string.Empty;
|
||||
if (string.Equals(artifactDriver, ScannerStorageDefaults.ObjectStoreProviders.RustFs, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
@@ -207,10 +207,7 @@ internal static class OrchestratorEventSerializer
|
||||
return;
|
||||
}
|
||||
|
||||
info.PolymorphismOptions ??= new JsonPolymorphismOptions
|
||||
{
|
||||
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.Fail
|
||||
};
|
||||
info.PolymorphismOptions ??= new JsonPolymorphismOptions();
|
||||
|
||||
AddDerivedType(info.PolymorphismOptions, typeof(ReportReadyEventPayload));
|
||||
AddDerivedType(info.PolymorphismOptions, typeof(ScanCompletedEventPayload));
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal interface ISurfacePointerService
|
||||
{
|
||||
Task<SurfacePointersDto?> TryBuildAsync(string imageDigest, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class SurfacePointerService : ISurfacePointerService
|
||||
{
|
||||
private static readonly JsonSerializerOptions ManifestSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly LinkRepository _linkRepository;
|
||||
private readonly ArtifactRepository _artifactRepository;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
private readonly ISurfaceEnvironment _surfaceEnvironment;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SurfacePointerService> _logger;
|
||||
|
||||
public SurfacePointerService(
|
||||
LinkRepository linkRepository,
|
||||
ArtifactRepository artifactRepository,
|
||||
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
||||
ISurfaceEnvironment surfaceEnvironment,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SurfacePointerService> logger)
|
||||
{
|
||||
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
|
||||
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<SurfacePointersDto?> TryBuildAsync(string imageDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageDigest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedDigest = imageDigest.Trim();
|
||||
|
||||
List<LinkDocument> links;
|
||||
try
|
||||
{
|
||||
links = await _linkRepository.ListBySourceAsync(LinkSourceType.Image, normalizedDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load link documents for digest {Digest}.", normalizedDigest);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (links.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue ?? new ScannerWebServiceOptions();
|
||||
var artifactStore = options.ArtifactStore ?? new ScannerWebServiceOptions.ArtifactStoreOptions();
|
||||
var bucket = ResolveBucket(artifactStore);
|
||||
var rootPrefix = artifactStore.RootPrefix ?? ScannerStorageDefaults.DefaultRootPrefix;
|
||||
var tenant = _surfaceEnvironment.Settings.Tenant;
|
||||
var generatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var artifacts = ImmutableArray.CreateBuilder<SurfaceManifestArtifact>();
|
||||
|
||||
foreach (var link in links)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
ArtifactDocument? artifactDocument;
|
||||
try
|
||||
{
|
||||
artifactDocument = await _artifactRepository.GetAsync(link.ArtifactId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load artifact document {ArtifactId}.", link.ArtifactId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (artifactDocument is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var objectKey = ArtifactObjectKeyBuilder.Build(
|
||||
artifactDocument.Type,
|
||||
artifactDocument.Format,
|
||||
artifactDocument.BytesSha256,
|
||||
rootPrefix);
|
||||
var uri = BuildCasUri(bucket, objectKey);
|
||||
var (kind, view) = MapKindAndView(artifactDocument);
|
||||
var format = MapFormat(artifactDocument.Format);
|
||||
var artifact = new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = kind,
|
||||
Uri = uri,
|
||||
Digest = artifactDocument.BytesSha256,
|
||||
MediaType = artifactDocument.MediaType,
|
||||
Format = format,
|
||||
SizeBytes = artifactDocument.SizeBytes,
|
||||
View = view
|
||||
};
|
||||
artifacts.Add(artifact);
|
||||
}
|
||||
|
||||
if (artifacts.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var orderedArtifacts = artifacts.OrderBy(a => a.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Format, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Digest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var manifest = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = tenant,
|
||||
ImageDigest = normalizedDigest,
|
||||
GeneratedAt = generatedAt,
|
||||
Artifacts = orderedArtifacts
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.SerializeToUtf8Bytes(manifest, ManifestSerializerOptions);
|
||||
var manifestDigest = ComputeDigest(manifestJson);
|
||||
var manifestUri = BuildManifestUri(bucket, rootPrefix, tenant, manifestDigest);
|
||||
|
||||
return new SurfacePointersDto
|
||||
{
|
||||
Tenant = tenant,
|
||||
GeneratedAt = generatedAt,
|
||||
ManifestDigest = manifestDigest,
|
||||
ManifestUri = manifestUri,
|
||||
Manifest = manifest with { GeneratedAt = generatedAt }
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveBucket(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(artifactStore.Bucket))
|
||||
{
|
||||
return artifactStore.Bucket.Trim();
|
||||
}
|
||||
|
||||
return ScannerStorageDefaults.DefaultBucketName;
|
||||
}
|
||||
|
||||
private static string MapFormat(ArtifactDocumentFormat format)
|
||||
=> format switch
|
||||
{
|
||||
ArtifactDocumentFormat.CycloneDxJson => "cdx-json",
|
||||
ArtifactDocumentFormat.CycloneDxProtobuf => "cdx-protobuf",
|
||||
ArtifactDocumentFormat.SpdxJson => "spdx-json",
|
||||
ArtifactDocumentFormat.BomIndex => "bom-index",
|
||||
ArtifactDocumentFormat.DsseJson => "dsse-json",
|
||||
_ => format.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
private static (string Kind, string? View) MapKindAndView(ArtifactDocument document)
|
||||
{
|
||||
if (document.Type == ArtifactDocumentType.ImageBom)
|
||||
{
|
||||
var view = ResolveView(document.MediaType);
|
||||
var kind = string.Equals(view, "usage", StringComparison.OrdinalIgnoreCase)
|
||||
? "sbom-usage"
|
||||
: "sbom-inventory";
|
||||
return (kind, view);
|
||||
}
|
||||
|
||||
return document.Type switch
|
||||
{
|
||||
ArtifactDocumentType.LayerBom => ("layer-sbom", null),
|
||||
ArtifactDocumentType.Diff => ("diff", null),
|
||||
ArtifactDocumentType.Attestation => ("attestation", null),
|
||||
ArtifactDocumentType.Index => ("bom-index", null),
|
||||
_ => (document.Type.ToString().ToLowerInvariant(), null)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveView(string mediaType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mediaType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mediaType.Contains("view=usage", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "usage";
|
||||
}
|
||||
|
||||
if (mediaType.Contains("view=inventory", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "inventory";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildCasUri(string bucket, string key)
|
||||
{
|
||||
var normalizedKey = string.IsNullOrWhiteSpace(key) ? string.Empty : key.Trim().TrimStart('/');
|
||||
return $"cas://{bucket}/{normalizedKey}";
|
||||
}
|
||||
|
||||
private static string BuildManifestUri(string bucket, string rootPrefix, string tenant, string manifestDigest)
|
||||
{
|
||||
var (algorithm, digestValue) = SplitDigest(manifestDigest);
|
||||
var prefix = string.IsNullOrWhiteSpace(rootPrefix)
|
||||
? "surface/manifests"
|
||||
: $"{TrimTrailingSlash(rootPrefix)}/surface/manifests";
|
||||
|
||||
var head = digestValue.Length >= 4
|
||||
? $"{digestValue[..2]}/{digestValue[2..4]}"
|
||||
: digestValue;
|
||||
|
||||
var key = $"{prefix}/{tenant}/{algorithm}/{head}/{digestValue}.json";
|
||||
return $"cas://{bucket}/{key}";
|
||||
}
|
||||
|
||||
private static (string Algorithm, string Digest) SplitDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return ("sha256", digest ?? string.Empty);
|
||||
}
|
||||
|
||||
var parts = digest.Split(':', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
return (parts[0], parts[1]);
|
||||
}
|
||||
|
||||
return ("sha256", digest);
|
||||
}
|
||||
|
||||
private static string TrimTrailingSlash(string value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? string.Empty
|
||||
: value.Trim().TrimEnd('/');
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
if (!SHA256.TryHashData(payload, hash, out _))
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
hash = sha.ComputeHash(payload.ToArray());
|
||||
}
|
||||
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@
|
||||
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
<ProjectReference Include="../../Zastava/__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SCAN-REPLAY-186-001 | TODO | Scanner WebService Guild | REPLAY-CORE-185-001 | Implement scan `record` mode producing replay manifests/bundles, capture policy/feed/tool hashes, and update `docs/modules/scanner/architecture.md` referencing `docs/replay/DETERMINISTIC_REPLAY.md` Section 6. | API/worker integration tests cover record mode; docs merged; replay artifacts stored per spec. |
|
||||
| SCANNER-SURFACE-02 | DOING (2025-11-02) | Scanner WebService Guild | SURFACE-FS-02 | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata.<br>2025-11-02: Scan/report API responses now include preview CAS URIs; attestation metadata draft published. | OpenAPI updated; clients regenerated; integration tests validate pointer presence and tenancy. |
|
||||
| SCANNER-SURFACE-02 | DONE (2025-11-05) | Scanner WebService Guild | SURFACE-FS-02 | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata.<br>2025-11-05: Surface pointers projected through scan/report endpoints, orchestrator samples + DSSE fixtures refreshed with manifest block, readiness tests updated to use validator stub. | OpenAPI updated; clients regenerated; integration tests validate pointer presence and tenancy. |
|
||||
| SCANNER-ENV-02 | DOING (2025-11-02) | Scanner WebService Guild, Ops Guild | SURFACE-ENV-02 | Wire Surface.Env helpers into WebService hosting (cache roots, feature flags) and document configuration.<br>2025-11-02: Cache root resolution switched to helper; feature flag bindings updated; Helm/Compose updates pending review. | Service uses helper; env table documented; helm/compose templates updated. |
|
||||
| SCANNER-SECRETS-02 | DOING (2025-11-02) | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens).<br>2025-11-02: Export/report flows now depend on Surface.Secrets stub; integration tests in progress. | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. |
|
||||
| SCANNER-EVENTS-16-301 | BLOCKED (2025-10-26) | Scanner WebService Guild | ORCH-SVC-38-101, NOTIFY-SVC-38-001 | Emit orchestrator-compatible envelopes (`scanner.event.*`) and update integration tests to verify Notifier ingestion (no Redis queue coupling). | Tests assert envelope schema + orchestrator publish; Notifier consumer harness passes; docs updated with new event contract. Blocked by .NET 10 preview OpenAPI/Auth dependency drift preventing `dotnet test` completion. |
|
||||
|
||||
Reference in New Issue
Block a user