new advisories work and features gaps work

This commit is contained in:
master
2026-01-14 18:39:19 +02:00
parent 95d5898650
commit 15aeac8e8b
148 changed files with 16731 additions and 554 deletions

View File

@@ -7,6 +7,8 @@
- Maintain evidence bundle schemas and export formats.
- Provide API and worker workflows for evidence packaging and retrieval.
- Enforce deterministic ordering, hashing, and offline-friendly behavior.
- Support transparency log (Rekor) and RFC3161 timestamp references in bundle metadata.
- Support S3 Object Lock for WORM retention and legal hold when configured.
## Required Reading
- docs/README.md
@@ -16,13 +18,19 @@
- docs/modules/evidence-locker/export-format.md
- docs/modules/evidence-locker/evidence-bundle-v1.md
- docs/modules/evidence-locker/attestation-contract.md
- docs/modules/evidence-locker/schemas/stellaops-evidence-pack.v1.schema.json
- docs/modules/evidence-locker/schemas/bundle.manifest.schema.json
## Working Agreement
- Deterministic ordering and invariant formatting for export artifacts.
- Use TimeProvider and IGuidGenerator where timestamps or IDs are created.
- Propagate CancellationToken for async operations.
- Keep offline-first behavior (no network dependencies unless explicitly configured).
- Bundle manifests must serialize transparency and timestamp references in deterministic order (logIndex, tokenPath).
- Object Lock configuration is validated at startup when enabled.
## Testing Strategy
- Unit tests for bundling, export serialization, and hash stability.
- Schema evolution tests for bundle compatibility.
- Tests for transparency and timestamp reference serialization.
- Tests for Object Lock configuration validation.

View File

@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Core.Builders;
@@ -26,13 +27,35 @@ public sealed record EvidenceManifestEntry(
string MediaType,
IReadOnlyDictionary<string, string> Attributes);
/// <summary>
/// Transparency log reference for audit trail verification.
/// </summary>
public sealed record TransparencyReference(
string Uuid,
long LogIndex,
string? RootHash = null,
string? InclusionProofPath = null,
string? LogUrl = null);
/// <summary>
/// RFC3161 timestamp reference for bundle time anchor.
/// </summary>
public sealed record TimestampReference(
string TokenPath,
string HashAlgorithm,
DateTimeOffset? SignedAt = null,
string? TsaName = null,
string? TsaUrl = null);
public sealed record EvidenceBundleManifest(
EvidenceBundleId BundleId,
TenantId TenantId,
EvidenceBundleKind Kind,
DateTimeOffset CreatedAt,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<EvidenceManifestEntry> Entries);
IReadOnlyList<EvidenceManifestEntry> Entries,
IReadOnlyList<TransparencyReference>? TransparencyReferences = null,
IReadOnlyList<TimestampReference>? TimestampReferences = null);
public sealed record EvidenceBundleBuildResult(
string RootHash,

View File

@@ -83,6 +83,54 @@ public sealed class AmazonS3StoreOptions
public string? Prefix { get; init; }
public bool UseIntelligentTiering { get; init; }
/// <summary>
/// S3 Object Lock configuration for WORM retention and legal hold support.
/// </summary>
public ObjectLockOptions? ObjectLock { get; init; }
}
/// <summary>
/// Object Lock semantics for immutable evidence objects.
/// </summary>
public enum ObjectLockMode
{
/// <summary>
/// Governance mode: can be bypassed by users with s3:BypassGovernanceRetention permission.
/// </summary>
Governance = 1,
/// <summary>
/// Compliance mode: cannot be overwritten or deleted by any user, including root.
/// </summary>
Compliance = 2
}
/// <summary>
/// S3 Object Lock configuration for WORM retention support.
/// </summary>
public sealed class ObjectLockOptions
{
/// <summary>
/// Whether Object Lock is enabled for evidence objects.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// Object Lock mode (Governance or Compliance).
/// </summary>
public ObjectLockMode Mode { get; init; } = ObjectLockMode.Governance;
/// <summary>
/// Default retention period in days for evidence objects.
/// </summary>
[Range(1, 36500)]
public int DefaultRetentionDays { get; init; } = 90;
/// <summary>
/// Whether to apply legal hold to evidence objects by default.
/// </summary>
public bool DefaultLegalHold { get; init; }
}
public sealed class QuotaOptions

View File

@@ -17,7 +17,9 @@ public sealed record EvidenceObjectWriteOptions(
string ArtifactName,
string ContentType,
bool EnforceWriteOnce = true,
IDictionary<string, string>? Tags = null);
IDictionary<string, string>? Tags = null,
int? RetentionOverrideDays = null,
bool? LegalHoldOverride = null);
public interface IEvidenceObjectStore
{

View File

@@ -230,6 +230,59 @@ public sealed class EvidenceSignatureService : IEvidenceSignatureService
writer.WriteEndObject();
}
writer.WriteEndArray();
// Serialize transparency references for audit trail verification
if (manifest.TransparencyReferences is { Count: > 0 })
{
writer.WriteStartArray("transparency");
foreach (var transparency in manifest.TransparencyReferences.OrderBy(t => t.LogIndex))
{
writer.WriteStartObject();
writer.WriteString("uuid", transparency.Uuid);
writer.WriteNumber("logIndex", transparency.LogIndex);
if (!string.IsNullOrWhiteSpace(transparency.RootHash))
{
writer.WriteString("rootHash", transparency.RootHash);
}
if (!string.IsNullOrWhiteSpace(transparency.InclusionProofPath))
{
writer.WriteString("inclusionProofPath", transparency.InclusionProofPath);
}
if (!string.IsNullOrWhiteSpace(transparency.LogUrl))
{
writer.WriteString("logUrl", transparency.LogUrl);
}
writer.WriteEndObject();
}
writer.WriteEndArray();
}
// Serialize timestamp references for RFC3161 time anchors
if (manifest.TimestampReferences is { Count: > 0 })
{
writer.WriteStartArray("timestamps");
foreach (var timestamp in manifest.TimestampReferences.OrderBy(t => t.TokenPath, StringComparer.Ordinal))
{
writer.WriteStartObject();
writer.WriteString("tokenPath", timestamp.TokenPath);
writer.WriteString("hashAlgorithm", timestamp.HashAlgorithm);
if (timestamp.SignedAt.HasValue)
{
writer.WriteString("signedAt", timestamp.SignedAt.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(timestamp.TsaName))
{
writer.WriteString("tsaName", timestamp.TsaName);
}
if (!string.IsNullOrWhiteSpace(timestamp.TsaUrl))
{
writer.WriteString("tsaUrl", timestamp.TsaUrl);
}
writer.WriteEndObject();
}
writer.WriteEndArray();
}
writer.WriteEndObject();
writer.Flush();
return buffer.WrittenSpan.ToArray();

View File

@@ -33,6 +33,34 @@ internal sealed class S3EvidenceObjectStore : IEvidenceObjectStore, IDisposable
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
ValidateObjectLockConfiguration();
}
/// <summary>
/// Validates Object Lock configuration at startup to ensure proper setup.
/// </summary>
private void ValidateObjectLockConfiguration()
{
var objectLock = _options.ObjectLock;
if (objectLock is null || !objectLock.Enabled)
{
return;
}
if (objectLock.DefaultRetentionDays <= 0)
{
throw new InvalidOperationException("Object Lock retention days must be greater than zero when enabled.");
}
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation(
"S3 Object Lock enabled: Mode={Mode}, RetentionDays={RetentionDays}, LegalHold={LegalHold}",
objectLock.Mode,
objectLock.DefaultRetentionDays,
objectLock.DefaultLegalHold);
}
}
public async Task<EvidenceObjectMetadata> StoreAsync(
@@ -188,10 +216,16 @@ internal sealed class S3EvidenceObjectStore : IEvidenceObjectStore, IDisposable
request.Headers["If-None-Match"] = "*";
}
// Apply Object Lock settings for WORM retention
ApplyObjectLockSettings(request, options);
try
{
var response = await _s3.PutObjectAsync(request, cancellationToken);
// Apply legal hold if configured (requires separate API call)
await ApplyLegalHoldAsync(storageKey, options, cancellationToken);
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Uploaded evidence object {Key} to bucket {Bucket} (ETag: {ETag}).", storageKey, _options.BucketName, response.ETag);
@@ -213,6 +247,81 @@ internal sealed class S3EvidenceObjectStore : IEvidenceObjectStore, IDisposable
}
}
/// <summary>
/// Applies Object Lock retention settings to a PutObject request.
/// </summary>
private void ApplyObjectLockSettings(PutObjectRequest request, EvidenceObjectWriteOptions writeOptions)
{
var objectLock = _options.ObjectLock;
if (objectLock is null || !objectLock.Enabled)
{
return;
}
// Set Object Lock mode
request.ObjectLockMode = objectLock.Mode switch
{
Core.Configuration.ObjectLockMode.Compliance => Amazon.S3.ObjectLockMode.Compliance,
Core.Configuration.ObjectLockMode.Governance => Amazon.S3.ObjectLockMode.Governance,
_ => Amazon.S3.ObjectLockMode.Governance
};
// Calculate retention date
var retentionDays = writeOptions.RetentionOverrideDays ?? objectLock.DefaultRetentionDays;
var retainUntil = _timeProvider.GetUtcNow().AddDays(retentionDays);
request.ObjectLockRetainUntilDate = retainUntil.UtcDateTime;
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(
"Applying Object Lock to {Key}: Mode={Mode}, RetainUntil={RetainUntil}",
request.Key,
request.ObjectLockMode,
request.ObjectLockRetainUntilDate);
}
}
/// <summary>
/// Applies legal hold to an uploaded object if configured.
/// </summary>
private async Task ApplyLegalHoldAsync(
string storageKey,
EvidenceObjectWriteOptions writeOptions,
CancellationToken cancellationToken)
{
var objectLock = _options.ObjectLock;
if (objectLock is null || !objectLock.Enabled)
{
return;
}
var applyLegalHold = writeOptions.LegalHoldOverride ?? objectLock.DefaultLegalHold;
if (!applyLegalHold)
{
return;
}
try
{
await _s3.PutObjectLegalHoldAsync(new PutObjectLegalHoldRequest
{
BucketName = _options.BucketName,
Key = storageKey,
LegalHold = new ObjectLockLegalHold { Status = ObjectLockLegalHoldStatus.On }
}, cancellationToken);
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Applied legal hold to evidence object {Key}.", storageKey);
}
}
catch (AmazonS3Exception ex)
{
_logger.LogWarning(ex, "Failed to apply legal hold to evidence object {Key}.", storageKey);
// Don't throw - legal hold is best-effort if Object Lock mode allows it
}
}
private static void TryCleanupTempFile(string path)
{
try

View File

@@ -159,6 +159,99 @@ public sealed class EvidenceSignatureServiceTests
Assert.Equal("zeta", enumerator.Current.Name);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignManifestAsync_SerializesTransparencyReferences_WhenPresent()
{
var timestampClient = new FakeTimestampAuthorityClient();
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
var service = CreateService(timestampClient, timeProvider);
var transparencyRefs = new List<TransparencyReference>
{
new("uuid-123", 42, "sha256:abc123", "/proof/path", "https://rekor.example")
};
var manifest = CreateManifest(transparencyReferences: transparencyRefs);
var signature = await service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None);
Assert.NotNull(signature);
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(signature!.Payload));
using var document = JsonDocument.Parse(payloadJson);
Assert.True(document.RootElement.TryGetProperty("transparency", out var transparencyElement));
Assert.Equal(JsonValueKind.Array, transparencyElement.ValueKind);
Assert.Single(transparencyElement.EnumerateArray());
var entry = transparencyElement[0];
Assert.Equal("uuid-123", entry.GetProperty("uuid").GetString());
Assert.Equal(42, entry.GetProperty("logIndex").GetInt64());
Assert.Equal("sha256:abc123", entry.GetProperty("rootHash").GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignManifestAsync_SerializesTimestampReferences_WhenPresent()
{
var timestampClient = new FakeTimestampAuthorityClient();
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
var service = CreateService(timestampClient, timeProvider);
var signedAt = new DateTimeOffset(2025, 11, 3, 9, 0, 0, TimeSpan.Zero);
var timestampRefs = new List<TimestampReference>
{
new("timestamps/manifest.tsr", "SHA256", signedAt, "Test TSA", "https://tsa.example")
};
var manifest = CreateManifest(timestampReferences: timestampRefs);
var signature = await service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None);
Assert.NotNull(signature);
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(signature!.Payload));
using var document = JsonDocument.Parse(payloadJson);
Assert.True(document.RootElement.TryGetProperty("timestamps", out var timestampsElement));
Assert.Equal(JsonValueKind.Array, timestampsElement.ValueKind);
Assert.Single(timestampsElement.EnumerateArray());
var entry = timestampsElement[0];
Assert.Equal("timestamps/manifest.tsr", entry.GetProperty("tokenPath").GetString());
Assert.Equal("SHA256", entry.GetProperty("hashAlgorithm").GetString());
Assert.Equal("Test TSA", entry.GetProperty("tsaName").GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignManifestAsync_OmitsTransparencyAndTimestampArrays_WhenEmpty()
{
var timestampClient = new FakeTimestampAuthorityClient();
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
var service = CreateService(timestampClient, timeProvider);
var manifest = CreateManifest();
var signature = await service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None);
Assert.NotNull(signature);
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(signature!.Payload));
using var document = JsonDocument.Parse(payloadJson);
// These arrays should not be present when empty
Assert.False(document.RootElement.TryGetProperty("transparency", out _));
Assert.False(document.RootElement.TryGetProperty("timestamps", out _));
}
private static EvidenceSignatureService CreateService(
ITimestampAuthorityClient timestampAuthorityClient,
TimeProvider timeProvider,
@@ -212,7 +305,9 @@ public sealed class EvidenceSignatureServiceTests
private static EvidenceBundleManifest CreateManifest(
(string key, string value)[]? metadataOrder = null,
EvidenceBundleId? bundleId = null,
TenantId? tenantId = null)
TenantId? tenantId = null,
IReadOnlyList<TransparencyReference>? transparencyReferences = null,
IReadOnlyList<TimestampReference>? timestampReferences = null)
{
metadataOrder ??= new[] { ("alpha", "1"), ("beta", "2") };
var metadataDictionary = new Dictionary<string, string>(StringComparer.Ordinal);
@@ -244,7 +339,9 @@ public sealed class EvidenceSignatureServiceTests
EvidenceBundleKind.Evaluation,
new DateTimeOffset(2025, 11, 3, 9, 30, 0, TimeSpan.Zero),
metadata,
new List<EvidenceManifestEntry> { manifestEntry });
new List<EvidenceManifestEntry> { manifestEntry },
transparencyReferences,
timestampReferences);
}
private sealed class FakeTimestampAuthorityClient : ITimestampAuthorityClient