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

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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; }
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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?>[]