up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Determinism;
|
||||
|
||||
/// <summary>
|
||||
/// Time provider that always returns a fixed instant, used to enforce deterministic timestamps.
|
||||
/// </summary>
|
||||
public sealed class DeterministicTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedInstantUtc;
|
||||
|
||||
public DeterministicTimeProvider(DateTimeOffset fixedInstantUtc)
|
||||
{
|
||||
_fixedInstantUtc = fixedInstantUtc;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedInstantUtc;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
@@ -76,6 +77,7 @@ internal static class ScanEndpoints
|
||||
ScanSubmitRequest request,
|
||||
IScanCoordinator coordinator,
|
||||
LinkGenerator links,
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -117,6 +119,18 @@ internal static class ScanEndpoints
|
||||
|
||||
var target = new ScanTarget(reference, digest).Normalize();
|
||||
var metadata = NormalizeMetadata(request.Metadata);
|
||||
|
||||
var determinism = options.Value?.Determinism ?? new ScannerWebServiceOptions.DeterminismOptions();
|
||||
if (!string.IsNullOrWhiteSpace(determinism.FeedSnapshotId) && !metadata.ContainsKey("determinism.feed"))
|
||||
{
|
||||
metadata["determinism.feed"] = determinism.FeedSnapshotId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(determinism.PolicySnapshotId) && !metadata.ContainsKey("determinism.policy"))
|
||||
{
|
||||
metadata["determinism.policy"] = determinism.PolicySnapshotId;
|
||||
}
|
||||
|
||||
var submission = new ScanSubmission(
|
||||
Target: target,
|
||||
Force: request.Force,
|
||||
|
||||
@@ -86,6 +86,11 @@ public sealed class ScannerWebServiceOptions
|
||||
/// Runtime ingestion configuration.
|
||||
/// </summary>
|
||||
public RuntimeOptions Runtime { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic execution switches for tests and replay.
|
||||
/// </summary>
|
||||
public DeterminismOptions Determinism { get; set; } = new();
|
||||
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
@@ -360,4 +365,21 @@ public sealed class ScannerWebServiceOptions
|
||||
|
||||
public int PolicyCacheTtlSeconds { get; set; } = 300;
|
||||
}
|
||||
|
||||
public sealed class DeterminismOptions
|
||||
{
|
||||
public bool FixedClock { get; set; }
|
||||
|
||||
public DateTimeOffset FixedInstantUtc { get; set; } = DateTimeOffset.UnixEpoch;
|
||||
|
||||
public int? RngSeed { get; set; }
|
||||
|
||||
public bool FilterLogs { get; set; }
|
||||
|
||||
public int? ConcurrencyLimit { get; set; }
|
||||
|
||||
public string? FeedSnapshotId { get; set; }
|
||||
|
||||
public string? PolicySnapshotId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,4 +463,27 @@ public static class ScannerWebServiceOptionsValidator
|
||||
throw new InvalidOperationException("Runtime policyCacheTtlSeconds must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateDeterminism(ScannerWebServiceOptions.DeterminismOptions determinism)
|
||||
{
|
||||
if (determinism.RngSeed is { } seed && seed < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Determinism rngSeed must be non-negative when provided.");
|
||||
}
|
||||
|
||||
if (determinism.ConcurrencyLimit is { } limit && limit <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Determinism concurrencyLimit must be greater than zero when provided.");
|
||||
}
|
||||
|
||||
if (determinism.FeedSnapshotId is { Length: 0 })
|
||||
{
|
||||
throw new InvalidOperationException("Determinism feedSnapshotId cannot be empty when provided.");
|
||||
}
|
||||
|
||||
if (determinism.PolicySnapshotId is { Length: 0 })
|
||||
{
|
||||
throw new InvalidOperationException("Determinism policySnapshotId cannot be empty when provided.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
using StellaOps.Scanner.WebService.Determinism;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using StellaOps.Scanner.WebService.Extensions;
|
||||
using StellaOps.Scanner.WebService.Hosting;
|
||||
@@ -80,7 +81,14 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
if (bootstrapOptions.Determinism.FixedClock)
|
||||
{
|
||||
builder.Services.AddSingleton<TimeProvider>(_ => new DeterministicTimeProvider(bootstrapOptions.Determinism.FixedInstantUtc));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
}
|
||||
builder.Services.AddScannerCache(builder.Configuration);
|
||||
builder.Services.AddSingleton<ServiceStatus>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
@@ -32,4 +32,9 @@ internal interface IRecordModeService
|
||||
string? logDigest = null,
|
||||
IEnumerable<(ReplayBundleWriteResult Result, string Type)>? additionalBundles = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecordModeResult> RecordAsync(
|
||||
RecordModeRequest request,
|
||||
IScanCoordinator coordinator,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Core.Replay;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
@@ -17,10 +24,32 @@ namespace StellaOps.Scanner.WebService.Replay;
|
||||
internal sealed class RecordModeService : IRecordModeService
|
||||
{
|
||||
private readonly RecordModeAssembler _assembler;
|
||||
private readonly ReachabilityReplayWriter _reachability;
|
||||
private readonly IArtifactObjectStore? _objectStore;
|
||||
private readonly ScannerStorageOptions? _storageOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RecordModeService>? _logger;
|
||||
|
||||
public RecordModeService(
|
||||
IArtifactObjectStore objectStore,
|
||||
IOptions<ScannerStorageOptions> storageOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RecordModeService> logger)
|
||||
{
|
||||
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
||||
_storageOptions = (storageOptions ?? throw new ArgumentNullException(nameof(storageOptions))).Value;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_assembler = new RecordModeAssembler(timeProvider);
|
||||
_reachability = new ReachabilityReplayWriter();
|
||||
}
|
||||
|
||||
// Legacy/testing constructor for unit tests that do not require storage.
|
||||
public RecordModeService(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_assembler = new RecordModeAssembler(timeProvider);
|
||||
_reachability = new ReachabilityReplayWriter();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<(ReplayRunRecord Run, IReadOnlyList<ReplayBundleRecord> Bundles)> BuildAsync(
|
||||
@@ -73,6 +102,50 @@ internal sealed class RecordModeService : IRecordModeService
|
||||
return attached ? replay : null;
|
||||
}
|
||||
|
||||
public async Task<RecordModeResult> RecordAsync(
|
||||
RecordModeRequest request,
|
||||
IScanCoordinator coordinator,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
|
||||
if (_objectStore is null || _storageOptions is null)
|
||||
{
|
||||
throw new InvalidOperationException("Record mode storage dependencies are not configured.");
|
||||
}
|
||||
|
||||
var manifest = BuildManifest(request);
|
||||
|
||||
var inputEntries = BuildInputBundleEntries(request, manifest);
|
||||
var outputEntries = BuildOutputBundleEntries(request);
|
||||
|
||||
var inputBundle = await StoreBundleAsync(inputEntries, "replay/input", cancellationToken).ConfigureAwait(false);
|
||||
var outputBundle = await StoreBundleAsync(outputEntries, "replay/output", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var additional = BuildAdditionalBundles(request);
|
||||
|
||||
var (run, bundles) = await BuildAsync(
|
||||
request.ScanId,
|
||||
manifest,
|
||||
inputBundle,
|
||||
outputBundle,
|
||||
request.SbomDigest,
|
||||
request.FindingsDigest,
|
||||
request.VexDigest,
|
||||
request.LogDigest,
|
||||
additional).ConfigureAwait(false);
|
||||
|
||||
var replay = BuildArtifacts(run.ManifestHash, bundles);
|
||||
var attached = await coordinator.AttachReplayAsync(new ScanId(request.ScanId), replay, cancellationToken).ConfigureAwait(false);
|
||||
if (!attached)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to attach replay artifacts to scan.");
|
||||
}
|
||||
|
||||
return new RecordModeResult(manifest, run, replay);
|
||||
}
|
||||
|
||||
private static ReplayArtifacts BuildArtifacts(string manifestHash, IReadOnlyList<ReplayBundleRecord> bundles)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestHash);
|
||||
@@ -101,4 +174,132 @@ internal sealed class RecordModeService : IRecordModeService
|
||||
? trimmed
|
||||
: $"sha256:{trimmed}";
|
||||
}
|
||||
|
||||
private ReplayManifest BuildManifest(RecordModeRequest request)
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = request.ScanId,
|
||||
Time = request.ScanTime ?? _timeProvider.GetUtcNow(),
|
||||
PolicyDigest = request.PolicyDigest,
|
||||
FeedSnapshot = request.FeedSnapshot,
|
||||
Toolchain = request.Toolchain,
|
||||
AnalyzerSetDigest = request.AnalyzerSetDigest
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
AnalysisId = request.ReachabilityAnalysisId
|
||||
}
|
||||
};
|
||||
|
||||
_reachability.AttachEvidence(manifest, request.ReachabilityGraphs, request.ReachabilityTraces);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static List<ReplayBundleEntry> BuildInputBundleEntries(RecordModeRequest request, ReplayManifest manifest)
|
||||
{
|
||||
var entries = new List<ReplayBundleEntry>
|
||||
{
|
||||
new("manifest/replay.json", manifest.ToCanonicalJson()),
|
||||
new("inputs/policy.digest", Encoding.UTF8.GetBytes(request.PolicyDigest ?? string.Empty)),
|
||||
new("inputs/feed.snapshot", Encoding.UTF8.GetBytes(request.FeedSnapshot ?? string.Empty)),
|
||||
new("inputs/toolchain.txt", Encoding.UTF8.GetBytes(request.Toolchain ?? string.Empty)),
|
||||
new("inputs/analyzers.digest", Encoding.UTF8.GetBytes(request.AnalyzerSetDigest ?? string.Empty)),
|
||||
new("inputs/image.digest", Encoding.UTF8.GetBytes(request.ImageDigest ?? string.Empty))
|
||||
};
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static List<ReplayBundleEntry> BuildOutputBundleEntries(RecordModeRequest request)
|
||||
{
|
||||
var entries = new List<ReplayBundleEntry>
|
||||
{
|
||||
new("outputs/sbom.json", request.Sbom),
|
||||
new("outputs/findings.ndjson", request.Findings)
|
||||
};
|
||||
|
||||
if (!request.Vex.IsEmpty)
|
||||
{
|
||||
entries.Add(new ReplayBundleEntry("outputs/vex.json", request.Vex));
|
||||
}
|
||||
|
||||
if (!request.Log.IsEmpty)
|
||||
{
|
||||
entries.Add(new ReplayBundleEntry("outputs/log.ndjson", request.Log));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async Task<ReplayBundleWriteResult> StoreBundleAsync(
|
||||
IReadOnlyCollection<ReplayBundleEntry> entries,
|
||||
string casPrefix,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var result = await ReplayBundleWriter.WriteTarZstAsync(entries, buffer, casPrefix: casPrefix, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
buffer.Position = 0;
|
||||
|
||||
var key = BuildReplayKey(result.ZstSha256, _storageOptions!.ObjectStore.RootPrefix, casPrefix);
|
||||
var descriptor = new ArtifactObjectDescriptor(
|
||||
_storageOptions.ObjectStore.BucketName,
|
||||
key,
|
||||
Immutable: true,
|
||||
RetainFor: _storageOptions.ObjectStore.ComplianceRetention);
|
||||
|
||||
await _objectStore!.PutAsync(descriptor, buffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogInformation("Stored replay bundle {Digest} at {Key}", result.ZstSha256, key);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(ReplayBundleWriteResult Result, string Type)>? BuildAdditionalBundles(RecordModeRequest request)
|
||||
=> request.AdditionalBundles is null ? null : request.AdditionalBundles.ToList();
|
||||
|
||||
private static string BuildReplayKey(string sha256, string? rootPrefix, string casPrefix)
|
||||
{
|
||||
var head = sha256[..2];
|
||||
var prefix = string.IsNullOrWhiteSpace(rootPrefix) ? string.Empty : rootPrefix.Trim().TrimEnd('/') + "/";
|
||||
var cas = string.IsNullOrWhiteSpace(casPrefix) ? "replay" : casPrefix.Trim('/');
|
||||
return $"{prefix}{cas}/{head}/{sha256}.tar.zst";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RecordModeRequest(
|
||||
string ScanId,
|
||||
string ImageDigest,
|
||||
string SbomDigest,
|
||||
string FindingsDigest,
|
||||
ReadOnlyMemory<byte> Sbom,
|
||||
ReadOnlyMemory<byte> Findings,
|
||||
ReadOnlyMemory<byte> Vex,
|
||||
ReadOnlyMemory<byte> Log)
|
||||
{
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
public string? FeedSnapshot { get; init; }
|
||||
|
||||
public string? Toolchain { get; init; }
|
||||
|
||||
public string? AnalyzerSetDigest { get; init; }
|
||||
|
||||
public string? ReachabilityAnalysisId { get; init; }
|
||||
|
||||
public IEnumerable<ReachabilityReplayGraph>? ReachabilityGraphs { get; init; }
|
||||
|
||||
public IEnumerable<ReachabilityReplayTrace>? ReachabilityTraces { get; init; }
|
||||
|
||||
public DateTimeOffset? ScanTime { get; init; }
|
||||
|
||||
public IEnumerable<(ReplayBundleWriteResult Result, string Type)>? AdditionalBundles { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RecordModeResult(
|
||||
ReplayManifest Manifest,
|
||||
ReplayRunRecord Run,
|
||||
ReplayArtifacts Artifacts);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
@@ -74,11 +75,20 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.AddSeconds(ttlSeconds);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var snapshot = await _policySnapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var policyRevision = snapshot?.RevisionId;
|
||||
var policyDigest = snapshot?.Digest;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var snapshot = await _policySnapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var determinism = _optionsMonitor.CurrentValue.Determinism ?? new ScannerWebServiceOptions.DeterminismOptions();
|
||||
if (!string.IsNullOrWhiteSpace(determinism.PolicySnapshotId))
|
||||
{
|
||||
if (snapshot is null || !string.Equals(snapshot.RevisionId, determinism.PolicySnapshotId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Deterministic policy pin {determinism.PolicySnapshotId} is not present; current revision is {snapshot?.RevisionId ?? "none"}.");
|
||||
}
|
||||
}
|
||||
|
||||
var policyRevision = snapshot?.RevisionId;
|
||||
var policyDigest = snapshot?.Digest;
|
||||
|
||||
var results = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal);
|
||||
var evaluationTags = new KeyValuePair<string, object?>[]
|
||||
|
||||
Reference in New Issue
Block a user