Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Infrastructure/Services/EvidenceBundlePackagingService.cs
master 2eb6852d34
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add unit tests for SBOM ingestion and transformation
- 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.
2025-11-04 07:49:39 +02:00

314 lines
12 KiB
C#

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);