Add unit tests for SBOM ingestion and transformation
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:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -0,0 +1,39 @@
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Core.Builders;
public sealed record EvidenceBundleBuildRequest(
EvidenceBundleId BundleId,
TenantId TenantId,
EvidenceBundleKind Kind,
DateTimeOffset CreatedAt,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<EvidenceBundleMaterial> Materials);
public sealed record EvidenceBundleMaterial(
string Section,
string Path,
string Sha256,
long SizeBytes,
string MediaType,
IReadOnlyDictionary<string, string>? Attributes = null);
public sealed record EvidenceManifestEntry(
string Section,
string CanonicalPath,
string Sha256,
long SizeBytes,
string MediaType,
IReadOnlyDictionary<string, string> Attributes);
public sealed record EvidenceBundleManifest(
EvidenceBundleId BundleId,
TenantId TenantId,
EvidenceBundleKind Kind,
DateTimeOffset CreatedAt,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<EvidenceManifestEntry> Entries);
public sealed record EvidenceBundleBuildResult(
string RootHash,
EvidenceBundleManifest Manifest);

View File

@@ -0,0 +1,8 @@
namespace StellaOps.EvidenceLocker.Core.Builders;
public interface IEvidenceBundleBuilder
{
Task<EvidenceBundleBuildResult> BuildAsync(
EvidenceBundleBuildRequest request,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,54 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.EvidenceLocker.Core.Builders;
public interface IMerkleTreeCalculator
{
string CalculateRootHash(IEnumerable<string> canonicalLeafValues);
}
public sealed class MerkleTreeCalculator : IMerkleTreeCalculator
{
public string CalculateRootHash(IEnumerable<string> canonicalLeafValues)
{
var leaves = canonicalLeafValues
.Select(value => HashString(value))
.ToArray();
if (leaves.Length == 0)
{
return HashString("stellaops:evidence:empty");
}
return BuildTree(leaves);
}
private static string BuildTree(IReadOnlyList<string> currentLevel)
{
if (currentLevel.Count == 1)
{
return currentLevel[0];
}
var nextLevel = new List<string>((currentLevel.Count + 1) / 2);
for (var i = 0; i < currentLevel.Count; i += 2)
{
var left = currentLevel[i];
var right = i + 1 < currentLevel.Count ? currentLevel[i + 1] : left;
var combined = string.CompareOrdinal(left, right) <= 0
? $"{left}|{right}"
: $"{right}|{left}";
nextLevel.Add(HashString(combined));
}
return BuildTree(nextLevel);
}
private static string HashString(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.EvidenceLocker.Core;
public class Class1
{
}

View File

@@ -0,0 +1,210 @@
using System.ComponentModel.DataAnnotations;
using StellaOps.Cryptography;
namespace StellaOps.EvidenceLocker.Core.Configuration;
public sealed class EvidenceLockerOptions
{
public const string SectionName = "EvidenceLocker";
[Required]
public required DatabaseOptions Database { get; init; }
[Required]
public required ObjectStoreOptions ObjectStore { get; init; }
[Required]
public required QuotaOptions Quotas { get; init; }
[Required]
public required SigningOptions Signing { get; init; }
public TimelineOptions? Timeline { get; init; }
public PortableOptions Portable { get; init; } = new();
public IncidentModeOptions Incident { get; init; } = new();
}
public sealed class DatabaseOptions
{
[Required]
public required string ConnectionString { get; init; }
/// <summary>
/// Enables automatic execution of SQL migrations at startup.
/// </summary>
public bool ApplyMigrationsAtStartup { get; init; } = true;
}
public enum ObjectStoreKind
{
FileSystem = 1,
AmazonS3 = 2
}
public sealed class ObjectStoreOptions
{
[Required]
public required ObjectStoreKind Kind { get; init; }
/// <summary>
/// When true, drivers must prevent object overwrite (WORM mode).
/// </summary>
public bool EnforceWriteOnce { get; init; } = true;
public FileSystemStoreOptions? FileSystem { get; init; }
public AmazonS3StoreOptions? AmazonS3 { get; init; }
}
public sealed class FileSystemStoreOptions
{
[Required]
public required string RootPath { get; init; }
}
public sealed class AmazonS3StoreOptions
{
[Required]
public required string BucketName { get; init; }
[Required]
public required string Region { get; init; }
/// <summary>
/// Optional prefix to namespace evidence objects.
/// </summary>
public string? Prefix { get; init; }
public bool UseIntelligentTiering { get; init; }
}
public sealed class QuotaOptions
{
[Range(1, 10_000)]
public int MaxMaterialCount { get; init; } = 128;
[Range(1, long.MaxValue)]
public long MaxTotalMaterialSizeBytes { get; init; } = 512L * 1024 * 1024;
[Range(0, 10_000)]
public int MaxMetadataEntries { get; init; } = 64;
[Range(0, 2048)]
public int MaxMetadataKeyLength { get; init; } = 128;
[Range(0, 8192)]
public int MaxMetadataValueLength { get; init; } = 512;
}
public sealed class SigningOptions
{
public bool Enabled { get; init; } = true;
[Required]
public string Algorithm { get; init; } = SignatureAlgorithms.Es256;
[Required]
public string KeyId { get; init; } = string.Empty;
public string? Provider { get; init; }
public string PayloadType { get; init; } = "application/vnd.stella.evidence.manifest+json";
public SigningKeyMaterialOptions? KeyMaterial { get; init; }
public TimestampingOptions? Timestamping { get; init; }
}
public sealed class SigningKeyMaterialOptions
{
/// <summary>
/// Optional PEM-encoded EC private key used to seed the default provider.
/// </summary>
public string? EcPrivateKeyPem { get; init; }
/// <summary>
/// Optional PEM-encoded EC public key to accompany the private key when seeding providers that require explicit public material.
/// </summary>
public string? EcPublicKeyPem { get; init; }
}
public sealed class TimestampingOptions
{
public bool Enabled { get; init; }
[Url]
public string? Endpoint { get; init; }
public string HashAlgorithm { get; init; } = "SHA256";
public bool RequireTimestamp { get; init; }
[Range(1, 300)]
public int RequestTimeoutSeconds { get; init; } = 30;
public TimestampAuthorityAuthenticationOptions? Authentication { get; init; }
}
public sealed class TimestampAuthorityAuthenticationOptions
{
public string? Username { get; init; }
public string? Password { get; init; }
}
public sealed class IncidentModeOptions
{
public bool Enabled { get; init; }
[Range(0, 3650)]
public int RetentionExtensionDays { get; init; } = 30;
public bool CaptureRequestSnapshot { get; init; } = true;
}
public sealed class TimelineOptions
{
public bool Enabled { get; init; }
[Url]
public string? Endpoint { get; init; }
[Range(1, 300)]
public int RequestTimeoutSeconds { get; init; } = 15;
public string Source { get; init; } = "stellaops.evidence-locker";
public TimelineAuthenticationOptions? Authentication { get; init; }
}
public sealed class TimelineAuthenticationOptions
{
public string HeaderName { get; init; } = "Authorization";
public string Scheme { get; init; } = "Bearer";
public string? Token { get; init; }
}
public sealed class PortableOptions
{
public bool Enabled { get; init; } = true;
[Required]
[MinLength(1)]
public string ArtifactName { get; init; } = "portable-bundle-v1.tgz";
[Required]
[MinLength(1)]
public string InstructionsFileName { get; init; } = "instructions-portable.txt";
[Required]
[MinLength(1)]
public string OfflineScriptFileName { get; init; } = "verify-offline.sh";
[Required]
[MinLength(1)]
public string MetadataFileName { get; init; } = "bundle.json";
}

View File

@@ -0,0 +1,54 @@
namespace StellaOps.EvidenceLocker.Core.Domain;
public enum EvidenceBundleKind
{
Evaluation = 1,
Job = 2,
Export = 3
}
public enum EvidenceBundleStatus
{
Pending = 1,
Assembling = 2,
Sealed = 3,
Failed = 4,
Archived = 5
}
public sealed record EvidenceBundle(
EvidenceBundleId Id,
TenantId TenantId,
EvidenceBundleKind Kind,
EvidenceBundleStatus Status,
string RootHash,
string StorageKey,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
string? Description = null,
DateTimeOffset? SealedAt = null,
DateTimeOffset? ExpiresAt = null,
string? PortableStorageKey = null,
DateTimeOffset? PortableGeneratedAt = null);
public sealed record EvidenceArtifact(
EvidenceArtifactId Id,
EvidenceBundleId BundleId,
TenantId TenantId,
string Name,
string ContentType,
long SizeBytes,
string StorageKey,
string Sha256,
DateTimeOffset CreatedAt);
public sealed record EvidenceHold(
EvidenceHoldId Id,
TenantId TenantId,
EvidenceBundleId? BundleId,
string CaseId,
string Reason,
DateTimeOffset CreatedAt,
DateTimeOffset? ExpiresAt,
DateTimeOffset? ReleasedAt,
string? Notes = null);

View File

@@ -0,0 +1,21 @@
using System;
namespace StellaOps.EvidenceLocker.Core.Domain;
public sealed record EvidenceBundleSignature(
EvidenceBundleId BundleId,
TenantId TenantId,
string PayloadType,
string Payload,
string Signature,
string? KeyId,
string Algorithm,
string Provider,
DateTimeOffset SignedAt,
DateTimeOffset? TimestampedAt = null,
string? TimestampAuthority = null,
byte[]? TimestampToken = null);
public sealed record EvidenceBundleDetails(
EvidenceBundle Bundle,
EvidenceBundleSignature? Signature);

View File

@@ -0,0 +1,41 @@
namespace StellaOps.EvidenceLocker.Core.Domain;
public readonly record struct TenantId(Guid Value)
{
public static TenantId FromGuid(Guid value)
=> value == Guid.Empty
? throw new ArgumentException("Tenant identifier cannot be empty.", nameof(value))
: new TenantId(value);
public override string ToString() => Value.ToString("N");
}
public readonly record struct EvidenceBundleId(Guid Value)
{
public static EvidenceBundleId FromGuid(Guid value)
=> value == Guid.Empty
? throw new ArgumentException("Bundle identifier cannot be empty.", nameof(value))
: new EvidenceBundleId(value);
public override string ToString() => Value.ToString("N");
}
public readonly record struct EvidenceArtifactId(Guid Value)
{
public static EvidenceArtifactId FromGuid(Guid value)
=> value == Guid.Empty
? throw new ArgumentException("Artifact identifier cannot be empty.", nameof(value))
: new EvidenceArtifactId(value);
public override string ToString() => Value.ToString("N");
}
public readonly record struct EvidenceHoldId(Guid Value)
{
public static EvidenceHoldId FromGuid(Guid value)
=> value == Guid.Empty
? throw new ArgumentException("Hold identifier cannot be empty.", nameof(value))
: new EvidenceHoldId(value);
public override string ToString() => Value.ToString("N");
}

View File

@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using StellaOps.EvidenceLocker.Core.Builders;
namespace StellaOps.EvidenceLocker.Core.Domain;
public sealed record EvidenceSnapshotRequest
{
public EvidenceBundleKind Kind { get; init; }
public string? Description { get; init; }
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
public IList<EvidenceSnapshotMaterial> Materials { get; init; } = new List<EvidenceSnapshotMaterial>();
}
public sealed record EvidenceSnapshotMaterial
{
public string? Section { get; init; }
public string? Path { get; init; }
public string Sha256 { get; init; } = string.Empty;
public long SizeBytes { get; init; }
public string? MediaType { get; init; }
public IDictionary<string, string> Attributes { get; init; } = new Dictionary<string, string>();
}
public sealed record EvidenceSnapshotResult(
Guid BundleId,
string RootHash,
EvidenceBundleManifest Manifest,
EvidenceBundleSignature? Signature);
public sealed record EvidenceHoldRequest
{
public Guid? BundleId { get; init; }
public string Reason { get; init; } = string.Empty;
public DateTimeOffset? ExpiresAt { get; init; }
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,21 @@
using System;
namespace StellaOps.EvidenceLocker.Core.Incident;
public interface IIncidentModeState
{
IncidentModeSnapshot Current { get; }
bool IsActive { get; }
}
public sealed record IncidentModeSnapshot(
bool IsActive,
DateTimeOffset ChangedAt,
int RetentionExtensionDays,
bool CaptureRequestSnapshot);
public sealed record IncidentModeChange(
bool IsActive,
DateTimeOffset ChangedAt,
int RetentionExtensionDays);

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Incident;
namespace StellaOps.EvidenceLocker.Core.Notifications;
public interface IEvidenceIncidentNotifier
{
Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken);
}
public sealed class NullEvidenceIncidentNotifier : IEvidenceIncidentNotifier
{
public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
=> Task.CompletedTask;
}

View File

@@ -0,0 +1,53 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Core.Repositories;
public interface IEvidenceBundleRepository
{
Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken);
Task SetBundleAssemblyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleStatus status,
string rootHash,
DateTimeOffset updatedAt,
CancellationToken cancellationToken);
Task MarkBundleSealedAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleStatus status,
DateTimeOffset sealedAt,
CancellationToken cancellationToken);
Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken);
Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken);
Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken);
Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken);
Task ExtendBundleRetentionAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
DateTimeOffset? holdExpiresAt,
DateTimeOffset processedAt,
CancellationToken cancellationToken);
Task UpdateStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
CancellationToken cancellationToken);
Task UpdatePortableStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
DateTimeOffset generatedAt,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Core.Signing;
public interface IEvidenceSignatureService
{
Task<EvidenceBundleSignature?> SignManifestAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleManifest manifest,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.EvidenceLocker.Core.Signing;
public interface ITimestampAuthorityClient
{
Task<TimestampResult?> RequestTimestampAsync(
ReadOnlyMemory<byte> signature,
string hashAlgorithm,
CancellationToken cancellationToken);
}
public sealed record TimestampResult(
DateTimeOffset Timestamp,
string Authority,
byte[] Token);

View File

@@ -1,18 +1,14 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
<?xml version="1.0"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Core.Storage;
public sealed record EvidenceObjectMetadata(
string StorageKey,
string ContentType,
long SizeBytes,
string Sha256,
string? ETag,
DateTimeOffset CreatedAt);
public sealed record EvidenceObjectWriteOptions(
TenantId TenantId,
EvidenceBundleId BundleId,
string ArtifactName,
string ContentType,
bool EnforceWriteOnce = true,
IDictionary<string, string>? Tags = null);
public interface IEvidenceObjectStore
{
Task<EvidenceObjectMetadata> StoreAsync(
Stream content,
EvidenceObjectWriteOptions options,
CancellationToken cancellationToken);
Task<Stream> OpenReadAsync(
string storageKey,
CancellationToken cancellationToken);
Task<bool> ExistsAsync(
string storageKey,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,24 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Incident;
namespace StellaOps.EvidenceLocker.Core.Timeline;
public interface IEvidenceTimelinePublisher
{
Task PublishBundleSealedAsync(
EvidenceBundleSignature signature,
EvidenceBundleManifest manifest,
string rootHash,
CancellationToken cancellationToken);
Task PublishHoldCreatedAsync(
EvidenceHold hold,
CancellationToken cancellationToken);
Task PublishIncidentModeChangedAsync(
IncidentModeChange change,
CancellationToken cancellationToken);
}