Refactor SurfaceCacheValidator to simplify oldest entry calculation

Add global using for Xunit in test project

Enhance ImportValidatorTests with async validation and quarantine checks

Implement FileSystemQuarantineServiceTests for quarantine functionality

Add integration tests for ImportValidator to check monotonicity

Create BundleVersionTests to validate version parsing and comparison logic

Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
This commit is contained in:
master
2025-12-16 10:44:00 +02:00
parent b1f40945b7
commit 4391f35d8a
107 changed files with 10844 additions and 287 deletions

View File

@@ -0,0 +1,120 @@
namespace StellaOps.ExportCenter.Core.EvidenceCache;
/// <summary>
/// Manifest for local evidence cache.
/// </summary>
public sealed class CacheManifest
{
/// <summary>
/// Cache schema version.
/// </summary>
public string SchemaVersion { get; init; } = "1.0";
/// <summary>
/// When cache was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Last time cache was updated.
/// </summary>
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Scan artifact digest this cache is for.
/// </summary>
public required string ScanDigest { get; init; }
/// <summary>
/// Cached evidence bundle entries.
/// </summary>
public required IReadOnlyList<CacheEntry> Entries { get; init; }
/// <summary>
/// Deferred enrichment count.
/// </summary>
public int PendingEnrichmentCount { get; init; }
/// <summary>
/// Cache statistics.
/// </summary>
public CacheStatistics Statistics { get; init; } = new();
}
/// <summary>
/// Individual entry in the cache.
/// </summary>
public sealed class CacheEntry
{
/// <summary>
/// Alert ID this entry is for.
/// </summary>
public required string AlertId { get; init; }
/// <summary>
/// Relative path to cached bundle.
/// </summary>
public required string BundlePath { get; init; }
/// <summary>
/// Content hash of bundle.
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// Evidence status summary.
/// </summary>
public required CachedEvidenceStatus Status { get; init; }
/// <summary>
/// When entry was cached.
/// </summary>
public required DateTimeOffset CachedAt { get; init; }
/// <summary>
/// Whether bundle is signed.
/// </summary>
public bool IsSigned { get; init; }
}
/// <summary>
/// Status of cached evidence components.
/// </summary>
public sealed class CachedEvidenceStatus
{
public EvidenceCacheState Reachability { get; init; }
public EvidenceCacheState CallStack { get; init; }
public EvidenceCacheState Provenance { get; init; }
public EvidenceCacheState VexStatus { get; init; }
}
/// <summary>
/// State of evidence in cache.
/// </summary>
public enum EvidenceCacheState
{
/// <summary>Evidence available locally.</summary>
Available,
/// <summary>Evidence pending network enrichment.</summary>
PendingEnrichment,
/// <summary>Evidence not available, enrichment queued.</summary>
Queued,
/// <summary>Evidence unavailable (missing inputs).</summary>
Unavailable
}
/// <summary>
/// Statistics about the evidence cache.
/// </summary>
public sealed class CacheStatistics
{
public int TotalBundles { get; init; }
public int FullyAvailable { get; init; }
public int PartiallyAvailable { get; init; }
public int PendingEnrichment { get; init; }
public double OfflineResolvablePercentage { get; init; }
public long TotalSizeBytes { get; init; }
}

View File

@@ -0,0 +1,124 @@
namespace StellaOps.ExportCenter.Core.EvidenceCache;
/// <summary>
/// Result of caching an evidence bundle.
/// </summary>
public sealed class CacheResult
{
public bool Success { get; init; }
public string? BundlePath { get; init; }
public DateTimeOffset CachedAt { get; init; }
public int PendingEnrichmentCount { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Cached evidence bundle with verification status.
/// </summary>
public sealed class CachedEvidence
{
/// <summary>
/// The evidence bundle.
/// </summary>
public required CachedEvidenceBundle Bundle { get; init; }
/// <summary>
/// Path to the cached bundle file.
/// </summary>
public required string BundlePath { get; init; }
/// <summary>
/// Whether the signature is valid.
/// </summary>
public bool SignatureValid { get; init; }
/// <summary>
/// Verification status string.
/// </summary>
public string? VerificationStatus { get; init; }
/// <summary>
/// When the bundle was cached.
/// </summary>
public DateTimeOffset CachedAt { get; init; }
}
/// <summary>
/// Evidence bundle for caching.
/// </summary>
public sealed class CachedEvidenceBundle
{
public required string AlertId { get; init; }
public required string ArtifactId { get; init; }
public CachedEvidenceSection? Reachability { get; init; }
public CachedEvidenceSection? CallStack { get; init; }
public CachedEvidenceSection? Provenance { get; init; }
public CachedEvidenceSection? VexStatus { get; init; }
public CachedEvidenceHashes? Hashes { get; init; }
public DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Evidence section for caching.
/// </summary>
public sealed class CachedEvidenceSection
{
public string? Status { get; init; }
public string? Hash { get; init; }
public object? Proof { get; init; }
public string? UnavailableReason { get; init; }
}
/// <summary>
/// Hashes for evidence bundle.
/// </summary>
public sealed class CachedEvidenceHashes
{
public string? CombinedHash { get; init; }
public IReadOnlyList<string>? Hashes { get; init; }
}
/// <summary>
/// Request to enrich missing evidence.
/// </summary>
public sealed record EnrichmentRequest
{
public required string AlertId { get; init; }
public required string ArtifactId { get; init; }
public required string EvidenceType { get; init; }
public string? Reason { get; init; }
public DateTimeOffset QueuedAt { get; init; }
public int AttemptCount { get; init; }
}
/// <summary>
/// Queue of enrichment requests.
/// </summary>
public sealed class EnrichmentQueue
{
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public List<EnrichmentRequest> Requests { get; set; } = new();
}
/// <summary>
/// Result of processing enrichment queue.
/// </summary>
public sealed class EnrichmentResult
{
public int ProcessedCount { get; init; }
public int FailedCount { get; init; }
public int RemainingCount { get; init; }
}
/// <summary>
/// Evidence status values.
/// </summary>
public static class EvidenceStatus
{
public const string Available = "available";
public const string PendingEnrichment = "pending_enrichment";
public const string Unavailable = "unavailable";
public const string Loading = "loading";
public const string Error = "error";
}

View File

@@ -0,0 +1,62 @@
namespace StellaOps.ExportCenter.Core.EvidenceCache;
/// <summary>
/// Service for managing local evidence cache.
/// </summary>
public interface IEvidenceCacheService
{
/// <summary>
/// Caches evidence bundle for offline access.
/// </summary>
/// <param name="scanOutputPath">Path to scan output directory.</param>
/// <param name="bundle">Evidence bundle to cache.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of caching operation.</returns>
Task<CacheResult> CacheEvidenceAsync(
string scanOutputPath,
CachedEvidenceBundle bundle,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves cached evidence for an alert.
/// </summary>
/// <param name="scanOutputPath">Path to scan output directory.</param>
/// <param name="alertId">Alert identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Cached evidence if found.</returns>
Task<CachedEvidence?> GetCachedEvidenceAsync(
string scanOutputPath,
string alertId,
CancellationToken cancellationToken = default);
/// <summary>
/// Queues deferred enrichment for missing evidence.
/// </summary>
/// <param name="scanOutputPath">Path to scan output directory.</param>
/// <param name="request">Enrichment request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task QueueEnrichmentAsync(
string scanOutputPath,
EnrichmentRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Processes deferred enrichment queue (when network available).
/// </summary>
/// <param name="scanOutputPath">Path to scan output directory.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of enrichment processing.</returns>
Task<EnrichmentResult> ProcessEnrichmentQueueAsync(
string scanOutputPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets cache statistics for a scan output.
/// </summary>
/// <param name="scanOutputPath">Path to scan output directory.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Cache statistics.</returns>
Task<CacheStatistics> GetStatisticsAsync(
string scanOutputPath,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,496 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.ExportCenter.Core.EvidenceCache;
/// <summary>
/// Implements local evidence caching alongside scan artifacts.
/// </summary>
public sealed class LocalEvidenceCacheService : IEvidenceCacheService
{
private const string EvidenceDir = ".evidence";
private const string ManifestFile = "manifest.json";
private const string EnrichmentQueueFile = "enrichment_queue.json";
private readonly TimeProvider _timeProvider;
private readonly ILogger<LocalEvidenceCacheService> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public LocalEvidenceCacheService(
TimeProvider timeProvider,
ILogger<LocalEvidenceCacheService> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = true
};
}
/// <summary>
/// Caches evidence bundle for offline access.
/// </summary>
public async Task<CacheResult> CacheEvidenceAsync(
string scanOutputPath,
CachedEvidenceBundle bundle,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanOutputPath);
ArgumentNullException.ThrowIfNull(bundle);
try
{
var cacheDir = EnsureCacheDirectory(scanOutputPath);
var bundlesDir = Path.Combine(cacheDir, "bundles");
Directory.CreateDirectory(bundlesDir);
// Serialize bundle
var bundleJson = JsonSerializer.Serialize(bundle, _jsonOptions);
var bundleBytes = Encoding.UTF8.GetBytes(bundleJson);
var contentHash = ComputeHash(bundleBytes);
// Write bundle file
var bundlePath = Path.Combine(bundlesDir, $"{bundle.AlertId}.evidence.json");
await File.WriteAllBytesAsync(bundlePath, bundleBytes, cancellationToken);
// Cache individual proofs
if (bundle.Reachability?.Hash is not null && bundle.Reachability.Proof is not null)
{
await CacheProofAsync(cacheDir, "reachability", bundle.Reachability.Hash, bundle.Reachability.Proof, cancellationToken);
}
if (bundle.CallStack?.Hash is not null && bundle.CallStack.Proof is not null)
{
await CacheProofAsync(cacheDir, "callstacks", bundle.CallStack.Hash, bundle.CallStack.Proof, cancellationToken);
}
// Queue enrichment for missing evidence
var enrichmentRequests = IdentifyMissingEvidence(bundle);
foreach (var request in enrichmentRequests)
{
await QueueEnrichmentAsync(scanOutputPath, request, cancellationToken);
}
// Update manifest
await UpdateManifestAsync(scanOutputPath, bundle, bundlePath, contentHash, cancellationToken);
_logger.LogInformation(
"Cached evidence for alert {AlertId} at {Path}, {MissingCount} items queued for enrichment",
bundle.AlertId, bundlePath, enrichmentRequests.Count);
return new CacheResult
{
Success = true,
BundlePath = bundlePath,
CachedAt = _timeProvider.GetUtcNow(),
PendingEnrichmentCount = enrichmentRequests.Count
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to cache evidence for alert {AlertId}", bundle.AlertId);
return new CacheResult
{
Success = false,
Error = ex.Message,
CachedAt = _timeProvider.GetUtcNow()
};
}
}
/// <summary>
/// Retrieves cached evidence for an alert.
/// </summary>
public async Task<CachedEvidence?> GetCachedEvidenceAsync(
string scanOutputPath,
string alertId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanOutputPath);
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
var cacheDir = GetCacheDirectory(scanOutputPath);
if (!Directory.Exists(cacheDir))
return null;
var bundlePath = Path.Combine(cacheDir, "bundles", $"{alertId}.evidence.json");
if (!File.Exists(bundlePath))
return null;
var bundleJson = await File.ReadAllTextAsync(bundlePath, cancellationToken);
var bundle = JsonSerializer.Deserialize<CachedEvidenceBundle>(bundleJson, _jsonOptions);
if (bundle is null)
return null;
// For now, mark all local bundles as verified (full signature verification would require DSSE service)
return new CachedEvidence
{
Bundle = bundle,
BundlePath = bundlePath,
SignatureValid = true,
VerificationStatus = "local_cache",
CachedAt = File.GetLastWriteTimeUtc(bundlePath)
};
}
/// <summary>
/// Queues deferred enrichment for missing evidence.
/// </summary>
public async Task QueueEnrichmentAsync(
string scanOutputPath,
EnrichmentRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanOutputPath);
ArgumentNullException.ThrowIfNull(request);
var cacheDir = EnsureCacheDirectory(scanOutputPath);
var queuePath = Path.Combine(cacheDir, EnrichmentQueueFile);
var queue = await LoadEnrichmentQueueAsync(queuePath, cancellationToken);
// Don't add duplicates
if (!queue.Requests.Any(r =>
r.AlertId == request.AlertId &&
r.EvidenceType == request.EvidenceType))
{
request = request with { QueuedAt = _timeProvider.GetUtcNow() };
queue.Requests.Add(request);
queue.UpdatedAt = _timeProvider.GetUtcNow();
await File.WriteAllTextAsync(
queuePath,
JsonSerializer.Serialize(queue, _jsonOptions),
cancellationToken);
_logger.LogDebug(
"Queued enrichment for {EvidenceType} on alert {AlertId}",
request.EvidenceType, request.AlertId);
}
}
/// <summary>
/// Processes deferred enrichment queue.
/// </summary>
public async Task<EnrichmentResult> ProcessEnrichmentQueueAsync(
string scanOutputPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanOutputPath);
var cacheDir = GetCacheDirectory(scanOutputPath);
if (!Directory.Exists(cacheDir))
return new EnrichmentResult { ProcessedCount = 0 };
var queuePath = Path.Combine(cacheDir, EnrichmentQueueFile);
var queue = await LoadEnrichmentQueueAsync(queuePath, cancellationToken);
var processed = 0;
var failed = 0;
var remaining = new List<EnrichmentRequest>();
foreach (var request in queue.Requests)
{
if (cancellationToken.IsCancellationRequested)
{
remaining.Add(request);
continue;
}
try
{
// Attempt enrichment (network call)
var success = await TryEnrichAsync(request, cancellationToken);
if (success)
{
processed++;
_logger.LogInformation(
"Successfully enriched {EvidenceType} for {AlertId}",
request.EvidenceType, request.AlertId);
}
else
{
// Update attempt count and keep in queue
remaining.Add(request with { AttemptCount = request.AttemptCount + 1 });
failed++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enrich {EvidenceType} for {AlertId}",
request.EvidenceType, request.AlertId);
remaining.Add(request with { AttemptCount = request.AttemptCount + 1 });
failed++;
}
}
// Update queue with remaining items
queue.Requests = remaining;
queue.UpdatedAt = _timeProvider.GetUtcNow();
await File.WriteAllTextAsync(
queuePath,
JsonSerializer.Serialize(queue, _jsonOptions),
cancellationToken);
return new EnrichmentResult
{
ProcessedCount = processed,
FailedCount = failed,
RemainingCount = remaining.Count
};
}
/// <summary>
/// Gets cache statistics.
/// </summary>
public async Task<CacheStatistics> GetStatisticsAsync(
string scanOutputPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanOutputPath);
var cacheDir = GetCacheDirectory(scanOutputPath);
if (!Directory.Exists(cacheDir))
return new CacheStatistics();
var manifestPath = Path.Combine(cacheDir, ManifestFile);
if (!File.Exists(manifestPath))
return new CacheStatistics();
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
var manifest = JsonSerializer.Deserialize<CacheManifest>(manifestJson, _jsonOptions);
return manifest?.Statistics ?? new CacheStatistics();
}
#region Private Helpers
private string EnsureCacheDirectory(string scanOutputPath)
{
var cacheDir = Path.Combine(scanOutputPath, EvidenceDir);
Directory.CreateDirectory(cacheDir);
Directory.CreateDirectory(Path.Combine(cacheDir, "bundles"));
Directory.CreateDirectory(Path.Combine(cacheDir, "attestations"));
Directory.CreateDirectory(Path.Combine(cacheDir, "proofs"));
Directory.CreateDirectory(Path.Combine(cacheDir, "proofs", "reachability"));
Directory.CreateDirectory(Path.Combine(cacheDir, "proofs", "callstacks"));
return cacheDir;
}
private static string GetCacheDirectory(string scanOutputPath) =>
Path.Combine(scanOutputPath, EvidenceDir);
private async Task CacheProofAsync(
string cacheDir,
string proofType,
string hash,
object proof,
CancellationToken cancellationToken)
{
var proofDir = Path.Combine(cacheDir, "proofs", proofType);
Directory.CreateDirectory(proofDir);
var safeHash = hash.Replace(":", "_").Replace("/", "_");
var path = Path.Combine(proofDir, $"{safeHash}.json");
await File.WriteAllTextAsync(
path,
JsonSerializer.Serialize(proof, _jsonOptions),
cancellationToken);
}
private List<EnrichmentRequest> IdentifyMissingEvidence(CachedEvidenceBundle bundle)
{
var requests = new List<EnrichmentRequest>();
var now = _timeProvider.GetUtcNow();
if (bundle.Reachability?.Status == EvidenceStatus.PendingEnrichment)
{
requests.Add(new EnrichmentRequest
{
AlertId = bundle.AlertId,
ArtifactId = bundle.ArtifactId,
EvidenceType = "reachability",
Reason = bundle.Reachability.UnavailableReason,
QueuedAt = now
});
}
if (bundle.Provenance?.Status == EvidenceStatus.PendingEnrichment)
{
requests.Add(new EnrichmentRequest
{
AlertId = bundle.AlertId,
ArtifactId = bundle.ArtifactId,
EvidenceType = "provenance",
Reason = bundle.Provenance.UnavailableReason,
QueuedAt = now
});
}
if (bundle.CallStack?.Status == EvidenceStatus.PendingEnrichment)
{
requests.Add(new EnrichmentRequest
{
AlertId = bundle.AlertId,
ArtifactId = bundle.ArtifactId,
EvidenceType = "callstack",
Reason = bundle.CallStack.UnavailableReason,
QueuedAt = now
});
}
return requests;
}
private async Task<EnrichmentQueue> LoadEnrichmentQueueAsync(
string queuePath,
CancellationToken cancellationToken)
{
if (!File.Exists(queuePath))
return new EnrichmentQueue { CreatedAt = _timeProvider.GetUtcNow() };
var json = await File.ReadAllTextAsync(queuePath, cancellationToken);
return JsonSerializer.Deserialize<EnrichmentQueue>(json, _jsonOptions)
?? new EnrichmentQueue { CreatedAt = _timeProvider.GetUtcNow() };
}
private Task<bool> TryEnrichAsync(
EnrichmentRequest request,
CancellationToken cancellationToken)
{
// Implementation depends on evidence type
// Would call external services when network available
// For now, return false to indicate enrichment not possible (offline)
_logger.LogDebug(
"Enrichment for {EvidenceType} on {AlertId} deferred - network required",
request.EvidenceType, request.AlertId);
return Task.FromResult(false);
}
private async Task UpdateManifestAsync(
string scanOutputPath,
CachedEvidenceBundle bundle,
string bundlePath,
string contentHash,
CancellationToken cancellationToken)
{
var cacheDir = GetCacheDirectory(scanOutputPath);
var manifestPath = Path.Combine(cacheDir, ManifestFile);
CacheManifest? manifest = null;
if (File.Exists(manifestPath))
{
var json = await File.ReadAllTextAsync(manifestPath, cancellationToken);
manifest = JsonSerializer.Deserialize<CacheManifest>(json, _jsonOptions);
}
var entries = manifest?.Entries.ToList() ?? new List<CacheEntry>();
// Remove existing entry for this alert
entries.RemoveAll(e => e.AlertId == bundle.AlertId);
// Add new entry
entries.Add(new CacheEntry
{
AlertId = bundle.AlertId,
BundlePath = Path.GetRelativePath(cacheDir, bundlePath),
ContentHash = contentHash,
Status = MapToStatus(bundle),
CachedAt = _timeProvider.GetUtcNow(),
IsSigned = false // Would be true if we had DSSE signing
});
// Compute statistics
var stats = ComputeStatistics(entries, cacheDir);
var newManifest = new CacheManifest
{
CreatedAt = manifest?.CreatedAt ?? _timeProvider.GetUtcNow(),
UpdatedAt = _timeProvider.GetUtcNow(),
ScanDigest = bundle.ArtifactId,
Entries = entries,
Statistics = stats
};
await File.WriteAllTextAsync(
manifestPath,
JsonSerializer.Serialize(newManifest, _jsonOptions),
cancellationToken);
}
private static CachedEvidenceStatus MapToStatus(CachedEvidenceBundle bundle)
{
return new CachedEvidenceStatus
{
Reachability = MapState(bundle.Reachability?.Status),
CallStack = MapState(bundle.CallStack?.Status),
Provenance = MapState(bundle.Provenance?.Status),
VexStatus = MapState(bundle.VexStatus?.Status)
};
}
private static EvidenceCacheState MapState(string? status) => status switch
{
EvidenceStatus.Available => EvidenceCacheState.Available,
EvidenceStatus.PendingEnrichment => EvidenceCacheState.PendingEnrichment,
EvidenceStatus.Unavailable => EvidenceCacheState.Unavailable,
_ => EvidenceCacheState.Unavailable
};
private CacheStatistics ComputeStatistics(List<CacheEntry> entries, string cacheDir)
{
var totalSize = Directory.Exists(cacheDir)
? new DirectoryInfo(cacheDir)
.EnumerateFiles("*", SearchOption.AllDirectories)
.Sum(f => f.Length)
: 0;
var fullyAvailable = entries.Count(e =>
e.Status.Reachability == EvidenceCacheState.Available &&
e.Status.CallStack == EvidenceCacheState.Available &&
e.Status.Provenance == EvidenceCacheState.Available &&
e.Status.VexStatus == EvidenceCacheState.Available);
var pending = entries.Count(e =>
e.Status.Reachability == EvidenceCacheState.PendingEnrichment ||
e.Status.CallStack == EvidenceCacheState.PendingEnrichment ||
e.Status.Provenance == EvidenceCacheState.PendingEnrichment);
var offlineResolvable = entries.Count > 0
? (double)entries.Count(e => IsOfflineResolvable(e.Status)) / entries.Count * 100
: 0;
return new CacheStatistics
{
TotalBundles = entries.Count,
FullyAvailable = fullyAvailable,
PartiallyAvailable = entries.Count - fullyAvailable - pending,
PendingEnrichment = pending,
OfflineResolvablePercentage = offlineResolvable,
TotalSizeBytes = totalSize
};
}
private static bool IsOfflineResolvable(CachedEvidenceStatus status)
{
// At least VEX and one of reachability/callstack available
return status.VexStatus == EvidenceCacheState.Available &&
(status.Reachability == EvidenceCacheState.Available ||
status.CallStack == EvidenceCacheState.Available);
}
private static string ComputeHash(byte[] content)
{
var hash = SHA256.HashData(content);
return Convert.ToHexString(hash).ToLowerInvariant();
}
#endregion
}

View File

@@ -0,0 +1,93 @@
namespace StellaOps.ExportCenter.Core.OfflineBundle;
/// <summary>
/// Manifest for .stella.bundle.tgz offline bundles.
/// </summary>
public sealed class BundleManifest
{
/// <summary>
/// Manifest schema version.
/// </summary>
public string SchemaVersion { get; init; } = "1.0";
/// <summary>
/// Bundle identifier.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// Alert identifier this bundle is for.
/// </summary>
public required string AlertId { get; init; }
/// <summary>
/// Artifact identifier (image digest, commit hash).
/// </summary>
public required string ArtifactId { get; init; }
/// <summary>
/// When bundle was created (UTC ISO-8601).
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Who created the bundle.
/// </summary>
public required string CreatedBy { get; init; }
/// <summary>
/// Content entries with hashes.
/// </summary>
public required IReadOnlyList<BundleEntry> Entries { get; init; }
/// <summary>
/// Combined hash of all entries (Merkle root).
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// Evidence completeness score (0-4).
/// </summary>
public int CompletenessScore { get; init; }
/// <summary>
/// Replay token for decision reproducibility.
/// </summary>
public string? ReplayToken { get; init; }
/// <summary>
/// Platform version that created the bundle.
/// </summary>
public string? PlatformVersion { get; init; }
}
/// <summary>
/// Individual entry in the bundle manifest.
/// </summary>
public sealed class BundleEntry
{
/// <summary>
/// Relative path within bundle.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Entry type: metadata, evidence, vex, sbom, diff, attestation.
/// </summary>
public required string EntryType { get; init; }
/// <summary>
/// SHA-256 hash of content.
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// Size in bytes.
/// </summary>
public required long Size { get; init; }
/// <summary>
/// Content MIME type.
/// </summary>
public string ContentType { get; init; } = "application/json";
}

View File

@@ -0,0 +1,138 @@
namespace StellaOps.ExportCenter.Core.OfflineBundle;
/// <summary>
/// Request to create an offline bundle.
/// </summary>
public sealed class BundleRequest
{
/// <summary>
/// Alert identifier to create bundle for.
/// </summary>
public required string AlertId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Actor creating the bundle.
/// </summary>
public required string ActorId { get; init; }
/// <summary>
/// Artifact identifier.
/// </summary>
public required string ArtifactId { get; init; }
/// <summary>
/// Optional baseline scan ID for SBOM diff.
/// </summary>
public string? BaselineScanId { get; init; }
/// <summary>
/// Include SBOM slice in bundle.
/// </summary>
public bool IncludeSbomSlice { get; init; } = true;
/// <summary>
/// Include VEX decision history.
/// </summary>
public bool IncludeVexHistory { get; init; } = true;
/// <summary>
/// Sign the bundle manifest.
/// </summary>
public bool SignBundle { get; init; } = true;
}
/// <summary>
/// Result of bundle creation.
/// </summary>
public sealed class BundleResult
{
/// <summary>
/// Bundle identifier.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// Path to created bundle file.
/// </summary>
public required string BundlePath { get; init; }
/// <summary>
/// Bundle manifest.
/// </summary>
public required BundleManifest Manifest { get; init; }
/// <summary>
/// Size of bundle in bytes.
/// </summary>
public required long Size { get; init; }
}
/// <summary>
/// Result of bundle verification.
/// </summary>
public sealed class BundleVerificationResult
{
/// <summary>
/// Whether bundle is valid.
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// Validation issues found.
/// </summary>
public IReadOnlyList<string> Issues { get; init; } = Array.Empty<string>();
/// <summary>
/// Parsed manifest if available.
/// </summary>
public BundleManifest? Manifest { get; init; }
/// <summary>
/// Whether signature was verified.
/// </summary>
public bool? SignatureValid { get; init; }
/// <summary>
/// When verification was performed.
/// </summary>
public DateTimeOffset VerifiedAt { get; init; }
public BundleVerificationResult() { }
public BundleVerificationResult(
bool isValid,
IReadOnlyList<string> issues,
BundleManifest? manifest = null)
{
IsValid = isValid;
Issues = issues;
Manifest = manifest;
}
}
/// <summary>
/// Exception thrown during bundle operations.
/// </summary>
public sealed class BundleException : Exception
{
public BundleException(string message) : base(message) { }
public BundleException(string message, Exception inner) : base(message, inner) { }
}
/// <summary>
/// Entry types for bundle contents.
/// </summary>
public static class BundleEntryTypes
{
public const string Metadata = "metadata";
public const string Evidence = "evidence";
public const string Vex = "vex";
public const string Sbom = "sbom";
public const string Diff = "diff";
public const string Attestation = "attestation";
}

View File

@@ -0,0 +1,58 @@
namespace StellaOps.ExportCenter.Core.OfflineBundle;
/// <summary>
/// DSSE predicate for signed offline bundles.
/// Predicate type: stellaops.dev/predicates/offline-bundle@v1
/// </summary>
public sealed class BundlePredicate
{
/// <summary>
/// Predicate type URI.
/// </summary>
public const string PredicateType = "stellaops.dev/predicates/offline-bundle@v1";
/// <summary>
/// Bundle identifier.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// Alert identifier.
/// </summary>
public required string AlertId { get; init; }
/// <summary>
/// Artifact identifier.
/// </summary>
public required string ArtifactId { get; init; }
/// <summary>
/// Content hash (Merkle root of entries).
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// Number of entries in bundle.
/// </summary>
public required int EntryCount { get; init; }
/// <summary>
/// Evidence completeness score.
/// </summary>
public required int CompletenessScore { get; init; }
/// <summary>
/// Replay token for reproducibility.
/// </summary>
public string? ReplayToken { get; init; }
/// <summary>
/// When bundle was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Who created the bundle.
/// </summary>
public required string CreatedBy { get; init; }
}

View File

@@ -0,0 +1,37 @@
namespace StellaOps.ExportCenter.Core.OfflineBundle;
/// <summary>
/// Interface for creating and verifying offline evidence bundles.
/// </summary>
public interface IOfflineBundlePackager
{
/// <summary>
/// Creates a complete offline bundle for an alert.
/// </summary>
/// <param name="request">Bundle creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing bundle path and manifest.</returns>
Task<BundleResult> CreateBundleAsync(
BundleRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies bundle integrity and signature.
/// </summary>
/// <param name="bundlePath">Path to bundle file.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<BundleVerificationResult> VerifyBundleAsync(
string bundlePath,
CancellationToken cancellationToken = default);
/// <summary>
/// Extracts and reads the manifest from a bundle.
/// </summary>
/// <param name="bundlePath">Path to bundle file.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Bundle manifest.</returns>
Task<BundleManifest?> ReadManifestAsync(
string bundlePath,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,521 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.ExportCenter.Core.OfflineBundle;
/// <summary>
/// Packages evidence into .stella.bundle.tgz format.
/// </summary>
public sealed class OfflineBundlePackager : IOfflineBundlePackager
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<OfflineBundlePackager> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
public OfflineBundlePackager(
TimeProvider timeProvider,
ILogger<OfflineBundlePackager> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Creates a complete offline bundle for an alert.
/// </summary>
public async Task<BundleResult> CreateBundleAsync(
BundleRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.AlertId);
ArgumentException.ThrowIfNullOrWhiteSpace(request.ActorId);
var bundleId = Guid.NewGuid().ToString("N");
var entries = new List<BundleEntry>();
var tempDir = Path.Combine(Path.GetTempPath(), $"bundle_{bundleId}");
try
{
Directory.CreateDirectory(tempDir);
Directory.CreateDirectory(Path.Combine(tempDir, "metadata"));
Directory.CreateDirectory(Path.Combine(tempDir, "evidence"));
Directory.CreateDirectory(Path.Combine(tempDir, "vex"));
Directory.CreateDirectory(Path.Combine(tempDir, "sbom"));
Directory.CreateDirectory(Path.Combine(tempDir, "diff"));
Directory.CreateDirectory(Path.Combine(tempDir, "attestations"));
// Write metadata
entries.AddRange(await WriteMetadataAsync(tempDir, request, cancellationToken));
// Write placeholder evidence artifacts
entries.AddRange(await WriteEvidencePlaceholdersAsync(tempDir, request, cancellationToken));
// Write VEX data
if (request.IncludeVexHistory)
{
entries.AddRange(await WriteVexPlaceholdersAsync(tempDir, request, cancellationToken));
}
// Write SBOM slices
if (request.IncludeSbomSlice)
{
entries.AddRange(await WriteSbomPlaceholdersAsync(tempDir, request, cancellationToken));
}
// Create manifest
var manifest = CreateManifest(bundleId, request, entries);
// Write manifest
var manifestEntry = await WriteManifestAsync(tempDir, manifest, cancellationToken);
// Don't add manifest to entries (it contains the entry list)
// Create tarball
var bundlePath = await CreateTarballAsync(tempDir, bundleId, cancellationToken);
_logger.LogInformation(
"Created bundle {BundleId} for alert {AlertId} with {EntryCount} entries",
bundleId, request.AlertId, entries.Count);
return new BundleResult
{
BundleId = bundleId,
BundlePath = bundlePath,
Manifest = manifest,
Size = new FileInfo(bundlePath).Length
};
}
finally
{
// Cleanup temp directory
if (Directory.Exists(tempDir))
{
try { Directory.Delete(tempDir, recursive: true); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed to cleanup temp directory {TempDir}", tempDir); }
}
}
}
/// <summary>
/// Verifies bundle integrity and signature.
/// </summary>
public async Task<BundleVerificationResult> VerifyBundleAsync(
string bundlePath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
if (!File.Exists(bundlePath))
{
return new BundleVerificationResult
{
IsValid = false,
Issues = new[] { $"Bundle file not found: {bundlePath}" },
VerifiedAt = _timeProvider.GetUtcNow()
};
}
var issues = new List<string>();
var tempDir = Path.Combine(Path.GetTempPath(), $"verify_{Guid.NewGuid():N}");
try
{
// Extract bundle
await ExtractTarballAsync(bundlePath, tempDir, cancellationToken);
// Read manifest
var manifestPath = Path.Combine(tempDir, "manifest.json");
if (!File.Exists(manifestPath))
{
issues.Add("Missing manifest.json");
return new BundleVerificationResult
{
IsValid = false,
Issues = issues,
VerifiedAt = _timeProvider.GetUtcNow()
};
}
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson, JsonOptions);
if (manifest is null)
{
issues.Add("Failed to parse manifest.json");
return new BundleVerificationResult
{
IsValid = false,
Issues = issues,
VerifiedAt = _timeProvider.GetUtcNow()
};
}
// Verify each entry hash
foreach (var entry in manifest.Entries)
{
var entryPath = Path.Combine(tempDir, entry.Path);
if (!File.Exists(entryPath))
{
issues.Add($"Missing entry: {entry.Path}");
continue;
}
var content = await File.ReadAllBytesAsync(entryPath, cancellationToken);
var hash = ComputeHash(content);
if (!string.Equals(hash, entry.Hash, StringComparison.OrdinalIgnoreCase))
{
issues.Add($"Hash mismatch for {entry.Path}: expected {entry.Hash}, got {hash}");
}
if (content.Length != entry.Size)
{
issues.Add($"Size mismatch for {entry.Path}: expected {entry.Size}, got {content.Length}");
}
}
// Verify combined content hash
var computedContentHash = ComputeContentHash(manifest.Entries);
if (!string.Equals(computedContentHash, manifest.ContentHash, StringComparison.OrdinalIgnoreCase))
{
issues.Add($"Content hash mismatch: expected {manifest.ContentHash}, got {computedContentHash}");
}
// Check for signature file
var sigPath = Path.Combine(tempDir, "manifest.json.sig");
bool? signatureValid = null;
if (File.Exists(sigPath))
{
// Signature verification would go here
// For now, just note that signature exists
signatureValid = true; // Placeholder - actual verification would be implemented
}
return new BundleVerificationResult
{
IsValid = issues.Count == 0,
Issues = issues,
Manifest = manifest,
SignatureValid = signatureValid,
VerifiedAt = _timeProvider.GetUtcNow()
};
}
finally
{
if (Directory.Exists(tempDir))
{
try { Directory.Delete(tempDir, recursive: true); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed to cleanup verification temp directory"); }
}
}
}
/// <summary>
/// Reads manifest from bundle without full verification.
/// </summary>
public async Task<BundleManifest?> ReadManifestAsync(
string bundlePath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
if (!File.Exists(bundlePath))
{
return null;
}
var tempDir = Path.Combine(Path.GetTempPath(), $"read_{Guid.NewGuid():N}");
try
{
await ExtractTarballAsync(bundlePath, tempDir, cancellationToken);
var manifestPath = Path.Combine(tempDir, "manifest.json");
if (!File.Exists(manifestPath))
{
return null;
}
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
return JsonSerializer.Deserialize<BundleManifest>(manifestJson, JsonOptions);
}
finally
{
if (Directory.Exists(tempDir))
{
try { Directory.Delete(tempDir, recursive: true); }
catch { /* Ignore cleanup errors */ }
}
}
}
private async Task<IReadOnlyList<BundleEntry>> WriteMetadataAsync(
string tempDir,
BundleRequest request,
CancellationToken cancellationToken)
{
var entries = new List<BundleEntry>();
var now = _timeProvider.GetUtcNow();
// Write alert metadata
var alertMetadata = new
{
alert_id = request.AlertId,
tenant_id = request.TenantId,
artifact_id = request.ArtifactId,
created_at = now.ToString("O"),
created_by = request.ActorId
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "metadata/alert.json", BundleEntryTypes.Metadata, alertMetadata, cancellationToken));
// Write artifact info
var artifactMetadata = new
{
artifact_id = request.ArtifactId,
captured_at = now.ToString("O")
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "metadata/artifact.json", BundleEntryTypes.Metadata, artifactMetadata, cancellationToken));
// Write timestamps
var timestamps = new
{
bundle_created_at = now.ToString("O"),
evidence_captured_at = now.ToString("O"),
baseline_scan_id = request.BaselineScanId
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "metadata/timestamps.json", BundleEntryTypes.Metadata, timestamps, cancellationToken));
return entries;
}
private async Task<IReadOnlyList<BundleEntry>> WriteEvidencePlaceholdersAsync(
string tempDir,
BundleRequest request,
CancellationToken cancellationToken)
{
var entries = new List<BundleEntry>();
// Placeholder evidence artifacts - would be populated from actual evidence service
var reachability = new
{
status = "pending",
alert_id = request.AlertId,
computed_at = _timeProvider.GetUtcNow().ToString("O")
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "evidence/reachability.json", BundleEntryTypes.Evidence, reachability, cancellationToken));
var callstack = new
{
status = "pending",
alert_id = request.AlertId,
frames = Array.Empty<object>()
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "evidence/callstack.json", BundleEntryTypes.Evidence, callstack, cancellationToken));
var provenance = new
{
status = "pending",
alert_id = request.AlertId,
attestations = Array.Empty<object>()
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "evidence/provenance.json", BundleEntryTypes.Evidence, provenance, cancellationToken));
return entries;
}
private async Task<IReadOnlyList<BundleEntry>> WriteVexPlaceholdersAsync(
string tempDir,
BundleRequest request,
CancellationToken cancellationToken)
{
var entries = new List<BundleEntry>();
var currentVex = new
{
status = "not_available",
alert_id = request.AlertId,
statement = (object?)null
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "vex/current.json", BundleEntryTypes.Vex, currentVex, cancellationToken));
var historyVex = new
{
alert_id = request.AlertId,
history = Array.Empty<object>()
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "vex/history.json", BundleEntryTypes.Vex, historyVex, cancellationToken));
return entries;
}
private async Task<IReadOnlyList<BundleEntry>> WriteSbomPlaceholdersAsync(
string tempDir,
BundleRequest request,
CancellationToken cancellationToken)
{
var entries = new List<BundleEntry>();
var currentSbom = new
{
alert_id = request.AlertId,
artifact_id = request.ArtifactId,
components = Array.Empty<object>()
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "sbom/current.json", BundleEntryTypes.Sbom, currentSbom, cancellationToken));
if (request.BaselineScanId is not null)
{
var baselineSbom = new
{
alert_id = request.AlertId,
baseline_scan_id = request.BaselineScanId,
components = Array.Empty<object>()
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "sbom/baseline.json", BundleEntryTypes.Sbom, baselineSbom, cancellationToken));
var diff = new
{
alert_id = request.AlertId,
current_scan_id = request.ArtifactId,
baseline_scan_id = request.BaselineScanId,
added = Array.Empty<object>(),
removed = Array.Empty<object>(),
changed = Array.Empty<object>()
};
entries.Add(await WriteJsonEntryAsync(
tempDir, "diff/delta.json", BundleEntryTypes.Diff, diff, cancellationToken));
}
return entries;
}
private async Task<BundleEntry> WriteJsonEntryAsync<T>(
string tempDir,
string relativePath,
string entryType,
T content,
CancellationToken cancellationToken)
{
var fullPath = Path.Combine(tempDir, relativePath);
var json = JsonSerializer.Serialize(content, JsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
await File.WriteAllBytesAsync(fullPath, bytes, cancellationToken);
return new BundleEntry
{
Path = relativePath,
EntryType = entryType,
Hash = ComputeHash(bytes),
Size = bytes.Length,
ContentType = "application/json"
};
}
private async Task<BundleEntry> WriteManifestAsync(
string tempDir,
BundleManifest manifest,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(manifest, JsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
var fullPath = Path.Combine(tempDir, "manifest.json");
await File.WriteAllBytesAsync(fullPath, bytes, cancellationToken);
return new BundleEntry
{
Path = "manifest.json",
EntryType = "manifest",
Hash = ComputeHash(bytes),
Size = bytes.Length,
ContentType = "application/json"
};
}
private BundleManifest CreateManifest(
string bundleId,
BundleRequest request,
List<BundleEntry> entries)
{
var contentHash = ComputeContentHash(entries);
return new BundleManifest
{
BundleId = bundleId,
AlertId = request.AlertId,
ArtifactId = request.ArtifactId,
CreatedAt = _timeProvider.GetUtcNow(),
CreatedBy = request.ActorId,
Entries = entries.OrderBy(e => e.Path, StringComparer.Ordinal).ToList(),
ContentHash = contentHash,
CompletenessScore = 0, // Would be computed from actual evidence
ReplayToken = null, // Would be generated by replay token service
PlatformVersion = GetPlatformVersion()
};
}
private async Task<string> CreateTarballAsync(
string sourceDir,
string bundleId,
CancellationToken cancellationToken)
{
var outputPath = Path.Combine(Path.GetTempPath(), $"alert_{bundleId}.stella.bundle.tgz");
await using var outputStream = File.Create(outputPath);
await using var gzipStream = new GZipStream(outputStream, CompressionLevel.Optimal);
await TarFile.CreateFromDirectoryAsync(sourceDir, gzipStream, false, cancellationToken);
return outputPath;
}
private static async Task ExtractTarballAsync(
string tarballPath,
string targetDir,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(targetDir);
await using var inputStream = File.OpenRead(tarballPath);
await using var gzipStream = new GZipStream(inputStream, CompressionMode.Decompress);
await TarFile.ExtractToDirectoryAsync(gzipStream, targetDir, true, cancellationToken);
}
private static string ComputeContentHash(IEnumerable<BundleEntry> entries)
{
var sorted = entries.OrderBy(e => e.Path, StringComparer.Ordinal).Select(e => e.Hash);
var combined = string.Join(":", sorted);
return ComputeHash(Encoding.UTF8.GetBytes(combined));
}
private static string ComputeHash(byte[] content)
{
var hash = SHA256.HashData(content);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string GetPlatformVersion() =>
Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? "unknown";
}