save work
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Core;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
@@ -85,7 +86,7 @@ internal static class ScoreReplayEndpoints
|
||||
RootHash: result.RootHash,
|
||||
BundleUri: result.BundleUri,
|
||||
ManifestHash: result.ManifestHash,
|
||||
ReplayedAtUtc: result.ReplayedAt,
|
||||
ReplayedAt: result.ReplayedAt,
|
||||
Deterministic: result.Deterministic));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
@@ -107,6 +108,8 @@ internal static class ScoreReplayEndpoints
|
||||
string scanId,
|
||||
[FromQuery] string? rootHash,
|
||||
IScoreReplayService replayService,
|
||||
IProofBundleWriter bundleWriter,
|
||||
IScanManifestSigner manifestSigner,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
@@ -131,11 +134,29 @@ internal static class ScoreReplayEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
bool manifestDsseValid;
|
||||
try
|
||||
{
|
||||
var contents = await bundleWriter.ReadBundleAsync(bundle.BundleUri, cancellationToken).ConfigureAwait(false);
|
||||
var verify = await manifestSigner.VerifyAsync(contents.SignedManifest, cancellationToken).ConfigureAwait(false);
|
||||
manifestDsseValid = verify.IsValid;
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new ScoreBundleResponse(
|
||||
ScanId: bundle.ScanId,
|
||||
RootHash: bundle.RootHash,
|
||||
BundleUri: bundle.BundleUri,
|
||||
CreatedAtUtc: bundle.CreatedAtUtc));
|
||||
ManifestDsseValid: manifestDsseValid,
|
||||
CreatedAt: bundle.CreatedAtUtc));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -213,14 +234,14 @@ public sealed record ScoreReplayRequest(
|
||||
/// <param name="RootHash">Root hash of the proof ledger.</param>
|
||||
/// <param name="BundleUri">URI to the proof bundle.</param>
|
||||
/// <param name="ManifestHash">Hash of the manifest used.</param>
|
||||
/// <param name="ReplayedAtUtc">When the replay was performed.</param>
|
||||
/// <param name="ReplayedAt">When the replay was performed.</param>
|
||||
/// <param name="Deterministic">Whether the replay was deterministic.</param>
|
||||
public sealed record ScoreReplayResponse(
|
||||
double Score,
|
||||
string RootHash,
|
||||
string BundleUri,
|
||||
string ManifestHash,
|
||||
DateTimeOffset ReplayedAtUtc,
|
||||
DateTimeOffset ReplayedAt,
|
||||
bool Deterministic);
|
||||
|
||||
/// <summary>
|
||||
@@ -230,7 +251,8 @@ public sealed record ScoreBundleResponse(
|
||||
string ScanId,
|
||||
string RootHash,
|
||||
string BundleUri,
|
||||
DateTimeOffset CreatedAtUtc);
|
||||
bool ManifestDsseValid,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request for bundle verification.
|
||||
|
||||
@@ -92,6 +92,11 @@ public sealed class ScannerWebServiceOptions
|
||||
/// </summary>
|
||||
public DeterminismOptions Determinism { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Score replay configuration (disabled by default).
|
||||
/// </summary>
|
||||
public ScoreReplayOptions ScoreReplay { get; set; } = new();
|
||||
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
public string Driver { get; set; } = "postgres";
|
||||
@@ -440,4 +445,19 @@ public sealed class ScannerWebServiceOptions
|
||||
|
||||
public string? PolicySnapshotId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ScoreReplayOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables score replay endpoints (/api/v1/score/*).
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory used to persist proof bundles created during replay.
|
||||
/// When empty, the host selects a safe default (tests use temp storage).
|
||||
/// </summary>
|
||||
public string BundleStoragePath { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Cache;
|
||||
using StellaOps.Scanner.Core;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.TrustAnchors;
|
||||
@@ -124,6 +125,27 @@ builder.Services.AddSingleton<IPolicyAuditRepository, InMemoryPolicyAuditReposit
|
||||
builder.Services.AddSingleton<PolicySnapshotStore>();
|
||||
builder.Services.AddSingleton<PolicyPreviewService>();
|
||||
builder.Services.AddSingleton<IRecordModeService, RecordModeService>();
|
||||
builder.Services.AddSingleton<IScoreReplayService, ScoreReplayService>();
|
||||
builder.Services.AddSingleton<IScanManifestRepository, InMemoryScanManifestRepository>();
|
||||
builder.Services.AddSingleton<IProofBundleRepository, InMemoryProofBundleRepository>();
|
||||
builder.Services.AddSingleton<IScoringService, DeterministicScoringService>();
|
||||
builder.Services.AddSingleton<IScanManifestSigner, ScanManifestSigner>();
|
||||
builder.Services.AddSingleton<IProofBundleWriter>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
|
||||
var hostEnvironment = sp.GetRequiredService<IHostEnvironment>();
|
||||
|
||||
var configuredPath = options.ScoreReplay.BundleStoragePath?.Trim() ?? string.Empty;
|
||||
var defaultPath = hostEnvironment.IsEnvironment("Testing")
|
||||
? Path.Combine(Path.GetTempPath(), "stellaops-proofs-testing")
|
||||
: Path.Combine(Path.GetTempPath(), "stellaops-proofs");
|
||||
|
||||
return new ProofBundleWriter(new ProofBundleWriterOptions
|
||||
{
|
||||
StorageBasePath = string.IsNullOrWhiteSpace(configuredPath) ? defaultPath : configuredPath,
|
||||
ContentAddressed = true
|
||||
});
|
||||
});
|
||||
builder.Services.AddReachabilityDrift();
|
||||
builder.Services.AddStellaOpsCrypto();
|
||||
builder.Services.AddBouncyCastleEd25519Provider();
|
||||
@@ -470,7 +492,12 @@ apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
|
||||
apiGroup.MapReachabilityDriftRootEndpoints();
|
||||
apiGroup.MapProofSpineEndpoints(resolvedOptions.Api.SpinesSegment, resolvedOptions.Api.ScansSegment);
|
||||
apiGroup.MapReplayEndpoints();
|
||||
if (resolvedOptions.ScoreReplay.Enabled)
|
||||
{
|
||||
apiGroup.MapScoreReplayEndpoints();
|
||||
}
|
||||
apiGroup.MapWitnessEndpoints(); // Sprint: SPRINT_3700_0001_0001
|
||||
apiGroup.MapEpssEndpoints(); // Sprint: SPRINT_3410_0002_0001
|
||||
|
||||
if (resolvedOptions.Features.EnablePolicyPreview)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Scoring;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class DeterministicScoringService : IScoringService
|
||||
{
|
||||
public Task<double> ReplayScoreAsync(
|
||||
string scanId,
|
||||
string concelierSnapshotHash,
|
||||
string excititorSnapshotHash,
|
||||
string latticePolicyHash,
|
||||
byte[] seed,
|
||||
DateTimeOffset freezeTimestamp,
|
||||
ProofLedger ledger,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
ArgumentNullException.ThrowIfNull(seed);
|
||||
ArgumentNullException.ThrowIfNull(ledger);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var input = string.Join(
|
||||
"|",
|
||||
scanId.Trim(),
|
||||
concelierSnapshotHash?.Trim() ?? string.Empty,
|
||||
excititorSnapshotHash?.Trim() ?? string.Empty,
|
||||
latticePolicyHash?.Trim() ?? string.Empty,
|
||||
freezeTimestamp.ToUniversalTime().ToString("O"),
|
||||
Convert.ToHexStringLower(seed));
|
||||
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
var value = BinaryPrimitives.ReadUInt64BigEndian(digest.AsSpan(0, sizeof(ulong)));
|
||||
var score = value / (double)ulong.MaxValue;
|
||||
score = Math.Clamp(score, 0.0, 1.0);
|
||||
|
||||
var actor = "scanner.webservice.score";
|
||||
var evidenceRefs = new[]
|
||||
{
|
||||
concelierSnapshotHash,
|
||||
excititorSnapshotHash,
|
||||
latticePolicyHash
|
||||
}.Where(v => !string.IsNullOrWhiteSpace(v)).ToArray();
|
||||
|
||||
var inputNodeId = $"input:{scanId}";
|
||||
ledger.Append(ProofNode.CreateInput(
|
||||
id: inputNodeId,
|
||||
ruleId: "deterministic",
|
||||
actor: actor,
|
||||
tsUtc: freezeTimestamp,
|
||||
seed: seed,
|
||||
initialValue: score,
|
||||
evidenceRefs: evidenceRefs));
|
||||
|
||||
ledger.Append(ProofNode.CreateScore(
|
||||
id: $"score:{scanId}",
|
||||
ruleId: "deterministic",
|
||||
actor: actor,
|
||||
tsUtc: freezeTimestamp,
|
||||
seed: seed,
|
||||
finalScore: score,
|
||||
parentIds: new[] { inputNodeId }));
|
||||
|
||||
return Task.FromResult(score);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Scanner.Core;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class InMemoryProofBundleRepository : IProofBundleRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, ProofBundle>> _bundles
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<ProofBundle?> GetBundleAsync(string scanId, string? rootHash = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
return Task.FromResult<ProofBundle?>(null);
|
||||
}
|
||||
|
||||
if (!_bundles.TryGetValue(scanId.Trim(), out var bundlesByRootHash) || bundlesByRootHash.Count == 0)
|
||||
{
|
||||
return Task.FromResult<ProofBundle?>(null);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rootHash))
|
||||
{
|
||||
var normalizedHash = NormalizeDigest(rootHash);
|
||||
return Task.FromResult(bundlesByRootHash.TryGetValue(normalizedHash, out var bundle) ? bundle : null);
|
||||
}
|
||||
|
||||
var best = bundlesByRootHash.Values
|
||||
.OrderByDescending(b => b.CreatedAtUtc)
|
||||
.ThenBy(b => b.RootHash, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult(best);
|
||||
}
|
||||
|
||||
public Task SaveBundleAsync(ProofBundle bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var bundlesByRootHash = _bundles.GetOrAdd(
|
||||
bundle.ScanId.Trim(),
|
||||
_ => new ConcurrentDictionary<string, ProofBundle>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
bundlesByRootHash[NormalizeDigest(bundle.RootHash)] = bundle;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
trimmed = $"sha256:{trimmed}";
|
||||
}
|
||||
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Core;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class InMemoryScanManifestRepository : IScanManifestRepository
|
||||
{
|
||||
private readonly IScanCoordinator _scanCoordinator;
|
||||
private readonly IScanManifestSigner _manifestSigner;
|
||||
private readonly ILogger<InMemoryScanManifestRepository> _logger;
|
||||
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, SignedScanManifest>> _manifestsByScanId
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public InMemoryScanManifestRepository(
|
||||
IScanCoordinator scanCoordinator,
|
||||
IScanManifestSigner manifestSigner,
|
||||
ILogger<InMemoryScanManifestRepository> logger)
|
||||
{
|
||||
_scanCoordinator = scanCoordinator ?? throw new ArgumentNullException(nameof(scanCoordinator));
|
||||
_manifestSigner = manifestSigner ?? throw new ArgumentNullException(nameof(manifestSigner));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<SignedScanManifest?> GetManifestAsync(
|
||||
string scanId,
|
||||
string? manifestHash = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedScanId = scanId.Trim();
|
||||
var normalizedManifestHash = NormalizeDigest(manifestHash);
|
||||
|
||||
if (_manifestsByScanId.TryGetValue(normalizedScanId, out var existingByHash))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(normalizedManifestHash))
|
||||
{
|
||||
return existingByHash.TryGetValue(normalizedManifestHash, out var hit) ? hit : null;
|
||||
}
|
||||
|
||||
return SelectDefault(existingByHash);
|
||||
}
|
||||
|
||||
var snapshot = await _scanCoordinator.GetAsync(new ScanId(normalizedScanId), cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var manifest = BuildManifest(snapshot);
|
||||
var signed = await _manifestSigner.SignAsync(manifest, cancellationToken).ConfigureAwait(false);
|
||||
await SaveManifestAsync(signed, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedManifestHash)
|
||||
&& !string.Equals(NormalizeDigest(signed.ManifestHash), normalizedManifestHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Created scan manifest for scan {ScanId} ({ManifestHash})", normalizedScanId, signed.ManifestHash);
|
||||
return signed;
|
||||
}
|
||||
|
||||
public Task SaveManifestAsync(SignedScanManifest manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var scanId = manifest.Manifest.ScanId.Trim();
|
||||
var hash = NormalizeDigest(manifest.ManifestHash) ?? manifest.ManifestHash.Trim();
|
||||
|
||||
var byHash = _manifestsByScanId.GetOrAdd(scanId, _ => new ConcurrentDictionary<string, SignedScanManifest>(StringComparer.OrdinalIgnoreCase));
|
||||
byHash[hash] = manifest;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<List<string>> FindAffectedScansAsync(AffectedScansQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return Task.FromResult(new List<string>());
|
||||
}
|
||||
|
||||
private static SignedScanManifest? SelectDefault(ConcurrentDictionary<string, SignedScanManifest> manifestsByHash)
|
||||
{
|
||||
if (manifestsByHash.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return manifestsByHash.Values
|
||||
.OrderByDescending(m => m.Manifest.CreatedAtUtc)
|
||||
.ThenBy(m => m.ManifestHash, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static ScanManifest BuildManifest(ScanSnapshot snapshot)
|
||||
{
|
||||
var targetDigest = NormalizeDigest(snapshot.Target.Digest) ?? "sha256:unknown";
|
||||
var seed = SHA256.HashData(Encoding.UTF8.GetBytes(snapshot.ScanId.Value));
|
||||
|
||||
var version = typeof(InMemoryScanManifestRepository).Assembly.GetName().Version?.ToString() ?? "0.0.0";
|
||||
|
||||
return ScanManifest.CreateBuilder(snapshot.ScanId.Value, targetDigest)
|
||||
.WithCreatedAt(snapshot.CreatedAt)
|
||||
.WithScannerVersion(version)
|
||||
.WithWorkerVersion(version)
|
||||
.WithConcelierSnapshot(ComputeDigest($"concelier:{snapshot.ScanId.Value}"))
|
||||
.WithExcititorSnapshot(ComputeDigest($"excititor:{snapshot.ScanId.Value}"))
|
||||
.WithLatticePolicyHash(ComputeDigest($"policy:{snapshot.ScanId.Value}"))
|
||||
.WithDeterministic(true)
|
||||
.WithSeed(seed)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string? NormalizeDigest(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
trimmed = $"sha256:{trimmed}";
|
||||
}
|
||||
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -394,26 +394,59 @@ internal sealed class RuntimeInventoryReconciler : IRuntimeInventoryReconciler
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _storageOptions.CurrentValue;
|
||||
var key = ArtifactObjectKeyBuilder.Build(
|
||||
|
||||
var primaryKey = ArtifactObjectKeyBuilder.Build(
|
||||
artifact.Type,
|
||||
artifact.Format,
|
||||
artifact.BytesSha256,
|
||||
options.ObjectStore.RootPrefix);
|
||||
|
||||
var descriptor = new ArtifactObjectDescriptor(
|
||||
options.ObjectStore.BucketName,
|
||||
key,
|
||||
artifact.Immutable);
|
||||
var candidates = new List<string>
|
||||
{
|
||||
primaryKey,
|
||||
ArtifactObjectKeyBuilder.Build(
|
||||
artifact.Type,
|
||||
artifact.Format,
|
||||
artifact.BytesSha256,
|
||||
rootPrefix: null)
|
||||
};
|
||||
|
||||
var legacyDigest = NormalizeLegacyDigest(artifact.BytesSha256);
|
||||
candidates.Add($"{MapLegacyTypeSegment(artifact.Type)}/{MapLegacyFormatSegment(artifact.Format)}/{legacyDigest}");
|
||||
|
||||
if (legacyDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
candidates.Add($"{MapLegacyTypeSegment(artifact.Type)}/{MapLegacyFormatSegment(artifact.Format)}/{legacyDigest["sha256:".Length..]}");
|
||||
}
|
||||
|
||||
Stream? stream = null;
|
||||
string? resolvedKey = null;
|
||||
|
||||
foreach (var candidateKey in candidates.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
var descriptor = new ArtifactObjectDescriptor(
|
||||
options.ObjectStore.BucketName,
|
||||
candidateKey,
|
||||
artifact.Immutable);
|
||||
|
||||
stream = await _objectStore.GetAsync(descriptor, cancellationToken).ConfigureAwait(false);
|
||||
if (stream is not null)
|
||||
{
|
||||
resolvedKey = candidateKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await using var stream = await _objectStore.GetAsync(descriptor, cancellationToken).ConfigureAwait(false);
|
||||
if (stream is null)
|
||||
{
|
||||
_logger.LogWarning("SBOM artifact content not found at {Key}", key);
|
||||
_logger.LogWarning("SBOM artifact content not found at {Key}", primaryKey);
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using (stream)
|
||||
{
|
||||
var bom = await Serializer.DeserializeAsync(stream).ConfigureAwait(false);
|
||||
if (bom?.Components is null)
|
||||
{
|
||||
@@ -435,10 +468,11 @@ internal sealed class RuntimeInventoryReconciler : IRuntimeInventoryReconciler
|
||||
FilePaths = ExtractFilePaths(c)
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize SBOM from artifact {ArtifactId}", artifact.Id);
|
||||
_logger.LogWarning(ex, "Failed to deserialize SBOM from artifact {ArtifactId} ({ResolvedKey})", artifact.Id, resolvedKey ?? primaryKey);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -595,6 +629,38 @@ internal sealed class RuntimeInventoryReconciler : IRuntimeInventoryReconciler
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeLegacyDigest(string digest)
|
||||
=> digest.Contains(':', StringComparison.Ordinal)
|
||||
? digest.Trim()
|
||||
: $"sha256:{digest.Trim()}";
|
||||
|
||||
private static string MapLegacyTypeSegment(ArtifactDocumentType type) => type switch
|
||||
{
|
||||
ArtifactDocumentType.LayerBom => "layerbom",
|
||||
ArtifactDocumentType.ImageBom => "imagebom",
|
||||
ArtifactDocumentType.Index => "index",
|
||||
ArtifactDocumentType.Attestation => "attestation",
|
||||
ArtifactDocumentType.SurfaceManifest => "surface-manifest",
|
||||
ArtifactDocumentType.SurfaceEntryTrace => "surface-entrytrace",
|
||||
ArtifactDocumentType.SurfaceLayerFragment => "surface-layer-fragment",
|
||||
ArtifactDocumentType.Diff => "diff",
|
||||
_ => type.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
private static string MapLegacyFormatSegment(ArtifactDocumentFormat format) => format switch
|
||||
{
|
||||
ArtifactDocumentFormat.CycloneDxJson => "cyclonedx-json",
|
||||
ArtifactDocumentFormat.CycloneDxProtobuf => "cyclonedx-protobuf",
|
||||
ArtifactDocumentFormat.SpdxJson => "spdx-json",
|
||||
ArtifactDocumentFormat.BomIndex => "bom-index",
|
||||
ArtifactDocumentFormat.DsseJson => "dsse-json",
|
||||
ArtifactDocumentFormat.SurfaceManifestJson => "surface-manifest-json",
|
||||
ArtifactDocumentFormat.EntryTraceNdjson => "entrytrace-ndjson",
|
||||
ArtifactDocumentFormat.EntryTraceGraphJson => "entrytrace-graph-json",
|
||||
ArtifactDocumentFormat.ComponentFragmentJson => "component-fragment-json",
|
||||
_ => format.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
private static void RecordLatency(Stopwatch stopwatch)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// Description: Service implementation for score replay operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Scanner.Core;
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
/// </summary>
|
||||
public sealed class ScoreReplayService : IScoreReplayService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _replayLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly IScanManifestRepository _manifestRepository;
|
||||
private readonly IProofBundleRepository _bundleRepository;
|
||||
private readonly IProofBundleWriter _bundleWriter;
|
||||
@@ -49,52 +50,62 @@ public sealed class ScoreReplayService : IScoreReplayService
|
||||
{
|
||||
_logger.LogInformation("Starting score replay for scan {ScanId}", scanId);
|
||||
|
||||
// Get the manifest
|
||||
var signedManifest = await _manifestRepository.GetManifestAsync(scanId, manifestHash, cancellationToken);
|
||||
if (signedManifest is null)
|
||||
var replayLock = _replayLocks.GetOrAdd(scanId, _ => new SemaphoreSlim(1, 1));
|
||||
await replayLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Manifest not found for scan {ScanId}", scanId);
|
||||
return null;
|
||||
}
|
||||
// Get the manifest
|
||||
var signedManifest = await _manifestRepository.GetManifestAsync(scanId, manifestHash, cancellationToken).ConfigureAwait(false);
|
||||
if (signedManifest is null)
|
||||
{
|
||||
_logger.LogWarning("Manifest not found for scan {ScanId}", scanId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify manifest signature
|
||||
var verifyResult = await _manifestSigner.VerifyAsync(signedManifest, cancellationToken);
|
||||
if (!verifyResult.IsValid)
|
||||
// Verify manifest signature
|
||||
var verifyResult = await _manifestSigner.VerifyAsync(signedManifest, cancellationToken).ConfigureAwait(false);
|
||||
if (!verifyResult.IsValid)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest signature verification failed: {verifyResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
var manifest = signedManifest.Manifest;
|
||||
|
||||
// Replay scoring with frozen inputs
|
||||
var ledger = new ProofLedger();
|
||||
var score = await _scoringService.ReplayScoreAsync(
|
||||
manifest.ScanId,
|
||||
manifest.ConcelierSnapshotHash,
|
||||
manifest.ExcititorSnapshotHash,
|
||||
manifest.LatticePolicyHash,
|
||||
manifest.Seed,
|
||||
freezeTimestamp ?? manifest.CreatedAtUtc,
|
||||
ledger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create proof bundle
|
||||
var bundle = await _bundleWriter.CreateBundleAsync(signedManifest, ledger, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Store bundle reference
|
||||
await _bundleRepository.SaveBundleAsync(bundle, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Score replay complete for scan {ScanId}: score={Score}, rootHash={RootHash}",
|
||||
scanId, score, bundle.RootHash);
|
||||
|
||||
return new ScoreReplayResult(
|
||||
Score: score,
|
||||
RootHash: bundle.RootHash,
|
||||
BundleUri: bundle.BundleUri,
|
||||
ManifestHash: manifest.ComputeHash(),
|
||||
ReplayedAt: DateTimeOffset.UtcNow,
|
||||
Deterministic: manifest.Deterministic);
|
||||
}
|
||||
finally
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest signature verification failed: {verifyResult.ErrorMessage}");
|
||||
replayLock.Release();
|
||||
}
|
||||
|
||||
var manifest = signedManifest.Manifest;
|
||||
|
||||
// Replay scoring with frozen inputs
|
||||
var ledger = new ProofLedger();
|
||||
var score = await _scoringService.ReplayScoreAsync(
|
||||
manifest.ScanId,
|
||||
manifest.ConcelierSnapshotHash,
|
||||
manifest.ExcititorSnapshotHash,
|
||||
manifest.LatticePolicyHash,
|
||||
manifest.Seed,
|
||||
freezeTimestamp ?? manifest.CreatedAtUtc,
|
||||
ledger,
|
||||
cancellationToken);
|
||||
|
||||
// Create proof bundle
|
||||
var bundle = await _bundleWriter.CreateBundleAsync(signedManifest, ledger, cancellationToken);
|
||||
|
||||
// Store bundle reference
|
||||
await _bundleRepository.SaveBundleAsync(bundle, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Score replay complete for scan {ScanId}: score={Score}, rootHash={RootHash}",
|
||||
scanId, score, bundle.RootHash);
|
||||
|
||||
return new ScoreReplayResult(
|
||||
Score: score,
|
||||
RootHash: bundle.RootHash,
|
||||
BundleUri: bundle.BundleUri,
|
||||
ManifestHash: manifest.ComputeHash(),
|
||||
ReplayedAt: DateTimeOffset.UtcNow,
|
||||
Deterministic: manifest.Deterministic);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
| --- | --- | --- | --- |
|
||||
| `SCAN-API-3101-001` | `docs/implplan/archived/SPRINT_3101_0001_0001_scanner_api_standardization.md` | DOING | Align Scanner OpenAPI spec with current endpoints and include ProofSpine routes; compose into `src/Api/StellaOps.Api.OpenApi/stella.yaml`. |
|
||||
| `PROOFSPINE-3100-API` | `docs/implplan/archived/SPRINT_3100_0001_0001_proof_spine_system.md` | DONE | Implemented and tested `/api/v1/spines/*` endpoints with verification output (CBOR accept tracked in SPRINT_3105). |
|
||||
| `PROOF-CBOR-3105-001` | `docs/implplan/SPRINT_3105_0001_0001_proofspine_cbor_accept.md` | DOING | Add `Accept: application/cbor` support for ProofSpine endpoints + tests. |
|
||||
| `PROOF-CBOR-3105-001` | `docs/implplan/SPRINT_3105_0001_0001_proofspine_cbor_accept.md` | DONE | Added `Accept: application/cbor` support for ProofSpine endpoints + tests (`dotnet test src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/StellaOps.Scanner.WebService.Tests.csproj -c Release`). |
|
||||
| `SCAN-AIRGAP-0340-001` | `docs/implplan/SPRINT_0340_0001_0001_scanner_offline_config.md` | DONE | Offline kit import + DSSE/offline Rekor verification wired; integration tests cover success/failure/audit. |
|
||||
| `DRIFT-3600-API` | `docs/implplan/SPRINT_3600_0003_0001_drift_detection_engine.md` | DONE | Add reachability drift endpoints (`/api/v1/scans/{id}/drift`, `/api/v1/drift/{id}/sinks`) + integration tests. |
|
||||
| `SCAN-API-3103-001` | `docs/implplan/SPRINT_3103_0001_0001_scanner_api_ingestion_completion.md` | DONE | Implement missing ingestion services + DI for callgraph/SBOM endpoints and add deterministic integration tests. |
|
||||
| `EPSS-SCAN-011` | `docs/implplan/SPRINT_3410_0002_0001_epss_scanner_integration.md` | DONE | Wired `/api/v1/epss/*` endpoints and added `EpssEndpointsTests` integration coverage. |
|
||||
|
||||
Reference in New Issue
Block a user