new advisories work and features gaps work
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user