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:
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user