save progress
This commit is contained in:
@@ -28,6 +28,7 @@ public sealed class AttestationBundler : IAttestationBundler
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly ILogger<AttestationBundler> _logger;
|
||||
private readonly BundlingOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new attestation bundler.
|
||||
@@ -38,7 +39,8 @@ public sealed class AttestationBundler : IAttestationBundler
|
||||
IMerkleTreeBuilder merkleBuilder,
|
||||
ILogger<AttestationBundler> logger,
|
||||
IOptions<BundlingOptions> options,
|
||||
IOrgKeySigner? orgSigner = null)
|
||||
IOrgKeySigner? orgSigner = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_aggregator = aggregator ?? throw new ArgumentNullException(nameof(aggregator));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
@@ -46,6 +48,7 @@ public sealed class AttestationBundler : IAttestationBundler
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new BundlingOptions();
|
||||
_orgSigner = orgSigner;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -60,13 +63,42 @@ public sealed class AttestationBundler : IAttestationBundler
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd);
|
||||
|
||||
// Collect attestations in deterministic order
|
||||
var attestations = await CollectAttestationsAsync(request, cancellationToken);
|
||||
|
||||
if (attestations.Count == 0)
|
||||
if (request.PeriodStart > request.PeriodEnd)
|
||||
{
|
||||
_logger.LogWarning("No attestations found for the specified period");
|
||||
throw new InvalidOperationException("No attestations found for the specified period.");
|
||||
throw new ArgumentException(
|
||||
"PeriodStart must be less than or equal to PeriodEnd.",
|
||||
nameof(request));
|
||||
}
|
||||
|
||||
var effectivePeriodStart = request.PeriodStart;
|
||||
var lookbackDays = _options.Aggregation.LookbackDays;
|
||||
if (lookbackDays > 0)
|
||||
{
|
||||
var lookbackStart = request.PeriodEnd.AddDays(-lookbackDays);
|
||||
if (effectivePeriodStart < lookbackStart)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Clamping period start from {RequestedStart} to {EffectiveStart} to honor lookback window.",
|
||||
request.PeriodStart,
|
||||
lookbackStart);
|
||||
effectivePeriodStart = lookbackStart;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect attestations in deterministic order
|
||||
var attestations = await CollectAttestationsAsync(
|
||||
request with { PeriodStart = effectivePeriodStart },
|
||||
cancellationToken);
|
||||
|
||||
var minimumAttestations = Math.Max(1, _options.Aggregation.MinAttestationsForBundle);
|
||||
if (attestations.Count < minimumAttestations)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Insufficient attestations for bundling. Required {Required}, found {Found}.",
|
||||
minimumAttestations,
|
||||
attestations.Count);
|
||||
throw new InvalidOperationException(
|
||||
$"Insufficient attestations for bundling. Required {minimumAttestations}, found {attestations.Count}.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Collected {Count} attestations for bundling", attestations.Count);
|
||||
@@ -83,8 +115,8 @@ public sealed class AttestationBundler : IAttestationBundler
|
||||
{
|
||||
BundleId = bundleId,
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = request.PeriodStart,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
PeriodStart = effectivePeriodStart,
|
||||
PeriodEnd = request.PeriodEnd,
|
||||
AttestationCount = attestations.Count,
|
||||
TenantId = request.TenantId
|
||||
@@ -104,6 +136,11 @@ public sealed class AttestationBundler : IAttestationBundler
|
||||
};
|
||||
|
||||
// Sign with organization key if requested
|
||||
if (request.SignWithOrgKey && _orgSigner == null)
|
||||
{
|
||||
throw new InvalidOperationException("Organization signer is not configured.");
|
||||
}
|
||||
|
||||
if (request.SignWithOrgKey && _orgSigner != null)
|
||||
{
|
||||
bundle = await SignBundleAsync(bundle, request.OrgKeyId, cancellationToken);
|
||||
@@ -146,14 +183,22 @@ public sealed class AttestationBundler : IAttestationBundler
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var issues = new List<BundleVerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
var verifiedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Verify Merkle root
|
||||
var merkleValid = VerifyMerkleRoot(bundle, issues);
|
||||
|
||||
// Verify org signature if present
|
||||
bool? orgSigValid = null;
|
||||
if (bundle.OrgSignature != null && _orgSigner != null)
|
||||
if (bundle.OrgSignature != null && _orgSigner == null)
|
||||
{
|
||||
issues.Add(new BundleVerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"ORG_SIG_VERIFIER_UNAVAILABLE",
|
||||
"Organization signature present but no signer is configured for verification."));
|
||||
orgSigValid = false;
|
||||
}
|
||||
else if (bundle.OrgSignature != null && _orgSigner != null)
|
||||
{
|
||||
orgSigValid = await VerifyOrgSignatureAsync(bundle, issues, cancellationToken);
|
||||
}
|
||||
@@ -236,11 +281,19 @@ public sealed class AttestationBundler : IAttestationBundler
|
||||
keyId);
|
||||
|
||||
// Return bundle with signature and updated metadata
|
||||
var fingerprint = await GetKeyFingerprintAsync(keyId, cancellationToken);
|
||||
if (fingerprint == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Organization key fingerprint not found for key {KeyId}; leaving fingerprint unset.",
|
||||
keyId);
|
||||
}
|
||||
|
||||
return bundle with
|
||||
{
|
||||
Metadata = bundle.Metadata with
|
||||
{
|
||||
OrgKeyFingerprint = $"sha256:{ComputeKeyFingerprint(keyId)}"
|
||||
OrgKeyFingerprint = fingerprint
|
||||
},
|
||||
OrgSignature = signature
|
||||
};
|
||||
@@ -328,10 +381,17 @@ public sealed class AttestationBundler : IAttestationBundler
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
}
|
||||
|
||||
private static string ComputeKeyFingerprint(string keyId)
|
||||
private async Task<string?> GetKeyFingerprintAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Simple fingerprint - in production this would use the actual public key
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(keyId));
|
||||
return Convert.ToHexString(hash[..16]).ToLowerInvariant();
|
||||
if (_orgSigner == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var keys = await _orgSigner.ListKeysAsync(cancellationToken) ?? Array.Empty<OrgKeyInfo>();
|
||||
var match = keys.FirstOrDefault(key => string.Equals(key.KeyId, keyId, StringComparison.Ordinal));
|
||||
return match?.Fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,15 +120,18 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
|
||||
private readonly IBundleStore _bundleStore;
|
||||
private readonly BundlingOptions _options;
|
||||
private readonly ILogger<OfflineKitBundleProvider> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public OfflineKitBundleProvider(
|
||||
IBundleStore bundleStore,
|
||||
IOptions<BundlingOptions> options,
|
||||
ILogger<OfflineKitBundleProvider> logger)
|
||||
ILogger<OfflineKitBundleProvider> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
_options = options?.Value ?? new BundlingOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -137,7 +140,7 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new OfflineKitExportOptions();
|
||||
options = ResolveExportOptions(options);
|
||||
|
||||
if (!_options.Export.IncludeInOfflineKit)
|
||||
{
|
||||
@@ -147,7 +150,7 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
|
||||
Bundles = [],
|
||||
TotalAttestations = 0,
|
||||
TotalSizeBytes = 0,
|
||||
ExportedAt = DateTimeOffset.UtcNow
|
||||
ExportedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,7 +206,7 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
|
||||
Bundles = exportedBundles,
|
||||
TotalAttestations = totalAttestations,
|
||||
TotalSizeBytes = totalSize,
|
||||
ExportedAt = DateTimeOffset.UtcNow
|
||||
ExportedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,9 +215,9 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
|
||||
OfflineKitExportOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new OfflineKitExportOptions();
|
||||
options = ResolveExportOptions(options);
|
||||
|
||||
var cutoffDate = DateTimeOffset.UtcNow.AddMonths(-options.MaxAgeMonths);
|
||||
var cutoffDate = _timeProvider.GetUtcNow().AddMonths(-options.MaxAgeMonths);
|
||||
var result = new List<BundleListItem>();
|
||||
string? cursor = null;
|
||||
|
||||
@@ -303,4 +306,58 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
|
||||
|
||||
return $"bundle-{hash}{extension}{compression}";
|
||||
}
|
||||
|
||||
private OfflineKitExportOptions ResolveExportOptions(OfflineKitExportOptions? options)
|
||||
{
|
||||
if (options != null)
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return new OfflineKitExportOptions
|
||||
{
|
||||
MaxAgeMonths = _options.Export.MaxAgeMonths,
|
||||
Format = ParseFormat(_options.Export.SupportedFormats ?? new List<string>()),
|
||||
Compression = ParseCompression(_options.Export.Compression),
|
||||
RequireOrgSignature = false,
|
||||
TenantId = null
|
||||
};
|
||||
}
|
||||
|
||||
private static BundleFormat ParseFormat(IList<string> supportedFormats)
|
||||
{
|
||||
if (supportedFormats.Count == 0)
|
||||
{
|
||||
return BundleFormat.Json;
|
||||
}
|
||||
|
||||
var format = supportedFormats
|
||||
.FirstOrDefault(value => value.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
?? supportedFormats.FirstOrDefault()
|
||||
?? "json";
|
||||
|
||||
return format.Equals("cbor", StringComparison.OrdinalIgnoreCase)
|
||||
? BundleFormat.Cbor
|
||||
: BundleFormat.Json;
|
||||
}
|
||||
|
||||
private static BundleCompression ParseCompression(string? compression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(compression))
|
||||
{
|
||||
return BundleCompression.None;
|
||||
}
|
||||
|
||||
if (compression.Equals("gzip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BundleCompression.Gzip;
|
||||
}
|
||||
|
||||
if (compression.Equals("zstd", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BundleCompression.Zstd;
|
||||
}
|
||||
|
||||
return BundleCompression.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,25 +164,28 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
private readonly IBundleExpiryNotifier? _notifier;
|
||||
private readonly BundleRetentionOptions _options;
|
||||
private readonly ILogger<RetentionPolicyEnforcer> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RetentionPolicyEnforcer(
|
||||
IBundleStore bundleStore,
|
||||
IOptions<BundlingOptions> options,
|
||||
ILogger<RetentionPolicyEnforcer> logger,
|
||||
IBundleArchiver? archiver = null,
|
||||
IBundleExpiryNotifier? notifier = null)
|
||||
IBundleExpiryNotifier? notifier = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
_options = options?.Value?.Retention ?? new BundleRetentionOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_archiver = archiver;
|
||||
_notifier = notifier;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<RetentionEnforcementResult> EnforceAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var failures = new List<BundleEnforcementFailure>();
|
||||
int evaluated = 0;
|
||||
int deleted = 0;
|
||||
@@ -196,7 +199,7 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
return new RetentionEnforcementResult
|
||||
{
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
BundlesEvaluated = 0,
|
||||
BundlesDeleted = 0,
|
||||
BundlesArchived = 0,
|
||||
@@ -213,10 +216,11 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
|
||||
// Process bundles in batches
|
||||
string? cursor = null;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var notificationCutoff = now.AddDays(_options.NotifyDaysBeforeExpiry);
|
||||
var gracePeriodCutoff = now.AddDays(-_options.GracePeriodDays);
|
||||
var expiredNotifications = new List<BundleExpiryNotification>();
|
||||
var applyOverrides = _options.TenantOverrides.Count > 0 || _options.PredicateTypeOverrides.Count > 0;
|
||||
|
||||
do
|
||||
{
|
||||
@@ -227,7 +231,29 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
evaluated++;
|
||||
var expiryDate = CalculateExpiryDate(bundle);
|
||||
string? tenantId = null;
|
||||
IReadOnlyList<string>? predicateTypes = null;
|
||||
|
||||
if (applyOverrides)
|
||||
{
|
||||
var fullBundle = await _bundleStore.GetBundleAsync(bundle.BundleId, cancellationToken);
|
||||
if (fullBundle == null)
|
||||
{
|
||||
failures.Add(new BundleEnforcementFailure(
|
||||
bundle.BundleId,
|
||||
"Bundle not found",
|
||||
"Failed to load bundle metadata for retention overrides."));
|
||||
continue;
|
||||
}
|
||||
|
||||
tenantId = fullBundle.Metadata.TenantId;
|
||||
predicateTypes = fullBundle.Attestations
|
||||
.Select(attestation => attestation.PredicateType)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var expiryDate = CalculateExpiryDate(tenantId, predicateTypes, bundle.CreatedAt);
|
||||
|
||||
// Check if bundle has expired
|
||||
if (expiryDate <= now)
|
||||
@@ -300,7 +326,7 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
}
|
||||
}
|
||||
|
||||
var completedAt = DateTimeOffset.UtcNow;
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
_logger.LogInformation(
|
||||
"Retention enforcement completed. Evaluated={Evaluated}, Deleted={Deleted}, Archived={Archived}, Marked={Marked}, Approaching={Approaching}, Failed={Failed}",
|
||||
evaluated, deleted, archived, markedExpired, approachingExpiry, failures.Count);
|
||||
@@ -324,9 +350,10 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var notifications = new List<BundleExpiryNotification>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cutoff = now.AddDays(daysBeforeExpiry);
|
||||
string? cursor = null;
|
||||
var applyOverrides = _options.TenantOverrides.Count > 0 || _options.PredicateTypeOverrides.Count > 0;
|
||||
|
||||
do
|
||||
{
|
||||
@@ -336,7 +363,25 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
|
||||
foreach (var bundle in listResult.Bundles)
|
||||
{
|
||||
var expiryDate = CalculateExpiryDate(bundle);
|
||||
string? tenantId = null;
|
||||
IReadOnlyList<string>? predicateTypes = null;
|
||||
|
||||
if (applyOverrides)
|
||||
{
|
||||
var fullBundle = await _bundleStore.GetBundleAsync(bundle.BundleId, cancellationToken);
|
||||
if (fullBundle == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tenantId = fullBundle.Metadata.TenantId;
|
||||
predicateTypes = fullBundle.Attestations
|
||||
.Select(attestation => attestation.PredicateType)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var expiryDate = CalculateExpiryDate(tenantId, predicateTypes, bundle.CreatedAt);
|
||||
if (expiryDate > now && expiryDate <= cutoff)
|
||||
{
|
||||
notifications.Add(new BundleExpiryNotification(
|
||||
@@ -364,17 +409,51 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset CalculateExpiryDate(string? tenantId, DateTimeOffset createdAt)
|
||||
{
|
||||
int retentionMonths = _options.DefaultMonths;
|
||||
var retentionMonths = ResolveRetentionMonths(tenantId, null);
|
||||
|
||||
return createdAt.AddMonths(retentionMonths);
|
||||
}
|
||||
|
||||
private DateTimeOffset CalculateExpiryDate(
|
||||
string? tenantId,
|
||||
IReadOnlyList<string>? predicateTypes,
|
||||
DateTimeOffset createdAt)
|
||||
{
|
||||
var retentionMonths = ResolveRetentionMonths(tenantId, predicateTypes);
|
||||
return createdAt.AddMonths(retentionMonths);
|
||||
}
|
||||
|
||||
private int ResolveRetentionMonths(
|
||||
string? tenantId,
|
||||
IReadOnlyList<string>? predicateTypes)
|
||||
{
|
||||
var retentionMonths = ClampRetentionMonths(_options.DefaultMonths);
|
||||
|
||||
// Check for tenant-specific override
|
||||
if (!string.IsNullOrEmpty(tenantId) &&
|
||||
_options.TenantOverrides.TryGetValue(tenantId, out var tenantMonths))
|
||||
{
|
||||
retentionMonths = Math.Max(tenantMonths, _options.MinimumMonths);
|
||||
retentionMonths = Math.Min(retentionMonths, _options.MaximumMonths);
|
||||
retentionMonths = ClampRetentionMonths(tenantMonths);
|
||||
}
|
||||
|
||||
return createdAt.AddMonths(retentionMonths);
|
||||
if (predicateTypes != null && _options.PredicateTypeOverrides.Count > 0)
|
||||
{
|
||||
foreach (var predicateType in predicateTypes)
|
||||
{
|
||||
if (_options.PredicateTypeOverrides.TryGetValue(predicateType, out var predicateMonths))
|
||||
{
|
||||
retentionMonths = Math.Max(retentionMonths, ClampRetentionMonths(predicateMonths));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return retentionMonths;
|
||||
}
|
||||
|
||||
private int ClampRetentionMonths(int months)
|
||||
{
|
||||
var clamped = Math.Max(months, _options.MinimumMonths);
|
||||
return Math.Min(clamped, _options.MaximumMonths);
|
||||
}
|
||||
|
||||
private async Task<(bool Success, BundleEnforcementFailure? Failure)> HandleExpiredBundleAsync(
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Bundling</RootNamespace>
|
||||
<Description>Attestation bundle aggregation and rotation for long-term verification in air-gapped environments.</Description>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Bundling</RootNamespace>
|
||||
<Description>Attestation bundle aggregation and rotation for long-term verification in air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0047-M | DONE | Maintainability audit for StellaOps.Attestor.Bundling. |
|
||||
| AUDIT-0047-T | DONE | Test coverage audit for StellaOps.Attestor.Bundling. |
|
||||
| AUDIT-0047-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0047-A | DONE | Applied bundling validation, defaults, and test coverage updates. |
|
||||
|
||||
@@ -39,6 +39,7 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
private readonly Func<string?, EnvelopeKey?> _keyResolver;
|
||||
private readonly IRekorClient? _rekorClient;
|
||||
private readonly GraphRootAttestorOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<GraphRootAttestor> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -56,7 +57,8 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
Func<string?, EnvelopeKey?> keyResolver,
|
||||
ILogger<GraphRootAttestor> logger,
|
||||
IRekorClient? rekorClient = null,
|
||||
IOptions<GraphRootAttestorOptions>? options = null)
|
||||
IOptions<GraphRootAttestorOptions>? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_merkleComputer = merkleComputer ?? throw new ArgumentNullException(nameof(merkleComputer));
|
||||
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
|
||||
@@ -64,6 +66,7 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_rekorClient = rekorClient;
|
||||
_options = options?.Value ?? new GraphRootAttestorOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -91,14 +94,20 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var normalizedPolicyDigest = NormalizeDigest(request.PolicyDigest);
|
||||
var normalizedFeedsDigest = NormalizeDigest(request.FeedsDigest);
|
||||
var normalizedToolchainDigest = NormalizeDigest(request.ToolchainDigest);
|
||||
var normalizedParamsDigest = NormalizeDigest(request.ParamsDigest);
|
||||
|
||||
// 2. Build leaf data for Merkle tree
|
||||
var leaves = BuildLeaves(
|
||||
sortedNodeIds,
|
||||
sortedEdgeIds,
|
||||
request.PolicyDigest,
|
||||
request.FeedsDigest,
|
||||
request.ToolchainDigest,
|
||||
request.ParamsDigest);
|
||||
sortedEvidenceIds,
|
||||
normalizedPolicyDigest,
|
||||
normalizedFeedsDigest,
|
||||
normalizedToolchainDigest,
|
||||
normalizedParamsDigest);
|
||||
|
||||
// 3. Compute Merkle root
|
||||
var rootBytes = _merkleComputer.ComputeRoot(leaves);
|
||||
@@ -108,7 +117,7 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
_logger.LogDebug("Computed Merkle root: {RootHash}", rootHash);
|
||||
|
||||
// 4. Build in-toto statement
|
||||
var computedAt = DateTimeOffset.UtcNow;
|
||||
var computedAt = _timeProvider.GetUtcNow();
|
||||
var attestation = BuildAttestation(
|
||||
request,
|
||||
sortedNodeIds,
|
||||
@@ -116,6 +125,10 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
sortedEvidenceIds,
|
||||
rootHash,
|
||||
rootHex,
|
||||
normalizedPolicyDigest,
|
||||
normalizedFeedsDigest,
|
||||
normalizedToolchainDigest,
|
||||
normalizedParamsDigest,
|
||||
computedAt);
|
||||
|
||||
// 5. Canonicalize the attestation
|
||||
@@ -129,7 +142,7 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
$"Unable to resolve signing key: {request.SigningKeyId ?? "(default)"}");
|
||||
}
|
||||
|
||||
var signResult = _signatureService.Sign(payload, key, ct);
|
||||
var signResult = _signatureService.SignDsse(PayloadType, payload, key, ct);
|
||||
if (!signResult.IsSuccess)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
@@ -260,8 +273,8 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
};
|
||||
|
||||
// Compute bundle hash
|
||||
var bundleJson = JsonSerializer.Serialize(EnvDsseEnvelope);
|
||||
var bundleHash = SHA256.HashData(Encoding.UTF8.GetBytes(bundleJson));
|
||||
var bundleJson = CanonJson.Canonicalize(EnvDsseEnvelope);
|
||||
var bundleHash = SHA256.HashData(bundleJson);
|
||||
|
||||
return new AttestorSubmissionRequest
|
||||
{
|
||||
@@ -303,6 +316,24 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
nodes.Count,
|
||||
edges.Count);
|
||||
|
||||
if (!string.Equals(envelope.PayloadType, PayloadType, StringComparison.Ordinal))
|
||||
{
|
||||
return new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = $"Unexpected payloadType '{envelope.PayloadType}'."
|
||||
};
|
||||
}
|
||||
|
||||
if (!TryVerifyEnvelopeSignatures(envelope, ct, out var signatureFailure))
|
||||
{
|
||||
return new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = signatureFailure ?? "No valid DSSE signatures found."
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Deserialize attestation from envelope payload
|
||||
GraphRootAttestation? attestation;
|
||||
try
|
||||
@@ -336,15 +367,69 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
.Select(e => e.EdgeId)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var predicateNodeIds = attestation.Predicate.NodeIds?.ToList() ?? [];
|
||||
var predicateEdgeIds = attestation.Predicate.EdgeIds?.ToList() ?? [];
|
||||
var predicateEvidenceIds = attestation.Predicate.EvidenceIds?.ToList() ?? [];
|
||||
|
||||
if (!SequenceEqual(predicateNodeIds, recomputedNodeIds))
|
||||
{
|
||||
return new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = "Predicate node IDs do not match provided graph data."
|
||||
};
|
||||
}
|
||||
|
||||
if (!SequenceEqual(predicateEdgeIds, recomputedEdgeIds))
|
||||
{
|
||||
return new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = "Predicate edge IDs do not match provided graph data."
|
||||
};
|
||||
}
|
||||
|
||||
var sortedPredicateEvidenceIds = predicateEvidenceIds
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (!SequenceEqual(predicateEvidenceIds, sortedPredicateEvidenceIds))
|
||||
{
|
||||
return new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = "Predicate evidence IDs are not in deterministic order."
|
||||
};
|
||||
}
|
||||
|
||||
string normalizedPolicyDigest;
|
||||
string normalizedFeedsDigest;
|
||||
string normalizedToolchainDigest;
|
||||
string normalizedParamsDigest;
|
||||
try
|
||||
{
|
||||
normalizedPolicyDigest = NormalizeDigest(attestation.Predicate.Inputs.PolicyDigest);
|
||||
normalizedFeedsDigest = NormalizeDigest(attestation.Predicate.Inputs.FeedsDigest);
|
||||
normalizedToolchainDigest = NormalizeDigest(attestation.Predicate.Inputs.ToolchainDigest);
|
||||
normalizedParamsDigest = NormalizeDigest(attestation.Predicate.Inputs.ParamsDigest);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = $"Invalid predicate digest: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Build leaves using the same inputs from the attestation
|
||||
var leaves = BuildLeaves(
|
||||
recomputedNodeIds,
|
||||
recomputedEdgeIds,
|
||||
attestation.Predicate.Inputs.PolicyDigest,
|
||||
attestation.Predicate.Inputs.FeedsDigest,
|
||||
attestation.Predicate.Inputs.ToolchainDigest,
|
||||
attestation.Predicate.Inputs.ParamsDigest);
|
||||
sortedPredicateEvidenceIds,
|
||||
normalizedPolicyDigest,
|
||||
normalizedFeedsDigest,
|
||||
normalizedToolchainDigest,
|
||||
normalizedParamsDigest);
|
||||
|
||||
// 4. Compute Merkle root
|
||||
var recomputedRootBytes = _merkleComputer.ComputeRoot(leaves);
|
||||
@@ -385,13 +470,14 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
private static List<ReadOnlyMemory<byte>> BuildLeaves(
|
||||
IReadOnlyList<string> sortedNodeIds,
|
||||
IReadOnlyList<string> sortedEdgeIds,
|
||||
IReadOnlyList<string> sortedEvidenceIds,
|
||||
string policyDigest,
|
||||
string feedsDigest,
|
||||
string toolchainDigest,
|
||||
string paramsDigest)
|
||||
{
|
||||
var leaves = new List<ReadOnlyMemory<byte>>(
|
||||
sortedNodeIds.Count + sortedEdgeIds.Count + 4);
|
||||
sortedNodeIds.Count + sortedEdgeIds.Count + sortedEvidenceIds.Count + 4);
|
||||
|
||||
// Add node IDs
|
||||
foreach (var nodeId in sortedNodeIds)
|
||||
@@ -405,6 +491,12 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
leaves.Add(Encoding.UTF8.GetBytes(edgeId));
|
||||
}
|
||||
|
||||
// Add evidence IDs
|
||||
foreach (var evidenceId in sortedEvidenceIds)
|
||||
{
|
||||
leaves.Add(Encoding.UTF8.GetBytes(evidenceId));
|
||||
}
|
||||
|
||||
// Add input digests (deterministic order)
|
||||
leaves.Add(Encoding.UTF8.GetBytes(policyDigest));
|
||||
leaves.Add(Encoding.UTF8.GetBytes(feedsDigest));
|
||||
@@ -421,6 +513,10 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
IReadOnlyList<string> sortedEvidenceIds,
|
||||
string rootHash,
|
||||
string rootHex,
|
||||
string policyDigest,
|
||||
string feedsDigest,
|
||||
string toolchainDigest,
|
||||
string paramsDigest,
|
||||
DateTimeOffset computedAt)
|
||||
{
|
||||
var subjects = new List<GraphRootSubject>
|
||||
@@ -457,10 +553,10 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
EdgeIds = sortedEdgeIds,
|
||||
Inputs = new GraphInputDigests
|
||||
{
|
||||
PolicyDigest = request.PolicyDigest,
|
||||
FeedsDigest = request.FeedsDigest,
|
||||
ToolchainDigest = request.ToolchainDigest,
|
||||
ParamsDigest = request.ParamsDigest
|
||||
PolicyDigest = policyDigest,
|
||||
FeedsDigest = feedsDigest,
|
||||
ToolchainDigest = toolchainDigest,
|
||||
ParamsDigest = paramsDigest
|
||||
},
|
||||
EvidenceIds = sortedEvidenceIds,
|
||||
CanonVersion = CanonVersion.Current,
|
||||
@@ -476,13 +572,13 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex > 0 && colonIndex < digest.Length - 1)
|
||||
{
|
||||
var algorithm = digest[..colonIndex];
|
||||
var value = digest[(colonIndex + 1)..];
|
||||
var algorithm = digest[..colonIndex].ToLowerInvariant();
|
||||
var value = digest[(colonIndex + 1)..].ToLowerInvariant();
|
||||
return new Dictionary<string, string> { [algorithm] = value };
|
||||
}
|
||||
|
||||
// Assume sha256 if no algorithm prefix
|
||||
return new Dictionary<string, string> { ["sha256"] = digest };
|
||||
return new Dictionary<string, string> { ["sha256"] = digest.ToLowerInvariant() };
|
||||
}
|
||||
|
||||
private static string GetToolVersion()
|
||||
@@ -493,4 +589,104 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
?? "1.0.0";
|
||||
return version;
|
||||
}
|
||||
|
||||
private bool TryVerifyEnvelopeSignatures(
|
||||
EnvDsseEnvelope envelope,
|
||||
CancellationToken ct,
|
||||
out string? failureReason)
|
||||
{
|
||||
if (envelope.Signatures.Count == 0)
|
||||
{
|
||||
failureReason = "Envelope does not contain signatures.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var signature in envelope.Signatures)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signature.KeyId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = _keyResolver(signature.KeyId);
|
||||
if (key is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(signature.KeyId, key.KeyId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryDecodeSignature(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var envelopeSignature = new EnvelopeSignature(signature.KeyId, key.AlgorithmId, signatureBytes);
|
||||
var verified = _signatureService.VerifyDsse(envelope.PayloadType, envelope.Payload.Span, envelopeSignature, key, ct);
|
||||
if (verified.IsSuccess)
|
||||
{
|
||||
failureReason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
failureReason = "DSSE signature verification failed.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryDecodeSignature(string signature, out byte[] signatureBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
signatureBytes = Convert.FromBase64String(signature);
|
||||
return signatureBytes.Length > 0;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
signatureBytes = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SequenceEqual(IReadOnlyList<string> left, IReadOnlyList<string> right)
|
||||
{
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < left.Count; i++)
|
||||
{
|
||||
if (!string.Equals(left[i], right[i], StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
throw new ArgumentException("Digest must be provided.", nameof(digest));
|
||||
}
|
||||
|
||||
var trimmed = digest.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex > 0 && colonIndex < trimmed.Length - 1)
|
||||
{
|
||||
var algorithm = trimmed[..colonIndex].ToLowerInvariant();
|
||||
var value = trimmed[(colonIndex + 1)..].ToLowerInvariant();
|
||||
return $"{algorithm}:{value}";
|
||||
}
|
||||
|
||||
return $"sha256:{trimmed.ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
@@ -18,6 +19,7 @@ public static class GraphRootServiceCollectionExtensions
|
||||
{
|
||||
services.TryAddSingleton<IMerkleRootComputer, Sha256MerkleRootComputer>();
|
||||
services.TryAddSingleton<EnvelopeSignatureService>();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGraphRootAttestor, GraphRootAttestor>();
|
||||
|
||||
return services;
|
||||
@@ -37,14 +39,16 @@ public static class GraphRootServiceCollectionExtensions
|
||||
|
||||
services.TryAddSingleton<IMerkleRootComputer, Sha256MerkleRootComputer>();
|
||||
services.TryAddSingleton<EnvelopeSignatureService>();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IGraphRootAttestor>(sp =>
|
||||
{
|
||||
var merkleComputer = sp.GetRequiredService<IMerkleRootComputer>();
|
||||
var signatureService = sp.GetRequiredService<EnvelopeSignatureService>();
|
||||
var logger = sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<GraphRootAttestor>>();
|
||||
var resolver = keyResolver(sp);
|
||||
var timeProvider = sp.GetService<TimeProvider>();
|
||||
|
||||
return new GraphRootAttestor(merkleComputer, signatureService, resolver, logger);
|
||||
return new GraphRootAttestor(merkleComputer, signatureService, resolver, logger, timeProvider: timeProvider);
|
||||
});
|
||||
|
||||
return services;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.GraphRoot</RootNamespace>
|
||||
<Description>Graph root attestation service for creating and verifying DSSE attestations of Merkle graph roots.</Description>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.GraphRoot</RootNamespace>
|
||||
<Description>Graph root attestation service for creating and verifying DSSE attestations of Merkle graph roots.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0053-M | DONE | Maintainability audit for StellaOps.Attestor.GraphRoot. |
|
||||
| AUDIT-0053-T | DONE | Test coverage audit for StellaOps.Attestor.GraphRoot. |
|
||||
| AUDIT-0053-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0053-A | DONE | Applied audit remediation for graph root attestation. |
|
||||
|
||||
@@ -91,9 +91,20 @@ public sealed record OciReference
|
||||
/// <summary>
|
||||
/// Gets the full reference string.
|
||||
/// </summary>
|
||||
public string FullReference => Tag is not null
|
||||
? $"{Registry}/{Repository}:{Tag}"
|
||||
: $"{Registry}/{Repository}@{Digest}";
|
||||
public string FullReference
|
||||
{
|
||||
get
|
||||
{
|
||||
var baseRef = $"{Registry}/{Repository}";
|
||||
if (!string.IsNullOrWhiteSpace(Digest))
|
||||
{
|
||||
return $"{baseRef}@{Digest}";
|
||||
}
|
||||
|
||||
var tag = string.IsNullOrWhiteSpace(Tag) ? "latest" : Tag;
|
||||
return $"{baseRef}:{tag}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an OCI reference string.
|
||||
@@ -102,45 +113,43 @@ public sealed record OciReference
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reference);
|
||||
|
||||
// Handle digest references: registry/repo@sha256:...
|
||||
string? digest = null;
|
||||
var name = reference;
|
||||
|
||||
var digestIndex = reference.IndexOf('@');
|
||||
if (digestIndex > 0)
|
||||
if (digestIndex >= 0)
|
||||
{
|
||||
var beforeDigest = reference[..digestIndex];
|
||||
var digest = reference[(digestIndex + 1)..];
|
||||
var (registry, repo) = ParseRegistryAndRepo(beforeDigest);
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = digest
|
||||
};
|
||||
}
|
||||
|
||||
// Handle tag references: registry/repo:tag
|
||||
var tagIndex = reference.LastIndexOf(':');
|
||||
if (tagIndex > 0)
|
||||
{
|
||||
var beforeTag = reference[..tagIndex];
|
||||
var tag = reference[(tagIndex + 1)..];
|
||||
|
||||
// Check if this is actually a port number
|
||||
if (!beforeTag.Contains('/') || tag.Contains('/'))
|
||||
name = reference[..digestIndex];
|
||||
digest = reference[(digestIndex + 1)..];
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
|
||||
}
|
||||
|
||||
var (registry, repo) = ParseRegistryAndRepo(beforeTag);
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = string.Empty, // Will be resolved
|
||||
Tag = tag
|
||||
};
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
|
||||
string? tag = null;
|
||||
var tagIndex = name.LastIndexOf(':');
|
||||
var slashIndex = name.LastIndexOf('/');
|
||||
if (tagIndex > slashIndex)
|
||||
{
|
||||
tag = name[(tagIndex + 1)..];
|
||||
name = name[..tagIndex];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
tag = "latest";
|
||||
}
|
||||
|
||||
var (registry, repo) = ParseRegistryAndRepo(name);
|
||||
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = digest ?? string.Empty,
|
||||
Tag = tag
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Registry, string Repo) ParseRegistryAndRepo(string reference)
|
||||
@@ -148,13 +157,35 @@ public sealed record OciReference
|
||||
var firstSlash = reference.IndexOf('/');
|
||||
if (firstSlash < 0)
|
||||
{
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}");
|
||||
return ("docker.io", NormalizeRepository("docker.io", reference));
|
||||
}
|
||||
|
||||
var registry = reference[..firstSlash];
|
||||
var repo = reference[(firstSlash + 1)..];
|
||||
var firstSegment = reference[..firstSlash];
|
||||
if (IsRegistryHost(firstSegment))
|
||||
{
|
||||
var repo = reference[(firstSlash + 1)..];
|
||||
return (firstSegment, NormalizeRepository(firstSegment, repo));
|
||||
}
|
||||
|
||||
return (registry, repo);
|
||||
return ("docker.io", NormalizeRepository("docker.io", reference));
|
||||
}
|
||||
|
||||
private static bool IsRegistryHost(string value)
|
||||
{
|
||||
return value.Contains('.', StringComparison.Ordinal)
|
||||
|| value.Contains(':', StringComparison.Ordinal)
|
||||
|| string.Equals(value, "localhost", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizeRepository(string registry, string repository)
|
||||
{
|
||||
if (string.Equals(registry, "docker.io", StringComparison.OrdinalIgnoreCase)
|
||||
&& !repository.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
return $"library/{repository}";
|
||||
}
|
||||
|
||||
return repository;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,19 +19,16 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
{
|
||||
private readonly IOciRegistryClient _registryClient;
|
||||
private readonly ILogger<OrasAttestationAttacher> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public OrasAttestationAttacher(
|
||||
IOciRegistryClient registryClient,
|
||||
ILogger<OrasAttestationAttacher> logger)
|
||||
ILogger<OrasAttestationAttacher> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -46,6 +43,8 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
|
||||
options ??= new AttachmentOptions();
|
||||
|
||||
var predicateType = ResolvePredicateType(attestation);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Attaching attestation to {Registry}/{Repository}@{Digest}",
|
||||
imageRef.Registry,
|
||||
@@ -66,18 +65,18 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
{
|
||||
var existing = await FindExistingAttestationAsync(
|
||||
imageRef,
|
||||
attestation.PayloadType,
|
||||
predicateType,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation with predicate type {PredicateType} already exists at {Digest}",
|
||||
attestation.PayloadType,
|
||||
predicateType,
|
||||
TruncateDigest(existing.Digest));
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Attestation with predicate type '{attestation.PayloadType}' already exists. " +
|
||||
$"Attestation with predicate type '{predicateType}' already exists. " +
|
||||
"Use ReplaceExisting=true to overwrite.");
|
||||
}
|
||||
}
|
||||
@@ -104,7 +103,8 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// 5. Build manifest with subject reference
|
||||
var annotations = BuildAnnotations(attestation, options);
|
||||
var attachedAt = _timeProvider.GetUtcNow();
|
||||
var annotations = BuildAnnotations(attestation, predicateType, options, attachedAt);
|
||||
var manifest = new OciManifest
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
@@ -131,7 +131,7 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
Size = attestationBytes.Length,
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
[AnnotationKeys.PredicateType] = attestation.PayloadType
|
||||
[AnnotationKeys.PredicateType] = predicateType
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -153,11 +153,17 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
TruncateDigest(imageRef.Digest),
|
||||
TruncateDigest(manifestDigest));
|
||||
|
||||
if (options.RecordInRekor)
|
||||
{
|
||||
_logger.LogWarning("RecordInRekor requested but Rekor integration is not configured for OCI attachments.");
|
||||
}
|
||||
|
||||
return new AttachmentResult
|
||||
{
|
||||
AttestationDigest = attestationDigest,
|
||||
AttestationRef = $"{imageRef.Registry}/{imageRef.Repository}@{manifestDigest}",
|
||||
AttachedAt = DateTimeOffset.UtcNow
|
||||
AttachedAt = attachedAt,
|
||||
RekorLogId = null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,7 +265,17 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
return null;
|
||||
}
|
||||
|
||||
var layerDigest = manifest.Layers[0].Digest;
|
||||
var layer = manifest.Layers.FirstOrDefault(l =>
|
||||
string.Equals(l.MediaType, MediaTypes.DsseEnvelope, StringComparison.Ordinal));
|
||||
if (layer is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation manifest {Digest} has no DSSE envelope layer",
|
||||
TruncateDigest(target.Digest));
|
||||
return null;
|
||||
}
|
||||
|
||||
var layerDigest = layer.Digest;
|
||||
|
||||
// Fetch the attestation blob
|
||||
var blobBytes = await _registryClient.FetchBlobAsync(
|
||||
@@ -305,12 +321,14 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
|
||||
private static Dictionary<string, string> BuildAnnotations(
|
||||
DsseEnvelope envelope,
|
||||
AttachmentOptions options)
|
||||
string predicateType,
|
||||
AttachmentOptions options,
|
||||
DateTimeOffset createdAt)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>
|
||||
var annotations = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[AnnotationKeys.Created] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
[AnnotationKeys.PredicateType] = envelope.PayloadType,
|
||||
[AnnotationKeys.Created] = createdAt.ToString("O"),
|
||||
[AnnotationKeys.PredicateType] = predicateType,
|
||||
[AnnotationKeys.CosignSignature] = "" // Cosign compatibility placeholder
|
||||
};
|
||||
|
||||
@@ -351,7 +369,7 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
private static DsseEnvelope DeserializeEnvelope(ReadOnlyMemory<byte> bytes)
|
||||
{
|
||||
// Parse the compact DSSE envelope format
|
||||
var json = JsonDocument.Parse(bytes);
|
||||
using var json = JsonDocument.Parse(bytes);
|
||||
var root = json.RootElement;
|
||||
|
||||
var payloadType = root.GetProperty("payloadType").GetString()
|
||||
@@ -360,7 +378,15 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
var payloadBase64 = root.GetProperty("payload").GetString()
|
||||
?? throw new InvalidOperationException("Missing payload");
|
||||
|
||||
var payload = Convert.FromBase64String(payloadBase64);
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = Convert.FromBase64String(payloadBase64);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Attestation payload is not valid base64.", ex);
|
||||
}
|
||||
|
||||
var signatures = new List<DsseSignature>();
|
||||
if (root.TryGetProperty("signatures", out var sigsElement))
|
||||
@@ -381,6 +407,41 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
return new DsseEnvelope(payloadType, payload, signatures);
|
||||
}
|
||||
|
||||
private static string ResolvePredicateType(DsseEnvelope envelope)
|
||||
{
|
||||
if (TryGetPredicateType(envelope.Payload.Span, out var predicateType))
|
||||
{
|
||||
return predicateType;
|
||||
}
|
||||
|
||||
return envelope.PayloadType;
|
||||
}
|
||||
|
||||
private static bool TryGetPredicateType(ReadOnlySpan<byte> payload, out string predicateType)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(payload.ToArray());
|
||||
if (json.RootElement.TryGetProperty("predicateType", out var predicateElement)
|
||||
&& predicateElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = predicateElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
predicateType = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Swallow and fallback to payload type
|
||||
}
|
||||
|
||||
predicateType = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Attestor.Oci</RootNamespace>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,4 +7,5 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0056-M | DONE | Maintainability audit for StellaOps.Attestor.Oci. |
|
||||
| AUDIT-0056-T | DONE | Test coverage audit for StellaOps.Attestor.Oci. |
|
||||
| AUDIT-0056-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0056-A | DONE | Applied audit remediation for OCI attacher and references. |
|
||||
| VAL-SMOKE-001 | DONE | Fixed build issue in Attestor OCI attacher. |
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Offline</RootNamespace>
|
||||
<Description>Offline verification of attestation bundles for air-gapped environments.</Description>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Offline</RootNamespace>
|
||||
<Description>Offline verification of attestation bundles for air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0058-M | DONE | Maintainability audit for StellaOps.Attestor.Offline. |
|
||||
| AUDIT-0058-T | DONE | Test coverage audit for StellaOps.Attestor.Offline. |
|
||||
| AUDIT-0058-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0058-A | DOING | Pending approval for changes. |
|
||||
|
||||
@@ -50,7 +50,7 @@ public class AuditLogEntity
|
||||
/// Additional details about the operation.
|
||||
/// </summary>
|
||||
[Column("details", TypeName = "jsonb")]
|
||||
public JsonDocument? Details { get; set; }
|
||||
public JsonElement? Details { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this log entry was created.
|
||||
|
||||
@@ -53,7 +53,7 @@ public class RekorEntryEntity
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("inclusion_proof", TypeName = "jsonb")]
|
||||
public JsonDocument InclusionProof { get; set; } = null!;
|
||||
public JsonElement InclusionProof { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this record was created.
|
||||
|
||||
@@ -16,7 +16,7 @@ function Resolve-RepoRoot {
|
||||
|
||||
$repoRoot = Resolve-RepoRoot
|
||||
$perfDir = Join-Path $repoRoot "src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Perf"
|
||||
$migrationFile = Join-Path $repoRoot "src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Migrations/20251214000001_AddProofChainSchema.sql"
|
||||
$migrationFile = Join-Path $repoRoot "src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Migrations/001_initial_schema.sql"
|
||||
$seedFile = Join-Path $perfDir "seed.sql"
|
||||
$queriesFile = Join-Path $perfDir "queries.sql"
|
||||
$reportFile = Join-Path $repoRoot "docs/db/reports/proofchain-schema-perf-2025-12-17.md"
|
||||
|
||||
@@ -57,6 +57,9 @@ public class ProofChainDbContext : DbContext
|
||||
entity.HasIndex(e => e.Purl).HasDatabaseName("idx_sbom_entries_purl");
|
||||
entity.HasIndex(e => e.ArtifactDigest).HasDatabaseName("idx_sbom_entries_artifact");
|
||||
entity.HasIndex(e => e.TrustAnchorId).HasDatabaseName("idx_sbom_entries_anchor");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
// Unique constraint
|
||||
entity.HasIndex(e => new { e.BomDigest, e.Purl, e.Version })
|
||||
@@ -87,6 +90,9 @@ public class ProofChainDbContext : DbContext
|
||||
.HasDatabaseName("idx_dsse_entry_predicate");
|
||||
entity.HasIndex(e => e.SignerKeyId).HasDatabaseName("idx_dsse_signer");
|
||||
entity.HasIndex(e => e.BodyHash).HasDatabaseName("idx_dsse_body_hash");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
// Unique constraint
|
||||
entity.HasIndex(e => new { e.EntryId, e.PredicateType, e.BodyHash })
|
||||
@@ -100,6 +106,9 @@ public class ProofChainDbContext : DbContext
|
||||
entity.HasIndex(e => e.BundleId).HasDatabaseName("idx_spines_bundle").IsUnique();
|
||||
entity.HasIndex(e => e.AnchorId).HasDatabaseName("idx_spines_anchor");
|
||||
entity.HasIndex(e => e.PolicyVersion).HasDatabaseName("idx_spines_policy");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
entity.HasOne(e => e.Anchor)
|
||||
.WithMany()
|
||||
@@ -114,6 +123,12 @@ public class ProofChainDbContext : DbContext
|
||||
entity.HasIndex(e => e.IsActive)
|
||||
.HasDatabaseName("idx_trust_anchors_active")
|
||||
.HasFilter("is_active = TRUE");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.ValueGeneratedOnAdd();
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.ValueGeneratedOnAddOrUpdate();
|
||||
});
|
||||
|
||||
// RekorEntryEntity configuration
|
||||
@@ -123,6 +138,9 @@ public class ProofChainDbContext : DbContext
|
||||
entity.HasIndex(e => e.LogId).HasDatabaseName("idx_rekor_log_id");
|
||||
entity.HasIndex(e => e.Uuid).HasDatabaseName("idx_rekor_uuid");
|
||||
entity.HasIndex(e => e.EnvId).HasDatabaseName("idx_rekor_env");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
entity.HasOne(e => e.Envelope)
|
||||
.WithOne(e => e.RekorEntry)
|
||||
@@ -138,6 +156,70 @@ public class ProofChainDbContext : DbContext
|
||||
entity.HasIndex(e => e.CreatedAt)
|
||||
.HasDatabaseName("idx_audit_created")
|
||||
.IsDescending();
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.ValueGeneratedOnAdd();
|
||||
});
|
||||
}
|
||||
|
||||
public override int SaveChanges()
|
||||
{
|
||||
NormalizeTrackedArrays();
|
||||
return base.SaveChanges();
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
NormalizeTrackedArrays();
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void NormalizeTrackedArrays()
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries<SpineEntity>())
|
||||
{
|
||||
if (entry.State is EntityState.Added or EntityState.Modified)
|
||||
{
|
||||
entry.Entity.EvidenceIds = NormalizeEvidenceIds(entry.Entity.EvidenceIds);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<TrustAnchorEntity>())
|
||||
{
|
||||
if (entry.State is EntityState.Added or EntityState.Modified)
|
||||
{
|
||||
entry.Entity.AllowedKeyIds = NormalizeKeyIds(entry.Entity.AllowedKeyIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] NormalizeEvidenceIds(string[] evidenceIds)
|
||||
{
|
||||
if (evidenceIds.Length == 0)
|
||||
{
|
||||
return evidenceIds;
|
||||
}
|
||||
|
||||
return evidenceIds
|
||||
.Select(id => id.Trim())
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string[] NormalizeKeyIds(string[] keyIds)
|
||||
{
|
||||
if (keyIds.Length == 0)
|
||||
{
|
||||
return keyIds;
|
||||
}
|
||||
|
||||
return keyIds
|
||||
.Select(id => id.Trim())
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
|
||||
private readonly ILogger<TrustAnchorMatcher> _logger;
|
||||
|
||||
// Cache compiled regex patterns
|
||||
private readonly Dictionary<string, Regex> _patternCache = new();
|
||||
private const int MaxRegexCacheSize = 1024;
|
||||
private readonly Dictionary<string, Regex> _patternCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _cacheLock = new();
|
||||
|
||||
public TrustAnchorMatcher(
|
||||
@@ -92,7 +93,7 @@ public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
|
||||
{
|
||||
var specificity = CalculateSpecificity(anchor.PurlPattern);
|
||||
|
||||
if (bestMatch == null || specificity > bestMatch.Specificity)
|
||||
if (IsBetterMatch(anchor, specificity, bestMatch))
|
||||
{
|
||||
bestMatch = new TrustAnchorMatchResult
|
||||
{
|
||||
@@ -190,6 +191,11 @@ public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
|
||||
var regexPattern = ConvertGlobToRegex(pattern);
|
||||
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
if (_patternCache.Count >= MaxRegexCacheSize)
|
||||
{
|
||||
_patternCache.Clear();
|
||||
}
|
||||
|
||||
_patternCache[pattern] = regex;
|
||||
return regex;
|
||||
}
|
||||
@@ -284,4 +290,36 @@ public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsBetterMatch(
|
||||
TrustAnchorEntity candidate,
|
||||
int specificity,
|
||||
TrustAnchorMatchResult? bestMatch)
|
||||
{
|
||||
if (bestMatch == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (specificity != bestMatch.Specificity)
|
||||
{
|
||||
return specificity > bestMatch.Specificity;
|
||||
}
|
||||
|
||||
var candidatePattern = candidate.PurlPattern ?? string.Empty;
|
||||
var bestPattern = bestMatch.MatchedPattern ?? string.Empty;
|
||||
|
||||
if (candidatePattern.Length != bestPattern.Length)
|
||||
{
|
||||
return candidatePattern.Length > bestPattern.Length;
|
||||
}
|
||||
|
||||
var patternCompare = string.Compare(candidatePattern, bestPattern, StringComparison.OrdinalIgnoreCase);
|
||||
if (patternCompare != 0)
|
||||
{
|
||||
return patternCompare < 0;
|
||||
}
|
||||
|
||||
return candidate.AnchorId.CompareTo(bestMatch.Anchor.AnchorId) < 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Attestor.Persistence</RootNamespace>
|
||||
<Description>Proof chain persistence layer with Entity Framework Core and PostgreSQL support.</Description>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0060-M | DONE | Maintainability audit for StellaOps.Attestor.Persistence. |
|
||||
| AUDIT-0060-T | DONE | Test coverage audit for StellaOps.Attestor.Persistence. |
|
||||
| AUDIT-0060-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0060-A | DONE | Applied defaults, normalization, deterministic matching, perf script, tests. |
|
||||
|
||||
@@ -19,10 +19,17 @@ public sealed class AuditHashLogger
|
||||
{
|
||||
private readonly ILogger<AuditHashLogger> _logger;
|
||||
private readonly bool _enableDetailedLogging;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AuditHashLogger(ILogger<AuditHashLogger> logger, bool enableDetailedLogging = false)
|
||||
: this(logger, TimeProvider.System, enableDetailedLogging)
|
||||
{
|
||||
}
|
||||
|
||||
public AuditHashLogger(ILogger<AuditHashLogger> logger, TimeProvider timeProvider, bool enableDetailedLogging = false)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_enableDetailedLogging = enableDetailedLogging;
|
||||
}
|
||||
|
||||
@@ -91,7 +98,7 @@ public sealed class AuditHashLogger
|
||||
RawSizeBytes = rawBytes.Length,
|
||||
CanonicalSizeBytes = canonicalBytes.Length,
|
||||
HashesMatch = rawHash.Equals(canonicalHash, StringComparison.Ordinal),
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Generators;
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.ProofChain.Models;
|
||||
using StellaOps.Canonical.Json;
|
||||
@@ -26,11 +27,33 @@ public sealed class BackportProofGenerator
|
||||
string fixedVersion,
|
||||
DateTimeOffset advisoryDate,
|
||||
JsonDocument advisoryData)
|
||||
{
|
||||
return FromDistroAdvisory(
|
||||
cveId,
|
||||
packagePurl,
|
||||
advisorySource,
|
||||
advisoryId,
|
||||
fixedVersion,
|
||||
advisoryDate,
|
||||
advisoryData,
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
public static ProofBlob FromDistroAdvisory(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string advisorySource,
|
||||
string advisoryId,
|
||||
string fixedVersion,
|
||||
DateTimeOffset advisoryDate,
|
||||
JsonDocument advisoryData,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:distro:{advisorySource}:{advisoryId}";
|
||||
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(advisoryData));
|
||||
var dataElement = advisoryData.RootElement.Clone();
|
||||
var dataHash = ComputeDataHash(advisoryData.RootElement.GetRawText());
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
@@ -38,7 +61,7 @@ public sealed class BackportProofGenerator
|
||||
Type = EvidenceType.DistroAdvisory,
|
||||
Source = advisorySource,
|
||||
Timestamp = advisoryDate,
|
||||
Data = advisoryData,
|
||||
Data = dataElement,
|
||||
DataHash = dataHash
|
||||
};
|
||||
|
||||
@@ -47,12 +70,12 @@ public sealed class BackportProofGenerator
|
||||
ProofId = "", // Will be computed
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
Evidences = new[] { evidence },
|
||||
Method = "distro_advisory_tier1",
|
||||
Confidence = 0.98, // Highest confidence - authoritative source
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
SnapshotId = GenerateSnapshotId(timeProvider)
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
@@ -66,12 +89,22 @@ public sealed class BackportProofGenerator
|
||||
string packagePurl,
|
||||
ChangelogEntry changelogEntry,
|
||||
string changelogSource)
|
||||
{
|
||||
return FromChangelog(cveId, packagePurl, changelogEntry, changelogSource, TimeProvider.System);
|
||||
}
|
||||
|
||||
public static ProofBlob FromChangelog(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
ChangelogEntry changelogEntry,
|
||||
string changelogSource,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:changelog:{changelogSource}:{changelogEntry.Version}";
|
||||
|
||||
var changelogData = JsonDocument.Parse(JsonSerializer.Serialize(changelogEntry));
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(changelogData));
|
||||
var changelogData = SerializeToElement(changelogEntry, out var changelogBytes);
|
||||
var dataHash = ComputeDataHash(changelogBytes);
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
@@ -88,12 +121,12 @@ public sealed class BackportProofGenerator
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
Evidences = new[] { evidence },
|
||||
Method = "changelog_mention_tier2",
|
||||
Confidence = changelogEntry.Confidence,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
SnapshotId = GenerateSnapshotId(timeProvider)
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
@@ -106,12 +139,21 @@ public sealed class BackportProofGenerator
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
PatchHeaderParseResult patchResult)
|
||||
{
|
||||
return FromPatchHeader(cveId, packagePurl, patchResult, TimeProvider.System);
|
||||
}
|
||||
|
||||
public static ProofBlob FromPatchHeader(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
PatchHeaderParseResult patchResult,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:patch_header:{patchResult.PatchFilePath}";
|
||||
|
||||
var patchData = JsonDocument.Parse(JsonSerializer.Serialize(patchResult));
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(patchData));
|
||||
var patchData = SerializeToElement(patchResult, out var patchBytes);
|
||||
var dataHash = ComputeDataHash(patchBytes);
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
@@ -128,12 +170,12 @@ public sealed class BackportProofGenerator
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
Evidences = new[] { evidence },
|
||||
Method = "patch_header_tier3",
|
||||
Confidence = patchResult.Confidence,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
SnapshotId = GenerateSnapshotId(timeProvider)
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
@@ -147,12 +189,22 @@ public sealed class BackportProofGenerator
|
||||
string packagePurl,
|
||||
PatchSignature patchSig,
|
||||
bool exactMatch)
|
||||
{
|
||||
return FromPatchSignature(cveId, packagePurl, patchSig, exactMatch, TimeProvider.System);
|
||||
}
|
||||
|
||||
public static ProofBlob FromPatchSignature(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
PatchSignature patchSig,
|
||||
bool exactMatch,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:hunksig:{patchSig.CommitSha}";
|
||||
|
||||
var patchData = JsonDocument.Parse(JsonSerializer.Serialize(patchSig));
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(patchData));
|
||||
var patchData = SerializeToElement(patchSig, out var patchBytes);
|
||||
var dataHash = ComputeDataHash(patchBytes);
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
@@ -172,12 +224,12 @@ public sealed class BackportProofGenerator
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
Evidences = new[] { evidence },
|
||||
Method = exactMatch ? "hunksig_exact_tier3" : "hunksig_fuzzy_tier3",
|
||||
Confidence = confidence,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
SnapshotId = GenerateSnapshotId(timeProvider)
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
@@ -193,19 +245,39 @@ public sealed class BackportProofGenerator
|
||||
string fingerprintValue,
|
||||
JsonDocument fingerprintData,
|
||||
double confidence)
|
||||
{
|
||||
return FromBinaryFingerprint(
|
||||
cveId,
|
||||
packagePurl,
|
||||
fingerprintMethod,
|
||||
fingerprintValue,
|
||||
fingerprintData,
|
||||
confidence,
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
public static ProofBlob FromBinaryFingerprint(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string fingerprintMethod,
|
||||
string fingerprintValue,
|
||||
JsonDocument fingerprintData,
|
||||
double confidence,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:binary:{fingerprintMethod}:{fingerprintValue}";
|
||||
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(fingerprintData));
|
||||
var dataElement = fingerprintData.RootElement.Clone();
|
||||
var dataHash = ComputeDataHash(fingerprintData.RootElement.GetRawText());
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = EvidenceType.BinaryFingerprint,
|
||||
Source = fingerprintMethod,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = fingerprintData,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Data = dataElement,
|
||||
DataHash = dataHash
|
||||
};
|
||||
|
||||
@@ -214,12 +286,12 @@ public sealed class BackportProofGenerator
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
Evidences = new[] { evidence },
|
||||
Method = $"binary_{fingerprintMethod}_tier4",
|
||||
Confidence = confidence,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
SnapshotId = GenerateSnapshotId(timeProvider)
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
@@ -232,6 +304,15 @@ public sealed class BackportProofGenerator
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
IReadOnlyList<ProofEvidence> evidences)
|
||||
{
|
||||
return CombineEvidence(cveId, packagePurl, evidences, TimeProvider.System);
|
||||
}
|
||||
|
||||
public static ProofBlob CombineEvidence(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
IReadOnlyList<ProofEvidence> evidences,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (evidences.Count == 0)
|
||||
{
|
||||
@@ -251,12 +332,12 @@ public sealed class BackportProofGenerator
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
Evidences = evidences,
|
||||
Method = method,
|
||||
Confidence = confidence,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
SnapshotId = GenerateSnapshotId(timeProvider)
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
@@ -270,19 +351,30 @@ public sealed class BackportProofGenerator
|
||||
string packagePurl,
|
||||
string reason,
|
||||
JsonDocument versionData)
|
||||
{
|
||||
return NotAffected(cveId, packagePurl, reason, versionData, TimeProvider.System);
|
||||
}
|
||||
|
||||
public static ProofBlob NotAffected(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string reason,
|
||||
JsonDocument versionData,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
var evidenceId = $"evidence:version_comparison:{cveId}";
|
||||
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(versionData));
|
||||
var dataElement = versionData.RootElement.Clone();
|
||||
var dataHash = ComputeDataHash(versionData.RootElement.GetRawText());
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = EvidenceType.VersionComparison,
|
||||
Source = "version_comparison",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = versionData,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Data = dataElement,
|
||||
DataHash = dataHash
|
||||
};
|
||||
|
||||
@@ -291,12 +383,12 @@ public sealed class BackportProofGenerator
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.NotAffected,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
Evidences = new[] { evidence },
|
||||
Method = reason,
|
||||
Confidence = 0.95,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
SnapshotId = GenerateSnapshotId(timeProvider)
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
@@ -309,6 +401,15 @@ public sealed class BackportProofGenerator
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string reason)
|
||||
{
|
||||
return Vulnerable(cveId, packagePurl, reason, TimeProvider.System);
|
||||
}
|
||||
|
||||
public static ProofBlob Vulnerable(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string reason,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
|
||||
@@ -318,12 +419,12 @@ public sealed class BackportProofGenerator
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.Vulnerable,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
Evidences = Array.Empty<ProofEvidence>(),
|
||||
Method = reason,
|
||||
Confidence = 0.85, // Lower confidence - absence of evidence is not evidence of absence
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
SnapshotId = GenerateSnapshotId(timeProvider)
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
@@ -337,6 +438,16 @@ public sealed class BackportProofGenerator
|
||||
string packagePurl,
|
||||
string reason,
|
||||
IReadOnlyList<ProofEvidence> partialEvidences)
|
||||
{
|
||||
return Unknown(cveId, packagePurl, reason, partialEvidences, TimeProvider.System);
|
||||
}
|
||||
|
||||
public static ProofBlob Unknown(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
string reason,
|
||||
IReadOnlyList<ProofEvidence> partialEvidences,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var subjectId = $"{cveId}:{packagePurl}";
|
||||
|
||||
@@ -345,12 +456,12 @@ public sealed class BackportProofGenerator
|
||||
ProofId = "",
|
||||
SubjectId = subjectId,
|
||||
Type = ProofBlobType.Unknown,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
Evidences = partialEvidences,
|
||||
Method = reason,
|
||||
Confidence = 0.0,
|
||||
ToolVersion = ToolVersion,
|
||||
SnapshotId = GenerateSnapshotId()
|
||||
SnapshotId = GenerateSnapshotId(timeProvider)
|
||||
};
|
||||
|
||||
return ProofHashing.WithHash(proof);
|
||||
@@ -418,9 +529,27 @@ public sealed class BackportProofGenerator
|
||||
return $"multi_tier_combined_{types.Count}";
|
||||
}
|
||||
|
||||
private static string GenerateSnapshotId()
|
||||
private static string GenerateSnapshotId(TimeProvider timeProvider)
|
||||
{
|
||||
// Snapshot ID format: YYYYMMDD-HHMMSS-UTC
|
||||
return DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss") + "-UTC";
|
||||
return timeProvider.GetUtcNow().ToString("yyyyMMdd-HHmmss") + "-UTC";
|
||||
}
|
||||
|
||||
private static JsonElement SerializeToElement<T>(T value, out byte[] jsonBytes)
|
||||
{
|
||||
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(value);
|
||||
using var document = JsonDocument.Parse(jsonBytes);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static string ComputeDataHash(ReadOnlySpan<byte> jsonBytes)
|
||||
{
|
||||
return CanonJson.Sha256Prefixed(CanonJson.CanonicalizeParsedJson(jsonBytes));
|
||||
}
|
||||
|
||||
private static string ComputeDataHash(string json)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
return ComputeDataHash(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,17 @@ public sealed class BinaryFingerprintEvidenceGenerator
|
||||
{
|
||||
private const string ToolId = "stellaops.binaryindex";
|
||||
private const string ToolVersion = "1.0.0";
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public BinaryFingerprintEvidenceGenerator()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public BinaryFingerprintEvidenceGenerator(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a proof segment from binary vulnerability findings.
|
||||
@@ -28,8 +39,8 @@ public sealed class BinaryFingerprintEvidenceGenerator
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
|
||||
var predicateJson = JsonSerializer.SerializeToDocument(predicate, GetJsonOptions());
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(predicateJson));
|
||||
var predicateJson = SerializeToElement(predicate, GetJsonOptions(), out var predicateBytes);
|
||||
var dataHash = CanonJson.Sha256Prefixed(CanonJson.CanonicalizeParsedJson(predicateBytes));
|
||||
|
||||
// Create subject ID from binary key and scan context
|
||||
var subjectId = $"binary:{predicate.BinaryIdentity.BinaryKey}";
|
||||
@@ -42,15 +53,15 @@ public sealed class BinaryFingerprintEvidenceGenerator
|
||||
var evidences = new List<ProofEvidence>();
|
||||
foreach (var match in predicate.Matches)
|
||||
{
|
||||
var matchData = JsonSerializer.SerializeToDocument(match, GetJsonOptions());
|
||||
var matchHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(matchData));
|
||||
var matchData = SerializeToElement(match, GetJsonOptions(), out var matchBytes);
|
||||
var matchHash = CanonJson.Sha256Prefixed(CanonJson.CanonicalizeParsedJson(matchBytes));
|
||||
|
||||
evidences.Add(new ProofEvidence
|
||||
{
|
||||
EvidenceId = $"evidence:binary:{predicate.BinaryIdentity.BinaryKey}:{match.CveId}",
|
||||
Type = EvidenceType.BinaryFingerprint,
|
||||
Source = match.Method,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
Data = matchData,
|
||||
DataHash = matchHash
|
||||
});
|
||||
@@ -65,7 +76,7 @@ public sealed class BinaryFingerprintEvidenceGenerator
|
||||
ProofId = "", // Will be computed by ProofHashing.WithHash
|
||||
SubjectId = subjectId,
|
||||
Type = proofType,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Evidences = evidences,
|
||||
Method = "binary_fingerprint_evidence",
|
||||
Confidence = confidence,
|
||||
@@ -176,9 +187,19 @@ public sealed class BinaryFingerprintEvidenceGenerator
|
||||
return totalWeight > 0 ? Math.Min(weightedSum / totalWeight, 0.98) : 0.0;
|
||||
}
|
||||
|
||||
private static string GenerateSnapshotId()
|
||||
private string GenerateSnapshotId()
|
||||
{
|
||||
return DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss") + "-UTC";
|
||||
return _timeProvider.GetUtcNow().ToString("yyyyMMdd-HHmmss") + "-UTC";
|
||||
}
|
||||
|
||||
private static JsonElement SerializeToElement<T>(
|
||||
T value,
|
||||
JsonSerializerOptions options,
|
||||
out byte[] jsonBytes)
|
||||
{
|
||||
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(value, options);
|
||||
using var document = JsonDocument.Parse(jsonBytes);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions GetJsonOptions()
|
||||
|
||||
@@ -111,24 +111,26 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SchemaValidationResult> ValidatePredicateAsync(
|
||||
public Task<SchemaValidationResult> ValidatePredicateAsync(
|
||||
string json,
|
||||
string predicateType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!HasSchema(predicateType))
|
||||
{
|
||||
return SchemaValidationResult.Failure(new SchemaValidationError
|
||||
return Task.FromResult(SchemaValidationResult.Failure(new SchemaValidationError
|
||||
{
|
||||
Path = "/",
|
||||
Message = $"No schema registered for predicate type: {predicateType}",
|
||||
Keyword = "predicateType"
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var document = JsonDocument.Parse(json);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
|
||||
// TODO: Implement actual JSON Schema validation
|
||||
// For now, do basic structural checks
|
||||
@@ -174,27 +176,29 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? SchemaValidationResult.Failure(errors.ToArray())
|
||||
: SchemaValidationResult.Success();
|
||||
? Task.FromResult(SchemaValidationResult.Failure(errors.ToArray()))
|
||||
: Task.FromResult(SchemaValidationResult.Success());
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return SchemaValidationResult.Failure(new SchemaValidationError
|
||||
return Task.FromResult(SchemaValidationResult.Failure(new SchemaValidationError
|
||||
{
|
||||
Path = "/",
|
||||
Message = $"Invalid JSON: {ex.Message}",
|
||||
Keyword = "format"
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SchemaValidationResult> ValidateStatementAsync<T>(
|
||||
public Task<SchemaValidationResult> ValidateStatementAsync<T>(
|
||||
T statement,
|
||||
CancellationToken ct = default) where T : Statements.InTotoStatement
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(statement);
|
||||
return await ValidatePredicateAsync(json, statement.PredicateType, ct);
|
||||
return ValidatePredicateAsync(json, statement.PredicateType, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -197,53 +197,119 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
private static void WriteNumber(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
var raw = element.GetRawText();
|
||||
if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ||
|
||||
double.IsNaN(value) ||
|
||||
double.IsInfinity(value))
|
||||
writer.WriteRawValue(NormalizeNumberString(raw), skipInputValidation: true);
|
||||
}
|
||||
|
||||
private static string NormalizeNumberString(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
throw new FormatException("Invalid JSON number.");
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
var negative = raw[index] == '-';
|
||||
if (negative)
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var intStart = index;
|
||||
while (index < raw.Length && char.IsDigit(raw[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index == intStart)
|
||||
{
|
||||
throw new FormatException($"Invalid JSON number: '{raw}'.");
|
||||
}
|
||||
|
||||
if (value == 0d)
|
||||
var intPart = raw[intStart..index];
|
||||
var fracPart = string.Empty;
|
||||
|
||||
if (index < raw.Length && raw[index] == '.')
|
||||
{
|
||||
writer.WriteRawValue("0", skipInputValidation: true);
|
||||
return;
|
||||
index++;
|
||||
var fracStart = index;
|
||||
while (index < raw.Length && char.IsDigit(raw[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
if (index == fracStart)
|
||||
{
|
||||
throw new FormatException($"Invalid JSON number: '{raw}'.");
|
||||
}
|
||||
fracPart = raw[fracStart..index];
|
||||
}
|
||||
|
||||
var formatted = value.ToString("R", CultureInfo.InvariantCulture);
|
||||
writer.WriteRawValue(NormalizeExponent(formatted), skipInputValidation: true);
|
||||
var exponent = 0;
|
||||
if (index < raw.Length && (raw[index] == 'e' || raw[index] == 'E'))
|
||||
{
|
||||
index++;
|
||||
var expNegative = false;
|
||||
if (index < raw.Length && (raw[index] == '+' || raw[index] == '-'))
|
||||
{
|
||||
expNegative = raw[index] == '-';
|
||||
index++;
|
||||
}
|
||||
|
||||
var expStart = index;
|
||||
while (index < raw.Length && char.IsDigit(raw[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index == expStart)
|
||||
{
|
||||
throw new FormatException($"Invalid JSON number: '{raw}'.");
|
||||
}
|
||||
|
||||
var expValue = int.Parse(raw[expStart..index], CultureInfo.InvariantCulture);
|
||||
exponent = expNegative ? -expValue : expValue;
|
||||
}
|
||||
|
||||
if (index != raw.Length)
|
||||
{
|
||||
throw new FormatException($"Invalid JSON number: '{raw}'.");
|
||||
}
|
||||
|
||||
var digits = (intPart + fracPart).TrimStart('0');
|
||||
if (digits.Length == 0)
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
var decimalExponent = exponent - fracPart.Length;
|
||||
var normalized = decimalExponent >= 0
|
||||
? digits + new string('0', decimalExponent)
|
||||
: InsertDecimalPoint(digits, decimalExponent);
|
||||
|
||||
return negative ? "-" + normalized : normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeExponent(string formatted)
|
||||
private static string InsertDecimalPoint(string digits, int decimalExponent)
|
||||
{
|
||||
var e = formatted.IndexOfAny(['E', 'e']);
|
||||
if (e < 0)
|
||||
var position = digits.Length + decimalExponent;
|
||||
if (position > 0)
|
||||
{
|
||||
return formatted;
|
||||
var integerPart = digits[..position].TrimStart('0');
|
||||
if (integerPart.Length == 0)
|
||||
{
|
||||
integerPart = "0";
|
||||
}
|
||||
|
||||
var fractionalPart = digits[position..].TrimEnd('0');
|
||||
if (fractionalPart.Length == 0)
|
||||
{
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
return $"{integerPart}.{fractionalPart}";
|
||||
}
|
||||
|
||||
var mantissa = formatted[..e];
|
||||
var exponent = formatted[(e + 1)..];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(exponent))
|
||||
{
|
||||
return mantissa;
|
||||
}
|
||||
|
||||
var sign = string.Empty;
|
||||
if (exponent[0] is '+' or '-')
|
||||
{
|
||||
sign = exponent[0] == '-' ? "-" : string.Empty;
|
||||
exponent = exponent[1..];
|
||||
}
|
||||
|
||||
exponent = exponent.TrimStart('0');
|
||||
if (exponent.Length == 0)
|
||||
{
|
||||
// 1e0 -> 1
|
||||
return mantissa;
|
||||
}
|
||||
|
||||
return $"{mantissa}e{sign}{exponent}";
|
||||
var zeros = new string('0', -position);
|
||||
var fraction = (zeros + digits).TrimEnd('0');
|
||||
return $"0.{fraction}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,14 @@ public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
|
||||
throw new ArgumentException("At least one leaf is required.", nameof(leafValues));
|
||||
}
|
||||
|
||||
var sortedLeaves = SortLeaves(leafValues);
|
||||
var levels = new List<IReadOnlyList<byte[]>>();
|
||||
|
||||
// Level 0: Hash all leaf values
|
||||
var leafHashes = new List<byte[]>(PadToPowerOfTwo(leafValues.Count));
|
||||
for (var i = 0; i < leafValues.Count; i++)
|
||||
var leafHashes = new List<byte[]>(PadToPowerOfTwo(sortedLeaves.Count));
|
||||
for (var i = 0; i < sortedLeaves.Count; i++)
|
||||
{
|
||||
leafHashes.Add(SHA256.HashData(leafValues[i].Span));
|
||||
leafHashes.Add(SHA256.HashData(sortedLeaves[i].Span));
|
||||
}
|
||||
|
||||
// Pad with duplicate of last leaf hash (deterministic)
|
||||
@@ -149,6 +150,49 @@ public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
|
||||
return currentHash.AsSpan().SequenceEqual(expectedRoot);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ReadOnlyMemory<byte>> SortLeaves(IReadOnlyList<ReadOnlyMemory<byte>> leaves)
|
||||
{
|
||||
if (leaves.Count <= 1)
|
||||
{
|
||||
return leaves;
|
||||
}
|
||||
|
||||
var indexed = new List<(ReadOnlyMemory<byte> Value, int Index)>(leaves.Count);
|
||||
for (var i = 0; i < leaves.Count; i++)
|
||||
{
|
||||
indexed.Add((leaves[i], i));
|
||||
}
|
||||
|
||||
indexed.Sort(static (left, right) =>
|
||||
{
|
||||
var comparison = CompareBytes(left.Value.Span, right.Value.Span);
|
||||
return comparison != 0 ? comparison : left.Index.CompareTo(right.Index);
|
||||
});
|
||||
|
||||
var ordered = new ReadOnlyMemory<byte>[indexed.Count];
|
||||
for (var i = 0; i < indexed.Count; i++)
|
||||
{
|
||||
ordered[i] = indexed[i].Value;
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static int CompareBytes(ReadOnlySpan<byte> left, ReadOnlySpan<byte> right)
|
||||
{
|
||||
var min = Math.Min(left.Length, right.Length);
|
||||
for (var i = 0; i < min; i++)
|
||||
{
|
||||
var diff = left[i].CompareTo(right[i]);
|
||||
if (diff != 0)
|
||||
{
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
|
||||
return left.Length.CompareTo(right.Length);
|
||||
}
|
||||
|
||||
private static int PadToPowerOfTwo(int count)
|
||||
{
|
||||
var power = 1;
|
||||
@@ -168,4 +212,3 @@ public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed record ProofEvidence
|
||||
public required EvidenceType Type { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required JsonDocument Data { get; init; }
|
||||
public required JsonElement Data { get; init; }
|
||||
public required string DataHash { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0062-M | DONE | Maintainability audit for StellaOps.Attestor.ProofChain. |
|
||||
| AUDIT-0062-T | DONE | Test coverage audit for StellaOps.Attestor.ProofChain. |
|
||||
| AUDIT-0062-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0062-A | DONE | Applied determinism, time providers, canonicalization, schema validation, tests. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Attestor.ProofChain.Json;
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates;
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace StellaOps.Attestor.StandardPredicates;
|
||||
/// </summary>
|
||||
public static class JsonCanonicalizer
|
||||
{
|
||||
private static readonly Rfc8785JsonCanonicalizer Canonicalizer = new();
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalize JSON according to RFC 8785.
|
||||
/// </summary>
|
||||
@@ -17,11 +19,14 @@ public static class JsonCanonicalizer
|
||||
/// <returns>Canonical JSON (minified, lexicographically sorted keys, stable number format)</returns>
|
||||
public static string Canonicalize(string json)
|
||||
{
|
||||
var node = JsonNode.Parse(json);
|
||||
if (node == null)
|
||||
return "null";
|
||||
if (json is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(json));
|
||||
}
|
||||
|
||||
return CanonicalizeNode(node);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var canonical = Canonicalizer.Canonicalize(bytes);
|
||||
return Encoding.UTF8.GetString(canonical);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -32,114 +37,4 @@ public static class JsonCanonicalizer
|
||||
var json = element.GetRawText();
|
||||
return Canonicalize(json);
|
||||
}
|
||||
|
||||
private static string CanonicalizeNode(JsonNode node)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
return CanonicalizeObject(obj);
|
||||
|
||||
case JsonArray arr:
|
||||
return CanonicalizeArray(arr);
|
||||
|
||||
case JsonValue val:
|
||||
return CanonicalizeValue(val);
|
||||
|
||||
default:
|
||||
return "null";
|
||||
}
|
||||
}
|
||||
|
||||
private static string CanonicalizeObject(JsonObject obj)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('{');
|
||||
|
||||
var sortedKeys = obj.Select(kvp => kvp.Key).OrderBy(k => k, StringComparer.Ordinal);
|
||||
var first = true;
|
||||
|
||||
foreach (var key in sortedKeys)
|
||||
{
|
||||
if (!first)
|
||||
sb.Append(',');
|
||||
first = false;
|
||||
|
||||
// Escape key according to JSON rules
|
||||
sb.Append(JsonSerializer.Serialize(key));
|
||||
sb.Append(':');
|
||||
|
||||
var value = obj[key];
|
||||
if (value != null)
|
||||
{
|
||||
sb.Append(CanonicalizeNode(value));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("null");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string CanonicalizeArray(JsonArray arr)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('[');
|
||||
|
||||
for (int i = 0; i < arr.Count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
sb.Append(',');
|
||||
|
||||
var item = arr[i];
|
||||
if (item != null)
|
||||
{
|
||||
sb.Append(CanonicalizeNode(item));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("null");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string CanonicalizeValue(JsonValue val)
|
||||
{
|
||||
// Let System.Text.Json handle proper escaping and number formatting
|
||||
var jsonElement = JsonSerializer.SerializeToElement(val);
|
||||
|
||||
switch (jsonElement.ValueKind)
|
||||
{
|
||||
case JsonValueKind.String:
|
||||
return JsonSerializer.Serialize(jsonElement.GetString());
|
||||
|
||||
case JsonValueKind.Number:
|
||||
// Use ToString to get deterministic number representation
|
||||
var number = jsonElement.GetDouble();
|
||||
// Check if it's actually an integer
|
||||
if (number == Math.Floor(number) && number >= long.MinValue && number <= long.MaxValue)
|
||||
{
|
||||
return jsonElement.GetInt64().ToString();
|
||||
}
|
||||
return number.ToString("G17"); // Full precision, no trailing zeros
|
||||
|
||||
case JsonValueKind.True:
|
||||
return "true";
|
||||
|
||||
case JsonValueKind.False:
|
||||
return "false";
|
||||
|
||||
case JsonValueKind.Null:
|
||||
return "null";
|
||||
|
||||
default:
|
||||
return JsonSerializer.Serialize(jsonElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -173,15 +175,15 @@ public sealed class CycloneDxPredicateParser : IPredicateParser
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> ExtractMetadata(JsonElement payload)
|
||||
private IReadOnlyDictionary<string, string> ExtractMetadata(JsonElement payload)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
var metadata = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
if (payload.TryGetProperty("specVersion", out var specVersion))
|
||||
metadata["specVersion"] = specVersion.GetString() ?? "";
|
||||
|
||||
if (payload.TryGetProperty("version", out var version))
|
||||
metadata["version"] = version.GetInt32().ToString();
|
||||
metadata["version"] = ReadVersionValue(version);
|
||||
|
||||
if (payload.TryGetProperty("serialNumber", out var serialNumber))
|
||||
metadata["serialNumber"] = serialNumber.GetString() ?? "";
|
||||
@@ -217,4 +219,15 @@ public sealed class CycloneDxPredicateParser : IPredicateParser
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static string ReadVersionValue(JsonElement version)
|
||||
{
|
||||
return version.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when version.TryGetInt32(out var numeric) => numeric.ToString(CultureInfo.InvariantCulture),
|
||||
JsonValueKind.Number => version.GetDouble().ToString(CultureInfo.InvariantCulture),
|
||||
JsonValueKind.String => version.GetString() ?? "",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -164,9 +166,9 @@ public sealed class SlsaProvenancePredicateParser : IPredicateParser
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> ExtractMetadata(JsonElement payload)
|
||||
private IReadOnlyDictionary<string, string> ExtractMetadata(JsonElement payload)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
var metadata = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
// Extract build definition metadata
|
||||
if (payload.TryGetProperty("buildDefinition", out var buildDef))
|
||||
@@ -253,7 +255,7 @@ public sealed class SlsaProvenancePredicateParser : IPredicateParser
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString() ?? "",
|
||||
JsonValueKind.Number => element.GetDouble().ToString(),
|
||||
JsonValueKind.Number => element.GetDouble().ToString(CultureInfo.InvariantCulture),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "null",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -198,9 +199,9 @@ public sealed class SpdxPredicateParser : IPredicateParser
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> ExtractMetadata(JsonElement payload, string version)
|
||||
private IReadOnlyDictionary<string, string> ExtractMetadata(JsonElement payload, string version)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
var metadata = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["spdxVersion"] = version
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates;
|
||||
|
||||
/// <summary>
|
||||
@@ -49,7 +51,8 @@ public sealed record PredicateMetadata
|
||||
/// <summary>
|
||||
/// Additional properties extracted from the predicate.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Properties { get; init; } = new();
|
||||
public IReadOnlyDictionary<string, string> Properties { get; init; }
|
||||
= new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -37,7 +37,19 @@ public sealed class StandardPredicateRegistry : IStandardPredicateRegistry
|
||||
/// <returns>True if parser found, false otherwise</returns>
|
||||
public bool TryGetParser(string predicateType, [NotNullWhen(true)] out IPredicateParser? parser)
|
||||
{
|
||||
return _parsers.TryGetValue(predicateType, out parser);
|
||||
if (_parsers.TryGetValue(predicateType, out parser))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = NormalizePredicateType(predicateType);
|
||||
if (normalized != null && _parsers.TryGetValue(normalized, out parser))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
parser = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -51,4 +63,24 @@ public sealed class StandardPredicateRegistry : IStandardPredicateRegistry
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
|
||||
private static string? NormalizePredicateType(string predicateType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(predicateType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (predicateType.StartsWith("https://cyclonedx.org/bom/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "https://cyclonedx.org/bom";
|
||||
}
|
||||
|
||||
if (predicateType.StartsWith("https://spdx.org/spdxdocs/spdx-v2.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "https://spdx.dev/Document";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0064-M | DONE | Maintainability audit for StellaOps.Attestor.StandardPredicates. |
|
||||
| AUDIT-0064-T | DONE | Test coverage audit for StellaOps.Attestor.StandardPredicates. |
|
||||
| AUDIT-0064-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0064-A | DONE | Applied canonicalization, registry normalization, parser metadata fixes, tests. |
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.GraphRoot.Models;
|
||||
using Xunit;
|
||||
@@ -15,6 +16,9 @@ namespace StellaOps.Attestor.GraphRoot.Tests;
|
||||
|
||||
public class GraphRootAttestorTests
|
||||
{
|
||||
private static readonly TimeProvider FixedTimeProviderInstance =
|
||||
new FixedTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
private readonly Mock<IMerkleRootComputer> _merkleComputerMock;
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
private readonly GraphRootAttestor _attestor;
|
||||
@@ -28,12 +32,7 @@ public class GraphRootAttestorTests
|
||||
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
|
||||
.Returns(new byte[32]); // 32-byte hash
|
||||
|
||||
// Create a real test key for signing (need both private and public for Ed25519)
|
||||
var privateKey = new byte[64]; // Ed25519 expanded private key is 64 bytes
|
||||
var publicKey = new byte[32];
|
||||
Random.Shared.NextBytes(privateKey);
|
||||
Random.Shared.NextBytes(publicKey);
|
||||
_testKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, "test-key-id");
|
||||
_testKey = CreateDeterministicKey("test-key-id");
|
||||
|
||||
_signatureService = new EnvelopeSignatureService();
|
||||
|
||||
@@ -41,7 +40,8 @@ public class GraphRootAttestorTests
|
||||
_merkleComputerMock.Object,
|
||||
_signatureService,
|
||||
_ => _testKey,
|
||||
NullLogger<GraphRootAttestor>.Instance);
|
||||
NullLogger<GraphRootAttestor>.Instance,
|
||||
timeProvider: FixedTimeProviderInstance);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -170,6 +170,40 @@ public class GraphRootAttestorTests
|
||||
Assert.Contains("sha256:params", digestStrings);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AttestAsync_IncludesEvidenceIdsInLeaves()
|
||||
{
|
||||
// Arrange
|
||||
var request = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.DependencyGraph,
|
||||
NodeIds = Array.Empty<string>(),
|
||||
EdgeIds = Array.Empty<string>(),
|
||||
PolicyDigest = "sha256:policy",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
ToolchainDigest = "sha256:toolchain",
|
||||
ParamsDigest = "sha256:params",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
EvidenceIds = new[] { "evidence-b", "evidence-a" }
|
||||
};
|
||||
|
||||
IReadOnlyList<ReadOnlyMemory<byte>>? capturedLeaves = null;
|
||||
_merkleComputerMock
|
||||
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
|
||||
.Callback<IReadOnlyList<ReadOnlyMemory<byte>>>(leaves => capturedLeaves = leaves)
|
||||
.Returns(new byte[32]);
|
||||
|
||||
// Act
|
||||
await _attestor.AttestAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedLeaves);
|
||||
var leafStrings = capturedLeaves.Select(l => System.Text.Encoding.UTF8.GetString(l.Span)).ToList();
|
||||
Assert.Contains("evidence-a", leafStrings);
|
||||
Assert.Contains("evidence-b", leafStrings);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AttestAsync_NullRequest_ThrowsArgumentNullException()
|
||||
@@ -187,7 +221,8 @@ public class GraphRootAttestorTests
|
||||
_merkleComputerMock.Object,
|
||||
_signatureService,
|
||||
_ => null,
|
||||
NullLogger<GraphRootAttestor>.Instance);
|
||||
NullLogger<GraphRootAttestor>.Instance,
|
||||
timeProvider: FixedTimeProviderInstance);
|
||||
|
||||
var request = CreateValidRequest();
|
||||
|
||||
@@ -249,4 +284,32 @@ public class GraphRootAttestorTests
|
||||
ArtifactDigest = "sha256:artifact345"
|
||||
};
|
||||
}
|
||||
|
||||
private static EnvelopeKey CreateDeterministicKey(string keyId)
|
||||
{
|
||||
var seed = new byte[32];
|
||||
for (var i = 0; i < seed.Length; i++)
|
||||
{
|
||||
seed[i] = (byte)(i + 1);
|
||||
}
|
||||
|
||||
var privateKeyParameters = new Ed25519PrivateKeyParameters(seed, 0);
|
||||
var publicKeyParameters = privateKeyParameters.GeneratePublicKey();
|
||||
var publicKey = publicKeyParameters.GetEncoded();
|
||||
var privateKey = privateKeyParameters.GetEncoded();
|
||||
|
||||
return EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, keyId);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime)
|
||||
{
|
||||
_fixedTime = fixedTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ public class GraphRootModelsTests
|
||||
public void GraphRootPredicate_RequiredProperties_Set()
|
||||
{
|
||||
// Arrange & Act
|
||||
var fixedTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var predicate = new GraphRootPredicate
|
||||
{
|
||||
GraphType = "DependencyGraph",
|
||||
@@ -79,7 +80,7 @@ public class GraphRootModelsTests
|
||||
ParamsDigest = "sha256:pr"
|
||||
},
|
||||
CanonVersion = "stella:canon:v1",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
ComputedAt = fixedTime,
|
||||
ComputedBy = "test",
|
||||
ComputedByVersion = "1.0.0"
|
||||
};
|
||||
@@ -97,6 +98,7 @@ public class GraphRootModelsTests
|
||||
public void GraphRootAttestation_HasCorrectDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var fixedTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var attestation = new GraphRootAttestation
|
||||
{
|
||||
Subject = new[]
|
||||
@@ -123,7 +125,7 @@ public class GraphRootModelsTests
|
||||
ParamsDigest = "sha256:pr"
|
||||
},
|
||||
CanonVersion = "v1",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
ComputedAt = fixedTime,
|
||||
ComputedBy = "test",
|
||||
ComputedByVersion = "1.0"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
@@ -33,16 +34,23 @@ namespace StellaOps.Attestor.GraphRoot.Tests;
|
||||
/// </summary>
|
||||
public class GraphRootPipelineIntegrationTests
|
||||
{
|
||||
private static readonly TimeProvider FixedTimeProviderInstance =
|
||||
new FixedTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static (EnvelopeKey Key, byte[] PublicKey) CreateTestKey()
|
||||
{
|
||||
// Generate a real Ed25519 key pair for testing
|
||||
var privateKey = new byte[64]; // Ed25519 expanded private key
|
||||
var publicKey = new byte[32];
|
||||
Random.Shared.NextBytes(privateKey);
|
||||
Random.Shared.NextBytes(publicKey);
|
||||
var seed = new byte[32];
|
||||
for (var i = 0; i < seed.Length; i++)
|
||||
{
|
||||
seed[i] = (byte)(i + 1);
|
||||
}
|
||||
|
||||
var privateKeyParameters = new Ed25519PrivateKeyParameters(seed, 0);
|
||||
var publicKeyParameters = privateKeyParameters.GeneratePublicKey();
|
||||
var publicKey = publicKeyParameters.GetEncoded();
|
||||
var privateKey = privateKeyParameters.GetEncoded();
|
||||
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, "test-integration-key");
|
||||
return (key, publicKey);
|
||||
}
|
||||
@@ -58,7 +66,8 @@ public class GraphRootPipelineIntegrationTests
|
||||
_ => key,
|
||||
NullLogger<GraphRootAttestor>.Instance,
|
||||
rekorClient,
|
||||
Options.Create(options ?? new GraphRootAttestorOptions()));
|
||||
Options.Create(options ?? new GraphRootAttestorOptions()),
|
||||
FixedTimeProviderInstance);
|
||||
}
|
||||
|
||||
private static GraphRootAttestationRequest CreateRealisticRequest(
|
||||
@@ -69,7 +78,7 @@ public class GraphRootPipelineIntegrationTests
|
||||
var nodeIds = Enumerable.Range(1, nodeCount)
|
||||
.Select(i =>
|
||||
{
|
||||
var content = $"node-{i}-content-{Guid.NewGuid()}";
|
||||
var content = $"node-{i}-content";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
})
|
||||
@@ -102,7 +111,7 @@ public class GraphRootPipelineIntegrationTests
|
||||
ToolchainDigest = toolchainDigest,
|
||||
ParamsDigest = paramsDigest,
|
||||
ArtifactDigest = artifactDigest,
|
||||
EvidenceIds = [$"evidence-{Guid.NewGuid()}", $"evidence-{Guid.NewGuid()}"]
|
||||
EvidenceIds = ["evidence-1", "evidence-2"]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,7 +228,7 @@ public class GraphRootPipelineIntegrationTests
|
||||
Uuid = "test-uuid-12345",
|
||||
Index = 42,
|
||||
Status = "included",
|
||||
IntegratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
IntegratedTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeSeconds()
|
||||
});
|
||||
|
||||
var options = new GraphRootAttestorOptions
|
||||
@@ -348,8 +357,7 @@ public class GraphRootPipelineIntegrationTests
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsValid);
|
||||
Assert.Contains("Root mismatch", verifyResult.FailureReason);
|
||||
Assert.NotEqual(verifyResult.ExpectedRoot, verifyResult.ComputedRoot);
|
||||
Assert.Contains("Predicate node IDs do not match", verifyResult.FailureReason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -375,7 +383,7 @@ public class GraphRootPipelineIntegrationTests
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsValid);
|
||||
Assert.Contains("Root mismatch", verifyResult.FailureReason);
|
||||
Assert.Contains("Predicate edge IDs do not match", verifyResult.FailureReason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -401,7 +409,7 @@ public class GraphRootPipelineIntegrationTests
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsValid);
|
||||
Assert.NotEqual(request.NodeIds.Count, verifyResult.NodeCount);
|
||||
Assert.Contains("Predicate node IDs do not match", verifyResult.FailureReason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -425,6 +433,7 @@ public class GraphRootPipelineIntegrationTests
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsValid);
|
||||
Assert.Contains("Predicate node IDs do not match", verifyResult.FailureReason);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -543,4 +552,16 @@ public class GraphRootPipelineIntegrationTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime)
|
||||
{
|
||||
_fixedTime = fixedTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user