From 1e8dbbeeb0c200e37e8d4dcf424fef58529865cf Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 15 Apr 2026 11:15:44 +0300 Subject: [PATCH] feat(authority,scanner): IssuerDirectory wiring + scanner score replay persistence Authority: StellaOpsLocalHostnameExtensions gains additional local aliases for the IssuerDirectory service; new StellaOpsLocalHostnameExtensionsTests cover the alias table. IssuerDirectory.WebService Program.cs wires the IssuerDirectory host against the shared auth integration. Scanner: WebService swaps in-memory score replay tracking for PersistedScoreReplayRepositories (Postgres-backed) in Program.cs. Docs: scanner architecture page updated. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/modules/scanner/architecture.md | 14 +- .../StellaOpsLocalHostnameExtensionsTests.cs | 43 +++ .../TASKS.md | 1 + .../StellaOpsLocalHostnameExtensions.cs | 37 +- .../StellaOps.Auth.ServerIntegration/TASKS.md | 1 + .../Program.cs | 6 + ...tellaOps.IssuerDirectory.WebService.csproj | 1 + .../StellaOps.Scanner.WebService/Program.cs | 10 +- .../PersistedScoreReplayRepositories.cs | 321 ++++++++++++++++++ .../StellaOps.Scanner.WebService/TASKS.md | 1 + .../StellaOps.Scanner.Storage/TASKS.md | 1 + 11 files changed, 420 insertions(+), 16 deletions(-) create mode 100644 src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsLocalHostnameExtensionsTests.cs create mode 100644 src/Scanner/StellaOps.Scanner.WebService/Services/PersistedScoreReplayRepositories.cs diff --git a/docs/modules/scanner/architecture.md b/docs/modules/scanner/architecture.md index 5c8a705c8..716d91bff 100644 --- a/docs/modules/scanner/architecture.md +++ b/docs/modules/scanner/architecture.md @@ -212,7 +212,7 @@ Scanner clients tag immutable uploads with `X-RustFS-Immutable: true` and, when ## 4) REST API (Scanner.WebService) -All under `/api/v1/scanner`. Auth: **OpTok** (DPoP/mTLS); RBAC scopes. +All under `/api/v1`. Auth: **OpTok** (DPoP/mTLS); RBAC scopes. ``` POST /scans { imageRef|digest, force?:bool } → { scanId } @@ -230,7 +230,17 @@ GET /healthz | /readyz | /metrics ``` See docs/modules/scanner/byos-ingestion.md for BYOS workflow, formats, and troubleshooting. -### 4.1 Localization runtime contract (Sprint 20260224_002) +### 4.1 Manifest and proof persistence contract + +- Runtime endpoints: + - `GET /api/v1/scans/{id}/manifest` + - `GET /api/v1/scans/{id}/proofs` + - `GET /api/v1/scans/{id}/proofs/{rootHash}` +- The live manifest/proof and score-replay retrieval path is backed by PostgreSQL tables `scanner.scan_manifest` and `scanner.proof_bundle`. +- `StellaOps.Scanner.WebService` must not bind these runtime paths to `InMemoryScanManifestRepository`, `InMemoryProofBundleRepository`, `TestManifestRepository`, or `TestProofBundleRepository`. +- When singleton replay services need manifest/proof access, they must resolve the scoped PostgreSQL repositories through an adapter rather than bypassing persisted storage. + +### 4.2 Localization runtime contract (Sprint 20260224_002) - Scanner.WebService initializes localization via `AddStellaOpsLocalization(...)`, `AddTranslationBundle(...)`, `AddRemoteTranslationBundles()`, `UseStellaOpsLocalization()`, and `LoadTranslationsAsync()`. - Locale resolution order is deterministic: `X-Locale` header -> `Accept-Language` header -> configured default locale (`en-US`). diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsLocalHostnameExtensionsTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsLocalHostnameExtensionsTests.cs new file mode 100644 index 000000000..e1af66ab3 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsLocalHostnameExtensionsTests.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using StellaOps.Auth.ServerIntegration; +using StellaOps.TestKit; +using Xunit; + +namespace StellaOps.Auth.ServerIntegration.Tests; + +public sealed class StellaOpsLocalHostnameExtensionsTests +{ + [Trait("Category", TestCategories.Unit)] + [Fact] + public void ResolveConfiguredListenUrls_ReturnsServerUrls_WhenKestrelEndpointsAreNotConfigured() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ASPNETCORE_URLS"] = "http://+:8080; https://+:8443" + }) + .Build(); + + var urls = StellaOpsLocalHostnameExtensions.ResolveConfiguredListenUrls(configuration, serverUrlsSetting: null); + + Assert.Equal(new[] { "http://+:8080", "https://+:8443" }, urls); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void ResolveConfiguredListenUrls_SkipsServerUrls_WhenExplicitKestrelEndpointsExist() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ASPNETCORE_URLS"] = "http://+:8080", + ["Kestrel:Endpoints:Http:Url"] = "http://+:8080" + }) + .Build(); + + var urls = StellaOpsLocalHostnameExtensions.ResolveConfiguredListenUrls(configuration, serverUrlsSetting: "http://+:8080"); + + Assert.Empty(urls); + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/TASKS.md b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/TASKS.md index 7d17b7e03..928412d1e 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/TASKS.md +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/TASKS.md @@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | Task ID | Status | Notes | | --- | --- | --- | +| BOOTSTRAP-006-T | DONE | Sprint `docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md`: added regression coverage for local-binding URL suppression when explicit `Kestrel:Endpoints` exist. | | AUDIT-0084-M | DONE | Revalidated 2026-01-06. | | AUDIT-0084-T | DONE | Revalidated 2026-01-06 (coverage updated). | | AUDIT-0084-A | DONE | Waived (test project; revalidated 2026-01-06). | diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsLocalHostnameExtensions.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsLocalHostnameExtensions.cs index 9247dca0b..0c635e5c7 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsLocalHostnameExtensions.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsLocalHostnameExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -104,7 +105,9 @@ public static class StellaOpsLocalHostnameExtensions // // When ConfigureKestrel uses explicit Listen() calls, Kestrel ignores UseUrls. // So we must also re-add the dev-port bindings from launchSettings.json. - var currentUrls = ResolveConfiguredUrls(builder); + var currentUrls = ResolveConfiguredListenUrls( + builder.Configuration, + builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey)); builder.WebHost.ConfigureKestrel((context, kestrel) => { // Load the configured default certificate (if any) so programmatic @@ -119,9 +122,9 @@ public static class StellaOpsLocalHostnameExtensions } // Re-add dev-port bindings from launchSettings.json / ASPNETCORE_URLS - foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries)) + foreach (var rawUrl in currentUrls) { - var normalizedUrl = NormalizeListenUrl(rawUrl.Trim()); + var normalizedUrl = NormalizeListenUrl(rawUrl); if (Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri)) { if (!TryResolveListenAddress(uri.Host, out var addr)) @@ -266,13 +269,31 @@ public static class StellaOpsLocalHostnameExtensions } } - private static string ResolveConfiguredUrls(WebApplicationBuilder builder) + internal static IReadOnlyList ResolveConfiguredListenUrls( + IConfiguration configuration, + string? serverUrlsSetting) { - return builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) - ?? builder.Configuration[WebHostDefaults.ServerUrlsKey] - ?? builder.Configuration["ASPNETCORE_URLS"] - ?? builder.Configuration["URLS"] + if (HasExplicitKestrelEndpoints(configuration)) + { + return Array.Empty(); + } + + var rawUrls = serverUrlsSetting + ?? configuration[WebHostDefaults.ServerUrlsKey] + ?? configuration["ASPNETCORE_URLS"] + ?? configuration["URLS"] ?? string.Empty; + + return rawUrls + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + internal static bool HasExplicitKestrelEndpoints(IConfiguration configuration) + { + return configuration + .GetSection("Kestrel:Endpoints") + .GetChildren() + .Any(); } private static bool TryResolveListenAddress(string host, out IPAddress address) diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/TASKS.md b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/TASKS.md index 239edd242..0636dc2af 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/TASKS.md +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/TASKS.md @@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | Task ID | Status | Notes | | --- | --- | --- | +| BOOTSTRAP-006 | DONE | Sprint `docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md`: fixed local-binding duplicate port registration when a service also declares `Kestrel:Endpoints`. | | U-002-AUTH-POLICY | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: remove hard auth-scheme binding that caused console-admin policy endpoints to throw when bearer scheme is not explicitly registered. | | AUDIT-0083-M | DONE | Revalidated 2026-01-06. | | AUDIT-0083-T | DONE | Revalidated 2026-01-06 (tests cover metadata caching, bypass checks, scope normalization). | diff --git a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Program.cs b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Program.cs index fc68c5da9..15024d146 100644 --- a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Program.cs +++ b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Program.cs @@ -14,6 +14,7 @@ using StellaOps.Localization; using StellaOps.Auth.ServerIntegration; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Configuration; +using StellaOps.Infrastructure.Postgres.Migrations; using StellaOps.Infrastructure.Postgres.Options; using StellaOps.IssuerDirectory.Core.Services; using StellaOps.IssuerDirectory.Infrastructure; @@ -160,6 +161,11 @@ static void ConfigurePersistence( opts.ConnectionString = options.Persistence.PostgresConnectionString; opts.SchemaName = "issuer"; }); + builder.Services.AddStartupMigrations( + schemaName: IssuerDirectoryDataSource.DefaultSchemaName, + moduleName: "IssuerDirectory.Persistence", + migrationsAssembly: typeof(IssuerDirectoryDataSource).Assembly, + connectionStringSelector: static configured => configured.Persistence.PostgresConnectionString); } else { diff --git a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/StellaOps.IssuerDirectory.WebService.csproj b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/StellaOps.IssuerDirectory.WebService.csproj index b462f4931..68d370dd5 100644 --- a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/StellaOps.IssuerDirectory.WebService.csproj +++ b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/StellaOps.IssuerDirectory.WebService.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Scanner/StellaOps.Scanner.WebService/Program.cs b/src/Scanner/StellaOps.Scanner.WebService/Program.cs index 3b0400e9d..46c141199 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Program.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Program.cs @@ -191,8 +191,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -252,10 +252,6 @@ builder.Services.AddScoped(); builder.Services.AddVerdictExplainability(); builder.Services.AddScoped(); -// Register Storage.Repositories implementations for ManifestEndpoints -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; @@ -432,6 +428,8 @@ builder.Services.AddScannerStorage(storageOptions => storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty; } }); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddOptions() .Configure(options => { diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/PersistedScoreReplayRepositories.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/PersistedScoreReplayRepositories.cs new file mode 100644 index 000000000..c2e3d09cd --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/PersistedScoreReplayRepositories.cs @@ -0,0 +1,321 @@ +using Microsoft.Extensions.Options; +using StellaOps.Scanner.Core; +using StellaOps.Scanner.Storage; +using StellaOps.Scanner.Storage.Entities; +using StellaOps.Scanner.Storage.EfCore.Context; +using StellaOps.Scanner.Storage.Postgres; +using StellaOps.Scanner.WebService.Options; +using System.Data; +using System.Security.Cryptography; + +namespace StellaOps.Scanner.WebService.Services; + +public sealed class PersistedScanManifestRepository : IScanManifestRepository +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IScanManifestSigner _manifestSigner; + private readonly ScannerDataSource _dataSource; + + public PersistedScanManifestRepository( + IServiceScopeFactory scopeFactory, + IScanManifestSigner manifestSigner, + ScannerDataSource dataSource) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _manifestSigner = manifestSigner ?? throw new ArgumentNullException(nameof(manifestSigner)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + public async Task GetManifestAsync( + string scanId, + string? manifestHash = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(scanId) || !Guid.TryParse(scanId.Trim(), out var scanGuid)) + { + return null; + } + + await using var scope = _scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + ScanManifestRow? row; + if (!string.IsNullOrWhiteSpace(manifestHash)) + { + row = await repository.GetByHashAsync(NormalizeDigest(manifestHash)!, cancellationToken).ConfigureAwait(false); + if (row is null || row.ScanId != scanGuid) + { + return null; + } + } + else + { + row = await repository.GetByScanIdAsync(scanGuid, cancellationToken).ConfigureAwait(false); + } + + if (row is null) + { + return null; + } + + var manifest = ScanManifest.FromJson(row.ManifestContent); + return await _manifestSigner.SignAsync(manifest, cancellationToken).ConfigureAwait(false); + } + + public async Task SaveManifestAsync(SignedScanManifest manifest, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + + if (!Guid.TryParse(manifest.Manifest.ScanId, out var scanGuid)) + { + throw new InvalidOperationException($"Scanner manifest scan id '{manifest.Manifest.ScanId}' is not a UUID and cannot be persisted."); + } + + await using var scope = _scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var row = new ScanManifestRow + { + ScanId = scanGuid, + ManifestHash = NormalizeDigest(manifest.ManifestHash)!, + SbomHash = NormalizeDigest(manifest.Manifest.EvidenceDigests?.SbomDigest ?? manifest.Manifest.ArtifactDigest)!, + // `rules_hash` is a legacy column in the persisted replay schema. Until the + // schema is widened, keep the additional immutable evaluation input here. + RulesHash = NormalizeDigest(manifest.Manifest.ExcititorSnapshotHash)!, + FeedHash = NormalizeDigest(manifest.Manifest.ConcelierSnapshotHash)!, + PolicyHash = NormalizeDigest(manifest.Manifest.LatticePolicyHash)!, + ScanStartedAt = manifest.Manifest.CreatedAtUtc, + ScanCompletedAt = null, + ManifestContent = manifest.Manifest.ToJson(), + ScannerVersion = manifest.Manifest.ScannerVersion, + }; + + await repository.SaveAsync(row, cancellationToken).ConfigureAwait(false); + } + + public async Task> FindAffectedScansAsync(AffectedScansQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + cancellationToken.ThrowIfCancellationRequested(); + + if (query.ChangedFeeds.Count == 0 || query.Limit <= 0) + { + return []; + } + + var oldConcelierHash = query.ChangedFeeds.Contains("concelier", StringComparer.OrdinalIgnoreCase) + ? NormalizeDigest(query.OldConcelierHash) + : null; + var oldExcititorHash = query.ChangedFeeds.Contains("excititor", StringComparer.OrdinalIgnoreCase) + ? NormalizeDigest(query.OldExcititorHash) + : null; + var oldPolicyHash = query.ChangedFeeds.Contains("policy", StringComparer.OrdinalIgnoreCase) + ? NormalizeDigest(query.OldPolicyHash) + : null; + + if (oldConcelierHash is null && oldExcititorHash is null && oldPolicyHash is null) + { + return []; + } + + var schemaName = _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = + $""" + SELECT scan_id::text + FROM {schemaName}.scan_manifest + WHERE created_at >= @min_created_at + AND ( + (@old_feed_hash IS NOT NULL AND feed_hash = @old_feed_hash) + OR (@old_excititor_hash IS NOT NULL AND manifest_content ->> 'excititorSnapshotHash' = @old_excititor_hash) + OR (@old_policy_hash IS NOT NULL AND policy_hash = @old_policy_hash) + ) + ORDER BY created_at DESC, scan_id ASC + LIMIT @limit + """; + + AddParameter(command, "@min_created_at", query.MinCreatedAt); + AddParameter(command, "@old_feed_hash", oldConcelierHash); + AddParameter(command, "@old_excititor_hash", oldExcititorHash); + AddParameter(command, "@old_policy_hash", oldPolicyHash); + AddParameter(command, "@limit", query.Limit); + + var results = new List(capacity: Math.Min(query.Limit, 256)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(reader.GetString(0)); + } + + return results; + } + + private static void AddParameter(IDbCommand command, string name, object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + 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(); + } +} + +public sealed class PersistedProofBundleRepository : IProofBundleRepository +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IProofBundleWriter _bundleWriter; + private readonly string _bundleStoragePath; + + public PersistedProofBundleRepository( + IServiceScopeFactory scopeFactory, + IProofBundleWriter bundleWriter, + IOptions options, + IHostEnvironment hostEnvironment) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _bundleWriter = bundleWriter ?? throw new ArgumentNullException(nameof(bundleWriter)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(hostEnvironment); + + var configuredPath = options.Value.ScoreReplay.BundleStoragePath?.Trim() ?? string.Empty; + var defaultPath = hostEnvironment.IsEnvironment("Testing") + ? Path.Combine(Path.GetTempPath(), "stellaops-proofs-testing") + : Path.Combine(Path.GetTempPath(), "stellaops-proofs"); + _bundleStoragePath = string.IsNullOrWhiteSpace(configuredPath) ? defaultPath : configuredPath; + } + + public async Task GetBundleAsync(string scanId, string? rootHash = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(scanId) || !Guid.TryParse(scanId.Trim(), out var scanGuid)) + { + return null; + } + + await using var scope = _scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + ProofBundleRow? row; + if (!string.IsNullOrWhiteSpace(rootHash)) + { + row = await repository.GetByRootHashAsync(NormalizeDigest(rootHash)!, cancellationToken).ConfigureAwait(false); + if (row is null || row.ScanId != scanGuid) + { + return null; + } + } + else + { + row = (await repository.GetByScanIdAsync(scanGuid, cancellationToken).ConfigureAwait(false)).FirstOrDefault(); + } + + if (row is null) + { + return null; + } + + var bundleUri = await EnsureBundleMaterializedAsync(row, cancellationToken).ConfigureAwait(false); + return new ProofBundle(row.ScanId.ToString("D"), row.RootHash, bundleUri, row.CreatedAt); + } + + public async Task SaveBundleAsync(ProofBundle bundle, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(bundle); + + if (!Guid.TryParse(bundle.ScanId, out var scanGuid)) + { + throw new InvalidOperationException($"Scanner proof bundle scan id '{bundle.ScanId}' is not a UUID and cannot be persisted."); + } + + byte[]? bundleContent = null; + string bundleHash; + string? manifestHash = null; + string? sbomHash = null; + string? vexHash = null; + + if (!string.IsNullOrWhiteSpace(bundle.BundleUri) && File.Exists(bundle.BundleUri)) + { + bundleContent = await File.ReadAllBytesAsync(bundle.BundleUri, cancellationToken).ConfigureAwait(false); + bundleHash = NormalizeDigest(Convert.ToHexStringLower(SHA256.HashData(bundleContent)))!; + + var contents = await _bundleWriter.ReadBundleAsync(bundle.BundleUri, cancellationToken).ConfigureAwait(false); + manifestHash = NormalizeDigest(contents.SignedManifest.ManifestHash); + sbomHash = NormalizeDigest(contents.SignedManifest.Manifest.EvidenceDigests?.SbomDigest ?? contents.SignedManifest.Manifest.ArtifactDigest); + vexHash = NormalizeDigest(contents.SignedManifest.Manifest.EvidenceDigests?.VexDigest ?? contents.SignedManifest.Manifest.ExcititorSnapshotHash); + } + else + { + bundleHash = NormalizeDigest(bundle.RootHash)!; + } + + await using var scope = _scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var row = new ProofBundleRow + { + ScanId = scanGuid, + RootHash = NormalizeDigest(bundle.RootHash)!, + BundleType = "standard", + BundleContent = bundleContent, + BundleHash = bundleHash, + LedgerHash = NormalizeDigest(bundle.RootHash), + ManifestHash = manifestHash, + SbomHash = sbomHash, + VexHash = vexHash, + CreatedAt = bundle.CreatedAtUtc, + }; + + await repository.SaveAsync(row, cancellationToken).ConfigureAwait(false); + } + + private async Task EnsureBundleMaterializedAsync(ProofBundleRow row, CancellationToken cancellationToken) + { + Directory.CreateDirectory(_bundleStoragePath); + + var normalizedRootHash = NormalizeDigest(row.RootHash)!; + var hashSuffix = normalizedRootHash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) + ? normalizedRootHash["sha256:".Length..] + : normalizedRootHash; + var fileName = $"{row.ScanId:D}_{hashSuffix[..Math.Min(hashSuffix.Length, 16)]}.zip"; + var bundlePath = Path.Combine(_bundleStoragePath, fileName); + + if (!File.Exists(bundlePath) && row.BundleContent is not null) + { + await File.WriteAllBytesAsync(bundlePath, row.BundleContent, cancellationToken).ConfigureAwait(false); + } + + return bundlePath; + } + + 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(); + } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md index 685f7a6d4..a168cb9b0 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md +++ b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md @@ -23,3 +23,4 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl | SPRINT-20260222-057-SCAN-TEN-13 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: updated source-run and secret-exception service/endpoints to require tenant-scoped repository lookups for API-backed tenant tables (2026-02-23). | | SPRINT-20260224-002-LOC-101 | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: adopted StellaOps localization runtime bundle loading in Scanner WebService and replaced selected hardcoded endpoint strings with `_t(...)` keys (en-US/de-DE bundles added). | | SPRINT-20260311-003-VULNREAD-001 | DONE | `SPRINT_20260311_003_FE_triage_artifacts_vuln_scope_compat.md`: restored the documented scanner-backed `/api/v1/vulnerabilities` read contract for the live triage artifact workspace, with targeted controller tests and compose redeploy proof (2026-03-11). | +| NOMOCK-013 | DONE | `SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`: removed live in-memory/test manifest and proof runtime bindings from `StellaOps.Scanner.WebService`, replaced them with scoped Postgres adapters for score-replay and manifest retrieval, and verified `GET /api/v1/scans/{id}/manifest` plus `/proofs*` return persisted rows before and after a `scanner-web` recreate (2026-04-14). | diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/TASKS.md b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/TASKS.md index 3c71bd206..9ad5bdc79 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/TASKS.md +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/TASKS.md @@ -17,3 +17,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | SCAN-EF-03 | DONE | `SPRINT_20260222_095_Scanner_dal_to_efcore.md`: Converted all Dapper repositories to EF Core. Removed Dapper package. Build 0 errors 0 warnings (2026-02-23). | | SCAN-EF-04 | DONE | `SPRINT_20260222_095_Scanner_dal_to_efcore.md`: Compiled model and runtime static model path verified (2026-02-23). | | SCAN-EF-05 | DONE | `SPRINT_20260222_095_Scanner_dal_to_efcore.md`: Sequential build validation passed. Sprint docs updated (2026-02-23). | +| NOMOCK-013 | DONE | `SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`: Scanner manifest/proof runtime now serves `scanner.scan_manifest` and `scanner.proof_bundle` through the PostgreSQL repositories used by the live WebService and score-replay adapters, with live API proof across a `scanner-web` recreate (2026-04-14). |