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:
@@ -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`).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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). |
|
||||
|
||||
@@ -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<string> 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<string>();
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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<IssuerDirectoryWebServiceOptions>(
|
||||
schemaName: IssuerDirectoryDataSource.DefaultSchemaName,
|
||||
moduleName: "IssuerDirectory.Persistence",
|
||||
migrationsAssembly: typeof(IssuerDirectoryDataSource).Assembly,
|
||||
connectionStringSelector: static configured => configured.Persistence.PostgresConnectionString);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<ProjectReference Include="..\\..\\StellaOps.Authority\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\\..\\StellaOps.Authority\\StellaOps.Auth.ServerIntegration\\StellaOps.Auth.ServerIntegration.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="..\..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -191,8 +191,8 @@ 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<IScanManifestRepository, PersistedScanManifestRepository>();
|
||||
builder.Services.AddSingleton<IProofBundleRepository, PersistedProofBundleRepository>();
|
||||
builder.Services.AddSingleton<IScoringService, DeterministicScoringService>();
|
||||
builder.Services.AddSingleton<IScanManifestSigner, ScanManifestSigner>();
|
||||
builder.Services.AddSingleton<IDeltaCompareService, DeltaCompareService>();
|
||||
@@ -252,10 +252,6 @@ builder.Services.AddScoped<IUnknownsQueryService, UnknownsQueryService>();
|
||||
builder.Services.AddVerdictExplainability();
|
||||
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 =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
|
||||
@@ -432,6 +428,8 @@ builder.Services.AddScannerStorage(storageOptions =>
|
||||
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>()
|
||||
.Configure(options =>
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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). |
|
||||
|
||||
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user