up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 23:44:42 +02:00
parent ef6e4b2067
commit 3b96b2e3ea
298 changed files with 47516 additions and 1168 deletions

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Surface.Secrets.Providers;
/// <summary>
/// Wraps a secret provider with audit logging. Each secret access is logged with tenant, component,
/// secret type, and provider metadata for observability and compliance.
/// </summary>
internal sealed class AuditingSurfaceSecretProvider : ISurfaceSecretProvider
{
private readonly ISurfaceSecretProvider _inner;
private readonly TimeProvider _timeProvider;
private readonly ILogger _logger;
private readonly string _componentName;
public AuditingSurfaceSecretProvider(
ISurfaceSecretProvider inner,
TimeProvider timeProvider,
ILogger logger,
string componentName)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_componentName = componentName ?? throw new ArgumentNullException(nameof(componentName));
}
public async ValueTask<SurfaceSecretHandle> GetAsync(
SurfaceSecretRequest request,
CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
try
{
var handle = await _inner.GetAsync(request, cancellationToken).ConfigureAwait(false);
var elapsed = _timeProvider.GetUtcNow() - startTime;
LogAuditEvent(
request,
handle.Metadata,
success: true,
elapsed,
error: null);
return handle;
}
catch (SurfaceSecretNotFoundException)
{
var elapsed = _timeProvider.GetUtcNow() - startTime;
LogAuditEvent(
request,
metadata: null,
success: false,
elapsed,
error: "NotFound");
throw;
}
catch (Exception ex)
{
var elapsed = _timeProvider.GetUtcNow() - startTime;
LogAuditEvent(
request,
metadata: null,
success: false,
elapsed,
error: ex.GetType().Name);
throw;
}
}
private void LogAuditEvent(
SurfaceSecretRequest request,
IReadOnlyDictionary<string, string>? metadata,
bool success,
TimeSpan elapsed,
string? error)
{
// Structured log entry for audit trail. NEVER log secret contents.
_logger.Log(
success ? LogLevel.Information : LogLevel.Warning,
"Surface secret access: " +
"Component={Component}, " +
"Tenant={Tenant}, " +
"RequestComponent={RequestComponent}, " +
"SecretType={SecretType}, " +
"Name={Name}, " +
"Success={Success}, " +
"ElapsedMs={ElapsedMs}, " +
"Provider={Provider}, " +
"Error={Error}",
_componentName,
request.Tenant,
request.Component,
request.SecretType,
request.Name ?? "default",
success,
elapsed.TotalMilliseconds,
metadata?.GetValueOrDefault("source") ?? "unknown",
error ?? "(none)");
}
}

View File

@@ -0,0 +1,95 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Surface.Secrets.Providers;
/// <summary>
/// Wraps a secret provider with a deterministic in-memory cache. Cache entries expire after
/// <see cref="CacheTtl"/> seconds. Cache keys are formed deterministically by tenant/component/secretType/name
/// sorted lexicographically for stable hashing.
/// </summary>
internal sealed class CachingSurfaceSecretProvider : ISurfaceSecretProvider
{
private readonly ISurfaceSecretProvider _inner;
private readonly TimeProvider _timeProvider;
private readonly ILogger _logger;
private readonly TimeSpan _ttl;
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.Ordinal);
public static TimeSpan DefaultCacheTtl { get; } = TimeSpan.FromSeconds(600);
public CachingSurfaceSecretProvider(
ISurfaceSecretProvider inner,
TimeProvider timeProvider,
ILogger logger,
TimeSpan? cacheTtl = null)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_ttl = cacheTtl ?? DefaultCacheTtl;
}
public TimeSpan CacheTtl => _ttl;
public async ValueTask<SurfaceSecretHandle> GetAsync(
SurfaceSecretRequest request,
CancellationToken cancellationToken = default)
{
var key = BuildCacheKey(request);
var now = _timeProvider.GetUtcNow();
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
{
_logger.LogDebug("Surface secret cache hit for {Key}.", key);
return entry.Handle;
}
var handle = await _inner.GetAsync(request, cancellationToken).ConfigureAwait(false);
var newEntry = new CacheEntry(handle, now.Add(_ttl));
_cache[key] = newEntry;
_logger.LogDebug("Surface secret cached for {Key}, expires at {ExpiresAt}.", key, newEntry.ExpiresAt);
return handle;
}
/// <summary>
/// Clears all cached entries.
/// </summary>
public void Clear()
{
_cache.Clear();
_logger.LogInformation("Surface secret cache cleared.");
}
/// <summary>
/// Removes a specific entry from the cache.
/// </summary>
public void Invalidate(SurfaceSecretRequest request)
{
var key = BuildCacheKey(request);
_cache.TryRemove(key, out _);
_logger.LogDebug("Surface secret cache entry invalidated for {Key}.", key);
}
/// <summary>
/// Builds a deterministic cache key from the request. Components are sorted to ensure
/// lexicographically stable ordering for deterministic behaviour.
/// </summary>
private static string BuildCacheKey(SurfaceSecretRequest request)
{
// Deterministic ordering: tenant < component < secretType < name
var name = request.Name ?? "default";
return string.Join(
":",
request.Tenant.ToLowerInvariant(),
request.Component.ToLowerInvariant(),
request.SecretType.ToLowerInvariant(),
name.ToLowerInvariant());
}
private sealed record CacheEntry(SurfaceSecretHandle Handle, DateTimeOffset ExpiresAt);
}

View File

@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Surface.Secrets.Providers;
/// <summary>
/// Specialized file provider for offline kit deployments. Reads secrets from the offline kit
/// layout: <c>&lt;root&gt;/&lt;tenant&gt;/&lt;component&gt;/&lt;secretType&gt;/&lt;name&gt;.json</c>.
/// Supports deterministic selection when multiple secrets exist (lexicographically smallest name).
/// Validates file integrity via SHA-256 hashes when manifest is present.
/// </summary>
internal sealed class OfflineSurfaceSecretProvider : ISurfaceSecretProvider
{
private readonly string _root;
private readonly ILogger _logger;
private readonly Dictionary<string, OfflineManifestEntry>? _manifest;
public OfflineSurfaceSecretProvider(string root, ILogger logger, string? manifestPath = null)
{
if (string.IsNullOrWhiteSpace(root))
{
throw new ArgumentException("Offline secret provider root cannot be null or whitespace.", nameof(root));
}
_root = root;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Load manifest if present for integrity verification
var defaultManifestPath = manifestPath ?? Path.Combine(root, "manifest.json");
if (File.Exists(defaultManifestPath))
{
_manifest = LoadManifest(defaultManifestPath);
_logger.LogInformation("Offline secrets manifest loaded from {Path} ({Count} entries).", defaultManifestPath, _manifest.Count);
}
}
public async ValueTask<SurfaceSecretHandle> GetAsync(
SurfaceSecretRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var directory = Path.Combine(_root, request.Tenant, request.Component, request.SecretType);
if (!Directory.Exists(directory))
{
_logger.LogDebug("Offline secret directory {Directory} not found.", directory);
throw new SurfaceSecretNotFoundException(request);
}
// Deterministic selection: if name is specified use it, otherwise pick lexicographically smallest
var targetName = request.Name ?? SelectDeterministicName(directory);
if (targetName is null)
{
throw new SurfaceSecretNotFoundException(request);
}
var path = Path.Combine(directory, targetName + ".json");
if (!File.Exists(path))
{
throw new SurfaceSecretNotFoundException(request);
}
await using var stream = File.OpenRead(path);
var descriptor = await JsonSerializer.DeserializeAsync<OfflineSecretDescriptor>(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (descriptor is null)
{
throw new SurfaceSecretNotFoundException(request);
}
if (string.IsNullOrWhiteSpace(descriptor.Payload))
{
return SurfaceSecretHandle.Empty;
}
var bytes = Convert.FromBase64String(descriptor.Payload);
// Verify integrity if manifest entry exists
var manifestKey = BuildManifestKey(request.Tenant, request.Component, request.SecretType, targetName);
if (_manifest?.TryGetValue(manifestKey, out var entry) == true)
{
var actualHash = ComputeSha256(bytes);
if (!string.Equals(actualHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"Offline secret integrity check failed for {Key}. Expected {Expected}, got {Actual}.",
manifestKey,
entry.Sha256,
actualHash);
throw new InvalidOperationException($"Offline secret integrity check failed for {manifestKey}.");
}
_logger.LogDebug("Offline secret integrity verified for {Key}.", manifestKey);
}
var metadata = descriptor.Metadata ?? new Dictionary<string, string>();
metadata["source"] = "offline";
metadata["path"] = path;
metadata["name"] = targetName;
return SurfaceSecretHandle.FromBytes(bytes, metadata);
}
/// <summary>
/// Selects the lexicographically smallest .json file name in the directory (without extension).
/// Returns null if directory is empty.
/// </summary>
private static string? SelectDeterministicName(string directory)
{
var files = Directory.GetFiles(directory, "*.json");
if (files.Length == 0)
{
return null;
}
return files
.Select(Path.GetFileNameWithoutExtension)
.Where(name => !string.IsNullOrWhiteSpace(name))
.OrderBy(name => name, StringComparer.Ordinal)
.FirstOrDefault();
}
private static string BuildManifestKey(string tenant, string component, string secretType, string name)
=> $"{tenant}/{component}/{secretType}/{name}".ToLowerInvariant();
private static string ComputeSha256(byte[] data)
{
var hash = SHA256.HashData(data);
return Convert.ToHexStringLower(hash);
}
private Dictionary<string, OfflineManifestEntry> LoadManifest(string path)
{
try
{
var json = File.ReadAllText(path);
var manifest = JsonSerializer.Deserialize<OfflineManifest>(json);
if (manifest?.Entries is null)
{
return new Dictionary<string, OfflineManifestEntry>(StringComparer.OrdinalIgnoreCase);
}
var result = new Dictionary<string, OfflineManifestEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in manifest.Entries)
{
if (string.IsNullOrWhiteSpace(entry.Key))
{
continue;
}
result[entry.Key] = entry;
}
return result;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load offline secrets manifest from {Path}.", path);
return new Dictionary<string, OfflineManifestEntry>(StringComparer.OrdinalIgnoreCase);
}
}
private sealed class OfflineSecretDescriptor
{
public string? Payload { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
private sealed class OfflineManifest
{
public List<OfflineManifestEntry>? Entries { get; init; }
}
private sealed class OfflineManifestEntry
{
public string? Key { get; init; }
public string? Sha256 { get; init; }
public long? Size { get; init; }
public string? CreatedAt { get; init; }
}
}

View File

@@ -28,8 +28,29 @@ public static class ServiceCollectionExtensions
{
var env = sp.GetRequiredService<ISurfaceEnvironment>();
var options = sp.GetRequiredService<IOptions<SurfaceSecretsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("SurfaceSecrets");
return CreateProviderChain(env.Settings.Secrets, logger);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("SurfaceSecrets");
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
// Build the base provider chain
ISurfaceSecretProvider provider = CreateProviderChain(env.Settings.Secrets, logger);
// Wrap with caching if enabled
if (options.EnableCaching)
{
var cacheTtl = TimeSpan.FromSeconds(Math.Max(30, options.CacheTtlSeconds));
var cacheLogger = loggerFactory.CreateLogger("SurfaceSecrets.Cache");
provider = new CachingSurfaceSecretProvider(provider, timeProvider, cacheLogger, cacheTtl);
}
// Wrap with auditing if enabled
if (options.EnableAuditLogging)
{
var auditLogger = loggerFactory.CreateLogger("SurfaceSecrets.Audit");
provider = new AuditingSurfaceSecretProvider(provider, timeProvider, auditLogger, options.ComponentName);
}
return provider;
});
return services;
@@ -63,6 +84,10 @@ public static class ServiceCollectionExtensions
return new KubernetesSurfaceSecretProvider(configuration, logger);
case "file":
return new FileSurfaceSecretProvider(configuration.Root ?? throw new ArgumentException("Secrets root is required for file provider."));
case "offline":
return new OfflineSurfaceSecretProvider(
configuration.Root ?? throw new ArgumentException("Secrets root is required for offline provider."),
logger);
case "inline":
return new InlineSurfaceSecretProvider(configuration);
default:

View File

@@ -14,4 +14,21 @@ public sealed class SurfaceSecretsOptions
/// Gets or sets the set of secret types that should be eagerly validated at startup.
/// </summary>
public ISet<string> RequiredSecretTypes { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets whether to enable in-memory caching of resolved secrets.
/// Default is true for performance.
/// </summary>
public bool EnableCaching { get; set; } = true;
/// <summary>
/// Gets or sets the cache TTL in seconds. Default is 600 (10 minutes).
/// </summary>
public int CacheTtlSeconds { get; set; } = 600;
/// <summary>
/// Gets or sets whether to enable audit logging of secret access.
/// Default is true for compliance and observability.
/// </summary>
public bool EnableAuditLogging { get; set; } = true;
}