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) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-15 11:15:44 +03:00
parent 291c3d3ad4
commit 1e8dbbeeb0
11 changed files with 420 additions and 16 deletions

View File

@@ -212,7 +212,7 @@ Scanner clients tag immutable uploads with `X-RustFS-Immutable: true` and, when
## 4) REST API (Scanner.WebService) ## 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 } 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. 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()`. - 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`). - Locale resolution order is deterministic: `X-Locale` header -> `Accept-Language` header -> configured default locale (`en-US`).

View File

@@ -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<string, string?>
{
["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<string, string?>
{
["ASPNETCORE_URLS"] = "http://+:8080",
["Kestrel:Endpoints:Http:Url"] = "http://+:8080"
})
.Build();
var urls = StellaOpsLocalHostnameExtensions.ResolveConfiguredListenUrls(configuration, serverUrlsSetting: "http://+:8080");
Assert.Empty(urls);
}
}

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes | | 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-M | DONE | Revalidated 2026-01-06. |
| AUDIT-0084-T | DONE | Revalidated 2026-01-06 (coverage updated). | | AUDIT-0084-T | DONE | Revalidated 2026-01-06 (coverage updated). |
| AUDIT-0084-A | DONE | Waived (test project; revalidated 2026-01-06). | | AUDIT-0084-A | DONE | Waived (test project; revalidated 2026-01-06). |

View File

@@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -104,7 +105,9 @@ public static class StellaOpsLocalHostnameExtensions
// //
// When ConfigureKestrel uses explicit Listen() calls, Kestrel ignores UseUrls. // When ConfigureKestrel uses explicit Listen() calls, Kestrel ignores UseUrls.
// So we must also re-add the dev-port bindings from launchSettings.json. // 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) => builder.WebHost.ConfigureKestrel((context, kestrel) =>
{ {
// Load the configured default certificate (if any) so programmatic // 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 // 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 (Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri))
{ {
if (!TryResolveListenAddress(uri.Host, out var addr)) if (!TryResolveListenAddress(uri.Host, out var addr))
@@ -266,13 +269,31 @@ public static class StellaOpsLocalHostnameExtensions
} }
} }
private static string ResolveConfiguredUrls(WebApplicationBuilder builder) internal static IReadOnlyList<string> ResolveConfiguredListenUrls(
IConfiguration configuration,
string? serverUrlsSetting)
{ {
return builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) if (HasExplicitKestrelEndpoints(configuration))
?? builder.Configuration[WebHostDefaults.ServerUrlsKey] {
?? builder.Configuration["ASPNETCORE_URLS"] return Array.Empty<string>();
?? builder.Configuration["URLS"] }
var rawUrls = serverUrlsSetting
?? configuration[WebHostDefaults.ServerUrlsKey]
?? configuration["ASPNETCORE_URLS"]
?? configuration["URLS"]
?? string.Empty; ?? 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) private static bool TryResolveListenAddress(string host, out IPAddress address)

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes | | 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. | | 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-M | DONE | Revalidated 2026-01-06. |
| AUDIT-0083-T | DONE | Revalidated 2026-01-06 (tests cover metadata caching, bypass checks, scope normalization). | | AUDIT-0083-T | DONE | Revalidated 2026-01-06 (tests cover metadata caching, bypass checks, scope normalization). |

View File

@@ -14,6 +14,7 @@ using StellaOps.Localization;
using StellaOps.Auth.ServerIntegration; using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Configuration; using StellaOps.Configuration;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Infrastructure.Postgres.Options; using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Services; using StellaOps.IssuerDirectory.Core.Services;
using StellaOps.IssuerDirectory.Infrastructure; using StellaOps.IssuerDirectory.Infrastructure;
@@ -160,6 +161,11 @@ static void ConfigurePersistence(
opts.ConnectionString = options.Persistence.PostgresConnectionString; opts.ConnectionString = options.Persistence.PostgresConnectionString;
opts.SchemaName = "issuer"; opts.SchemaName = "issuer";
}); });
builder.Services.AddStartupMigrations<IssuerDirectoryWebServiceOptions>(
schemaName: IssuerDirectoryDataSource.DefaultSchemaName,
moduleName: "IssuerDirectory.Persistence",
migrationsAssembly: typeof(IssuerDirectoryDataSource).Assembly,
connectionStringSelector: static configured => configured.Persistence.PostgresConnectionString);
} }
else else
{ {

View File

@@ -22,6 +22,7 @@
<ProjectReference Include="..\\..\\StellaOps.Authority\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" /> <ProjectReference Include="..\\..\\StellaOps.Authority\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\\..\\StellaOps.Authority\\StellaOps.Auth.ServerIntegration\\StellaOps.Auth.ServerIntegration.csproj" /> <ProjectReference Include="..\\..\\StellaOps.Authority\\StellaOps.Auth.ServerIntegration\\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Configuration\\StellaOps.Configuration.csproj" /> <ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Configuration\\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" /> <ProjectReference Include="..\..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" /> <ProjectReference Include="..\..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -191,8 +191,8 @@ builder.Services.AddSingleton<PolicySnapshotStore>();
builder.Services.AddSingleton<PolicyPreviewService>(); builder.Services.AddSingleton<PolicyPreviewService>();
builder.Services.AddSingleton<IRecordModeService, RecordModeService>(); builder.Services.AddSingleton<IRecordModeService, RecordModeService>();
builder.Services.AddSingleton<IScoreReplayService, ScoreReplayService>(); builder.Services.AddSingleton<IScoreReplayService, ScoreReplayService>();
builder.Services.AddSingleton<IScanManifestRepository, InMemoryScanManifestRepository>(); builder.Services.AddSingleton<IScanManifestRepository, PersistedScanManifestRepository>();
builder.Services.AddSingleton<IProofBundleRepository, InMemoryProofBundleRepository>(); builder.Services.AddSingleton<IProofBundleRepository, PersistedProofBundleRepository>();
builder.Services.AddSingleton<IScoringService, DeterministicScoringService>(); builder.Services.AddSingleton<IScoringService, DeterministicScoringService>();
builder.Services.AddSingleton<IScanManifestSigner, ScanManifestSigner>(); builder.Services.AddSingleton<IScanManifestSigner, ScanManifestSigner>();
builder.Services.AddSingleton<IDeltaCompareService, DeltaCompareService>(); builder.Services.AddSingleton<IDeltaCompareService, DeltaCompareService>();
@@ -252,10 +252,6 @@ builder.Services.AddScoped<IUnknownsQueryService, UnknownsQueryService>();
builder.Services.AddVerdictExplainability(); builder.Services.AddVerdictExplainability();
builder.Services.AddScoped<IFindingRationaleService, FindingRationaleService>(); builder.Services.AddScoped<IFindingRationaleService, FindingRationaleService>();
// Register Storage.Repositories implementations for ManifestEndpoints
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository, TestManifestRepository>();
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository, TestProofBundleRepository>();
builder.Services.AddSingleton<IProofBundleWriter>(sp => builder.Services.AddSingleton<IProofBundleWriter>(sp =>
{ {
var options = sp.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value; var options = sp.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
@@ -432,6 +428,8 @@ builder.Services.AddScannerStorage(storageOptions =>
storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty; storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty;
} }
}); });
builder.Services.AddScoped<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository, PostgresScanManifestRepository>();
builder.Services.AddScoped<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository, PostgresProofBundleRepository>();
builder.Services.AddOptions<PostgresOptions>() builder.Services.AddOptions<PostgresOptions>()
.Configure(options => .Configure(options =>
{ {

View File

@@ -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<SignedScanManifest?> 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<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository>();
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<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository>();
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<List<string>> 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<string>(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<ScannerWebServiceOptions> 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<ProofBundle?> 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<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository>();
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<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository>();
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<string> 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();
}
}

View File

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

View File

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