372 lines
16 KiB
C#
372 lines
16 KiB
C#
|
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.EvidenceLocker.Core.Configuration;
|
|
using StellaOps.EvidenceLocker.Core.Domain;
|
|
using StellaOps.EvidenceLocker.Core.Repositories;
|
|
using StellaOps.EvidenceLocker.Core.Storage;
|
|
using StellaOps.EvidenceLocker.Infrastructure.Services;
|
|
using StellaOps.TestKit;
|
|
using System.Formats.Tar;
|
|
using System.IO.Compression;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.EvidenceLocker.Tests;
|
|
|
|
public sealed class EvidencePortableBundleServiceTests
|
|
{
|
|
private static readonly TenantId TenantId = TenantId.FromGuid(Guid.NewGuid());
|
|
private static readonly EvidenceBundleId BundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
|
private static readonly DateTimeOffset CreatedAt = new(2025, 11, 4, 10, 30, 0, TimeSpan.Zero);
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task EnsurePortablePackageAsync_ReturnsCached_WhenObjectExists()
|
|
{
|
|
var bundle = CreateSealedBundle(
|
|
portableStorageKey: "tenants/foo/bundles/bar/portable-bundle-v1.tgz",
|
|
portableGeneratedAt: CreatedAt);
|
|
var repository = new FakeRepository(bundle, CreateSignature());
|
|
var objectStore = new FakeObjectStore(exists: true);
|
|
var service = CreateService(repository, objectStore);
|
|
|
|
var result = await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
|
|
|
|
Assert.False(result.Created);
|
|
Assert.Equal(bundle.RootHash, result.RootHash);
|
|
Assert.Equal(bundle.PortableStorageKey, result.StorageKey);
|
|
Assert.False(objectStore.Stored);
|
|
Assert.False(repository.PortableStorageKeyUpdated);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task EnsurePortablePackageAsync_CreatesPortableArchiveWithRedactedMetadata()
|
|
{
|
|
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature(includeTimestamp: true));
|
|
var objectStore = new FakeObjectStore(exists: false, fixedStorageKey: "tenants/foo/bundles/bar/portable-bundle-v1.tgz");
|
|
var service = CreateService(repository, objectStore);
|
|
|
|
var result = await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
|
|
|
|
Assert.True(result.Created);
|
|
Assert.True(objectStore.Stored);
|
|
Assert.NotNull(objectStore.StoredBytes);
|
|
Assert.True(repository.PortableStorageKeyUpdated);
|
|
Assert.NotNull(repository.Bundle.PortableStorageKey);
|
|
Assert.NotNull(repository.Bundle.PortableGeneratedAt);
|
|
|
|
var entries = ReadArchiveEntries(objectStore.StoredBytes!);
|
|
Assert.Contains("manifest.json", entries.Keys);
|
|
Assert.Contains("signature.json", entries.Keys);
|
|
Assert.Contains("bundle.json", entries.Keys);
|
|
Assert.Contains("instructions-portable.txt", entries.Keys);
|
|
Assert.Contains("verify-offline.sh", entries.Keys);
|
|
|
|
using var bundleJson = JsonDocument.Parse(entries["bundle.json"]);
|
|
var root = bundleJson.RootElement;
|
|
Assert.False(root.TryGetProperty("tenantId", out _));
|
|
Assert.False(root.TryGetProperty("storageKey", out _));
|
|
Assert.False(root.TryGetProperty("description", out _));
|
|
Assert.Equal(repository.Bundle.Id.Value.ToString("D"), root.GetProperty("bundleId").GetString());
|
|
Assert.Equal(repository.Bundle.RootHash, root.GetProperty("rootHash").GetString());
|
|
Assert.True(root.TryGetProperty("portableGeneratedAt", out var generatedAtProperty));
|
|
Assert.True(DateTimeOffset.TryParse(generatedAtProperty.GetString(), out _));
|
|
|
|
var incidentMetadata = root.GetProperty("incidentMetadata");
|
|
Assert.Equal(JsonValueKind.Object, incidentMetadata.ValueKind);
|
|
Assert.Contains(incidentMetadata.EnumerateObject(), p => p.Name.StartsWith("incident.", StringComparison.Ordinal));
|
|
|
|
var instructions = entries["instructions-portable.txt"];
|
|
Assert.Contains("Portable Evidence Bundle Instructions", instructions, StringComparison.Ordinal);
|
|
Assert.Contains("verify-offline.sh", instructions, StringComparison.Ordinal);
|
|
|
|
var script = entries["verify-offline.sh"];
|
|
Assert.StartsWith("#!/usr/bin/env sh", script, StringComparison.Ordinal);
|
|
Assert.Contains("sha256sum", script, StringComparison.Ordinal);
|
|
Assert.Contains("stella evidence verify", script, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task EnsurePortablePackageAsync_Throws_WhenSignatureMissing()
|
|
{
|
|
var repository = new FakeRepository(CreateSealedBundle(), signature: null);
|
|
var objectStore = new FakeObjectStore(exists: false);
|
|
var service = CreateService(repository, objectStore);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None));
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task EnsurePortablePackageAsync_ProducesDeterministicTarEntryMetadata()
|
|
{
|
|
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature(includeTimestamp: true));
|
|
var objectStore = new FakeObjectStore(exists: false);
|
|
var service = CreateService(repository, objectStore);
|
|
|
|
await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
|
|
|
|
Assert.True(objectStore.Stored);
|
|
var entryMetadata = ReadArchiveEntryMetadata(objectStore.StoredBytes!);
|
|
|
|
// Verify all entries have deterministic uid/gid/username/groupname per bundle-packaging.md
|
|
foreach (var (name, meta) in entryMetadata)
|
|
{
|
|
Assert.Equal(0, meta.Uid);
|
|
Assert.Equal(0, meta.Gid);
|
|
Assert.True(
|
|
string.IsNullOrEmpty(meta.UserName),
|
|
$"Entry '{name}' should have empty username but was '{meta.UserName}'");
|
|
Assert.True(
|
|
string.IsNullOrEmpty(meta.GroupName),
|
|
$"Entry '{name}' should have empty groupname but was '{meta.GroupName}'");
|
|
Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), meta.ModificationTime);
|
|
}
|
|
}
|
|
|
|
private static EvidencePortableBundleService CreateService(FakeRepository repository, IEvidenceObjectStore objectStore)
|
|
{
|
|
var options = Options.Create(new EvidenceLockerOptions
|
|
{
|
|
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
|
|
ObjectStore = new ObjectStoreOptions { Kind = ObjectStoreKind.FileSystem, FileSystem = new FileSystemStoreOptions { RootPath = "." } },
|
|
Quotas = new QuotaOptions(),
|
|
Signing = new SigningOptions(),
|
|
Portable = new PortableOptions()
|
|
});
|
|
|
|
return new EvidencePortableBundleService(
|
|
repository,
|
|
objectStore,
|
|
options,
|
|
TimeProvider.System,
|
|
NullLogger<EvidencePortableBundleService>.Instance);
|
|
}
|
|
|
|
private static EvidenceBundle CreateSealedBundle(
|
|
string? portableStorageKey = null,
|
|
DateTimeOffset? portableGeneratedAt = null)
|
|
=> new EvidenceBundle(
|
|
BundleId,
|
|
TenantId,
|
|
EvidenceBundleKind.Evaluation,
|
|
EvidenceBundleStatus.Sealed,
|
|
new string('f', 64),
|
|
"tenants/foo/bundles/bar/bundle.tgz",
|
|
CreatedAt,
|
|
CreatedAt,
|
|
Description: "sensitive",
|
|
SealedAt: CreatedAt.AddMinutes(5),
|
|
ExpiresAt: CreatedAt.AddDays(30),
|
|
PortableStorageKey: portableStorageKey,
|
|
PortableGeneratedAt: portableGeneratedAt);
|
|
|
|
private static EvidenceBundleSignature CreateSignature(bool includeTimestamp = false)
|
|
{
|
|
var manifest = new
|
|
{
|
|
bundleId = BundleId.Value,
|
|
tenantId = TenantId.Value,
|
|
kind = (int)EvidenceBundleKind.Evaluation,
|
|
createdAt = CreatedAt,
|
|
metadata = new Dictionary<string, string>
|
|
{
|
|
["pipeline"] = "ops",
|
|
["incident.mode"] = "enabled",
|
|
["incident.changedAt"] = CreatedAt.ToString("O"),
|
|
["incident.retentionExtensionDays"] = "60"
|
|
},
|
|
entries = new[]
|
|
{
|
|
new
|
|
{
|
|
section = "inputs",
|
|
canonicalPath = "inputs/config.json",
|
|
sha256 = new string('a', 64),
|
|
sizeBytes = 128L,
|
|
mediaType = "application/json",
|
|
attributes = new Dictionary<string, string>()
|
|
}
|
|
}
|
|
};
|
|
|
|
var payload = JsonSerializer.Serialize(manifest);
|
|
|
|
return new EvidenceBundleSignature(
|
|
BundleId,
|
|
TenantId,
|
|
"application/vnd.stella.evidence.manifest+json",
|
|
Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)),
|
|
"sig-payload",
|
|
"key-id",
|
|
"ES256",
|
|
"provider",
|
|
CreatedAt,
|
|
includeTimestamp ? CreatedAt.AddMinutes(1) : null,
|
|
includeTimestamp ? "tsa.default" : null,
|
|
includeTimestamp ? Encoding.UTF8.GetBytes("tsa-token") : null);
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, string> ReadArchiveEntries(byte[] archive)
|
|
{
|
|
using var memory = new MemoryStream(archive);
|
|
using var gzip = new GZipStream(memory, CompressionMode.Decompress);
|
|
using var tarReader = new TarReader(gzip);
|
|
|
|
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
TarEntry? entry;
|
|
while ((entry = tarReader.GetNextEntry()) is not null)
|
|
{
|
|
if (entry.EntryType != TarEntryType.RegularFile)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
using var entryStream = new MemoryStream();
|
|
entry.DataStream!.CopyTo(entryStream);
|
|
entries[entry.Name] = Encoding.UTF8.GetString(entryStream.ToArray());
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
private static Dictionary<string, TarEntryMetadata> ReadArchiveEntryMetadata(byte[] archive)
|
|
{
|
|
using var memory = new MemoryStream(archive);
|
|
using var gzip = new GZipStream(memory, CompressionMode.Decompress);
|
|
using var tarReader = new TarReader(gzip);
|
|
|
|
var entries = new Dictionary<string, TarEntryMetadata>(StringComparer.Ordinal);
|
|
TarEntry? entry;
|
|
while ((entry = tarReader.GetNextEntry()) is not null)
|
|
{
|
|
if (entry.EntryType != TarEntryType.RegularFile)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var posixEntry = entry as PosixTarEntry;
|
|
entries[entry.Name] = new TarEntryMetadata(
|
|
entry.Uid,
|
|
entry.Gid,
|
|
posixEntry?.UserName ?? string.Empty,
|
|
posixEntry?.GroupName ?? string.Empty,
|
|
entry.ModificationTime);
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
private sealed record TarEntryMetadata(
|
|
int Uid,
|
|
int Gid,
|
|
string UserName,
|
|
string GroupName,
|
|
DateTimeOffset ModificationTime);
|
|
|
|
private sealed class FakeRepository : IEvidenceBundleRepository
|
|
{
|
|
private EvidenceBundle _bundle;
|
|
|
|
public FakeRepository(EvidenceBundle bundle, EvidenceBundleSignature? signature)
|
|
{
|
|
_bundle = bundle;
|
|
Signature = signature;
|
|
}
|
|
|
|
public EvidenceBundle Bundle => _bundle;
|
|
public EvidenceBundleSignature? Signature { get; }
|
|
public bool PortableStorageKeyUpdated { get; private set; }
|
|
|
|
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
|
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
|
|
|
|
public Task<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
|
|
TenantId tenantId,
|
|
DateTimeOffset? since,
|
|
DateTimeOffset? cursorUpdatedAt,
|
|
EvidenceBundleId? cursorBundleId,
|
|
int limit,
|
|
CancellationToken cancellationToken)
|
|
=> Task.FromResult<IReadOnlyList<EvidenceBundleDetails>>(Array.Empty<EvidenceBundleDetails>());
|
|
|
|
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
|
=> Task.FromResult(true);
|
|
|
|
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
|
=> Task.FromResult(hold);
|
|
|
|
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
|
|
{
|
|
PortableStorageKeyUpdated = true;
|
|
_bundle = _bundle with
|
|
{
|
|
PortableStorageKey = storageKey,
|
|
PortableGeneratedAt = generatedAt
|
|
};
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeObjectStore : IEvidenceObjectStore
|
|
{
|
|
private readonly bool _exists;
|
|
private readonly string? _fixedStorageKey;
|
|
|
|
public FakeObjectStore(bool exists, string? fixedStorageKey = null)
|
|
{
|
|
_exists = exists;
|
|
_fixedStorageKey = fixedStorageKey;
|
|
}
|
|
|
|
public bool Stored { get; private set; }
|
|
public byte[]? StoredBytes { get; private set; }
|
|
|
|
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
|
|
{
|
|
Stored = true;
|
|
using var memory = new MemoryStream();
|
|
content.CopyTo(memory);
|
|
StoredBytes = memory.ToArray();
|
|
|
|
var storageKey = _fixedStorageKey ?? $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/portable-bundle-v1.tgz";
|
|
|
|
return Task.FromResult(new EvidenceObjectMetadata(
|
|
storageKey,
|
|
options.ContentType,
|
|
StoredBytes.Length,
|
|
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(StoredBytes)).ToLowerInvariant(),
|
|
ETag: null,
|
|
CreatedAt: DateTimeOffset.UtcNow));
|
|
}
|
|
|
|
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
|
|
=> Task.FromResult<Stream>(new MemoryStream(StoredBytes ?? Array.Empty<byte>()));
|
|
|
|
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
|
|
=> Task.FromResult(_exists);
|
|
}
|
|
}
|