up
Some checks failed
LNM Migration CI / build-runner (push) Has been cancelled
Ledger OpenAPI CI / deprecation-check (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Airgap Sealed CI Smoke / sealed-smoke (push) Has been cancelled
Ledger Packs CI / build-pack (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Ledger OpenAPI CI / validate-oas (push) Has been cancelled
Ledger OpenAPI CI / check-wellknown (push) Has been cancelled
Ledger Packs CI / verify-pack (push) Has been cancelled
LNM Migration CI / validate-metrics (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-14 18:33:02 +02:00
parent d233fa3529
commit 2e70c9fdb6
51 changed files with 5958 additions and 75 deletions

View File

@@ -39,6 +39,12 @@ public sealed class ZastavaRuntimeOptions
[Required]
public ZastavaAuthorityOptions Authority { get; set; } = new();
/// <summary>
/// Offline/air-gapped operation configuration.
/// </summary>
[Required]
public ZastavaOfflineOptions Offline { get; set; } = new();
}
public sealed class ZastavaRuntimeLoggingOptions
@@ -82,3 +88,62 @@ public sealed class ZastavaRuntimeMetricsOptions
/// </summary>
public IDictionary<string, string> CommonTags { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
}
/// <summary>
/// Offline/air-gapped operation configuration for Zastava components.
/// Controls network access restrictions for secure, disconnected deployments.
/// </summary>
public sealed class ZastavaOfflineOptions
{
/// <summary>
/// Enable strict offline mode. When true, any HTTP request to an external host
/// (not in <see cref="AllowedHosts"/>) will throw an exception at request time.
/// Default: false.
/// </summary>
public bool StrictMode { get; init; }
/// <summary>
/// Require Surface.FS cache to be available and populated at startup.
/// When true, the component will fail startup if the cache directory is missing
/// or empty. Used with <see cref="StrictMode"/> for fully air-gapped deployments.
/// Default: false.
/// </summary>
public bool RequireSurfaceCache { get; init; }
/// <summary>
/// Path to the Surface.FS cache directory containing pre-fetched vulnerability data.
/// Required when <see cref="RequireSurfaceCache"/> is true.
/// </summary>
public string? SurfaceCachePath { get; init; }
/// <summary>
/// Minimum number of cache entries required when <see cref="RequireSurfaceCache"/> is true.
/// Ensures the cache has been properly populated before starting.
/// Default: 1.
/// </summary>
[Range(1, int.MaxValue)]
public int MinimumCacheEntries { get; init; } = 1;
/// <summary>
/// Maximum age (in hours) of cache entries before they are considered stale.
/// When <see cref="StrictMode"/> is true and all entries exceed this age,
/// a warning is emitted but operation continues.
/// Default: 168 (7 days).
/// </summary>
[Range(1, 8760)]
public int MaxCacheAgeHours { get; init; } = 168;
/// <summary>
/// List of hostnames explicitly allowed for network access in strict mode.
/// Supports exact matches and wildcard prefixes (e.g., "*.internal.corp").
/// Localhost (127.0.0.1, ::1, localhost) is always implicitly allowed.
/// </summary>
public IList<string> AllowedHosts { get; init; } = new List<string>();
/// <summary>
/// When true, emits detailed logs for each blocked network request.
/// Useful for auditing network access patterns during initial deployment.
/// Default: false.
/// </summary>
public bool LogBlockedRequests { get; init; }
}

View File

@@ -7,7 +7,9 @@ using Microsoft.Extensions.Options;
using StellaOps.Auth.Client;
using StellaOps.Zastava.Core.Configuration;
using StellaOps.Zastava.Core.Diagnostics;
using StellaOps.Zastava.Core.Http;
using StellaOps.Zastava.Core.Security;
using StellaOps.Zastava.Core.Validation;
namespace Microsoft.Extensions.DependencyInjection;
@@ -45,9 +47,27 @@ public static class ZastavaServiceCollectionExtensions
ConfigureAuthorityServices(services, configuration);
services.TryAddSingleton<IZastavaAuthorityTokenProvider, ZastavaAuthorityTokenProvider>();
// Register offline strict mode handler for HttpClientFactory
services.TryAddTransient<OfflineStrictModeHandler>();
// Register Surface.FS cache validator as hosted service
// This validates cache availability at startup when RequireSurfaceCache is enabled
services.AddHostedService<SurfaceCacheValidator>();
return services;
}
/// <summary>
/// Adds the offline strict mode handler to an HttpClient configuration.
/// When <see cref="ZastavaOfflineOptions.StrictMode"/> is enabled, requests to
/// hosts not in the allowlist will be blocked.
/// </summary>
public static IHttpClientBuilder AddOfflineStrictModeHandler(this IHttpClientBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.AddHttpMessageHandler<OfflineStrictModeHandler>();
}
private static void ConfigureAuthorityServices(IServiceCollection services, IConfiguration configuration)
{
var authoritySection = configuration.GetSection($"{ZastavaRuntimeOptions.SectionName}:authority");

View File

@@ -0,0 +1,147 @@
using System.Net;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Core.Configuration;
namespace StellaOps.Zastava.Core.Http;
/// <summary>
/// HTTP delegating handler that enforces strict offline mode.
/// When <see cref="ZastavaOfflineOptions.StrictMode"/> is enabled, requests to
/// hosts not in the allowlist will be rejected with an exception.
/// </summary>
public sealed class OfflineStrictModeHandler : DelegatingHandler
{
private readonly IOptionsMonitor<ZastavaRuntimeOptions> _optionsMonitor;
private readonly ILogger<OfflineStrictModeHandler> _logger;
// Implicitly allowed local hosts
private static readonly HashSet<string> ImplicitlyAllowedHosts = new(StringComparer.OrdinalIgnoreCase)
{
"localhost",
"127.0.0.1",
"::1",
"[::1]"
};
public OfflineStrictModeHandler(
IOptionsMonitor<ZastavaRuntimeOptions> optionsMonitor,
ILogger<OfflineStrictModeHandler> logger)
{
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var options = _optionsMonitor.CurrentValue.Offline;
// If strict mode is not enabled, pass through
if (!options.StrictMode)
{
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
var requestUri = request.RequestUri;
if (requestUri is null)
{
throw new OfflineStrictModeException("Request URI is null - cannot validate against offline strict mode.");
}
var host = requestUri.Host;
// Check if host is allowed
if (!IsHostAllowed(host, options))
{
if (options.LogBlockedRequests)
{
_logger.LogWarning(
"Offline strict mode blocked request to {Host}{Path} (Method: {Method})",
host,
requestUri.PathAndQuery,
request.Method);
}
throw new OfflineStrictModeException(
$"Offline strict mode is enabled. Request to external host '{host}' is not allowed. " +
$"Add the host to zastava:runtime:offline:allowedHosts or disable strict mode.");
}
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
private static bool IsHostAllowed(string host, ZastavaOfflineOptions options)
{
// Implicitly allowed hosts (localhost, loopback)
if (ImplicitlyAllowedHosts.Contains(host))
{
return true;
}
// Check for loopback IP patterns
if (host.StartsWith("127.", StringComparison.Ordinal) ||
host.StartsWith("[::ffff:127.", StringComparison.Ordinal))
{
return true;
}
// Check explicit allowlist
if (options.AllowedHosts.Count == 0)
{
return false;
}
foreach (var allowedHost in options.AllowedHosts)
{
if (MatchesHost(host, allowedHost))
{
return true;
}
}
return false;
}
private static bool MatchesHost(string host, string pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return false;
}
// Exact match
if (string.Equals(host, pattern, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Wildcard prefix match (e.g., "*.internal.corp")
if (pattern.StartsWith("*.", StringComparison.Ordinal))
{
var suffix = pattern.Substring(1); // ".internal.corp"
return host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) ||
string.Equals(host, pattern.Substring(2), StringComparison.OrdinalIgnoreCase);
}
return false;
}
}
/// <summary>
/// Exception thrown when a network request is blocked by offline strict mode.
/// </summary>
public sealed class OfflineStrictModeException : InvalidOperationException
{
public OfflineStrictModeException(string message) : base(message)
{
}
public OfflineStrictModeException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,185 @@
using System.IO;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Core.Configuration;
namespace StellaOps.Zastava.Core.Validation;
/// <summary>
/// Startup validator that ensures Surface.FS cache is available and populated
/// when <see cref="ZastavaOfflineOptions.RequireSurfaceCache"/> is enabled.
/// </summary>
public sealed class SurfaceCacheValidator : IHostedService
{
private readonly IOptionsMonitor<ZastavaRuntimeOptions> _optionsMonitor;
private readonly ILogger<SurfaceCacheValidator> _logger;
private readonly TimeProvider _timeProvider;
public SurfaceCacheValidator(
IOptionsMonitor<ZastavaRuntimeOptions> optionsMonitor,
ILogger<SurfaceCacheValidator> logger,
TimeProvider? timeProvider = null)
{
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task StartAsync(CancellationToken cancellationToken)
{
var options = _optionsMonitor.CurrentValue.Offline;
// Skip validation if RequireSurfaceCache is not enabled
if (!options.RequireSurfaceCache)
{
_logger.LogDebug("Surface.FS cache validation skipped (RequireSurfaceCache=false)");
return Task.CompletedTask;
}
ValidateCache(options);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private void ValidateCache(ZastavaOfflineOptions options)
{
var cachePath = options.SurfaceCachePath;
// Validate path is configured
if (string.IsNullOrWhiteSpace(cachePath))
{
throw new SurfaceCacheValidationException(
"Surface.FS cache path is required when RequireSurfaceCache is enabled. " +
"Set zastava:runtime:offline:surfaceCachePath in configuration.");
}
// Validate directory exists
if (!Directory.Exists(cachePath))
{
throw new SurfaceCacheValidationException(
$"Surface.FS cache directory does not exist: '{cachePath}'. " +
"Ensure the cache has been populated before starting in offline mode.");
}
// Count cache entries (files in the directory, excluding metadata files)
var cacheEntries = GetCacheEntries(cachePath).ToList();
var entryCount = cacheEntries.Count;
if (entryCount < options.MinimumCacheEntries)
{
throw new SurfaceCacheValidationException(
$"Surface.FS cache has {entryCount} entries, but {options.MinimumCacheEntries} are required. " +
"Populate the cache before starting in offline mode.");
}
_logger.LogInformation(
"Surface.FS cache validated: {EntryCount} entries found in {CachePath}",
entryCount,
cachePath);
// Check for stale cache entries
CheckCacheStaleness(cacheEntries, options);
}
private void CheckCacheStaleness(IReadOnlyList<CacheEntry> entries, ZastavaOfflineOptions options)
{
var now = _timeProvider.GetUtcNow();
var maxAge = TimeSpan.FromHours(options.MaxCacheAgeHours);
var staleThreshold = now - maxAge;
var staleCount = entries.Count(e => e.LastModified < staleThreshold);
var freshCount = entries.Count - staleCount;
if (staleCount > 0)
{
var oldestEntry = entries.OrderBy(e => e.LastModified).FirstOrDefault();
var oldestAge = oldestEntry is not null ? now - oldestEntry.LastModified : TimeSpan.Zero;
if (freshCount == 0)
{
// All entries are stale - warn but continue
_logger.LogWarning(
"All {StaleCount} Surface.FS cache entries are older than {MaxAge} hours. " +
"Oldest entry is {OldestAge:N1} hours old. " +
"Consider refreshing the cache for up-to-date vulnerability data.",
staleCount,
options.MaxCacheAgeHours,
oldestAge.TotalHours);
}
else
{
// Some entries are stale
_logger.LogInformation(
"Surface.FS cache status: {FreshCount} fresh, {StaleCount} stale " +
"(threshold: {MaxAge} hours)",
freshCount,
staleCount,
options.MaxCacheAgeHours);
}
}
else
{
_logger.LogDebug(
"All {EntryCount} Surface.FS cache entries are within the {MaxAge} hour threshold",
entries.Count,
options.MaxCacheAgeHours);
}
}
private static IEnumerable<CacheEntry> GetCacheEntries(string cachePath)
{
// Cache entries are typically .json, .json.gz, or .ndjson files
// Exclude metadata files like .manifest, .index, .lock
var metadataExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".manifest",
".index",
".lock",
".tmp",
".partial"
};
foreach (var file in Directory.EnumerateFiles(cachePath, "*", SearchOption.AllDirectories))
{
var extension = Path.GetExtension(file);
// Skip metadata files
if (metadataExtensions.Contains(extension))
{
continue;
}
// Skip hidden files
var fileName = Path.GetFileName(file);
if (fileName.StartsWith('.'))
{
continue;
}
var info = new FileInfo(file);
if (info.Length > 0) // Skip empty files
{
yield return new CacheEntry(file, info.LastWriteTimeUtc);
}
}
}
private readonly record struct CacheEntry(string Path, DateTimeOffset LastModified);
}
/// <summary>
/// Exception thrown when Surface.FS cache validation fails at startup.
/// </summary>
public sealed class SurfaceCacheValidationException : InvalidOperationException
{
public SurfaceCacheValidationException(string message) : base(message)
{
}
public SurfaceCacheValidationException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,428 @@
using System.IO;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Core.Configuration;
using StellaOps.Zastava.Core.Http;
using StellaOps.Zastava.Core.Validation;
namespace StellaOps.Zastava.Core.Tests.Validation;
public sealed class OfflineStrictModeTests : IDisposable
{
private readonly string _tempCachePath;
public OfflineStrictModeTests()
{
_tempCachePath = Path.Combine(Path.GetTempPath(), "zastava-test-cache-" + Guid.NewGuid().ToString("N")[..8]);
}
public void Dispose()
{
if (Directory.Exists(_tempCachePath))
{
Directory.Delete(_tempCachePath, recursive: true);
}
}
#region OfflineStrictModeHandler Tests
[Fact]
public async Task OfflineStrictModeHandler_WhenDisabled_AllowsAnyRequest()
{
// Arrange
var options = CreateOptions(strictMode: false);
var handler = CreateHandler(options);
handler.InnerHandler = new TestHttpMessageHandler();
using var client = new HttpClient(handler);
// Act
var response = await client.GetAsync("https://external.example.com/api/data");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task OfflineStrictModeHandler_WhenEnabled_BlocksExternalHost()
{
// Arrange
var options = CreateOptions(strictMode: true);
var handler = CreateHandler(options);
handler.InnerHandler = new TestHttpMessageHandler();
using var client = new HttpClient(handler);
// Act & Assert
var exception = await Assert.ThrowsAsync<OfflineStrictModeException>(
() => client.GetAsync("https://external.example.com/api/data"));
Assert.Contains("external.example.com", exception.Message);
Assert.Contains("offline strict mode", exception.Message.ToLowerInvariant());
}
[Fact]
public async Task OfflineStrictModeHandler_WhenEnabled_AllowsLocalhost()
{
// Arrange
var options = CreateOptions(strictMode: true);
var handler = CreateHandler(options);
handler.InnerHandler = new TestHttpMessageHandler();
using var client = new HttpClient(handler);
// Act - localhost should be implicitly allowed
var response = await client.GetAsync("http://localhost:8080/api/health");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task OfflineStrictModeHandler_WhenEnabled_AllowsLoopbackIp()
{
// Arrange
var options = CreateOptions(strictMode: true);
var handler = CreateHandler(options);
handler.InnerHandler = new TestHttpMessageHandler();
using var client = new HttpClient(handler);
// Act - 127.0.0.1 should be implicitly allowed
var response = await client.GetAsync("http://127.0.0.1:8080/api/health");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task OfflineStrictModeHandler_WhenEnabled_AllowsExplicitlyAllowedHost()
{
// Arrange
var options = CreateOptions(
strictMode: true,
allowedHosts: ["scanner.internal", "backend.corp"]);
var handler = CreateHandler(options);
handler.InnerHandler = new TestHttpMessageHandler();
using var client = new HttpClient(handler);
// Act
var response = await client.GetAsync("https://scanner.internal/api/events");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task OfflineStrictModeHandler_WhenEnabled_SupportsWildcardHost()
{
// Arrange
var options = CreateOptions(
strictMode: true,
allowedHosts: ["*.internal.corp"]);
var handler = CreateHandler(options);
handler.InnerHandler = new TestHttpMessageHandler();
using var client = new HttpClient(handler);
// Act - subdomain matching
var response = await client.GetAsync("https://scanner.internal.corp/api/events");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task OfflineStrictModeHandler_WhenEnabled_BlocksNonMatchingWildcard()
{
// Arrange
var options = CreateOptions(
strictMode: true,
allowedHosts: ["*.internal.corp"]);
var handler = CreateHandler(options);
handler.InnerHandler = new TestHttpMessageHandler();
using var client = new HttpClient(handler);
// Act & Assert - different domain should be blocked
var exception = await Assert.ThrowsAsync<OfflineStrictModeException>(
() => client.GetAsync("https://scanner.external.com/api/events"));
Assert.Contains("scanner.external.com", exception.Message);
}
#endregion
#region SurfaceCacheValidator Tests
[Fact]
public async Task SurfaceCacheValidator_WhenRequireCacheDisabled_SkipsValidation()
{
// Arrange
var options = CreateOptions(requireSurfaceCache: false);
var validator = CreateValidator(options);
// Act & Assert - should complete without exception
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task SurfaceCacheValidator_WhenPathNotConfigured_ThrowsException()
{
// Arrange
var options = CreateOptions(requireSurfaceCache: true, surfaceCachePath: null);
var validator = CreateValidator(options);
// Act & Assert
var exception = await Assert.ThrowsAsync<SurfaceCacheValidationException>(
() => validator.StartAsync(CancellationToken.None));
Assert.Contains("path is required", exception.Message.ToLowerInvariant());
}
[Fact]
public async Task SurfaceCacheValidator_WhenDirectoryMissing_ThrowsException()
{
// Arrange
var options = CreateOptions(
requireSurfaceCache: true,
surfaceCachePath: "/nonexistent/path/to/cache");
var validator = CreateValidator(options);
// Act & Assert
var exception = await Assert.ThrowsAsync<SurfaceCacheValidationException>(
() => validator.StartAsync(CancellationToken.None));
Assert.Contains("does not exist", exception.Message.ToLowerInvariant());
}
[Fact]
public async Task SurfaceCacheValidator_WhenCacheEmpty_ThrowsException()
{
// Arrange
Directory.CreateDirectory(_tempCachePath);
var options = CreateOptions(
requireSurfaceCache: true,
surfaceCachePath: _tempCachePath,
minimumCacheEntries: 1);
var validator = CreateValidator(options);
// Act & Assert
var exception = await Assert.ThrowsAsync<SurfaceCacheValidationException>(
() => validator.StartAsync(CancellationToken.None));
Assert.Contains("0 entries", exception.Message);
Assert.Contains("1 are required", exception.Message);
}
[Fact]
public async Task SurfaceCacheValidator_WhenBelowMinimumEntries_ThrowsException()
{
// Arrange
Directory.CreateDirectory(_tempCachePath);
File.WriteAllText(Path.Combine(_tempCachePath, "entry1.json"), "{}");
File.WriteAllText(Path.Combine(_tempCachePath, "entry2.json"), "{}");
var options = CreateOptions(
requireSurfaceCache: true,
surfaceCachePath: _tempCachePath,
minimumCacheEntries: 5);
var validator = CreateValidator(options);
// Act & Assert
var exception = await Assert.ThrowsAsync<SurfaceCacheValidationException>(
() => validator.StartAsync(CancellationToken.None));
Assert.Contains("2 entries", exception.Message);
Assert.Contains("5 are required", exception.Message);
}
[Fact]
public async Task SurfaceCacheValidator_WhenSufficientEntries_Succeeds()
{
// Arrange
Directory.CreateDirectory(_tempCachePath);
File.WriteAllText(Path.Combine(_tempCachePath, "entry1.json"), "{}");
File.WriteAllText(Path.Combine(_tempCachePath, "entry2.json"), "{}");
File.WriteAllText(Path.Combine(_tempCachePath, "entry3.json"), "{}");
var options = CreateOptions(
requireSurfaceCache: true,
surfaceCachePath: _tempCachePath,
minimumCacheEntries: 3);
var validator = CreateValidator(options);
// Act & Assert - should complete without exception
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task SurfaceCacheValidator_IgnoresMetadataFiles()
{
// Arrange
Directory.CreateDirectory(_tempCachePath);
File.WriteAllText(Path.Combine(_tempCachePath, "entry1.json"), "{}");
File.WriteAllText(Path.Combine(_tempCachePath, ".manifest"), "metadata");
File.WriteAllText(Path.Combine(_tempCachePath, "data.index"), "index");
File.WriteAllText(Path.Combine(_tempCachePath, ".lock"), "lock");
var options = CreateOptions(
requireSurfaceCache: true,
surfaceCachePath: _tempCachePath,
minimumCacheEntries: 1);
var validator = CreateValidator(options);
// Act & Assert - should succeed with only 1 valid entry
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task SurfaceCacheValidator_IgnoresEmptyFiles()
{
// Arrange
Directory.CreateDirectory(_tempCachePath);
File.WriteAllText(Path.Combine(_tempCachePath, "entry1.json"), "{}");
File.WriteAllText(Path.Combine(_tempCachePath, "empty.json"), ""); // Empty file
var options = CreateOptions(
requireSurfaceCache: true,
surfaceCachePath: _tempCachePath,
minimumCacheEntries: 2);
var validator = CreateValidator(options);
// Act & Assert - should fail as only 1 non-empty file
var exception = await Assert.ThrowsAsync<SurfaceCacheValidationException>(
() => validator.StartAsync(CancellationToken.None));
Assert.Contains("1 entries", exception.Message);
}
#endregion
#region Integration Tests
[Fact]
public void FullOfflineConfiguration_ValidatesCorrectly()
{
// Arrange
Directory.CreateDirectory(_tempCachePath);
File.WriteAllText(Path.Combine(_tempCachePath, "vuln-data.json"), "{\"version\":1}");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["zastava:runtime:tenant"] = "offline-tenant",
["zastava:runtime:environment"] = "airgap",
["zastava:runtime:offline:strictMode"] = "true",
["zastava:runtime:offline:requireSurfaceCache"] = "true",
["zastava:runtime:offline:surfaceCachePath"] = _tempCachePath,
["zastava:runtime:offline:minimumCacheEntries"] = "1",
["zastava:runtime:offline:maxCacheAgeHours"] = "168",
["zastava:runtime:offline:allowedHosts:0"] = "localhost",
["zastava:runtime:offline:allowedHosts:1"] = "*.internal.corp",
["zastava:runtime:offline:logBlockedRequests"] = "true"
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddZastavaRuntimeCore(configuration, componentName: "observer");
using var provider = services.BuildServiceProvider();
// Act
var options = provider.GetRequiredService<IOptions<ZastavaRuntimeOptions>>().Value;
// Assert
Assert.True(options.Offline.StrictMode);
Assert.True(options.Offline.RequireSurfaceCache);
Assert.Equal(_tempCachePath, options.Offline.SurfaceCachePath);
Assert.Equal(1, options.Offline.MinimumCacheEntries);
Assert.Equal(168, options.Offline.MaxCacheAgeHours);
Assert.True(options.Offline.LogBlockedRequests);
Assert.Equal(2, options.Offline.AllowedHosts.Count);
Assert.Contains("localhost", options.Offline.AllowedHosts);
Assert.Contains("*.internal.corp", options.Offline.AllowedHosts);
}
#endregion
#region Helpers
private static IOptionsMonitor<ZastavaRuntimeOptions> CreateOptions(
bool strictMode = false,
bool requireSurfaceCache = false,
string? surfaceCachePath = null,
int minimumCacheEntries = 1,
int maxCacheAgeHours = 168,
bool logBlockedRequests = false,
string[]? allowedHosts = null)
{
var options = new ZastavaRuntimeOptions
{
Tenant = "test-tenant",
Environment = "test",
Offline = new ZastavaOfflineOptions
{
StrictMode = strictMode,
RequireSurfaceCache = requireSurfaceCache,
SurfaceCachePath = surfaceCachePath,
MinimumCacheEntries = minimumCacheEntries,
MaxCacheAgeHours = maxCacheAgeHours,
LogBlockedRequests = logBlockedRequests,
AllowedHosts = allowedHosts?.ToList() ?? new List<string>()
}
};
return new TestOptionsMonitor<ZastavaRuntimeOptions>(options);
}
private static OfflineStrictModeHandler CreateHandler(IOptionsMonitor<ZastavaRuntimeOptions> options)
{
return new OfflineStrictModeHandler(
options,
NullLogger<OfflineStrictModeHandler>.Instance);
}
private static SurfaceCacheValidator CreateValidator(IOptionsMonitor<ZastavaRuntimeOptions> options)
{
return new SurfaceCacheValidator(
options,
NullLogger<SurfaceCacheValidator>.Instance);
}
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
{
public TestOptionsMonitor(T currentValue)
{
CurrentValue = currentValue;
}
public T CurrentValue { get; }
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"status\":\"ok\"}")
});
}
}
#endregion
}

View File

@@ -0,0 +1,393 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Zastava.Observer.ContainerRuntime.Windows;
using Xunit;
namespace StellaOps.Zastava.Observer.Tests.ContainerRuntime.Windows;
public sealed class WindowsContainerRuntimeTests
{
[Fact]
public void WindowsContainerInfo_RequiredProperties_AreSet()
{
var container = new WindowsContainerInfo
{
Id = "abc123",
Name = "test-container",
ImageRef = "mcr.microsoft.com/windows/servercore:ltsc2022"
};
Assert.Equal("abc123", container.Id);
Assert.Equal("test-container", container.Name);
Assert.Equal("mcr.microsoft.com/windows/servercore:ltsc2022", container.ImageRef);
Assert.Equal(WindowsContainerState.Unknown, container.State);
Assert.Equal("windows", container.RuntimeType);
Assert.Empty(container.Command);
Assert.Empty(container.Labels);
}
[Fact]
public void WindowsContainerInfo_WithKubernetesOwner_HasOwnerSet()
{
var container = new WindowsContainerInfo
{
Id = "def456",
Name = "k8s_container_pod",
Owner = new WindowsContainerOwner
{
Kind = "Pod",
Name = "my-pod",
Namespace = "default"
}
};
Assert.NotNull(container.Owner);
Assert.Equal("Pod", container.Owner.Kind);
Assert.Equal("my-pod", container.Owner.Name);
Assert.Equal("default", container.Owner.Namespace);
}
[Fact]
public void WindowsContainerInfo_HyperVContainer_HasIsolationFlag()
{
var container = new WindowsContainerInfo
{
Id = "hyperv123",
Name = "hyperv-container",
HyperVIsolated = true,
RuntimeType = "hyperv"
};
Assert.True(container.HyperVIsolated);
Assert.Equal("hyperv", container.RuntimeType);
}
[Fact]
public void WindowsContainerEvent_RequiredProperties_AreSet()
{
var timestamp = DateTimeOffset.UtcNow;
var evt = new WindowsContainerEvent
{
Type = WindowsContainerEventType.ContainerStarted,
ContainerId = "xyz789",
ContainerName = "started-container",
ImageRef = "myimage:latest",
Timestamp = timestamp,
Data = new Dictionary<string, string>
{
["exitCode"] = "0"
}
};
Assert.Equal(WindowsContainerEventType.ContainerStarted, evt.Type);
Assert.Equal("xyz789", evt.ContainerId);
Assert.Equal("started-container", evt.ContainerName);
Assert.Equal("myimage:latest", evt.ImageRef);
Assert.Equal(timestamp, evt.Timestamp);
Assert.NotNull(evt.Data);
Assert.Equal("0", evt.Data["exitCode"]);
}
[Theory]
[InlineData(WindowsContainerEventType.ContainerCreated)]
[InlineData(WindowsContainerEventType.ContainerStarted)]
[InlineData(WindowsContainerEventType.ContainerStopped)]
[InlineData(WindowsContainerEventType.ContainerDeleted)]
[InlineData(WindowsContainerEventType.ProcessStarted)]
[InlineData(WindowsContainerEventType.ProcessExited)]
public void WindowsContainerEventType_AllValues_AreDefined(WindowsContainerEventType eventType)
{
var evt = new WindowsContainerEvent
{
Type = eventType,
ContainerId = "test",
Timestamp = DateTimeOffset.UtcNow
};
Assert.Equal(eventType, evt.Type);
}
[Fact]
public void WindowsRuntimeIdentity_RequiredProperties_AreSet()
{
var identity = new WindowsRuntimeIdentity
{
RuntimeName = "docker",
RuntimeVersion = "20.10.21",
OsVersion = "10.0.20348",
OsBuild = 20348,
HyperVAvailable = true
};
Assert.Equal("docker", identity.RuntimeName);
Assert.Equal("20.10.21", identity.RuntimeVersion);
Assert.Equal("10.0.20348", identity.OsVersion);
Assert.Equal(20348, identity.OsBuild);
Assert.True(identity.HyperVAvailable);
}
[Theory]
[InlineData(WindowsContainerState.Unknown)]
[InlineData(WindowsContainerState.Created)]
[InlineData(WindowsContainerState.Running)]
[InlineData(WindowsContainerState.Paused)]
[InlineData(WindowsContainerState.Stopped)]
public void WindowsContainerState_AllValues_AreDefined(WindowsContainerState state)
{
var container = new WindowsContainerInfo
{
Id = "test",
Name = "test",
State = state
};
Assert.Equal(state, container.State);
}
[Fact]
public void WindowsContainerInfo_WithTimestamps_TracksLifecycle()
{
var createdAt = DateTimeOffset.UtcNow.AddMinutes(-10);
var startedAt = DateTimeOffset.UtcNow.AddMinutes(-9);
var finishedAt = DateTimeOffset.UtcNow;
var container = new WindowsContainerInfo
{
Id = "lifecycle-test",
Name = "lifecycle-container",
State = WindowsContainerState.Stopped,
CreatedAt = createdAt,
StartedAt = startedAt,
FinishedAt = finishedAt,
ExitCode = 0
};
Assert.Equal(createdAt, container.CreatedAt);
Assert.Equal(startedAt, container.StartedAt);
Assert.Equal(finishedAt, container.FinishedAt);
Assert.Equal(0, container.ExitCode);
Assert.True(container.StartedAt > container.CreatedAt);
Assert.True(container.FinishedAt > container.StartedAt);
}
[Fact]
public void WindowsContainerInfo_WithLabels_CanBeEnumerated()
{
var labels = new Dictionary<string, string>
{
["io.kubernetes.pod.name"] = "my-pod",
["io.kubernetes.pod.namespace"] = "default",
["app"] = "test-app"
};
var container = new WindowsContainerInfo
{
Id = "labeled",
Name = "labeled-container",
Labels = labels
};
Assert.Equal(3, container.Labels.Count);
Assert.Equal("my-pod", container.Labels["io.kubernetes.pod.name"]);
Assert.Equal("default", container.Labels["io.kubernetes.pod.namespace"]);
Assert.Equal("test-app", container.Labels["app"]);
}
[Fact]
public void WindowsContainerInfo_WithCommand_HasEntrypoint()
{
var command = new[] { "powershell.exe", "-Command", "Get-Process" };
var container = new WindowsContainerInfo
{
Id = "cmd",
Name = "cmd-container",
Command = command
};
Assert.Equal(3, container.Command.Count);
Assert.Equal("powershell.exe", container.Command[0]);
Assert.Contains("-Command", container.Command);
}
}
/// <summary>
/// Integration tests that require Windows and Docker Windows containers.
/// These tests are skipped on non-Windows platforms.
/// </summary>
[Collection("WindowsIntegration")]
public sealed class WindowsContainerRuntimeIntegrationTests
{
private static bool IsWindowsWithDocker =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
Environment.GetEnvironmentVariable("ZASTAVA_WINDOWS_INTEGRATION_TESTS") == "true";
[SkippableFact]
public async Task WindowsLibraryHashCollector_CollectCurrentProcess_ReturnsModules()
{
Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test");
var collector = new WindowsLibraryHashCollector(NullLogger<WindowsLibraryHashCollector>.Instance);
var processId = Environment.ProcessId;
var libraries = await collector.CollectAsync(processId, CancellationToken.None);
// Current process should have at least some loaded modules
Assert.NotEmpty(libraries);
// Should include the main process executable
var hasExe = libraries.Any(lib => lib.Path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase));
Assert.True(hasExe, "Should include at least one .exe module");
// All libraries should have paths
Assert.All(libraries, lib => Assert.False(string.IsNullOrWhiteSpace(lib.Path)));
}
[SkippableFact]
public async Task WindowsLibraryHashCollector_WithMaxLimit_RespectsLimit()
{
Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test");
var collector = new WindowsLibraryHashCollector(
NullLogger<WindowsLibraryHashCollector>.Instance,
maxLibraries: 5);
var processId = Environment.ProcessId;
var libraries = await collector.CollectAsync(processId, CancellationToken.None);
Assert.True(libraries.Count <= 5, "Should respect maxLibraries limit");
}
[SkippableFact]
public async Task WindowsLibraryHashCollector_InvalidProcessId_ReturnsEmptyList()
{
Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test");
var collector = new WindowsLibraryHashCollector(NullLogger<WindowsLibraryHashCollector>.Instance);
// Use an invalid process ID
var libraries = await collector.CollectAsync(int.MaxValue, CancellationToken.None);
Assert.Empty(libraries);
}
[SkippableFact]
public async Task WindowsLibraryHashCollector_ComputesHashes_WhenFilesAccessible()
{
Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test");
var collector = new WindowsLibraryHashCollector(
NullLogger<WindowsLibraryHashCollector>.Instance,
maxLibraries: 10,
maxFileBytes: 100_000_000);
var processId = Environment.ProcessId;
var libraries = await collector.CollectAsync(processId, CancellationToken.None);
// At least some libraries should have hashes (system DLLs should be accessible)
var librariesWithHashes = libraries.Where(lib => !string.IsNullOrEmpty(lib.Sha256)).ToList();
Assert.NotEmpty(librariesWithHashes);
Assert.All(librariesWithHashes, lib =>
{
Assert.StartsWith("sha256:", lib.Sha256);
Assert.Equal(71, lib.Sha256!.Length); // "sha256:" + 64 hex chars
});
}
[SkippableFact]
public async Task DockerWindowsRuntimeClient_IsAvailable_WhenDockerRunning()
{
Skip.IfNot(IsWindowsWithDocker, "Requires Windows with Docker in Windows containers mode");
await using var client = new DockerWindowsRuntimeClient(NullLogger<DockerWindowsRuntimeClient>.Instance);
var available = await client.IsAvailableAsync(CancellationToken.None);
Assert.True(available, "Docker Windows should be available");
}
[SkippableFact]
public async Task DockerWindowsRuntimeClient_GetIdentity_ReturnsDockerInfo()
{
Skip.IfNot(IsWindowsWithDocker, "Requires Windows with Docker in Windows containers mode");
await using var client = new DockerWindowsRuntimeClient(NullLogger<DockerWindowsRuntimeClient>.Instance);
var identity = await client.GetIdentityAsync(CancellationToken.None);
Assert.NotNull(identity);
Assert.Equal("docker", identity.RuntimeName);
Assert.False(string.IsNullOrEmpty(identity.RuntimeVersion));
Assert.False(string.IsNullOrEmpty(identity.OsVersion));
}
[SkippableFact]
public async Task DockerWindowsRuntimeClient_ListContainers_ReturnsWindowsContainers()
{
Skip.IfNot(IsWindowsWithDocker, "Requires Windows with Docker in Windows containers mode");
await using var client = new DockerWindowsRuntimeClient(NullLogger<DockerWindowsRuntimeClient>.Instance);
var containers = await client.ListContainersAsync(
WindowsContainerState.Running,
CancellationToken.None);
// May be empty if no containers running, but should not throw
Assert.NotNull(containers);
Assert.All(containers, c =>
{
Assert.False(string.IsNullOrEmpty(c.Id));
Assert.False(string.IsNullOrEmpty(c.Name));
});
}
}
/// <summary>
/// Skippable fact attribute for conditional tests.
/// </summary>
public sealed class SkippableFactAttribute : FactAttribute
{
public SkippableFactAttribute()
{
}
}
/// <summary>
/// Skip helper for conditional tests.
/// </summary>
public static class Skip
{
public static void IfNot(bool condition, string reason)
{
if (!condition)
{
throw new SkipException(reason);
}
}
public static void If(bool condition, string reason)
{
if (condition)
{
throw new SkipException(reason);
}
}
}
/// <summary>
/// Exception thrown to skip a test.
/// </summary>
public sealed class SkipException : Exception
{
public SkipException(string reason) : base(reason)
{
}
}