Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
public sealed class EvidenceBundlePackagingService
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly IEvidenceBundleRepository _repository;
|
||||
private readonly IEvidenceObjectStore _objectStore;
|
||||
private readonly ILogger<EvidenceBundlePackagingService> _logger;
|
||||
|
||||
public EvidenceBundlePackagingService(
|
||||
IEvidenceBundleRepository repository,
|
||||
IEvidenceObjectStore objectStore,
|
||||
ILogger<EvidenceBundlePackagingService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<EvidenceBundlePackageResult> EnsurePackageAsync(
|
||||
TenantId tenantId,
|
||||
EvidenceBundleId bundleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId.Value == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier cannot be empty.", nameof(tenantId));
|
||||
}
|
||||
|
||||
if (bundleId.Value == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Bundle identifier cannot be empty.", nameof(bundleId));
|
||||
}
|
||||
|
||||
var details = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException($"Evidence bundle '{bundleId.Value:D}' not found for tenant '{tenantId.Value:D}'.");
|
||||
|
||||
if (details.Bundle.Status != EvidenceBundleStatus.Sealed)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle must be sealed before packaging.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(details.Bundle.StorageKey))
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle storage key is not set.");
|
||||
}
|
||||
|
||||
if (await _objectStore.ExistsAsync(details.Bundle.StorageKey, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return new EvidenceBundlePackageResult(details.Bundle.StorageKey, details.Bundle.RootHash, Created: false);
|
||||
}
|
||||
|
||||
if (details.Signature is null)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle signature is required for packaging.");
|
||||
}
|
||||
|
||||
var manifestDocument = DecodeManifest(details.Signature);
|
||||
var packageStream = BuildPackageStream(details, manifestDocument);
|
||||
|
||||
var metadata = await _objectStore.StoreAsync(
|
||||
packageStream,
|
||||
new EvidenceObjectWriteOptions(
|
||||
tenantId,
|
||||
bundleId,
|
||||
"bundle.tgz",
|
||||
"application/gzip",
|
||||
EnforceWriteOnce: true),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!string.Equals(metadata.StorageKey, details.Bundle.StorageKey, StringComparison.Ordinal))
|
||||
{
|
||||
await _repository.UpdateStorageKeyAsync(bundleId, tenantId, metadata.StorageKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Packaged evidence bundle {BundleId} for tenant {TenantId} at storage key {StorageKey}.",
|
||||
bundleId.Value,
|
||||
tenantId.Value,
|
||||
metadata.StorageKey);
|
||||
|
||||
return new EvidenceBundlePackageResult(metadata.StorageKey, details.Bundle.RootHash, Created: true);
|
||||
}
|
||||
|
||||
private ManifestDocument DecodeManifest(EvidenceBundleSignature signature)
|
||||
{
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = Convert.FromBase64String(signature.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Evidence bundle manifest payload for bundle {BundleId} (tenant {TenantId}) is not valid base64.",
|
||||
signature.BundleId.Value,
|
||||
signature.TenantId.Value);
|
||||
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var document = JsonSerializer.Deserialize<ManifestDocument>(payload, SerializerOptions)
|
||||
?? throw new InvalidOperationException();
|
||||
return document;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Evidence bundle manifest payload for bundle {BundleId} (tenant {TenantId}) could not be parsed.",
|
||||
signature.BundleId.Value,
|
||||
signature.TenantId.Value);
|
||||
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Stream BuildPackageStream(EvidenceBundleDetails details, ManifestDocument manifest)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
WriteTextEntry(tarWriter, "manifest.json", GetManifestJson(details.Signature!));
|
||||
WriteTextEntry(tarWriter, "signature.json", GetSignatureJson(details.Signature!));
|
||||
WriteTextEntry(tarWriter, "bundle.json", GetBundleMetadataJson(details));
|
||||
WriteTextEntry(tarWriter, "checksums.txt", BuildChecksums(manifest, details.Bundle.RootHash));
|
||||
WriteTextEntry(tarWriter, "instructions.txt", BuildInstructions(details, manifest));
|
||||
}
|
||||
|
||||
ApplyDeterministicGZipHeader(stream);
|
||||
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(TarWriter writer, string path, string content)
|
||||
{
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
|
||||
ModificationTime = FixedTimestamp
|
||||
};
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
entry.DataStream = new MemoryStream(bytes);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static string GetManifestJson(EvidenceBundleSignature signature)
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(Convert.FromBase64String(signature.Payload));
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(document.RootElement, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string GetSignatureJson(EvidenceBundleSignature signature)
|
||||
{
|
||||
var model = new SignatureDocument(
|
||||
signature.PayloadType,
|
||||
signature.Payload,
|
||||
signature.Signature,
|
||||
signature.KeyId,
|
||||
signature.Algorithm,
|
||||
signature.Provider,
|
||||
signature.SignedAt,
|
||||
signature.TimestampedAt,
|
||||
signature.TimestampAuthority,
|
||||
signature.TimestampToken is null ? null : Convert.ToBase64String(signature.TimestampToken));
|
||||
|
||||
return JsonSerializer.Serialize(model, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string GetBundleMetadataJson(EvidenceBundleDetails details)
|
||||
{
|
||||
var document = new BundleMetadataDocument(
|
||||
details.Bundle.Id.Value,
|
||||
details.Bundle.TenantId.Value,
|
||||
details.Bundle.Kind,
|
||||
details.Bundle.Status,
|
||||
details.Bundle.RootHash,
|
||||
details.Bundle.StorageKey,
|
||||
details.Bundle.CreatedAt,
|
||||
details.Bundle.SealedAt);
|
||||
|
||||
return JsonSerializer.Serialize(document, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string BuildChecksums(ManifestDocument manifest, string rootHash)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Evidence bundle checksums (sha256)");
|
||||
builder.Append("root ").AppendLine(rootHash);
|
||||
|
||||
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
||||
foreach (var entry in entries.OrderBy(e => e.CanonicalPath, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(entry.Sha256)
|
||||
.Append(" ")
|
||||
.AppendLine(entry.CanonicalPath);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildInstructions(EvidenceBundleDetails details, ManifestDocument manifest)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("Evidence Bundle Instructions");
|
||||
builder.AppendLine("============================");
|
||||
builder.Append("Bundle ID: ").AppendLine(details.Bundle.Id.Value.ToString("D"));
|
||||
builder.Append("Root Hash: ").AppendLine(details.Bundle.RootHash);
|
||||
builder.Append("Created At: ").AppendLine(manifest.CreatedAt.ToString("O"));
|
||||
if (details.Signature?.TimestampedAt is { } timestampedAt)
|
||||
{
|
||||
builder.Append("Timestamped At: ").AppendLine(timestampedAt.ToString("O"));
|
||||
}
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Verification steps:");
|
||||
builder.AppendLine("1. Inspect `manifest.json` and ensure the bundle contents match expectations.");
|
||||
builder.AppendLine("2. Compute the Merkle root using the manifest entries and compare with the Root Hash above.");
|
||||
builder.AppendLine("3. Validate `signature.json` using the StellaOps provenance verifier (`stella evidence verify <bundle.tgz>`).");
|
||||
if (details.Signature?.TimestampToken is not null)
|
||||
{
|
||||
builder.AppendLine("4. Validate the RFC3161 timestamp token with your configured TSA before trusting the bundle.");
|
||||
builder.AppendLine("5. Review `checksums.txt` when transferring the bundle between systems.");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.AppendLine("4. Review `checksums.txt` when transferring the bundle between systems.");
|
||||
}
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("For offline verification guidance, consult docs/forensics/evidence-locker.md (portable evidence section).");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void ApplyDeterministicGZipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written for evidence bundle package.");
|
||||
}
|
||||
|
||||
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
stream.Position = 4;
|
||||
stream.Write(buffer);
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
|
||||
private sealed record ManifestDocument(
|
||||
Guid BundleId,
|
||||
Guid TenantId,
|
||||
int Kind,
|
||||
DateTimeOffset CreatedAt,
|
||||
IDictionary<string, string>? Metadata,
|
||||
ManifestEntryDocument[]? Entries);
|
||||
|
||||
private sealed record ManifestEntryDocument(
|
||||
string Section,
|
||||
string CanonicalPath,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
string? MediaType,
|
||||
IDictionary<string, string>? Attributes);
|
||||
|
||||
private sealed record SignatureDocument(
|
||||
string PayloadType,
|
||||
string Payload,
|
||||
string Signature,
|
||||
string? KeyId,
|
||||
string Algorithm,
|
||||
string Provider,
|
||||
DateTimeOffset SignedAt,
|
||||
DateTimeOffset? TimestampedAt,
|
||||
string? TimestampAuthority,
|
||||
string? TimestampToken);
|
||||
|
||||
private sealed record BundleMetadataDocument(
|
||||
Guid BundleId,
|
||||
Guid TenantId,
|
||||
EvidenceBundleKind Kind,
|
||||
EvidenceBundleStatus Status,
|
||||
string RootHash,
|
||||
string StorageKey,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? SealedAt);
|
||||
}
|
||||
|
||||
public sealed record EvidenceBundlePackageResult(string StorageKey, string RootHash, bool Created);
|
||||
@@ -0,0 +1,414 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
public sealed class EvidencePortableBundleService
|
||||
{
|
||||
private const string PortableManifestFileName = "manifest.json";
|
||||
private const string PortableSignatureFileName = "signature.json";
|
||||
private const string PortableChecksumsFileName = "checksums.txt";
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly UnixFileMode DefaultFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
private static readonly UnixFileMode ExecutableFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
|
||||
|
||||
private readonly IEvidenceBundleRepository _repository;
|
||||
private readonly IEvidenceObjectStore _objectStore;
|
||||
private readonly ILogger<EvidencePortableBundleService> _logger;
|
||||
private readonly PortableOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EvidencePortableBundleService(
|
||||
IEvidenceBundleRepository repository,
|
||||
IEvidenceObjectStore objectStore,
|
||||
IOptions<EvidenceLockerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EvidencePortableBundleService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value.Portable ?? new PortableOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<EvidenceBundlePackageResult> EnsurePortablePackageAsync(
|
||||
TenantId tenantId,
|
||||
EvidenceBundleId bundleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId.Value == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier cannot be empty.", nameof(tenantId));
|
||||
}
|
||||
|
||||
if (bundleId.Value == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Bundle identifier cannot be empty.", nameof(bundleId));
|
||||
}
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Portable bundle packaging is disabled for this deployment.");
|
||||
}
|
||||
|
||||
var details = await _repository
|
||||
.GetBundleAsync(bundleId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException($"Evidence bundle '{bundleId.Value:D}' not found for tenant '{tenantId.Value:D}'.");
|
||||
|
||||
if (details.Bundle.Status != EvidenceBundleStatus.Sealed)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle must be sealed before creating a portable package.");
|
||||
}
|
||||
|
||||
if (details.Signature is null)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle signature is required for portable packaging.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(details.Bundle.PortableStorageKey)
|
||||
&& await _objectStore.ExistsAsync(details.Bundle.PortableStorageKey, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return new EvidenceBundlePackageResult(details.Bundle.PortableStorageKey!, details.Bundle.RootHash, Created: false);
|
||||
}
|
||||
|
||||
var manifestDocument = DecodeManifest(details.Signature);
|
||||
var generatedAt = _timeProvider.GetUtcNow();
|
||||
var packageStream = BuildPackageStream(details, manifestDocument, generatedAt);
|
||||
|
||||
var metadata = await _objectStore
|
||||
.StoreAsync(
|
||||
packageStream,
|
||||
new EvidenceObjectWriteOptions(
|
||||
tenantId,
|
||||
bundleId,
|
||||
_options.ArtifactName,
|
||||
"application/gzip",
|
||||
EnforceWriteOnce: true),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await _repository
|
||||
.UpdatePortableStorageKeyAsync(bundleId, tenantId, metadata.StorageKey, generatedAt, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Portable evidence bundle {BundleId} for tenant {TenantId} stored at {StorageKey}.",
|
||||
bundleId.Value,
|
||||
tenantId.Value,
|
||||
metadata.StorageKey);
|
||||
|
||||
return new EvidenceBundlePackageResult(metadata.StorageKey, details.Bundle.RootHash, Created: true);
|
||||
}
|
||||
|
||||
private static Stream BuildPackageStream(
|
||||
EvidenceBundleDetails details,
|
||||
ManifestDocument manifest,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
WriteTextEntry(tarWriter, PortableManifestFileName, GetManifestJson(details.Signature!));
|
||||
WriteTextEntry(tarWriter, PortableSignatureFileName, GetSignatureJson(details.Signature!));
|
||||
WriteTextEntry(tarWriter, PortableChecksumsFileName, BuildChecksums(manifest, details.Bundle.RootHash));
|
||||
|
||||
var metadataDocument = BuildPortableMetadata(details, manifest, generatedAt);
|
||||
WriteTextEntry(
|
||||
tarWriter,
|
||||
_options.MetadataFileName,
|
||||
JsonSerializer.Serialize(metadataDocument, SerializerOptions));
|
||||
|
||||
WriteTextEntry(
|
||||
tarWriter,
|
||||
_options.InstructionsFileName,
|
||||
BuildInstructions(details, manifest, generatedAt));
|
||||
|
||||
WriteTextEntry(
|
||||
tarWriter,
|
||||
_options.OfflineScriptFileName,
|
||||
BuildOfflineScript(_options.ArtifactName, _options.MetadataFileName),
|
||||
ExecutableFileMode);
|
||||
}
|
||||
|
||||
ApplyDeterministicGZipHeader(stream);
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static ManifestDocument DecodeManifest(EvidenceBundleSignature signature)
|
||||
{
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = Convert.FromBase64String(signature.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ManifestDocument>(payload, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Evidence bundle manifest payload is empty.");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static PortableBundleMetadataDocument BuildPortableMetadata(
|
||||
EvidenceBundleDetails details,
|
||||
ManifestDocument manifest,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
||||
var entryCount = entries.Length;
|
||||
var totalSize = entries.Sum(e => e.SizeBytes);
|
||||
|
||||
IReadOnlyDictionary<string, string>? incidentMetadata = null;
|
||||
if (manifest.Metadata is { Count: > 0 })
|
||||
{
|
||||
var incidentPairs = manifest.Metadata
|
||||
.Where(kvp => kvp.Key.StartsWith("incident.", StringComparison.Ordinal))
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
|
||||
|
||||
if (incidentPairs.Count > 0)
|
||||
{
|
||||
incidentMetadata = new ReadOnlyDictionary<string, string>(incidentPairs);
|
||||
}
|
||||
}
|
||||
|
||||
return new PortableBundleMetadataDocument(
|
||||
details.Bundle.Id.Value,
|
||||
details.Bundle.Kind,
|
||||
details.Bundle.RootHash,
|
||||
manifest.CreatedAt,
|
||||
details.Bundle.SealedAt,
|
||||
details.Bundle.ExpiresAt,
|
||||
details.Signature?.TimestampedAt is not null,
|
||||
details.Signature?.TimestampedAt,
|
||||
generatedAt,
|
||||
entryCount,
|
||||
totalSize,
|
||||
incidentMetadata);
|
||||
}
|
||||
|
||||
private static string BuildInstructions(
|
||||
EvidenceBundleDetails details,
|
||||
ManifestDocument manifest,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("Portable Evidence Bundle Instructions");
|
||||
builder.AppendLine("===================================");
|
||||
builder.Append("Bundle ID: ").AppendLine(details.Bundle.Id.Value.ToString("D"));
|
||||
builder.Append("Root Hash: ").AppendLine(details.Bundle.RootHash);
|
||||
builder.Append("Created At: ").AppendLine(manifest.CreatedAt.ToString("O"));
|
||||
if (details.Bundle.SealedAt is { } sealedAt)
|
||||
{
|
||||
builder.Append("Sealed At: ").AppendLine(sealedAt.ToString("O"));
|
||||
}
|
||||
|
||||
if (details.Signature?.TimestampedAt is { } timestampedAt)
|
||||
{
|
||||
builder.Append("Timestamped At: ").AppendLine(timestampedAt.ToString("O"));
|
||||
}
|
||||
|
||||
builder.Append("Portable Generated At: ").AppendLine(generatedAt.ToString("O"));
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Verification steps:");
|
||||
builder.Append("1. Copy '").Append(_options.ArtifactName).AppendLine("' into the sealed environment.");
|
||||
builder.Append("2. Execute './").Append(_options.OfflineScriptFileName).Append(' ');
|
||||
builder.Append(_options.ArtifactName).AppendLine("' to extract contents and verify checksums.");
|
||||
builder.AppendLine("3. Review 'bundle.json' for sanitized metadata and incident context.");
|
||||
builder.AppendLine("4. Run 'stella evidence verify --bundle <path>' or use an offline verifier with 'manifest.json' + 'signature.json'.");
|
||||
builder.AppendLine("5. Store the bundle and verification output with the receiving enclave's evidence locker.");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Notes:");
|
||||
builder.AppendLine("- Metadata is redacted to remove tenant identifiers, storage coordinates, and free-form descriptions.");
|
||||
builder.AppendLine("- Incident metadata (if present) is exposed under 'incidentMetadata'.");
|
||||
builder.AppendLine("- Checksums cover every canonical entry and the Merkle root hash for tamper detection.");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildOfflineScript(string defaultArchiveName, string metadataFileName)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("#!/usr/bin/env sh");
|
||||
builder.AppendLine("set -euo pipefail");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"ARCHIVE=\"${{1:-{defaultArchiveName}}}\"");
|
||||
builder.AppendLine("if [ ! -f \"$ARCHIVE\" ]; then");
|
||||
builder.AppendLine(" echo \"Usage: $0 <portable-bundle.tgz>\" >&2");
|
||||
builder.AppendLine(" exit 1");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("WORKDIR=\"$(mktemp -d)\"");
|
||||
builder.AppendLine("cleanup() { rm -rf \"$WORKDIR\"; }");
|
||||
builder.AppendLine("trap cleanup EXIT INT TERM");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("tar -xzf \"$ARCHIVE\" -C \"$WORKDIR\"");
|
||||
builder.AppendLine("echo \"Portable evidence extracted to $WORKDIR\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" (cd \"$WORKDIR\" && sha256sum --check checksums.txt)");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" (cd \"$WORKDIR\" && shasum -a 256 --check checksums.txt)");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("ROOT_HASH=$(sed -n 's/.*\"rootHash\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$WORKDIR\"/" + metadataFileName + " | head -n 1)");
|
||||
builder.AppendLine("echo \"Root hash: ${ROOT_HASH:-unknown}\"");
|
||||
builder.AppendLine("echo \"Verify DSSE signature with: stella evidence verify --bundle $ARCHIVE\"");
|
||||
builder.AppendLine("echo \"or provide manifest.json and signature.json to an offline verifier.\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"Leaving extracted contents in $WORKDIR for manual inspection.\"");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string GetManifestJson(EvidenceBundleSignature signature)
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(Convert.FromBase64String(signature.Payload));
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(document.RootElement, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string GetSignatureJson(EvidenceBundleSignature signature)
|
||||
{
|
||||
var model = new SignatureDocument(
|
||||
signature.PayloadType,
|
||||
signature.Payload,
|
||||
signature.Signature,
|
||||
signature.KeyId,
|
||||
signature.Algorithm,
|
||||
signature.Provider,
|
||||
signature.SignedAt,
|
||||
signature.TimestampedAt,
|
||||
signature.TimestampAuthority,
|
||||
signature.TimestampToken is null ? null : Convert.ToBase64String(signature.TimestampToken));
|
||||
|
||||
return JsonSerializer.Serialize(model, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string BuildChecksums(ManifestDocument manifest, string rootHash)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Evidence bundle checksums (sha256)");
|
||||
builder.Append("root ").AppendLine(rootHash);
|
||||
|
||||
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
||||
foreach (var entry in entries.OrderBy(e => e.CanonicalPath, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(entry.Sha256)
|
||||
.Append(" ")
|
||||
.AppendLine(entry.CanonicalPath);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(
|
||||
TarWriter writer,
|
||||
string path,
|
||||
string content,
|
||||
UnixFileMode mode = default)
|
||||
{
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = mode == default ? DefaultFileMode : mode,
|
||||
ModificationTime = FixedTimestamp
|
||||
};
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
entry.DataStream = new MemoryStream(bytes);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void ApplyDeterministicGZipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written for portable evidence package.");
|
||||
}
|
||||
|
||||
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
stream.Position = 4;
|
||||
stream.Write(buffer);
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
|
||||
private sealed record ManifestDocument(
|
||||
Guid BundleId,
|
||||
Guid TenantId,
|
||||
int Kind,
|
||||
DateTimeOffset CreatedAt,
|
||||
IDictionary<string, string>? Metadata,
|
||||
ManifestEntryDocument[]? Entries);
|
||||
|
||||
private sealed record ManifestEntryDocument(
|
||||
string Section,
|
||||
string CanonicalPath,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
string? MediaType,
|
||||
IDictionary<string, string>? Attributes);
|
||||
|
||||
private sealed record SignatureDocument(
|
||||
string PayloadType,
|
||||
string Payload,
|
||||
string Signature,
|
||||
string? KeyId,
|
||||
string Algorithm,
|
||||
string Provider,
|
||||
DateTimeOffset SignedAt,
|
||||
DateTimeOffset? TimestampedAt,
|
||||
string? TimestampAuthority,
|
||||
string? TimestampToken);
|
||||
|
||||
private sealed record PortableBundleMetadataDocument(
|
||||
Guid BundleId,
|
||||
EvidenceBundleKind Kind,
|
||||
string RootHash,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? SealedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
bool Timestamped,
|
||||
DateTimeOffset? TimestampedAt,
|
||||
DateTimeOffset PortableGeneratedAt,
|
||||
int EntryCount,
|
||||
long TotalSizeBytes,
|
||||
IReadOnlyDictionary<string, string>? IncidentMetadata);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Core.Signing;
|
||||
using StellaOps.EvidenceLocker.Core.Incident;
|
||||
using StellaOps.EvidenceLocker.Core.Timeline;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
public sealed class EvidenceSnapshotService
|
||||
{
|
||||
private static readonly string EmptyRoot = new('0', 64);
|
||||
private static readonly JsonSerializerOptions IncidentSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly IEvidenceBundleRepository _repository;
|
||||
private readonly IEvidenceBundleBuilder _bundleBuilder;
|
||||
private readonly IEvidenceSignatureService _signatureService;
|
||||
private readonly IEvidenceTimelinePublisher _timelinePublisher;
|
||||
private readonly IIncidentModeState _incidentMode;
|
||||
private readonly IEvidenceObjectStore _objectStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<EvidenceSnapshotService> _logger;
|
||||
private readonly QuotaOptions _quotas;
|
||||
|
||||
public EvidenceSnapshotService(
|
||||
IEvidenceBundleRepository repository,
|
||||
IEvidenceBundleBuilder bundleBuilder,
|
||||
IEvidenceSignatureService signatureService,
|
||||
IEvidenceTimelinePublisher timelinePublisher,
|
||||
IIncidentModeState incidentMode,
|
||||
IEvidenceObjectStore objectStore,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<EvidenceLockerOptions> options,
|
||||
ILogger<EvidenceSnapshotService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_bundleBuilder = bundleBuilder ?? throw new ArgumentNullException(nameof(bundleBuilder));
|
||||
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
|
||||
_timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher));
|
||||
_incidentMode = incidentMode ?? throw new ArgumentNullException(nameof(incidentMode));
|
||||
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_quotas = options.Value.Quotas ?? throw new InvalidOperationException("Quota options are required.");
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<EvidenceSnapshotResult> CreateSnapshotAsync(
|
||||
TenantId tenantId,
|
||||
EvidenceSnapshotRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId == default)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ValidateRequest(request);
|
||||
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
var storageKey = $"tenants/{tenantId.Value:N}/bundles/{bundleId.Value:N}/bundle.tgz";
|
||||
var incidentSnapshot = _incidentMode.Current;
|
||||
DateTimeOffset? expiresAt = null;
|
||||
|
||||
if (incidentSnapshot.IsActive && incidentSnapshot.RetentionExtensionDays > 0)
|
||||
{
|
||||
expiresAt = createdAt.AddDays(incidentSnapshot.RetentionExtensionDays);
|
||||
}
|
||||
|
||||
var metadataBuffer = new Dictionary<string, string>(
|
||||
request.Metadata ?? new Dictionary<string, string>(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
if (incidentSnapshot.IsActive)
|
||||
{
|
||||
metadataBuffer["incident.mode"] = "enabled";
|
||||
metadataBuffer["incident.changedAt"] = incidentSnapshot.ChangedAt.ToString("O", CultureInfo.InvariantCulture);
|
||||
metadataBuffer["incident.retentionExtensionDays"] = incidentSnapshot.RetentionExtensionDays.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var normalizedMetadata = NormalizeMetadata(metadataBuffer);
|
||||
var bundle = new EvidenceBundle(
|
||||
bundleId,
|
||||
tenantId,
|
||||
request.Kind,
|
||||
EvidenceBundleStatus.Pending,
|
||||
EmptyRoot,
|
||||
storageKey,
|
||||
createdAt,
|
||||
createdAt,
|
||||
request.Description,
|
||||
null,
|
||||
expiresAt);
|
||||
|
||||
await _repository.CreateBundleAsync(bundle, cancellationToken).ConfigureAwait(false);
|
||||
var normalizedMaterials = request.Materials
|
||||
.Select(material => new EvidenceBundleMaterial(
|
||||
material.Section ?? string.Empty,
|
||||
material.Path ?? string.Empty,
|
||||
material.Sha256,
|
||||
material.SizeBytes,
|
||||
material.MediaType ?? "application/octet-stream",
|
||||
NormalizeAttributes(material.Attributes)))
|
||||
.ToList();
|
||||
|
||||
if (incidentSnapshot.IsActive &&
|
||||
incidentSnapshot.CaptureRequestSnapshot &&
|
||||
normalizedMaterials.Count < _quotas.MaxMaterialCount)
|
||||
{
|
||||
var incidentMaterial = await TryCaptureIncidentSnapshotAsync(
|
||||
tenantId,
|
||||
bundleId,
|
||||
incidentSnapshot,
|
||||
request,
|
||||
normalizedMetadata,
|
||||
createdAt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (incidentMaterial is not null)
|
||||
{
|
||||
normalizedMaterials.Add(incidentMaterial);
|
||||
}
|
||||
}
|
||||
|
||||
var buildRequest = new EvidenceBundleBuildRequest(
|
||||
bundleId,
|
||||
tenantId,
|
||||
request.Kind,
|
||||
createdAt,
|
||||
normalizedMetadata,
|
||||
normalizedMaterials);
|
||||
|
||||
var buildResult = await _bundleBuilder.BuildAsync(buildRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _repository.SetBundleAssemblyAsync(
|
||||
bundleId,
|
||||
tenantId,
|
||||
EvidenceBundleStatus.Assembling,
|
||||
buildResult.RootHash,
|
||||
createdAt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var signature = await _signatureService.SignManifestAsync(
|
||||
bundleId,
|
||||
tenantId,
|
||||
buildResult.Manifest,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (signature is not null)
|
||||
{
|
||||
await _repository.UpsertSignatureAsync(signature, cancellationToken).ConfigureAwait(false);
|
||||
await _timelinePublisher.PublishBundleSealedAsync(signature, buildResult.Manifest, buildResult.RootHash, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var sealedAt = signature?.TimestampedAt ?? signature?.SignedAt ?? _timeProvider.GetUtcNow();
|
||||
|
||||
await _repository.MarkBundleSealedAsync(
|
||||
bundleId,
|
||||
tenantId,
|
||||
EvidenceBundleStatus.Sealed,
|
||||
sealedAt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new EvidenceSnapshotResult(bundleId.Value, buildResult.RootHash, buildResult.Manifest, signature);
|
||||
}
|
||||
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(
|
||||
TenantId tenantId,
|
||||
EvidenceBundleId bundleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId == default)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
|
||||
}
|
||||
|
||||
if (bundleId == default)
|
||||
{
|
||||
throw new ArgumentException("Bundle identifier is required.", nameof(bundleId));
|
||||
}
|
||||
|
||||
return _repository.GetBundleAsync(bundleId, tenantId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyAsync(
|
||||
TenantId tenantId,
|
||||
EvidenceBundleId bundleId,
|
||||
string expectedRootHash,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expectedRootHash))
|
||||
{
|
||||
throw new ArgumentException("Expected root hash must be provided.", nameof(expectedRootHash));
|
||||
}
|
||||
|
||||
var details = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return details is not null &&
|
||||
string.Equals(details.Bundle.RootHash, expectedRootHash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async Task<EvidenceHold> CreateHoldAsync(
|
||||
TenantId tenantId,
|
||||
string caseId,
|
||||
EvidenceHoldRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId == default)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
|
||||
}
|
||||
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(caseId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason);
|
||||
|
||||
EvidenceBundleId? bundleId = null;
|
||||
if (request.BundleId.HasValue)
|
||||
{
|
||||
bundleId = EvidenceBundleId.FromGuid(request.BundleId.Value);
|
||||
var exists = await _repository.ExistsAsync(bundleId.Value, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!exists)
|
||||
{
|
||||
throw new InvalidOperationException($"Referenced bundle '{bundleId.Value.Value:D}' does not exist for tenant '{tenantId.Value:D}'.");
|
||||
}
|
||||
}
|
||||
|
||||
var holdId = EvidenceHoldId.FromGuid(Guid.NewGuid());
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
var hold = new EvidenceHold(
|
||||
holdId,
|
||||
tenantId,
|
||||
bundleId,
|
||||
caseId,
|
||||
request.Reason,
|
||||
createdAt,
|
||||
request.ExpiresAt,
|
||||
null,
|
||||
request.Notes);
|
||||
|
||||
EvidenceHold persisted;
|
||||
try
|
||||
{
|
||||
persisted = await _repository.CreateHoldAsync(hold, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"A hold already exists for case '{caseId}' in tenant '{tenantId.Value:D}'.", ex);
|
||||
}
|
||||
|
||||
if (bundleId.HasValue)
|
||||
{
|
||||
await _repository.ExtendBundleRetentionAsync(
|
||||
bundleId.Value,
|
||||
tenantId,
|
||||
request.ExpiresAt,
|
||||
createdAt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _timelinePublisher.PublishHoldCreatedAsync(persisted, cancellationToken).ConfigureAwait(false);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
private void ValidateRequest(EvidenceSnapshotRequest request)
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(EvidenceBundleKind), request.Kind))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported evidence bundle kind '{request.Kind}'.");
|
||||
}
|
||||
|
||||
var metadataCount = request.Metadata?.Count ?? 0;
|
||||
if (metadataCount > _quotas.MaxMetadataEntries)
|
||||
{
|
||||
throw new InvalidOperationException($"Metadata entry count {metadataCount} exceeds limit of {_quotas.MaxMetadataEntries}.");
|
||||
}
|
||||
|
||||
if (request.Materials is null || request.Materials.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one material must be supplied for an evidence snapshot.");
|
||||
}
|
||||
|
||||
if (request.Materials.Count > _quotas.MaxMaterialCount)
|
||||
{
|
||||
throw new InvalidOperationException($"Material count {request.Materials.Count} exceeds limit of {_quotas.MaxMaterialCount}.");
|
||||
}
|
||||
|
||||
long totalSizeBytes = 0;
|
||||
|
||||
foreach (var entry in request.Metadata ?? new Dictionary<string, string>())
|
||||
{
|
||||
ValidateMetadata(entry.Key, entry.Value);
|
||||
}
|
||||
|
||||
foreach (var material in request.Materials)
|
||||
{
|
||||
ValidateMaterial(material);
|
||||
totalSizeBytes = checked(totalSizeBytes + material.SizeBytes);
|
||||
if (totalSizeBytes > _quotas.MaxTotalMaterialSizeBytes)
|
||||
{
|
||||
throw new InvalidOperationException($"Material size total {totalSizeBytes} exceeds limit of {_quotas.MaxTotalMaterialSizeBytes} bytes.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateMetadata(string key, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
throw new InvalidOperationException("Metadata keys must be non-empty.");
|
||||
}
|
||||
|
||||
if (key.Length > _quotas.MaxMetadataKeyLength)
|
||||
{
|
||||
throw new InvalidOperationException($"Metadata key '{key}' exceeds length limit of {_quotas.MaxMetadataKeyLength} characters.");
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Metadata value for key '{key}' must not be null.");
|
||||
}
|
||||
|
||||
if (value.Length > _quotas.MaxMetadataValueLength)
|
||||
{
|
||||
throw new InvalidOperationException($"Metadata value for key '{key}' exceeds length limit of {_quotas.MaxMetadataValueLength} characters.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateMaterial(EvidenceSnapshotMaterial material)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(material.Sha256))
|
||||
{
|
||||
throw new InvalidOperationException("Material SHA-256 digest must be provided.");
|
||||
}
|
||||
|
||||
if (material.Sha256.Length != 64 || !IsHex(material.Sha256))
|
||||
{
|
||||
throw new InvalidOperationException($"Material SHA-256 digest '{material.Sha256}' must be 64 hex characters.");
|
||||
}
|
||||
|
||||
if (material.SizeBytes < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Material size bytes cannot be negative.");
|
||||
}
|
||||
|
||||
foreach (var attribute in material.Attributes ?? new Dictionary<string, string>())
|
||||
{
|
||||
ValidateMetadata(attribute.Key, attribute.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> NormalizeMetadata(IDictionary<string, string>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> NormalizeAttributes(IDictionary<string, string>? attributes)
|
||||
{
|
||||
if (attributes is null || attributes.Count == 0)
|
||||
{
|
||||
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private static bool IsHex(string value)
|
||||
{
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var ch = value[i];
|
||||
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<EvidenceBundleMaterial?> TryCaptureIncidentSnapshotAsync(
|
||||
TenantId tenantId,
|
||||
EvidenceBundleId bundleId,
|
||||
IncidentModeSnapshot incidentSnapshot,
|
||||
EvidenceSnapshotRequest request,
|
||||
IReadOnlyDictionary<string, string> normalizedMetadata,
|
||||
DateTimeOffset capturedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
capturedAt = capturedAt,
|
||||
incident = new
|
||||
{
|
||||
state = incidentSnapshot.IsActive ? "enabled" : "disabled",
|
||||
retentionExtensionDays = incidentSnapshot.RetentionExtensionDays
|
||||
},
|
||||
request = new
|
||||
{
|
||||
kind = request.Kind,
|
||||
metadata = normalizedMetadata,
|
||||
materials = request.Materials.Select(material => new
|
||||
{
|
||||
section = material.Section,
|
||||
path = material.Path,
|
||||
sha256 = material.Sha256,
|
||||
sizeBytes = material.SizeBytes,
|
||||
mediaType = material.MediaType,
|
||||
attributes = material.Attributes
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, IncidentSerializerOptions);
|
||||
var artifactFileName = $"request-{capturedAt:yyyyMMddHHmmssfff}.json";
|
||||
var artifactName = $"incident/{artifactFileName}";
|
||||
await using var stream = new MemoryStream(bytes);
|
||||
var metadata = await _objectStore.StoreAsync(
|
||||
stream,
|
||||
new EvidenceObjectWriteOptions(
|
||||
tenantId,
|
||||
bundleId,
|
||||
artifactName,
|
||||
"application/json"),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["storageKey"] = metadata.StorageKey
|
||||
};
|
||||
|
||||
return new EvidenceBundleMaterial(
|
||||
"incident",
|
||||
artifactFileName,
|
||||
metadata.Sha256,
|
||||
metadata.SizeBytes,
|
||||
metadata.ContentType,
|
||||
attributes);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to capture incident snapshot for bundle {BundleId}: {Message}",
|
||||
bundleId.Value,
|
||||
ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Incident;
|
||||
using StellaOps.EvidenceLocker.Core.Notifications;
|
||||
using StellaOps.EvidenceLocker.Core.Timeline;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
internal sealed class IncidentModeManager : IIncidentModeState, IDisposable
|
||||
{
|
||||
private readonly IEvidenceTimelinePublisher _timelinePublisher;
|
||||
private readonly IEvidenceIncidentNotifier _incidentNotifier;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<IncidentModeManager> _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly IDisposable _subscription = null!;
|
||||
|
||||
private IncidentModeSnapshot _current;
|
||||
|
||||
public IncidentModeManager(
|
||||
IOptionsMonitor<EvidenceLockerOptions> optionsMonitor,
|
||||
IEvidenceTimelinePublisher timelinePublisher,
|
||||
IEvidenceIncidentNotifier incidentNotifier,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<IncidentModeManager> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(optionsMonitor);
|
||||
_timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher));
|
||||
_incidentNotifier = incidentNotifier ?? throw new ArgumentNullException(nameof(incidentNotifier));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_current = CreateSnapshot(optionsMonitor.CurrentValue?.Incident, _timeProvider.GetUtcNow());
|
||||
if (_current.IsActive)
|
||||
{
|
||||
var initialChange = new IncidentModeChange(_current.IsActive, _current.ChangedAt, _current.RetentionExtensionDays);
|
||||
_ = Task.Run(() => PublishChangeAsync(initialChange, _cts.Token), _cts.Token);
|
||||
}
|
||||
_subscription = optionsMonitor.OnChange((options, _) => HandleOptionsChanged(options?.Incident))!;
|
||||
}
|
||||
|
||||
public IncidentModeSnapshot Current => _current;
|
||||
|
||||
public bool IsActive => _current.IsActive;
|
||||
|
||||
private void HandleOptionsChanged(IncidentModeOptions? options)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var next = CreateSnapshot(options, now);
|
||||
|
||||
var previous = Interlocked.Exchange(ref _current, next);
|
||||
|
||||
if (previous.IsActive == next.IsActive &&
|
||||
previous.RetentionExtensionDays == next.RetentionExtensionDays &&
|
||||
previous.CaptureRequestSnapshot == next.CaptureRequestSnapshot)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (previous.IsActive != next.IsActive)
|
||||
{
|
||||
var change = new IncidentModeChange(next.IsActive, next.ChangedAt, next.RetentionExtensionDays);
|
||||
_logger.LogInformation(
|
||||
"Incident mode changed to {State} at {ChangedAt} (retention extension: {RetentionDays} days).",
|
||||
next.IsActive ? "enabled" : "disabled",
|
||||
next.ChangedAt,
|
||||
next.RetentionExtensionDays);
|
||||
|
||||
_ = Task.Run(() => PublishChangeAsync(change, _cts.Token), _cts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Incident mode configuration updated (retention extension: {RetentionDays} days, capture request snapshot: {CaptureRequestSnapshot}).",
|
||||
next.RetentionExtensionDays,
|
||||
next.CaptureRequestSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PublishChangeAsync(IncidentModeChange change, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _timelinePublisher.PublishIncidentModeChangedAsync(change, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to publish incident mode change to timeline: {Message}", ex.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _incidentNotifier.PublishIncidentModeChangedAsync(change, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to publish incident mode change notification: {Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private IncidentModeSnapshot CreateSnapshot(IncidentModeOptions? options, DateTimeOffset timestamp)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
return new IncidentModeSnapshot(false, timestamp, 0, false);
|
||||
}
|
||||
|
||||
var retentionExtensionDays = Math.Max(0, options.RetentionExtensionDays);
|
||||
return new IncidentModeSnapshot(
|
||||
options.Enabled,
|
||||
timestamp,
|
||||
retentionExtensionDays,
|
||||
options.CaptureRequestSnapshot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_cts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed by another path.
|
||||
}
|
||||
|
||||
_subscription.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user