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

@@ -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 =>
{

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

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