doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DeltaEvidenceComposer.cs
|
||||
// Sprint: SPRINT_20260118_026_Scanner_delta_scanning_engine
|
||||
// Task: TASK-026-05 - Delta Evidence Composer
|
||||
// Description: Implementation of delta scan evidence composition
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Delta.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Composes signed, DSSE-wrapped delta evidence for policy gates.
|
||||
/// Produces deterministic, canonical JSON for consistent hashing.
|
||||
/// </summary>
|
||||
public sealed class DeltaEvidenceComposer : IDeltaEvidenceComposer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
// Ensure deterministic key ordering
|
||||
PropertyNameCaseInsensitive = false
|
||||
};
|
||||
|
||||
private readonly IEvidenceSigningService? _signingService;
|
||||
private readonly IRekorSubmissionService? _rekorService;
|
||||
private readonly ILogger<DeltaEvidenceComposer> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly string ScannerVersion = Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "1.0.0";
|
||||
|
||||
public DeltaEvidenceComposer(
|
||||
ILogger<DeltaEvidenceComposer> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IEvidenceSigningService? signingService = null,
|
||||
IRekorSubmissionService? rekorService = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_signingService = signingService;
|
||||
_rekorService = rekorService;
|
||||
}
|
||||
|
||||
public async Task<DeltaScanEvidence> ComposeAsync(
|
||||
DeltaScanResult scanResult,
|
||||
EvidenceCompositionOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scanResult);
|
||||
options ??= new EvidenceCompositionOptions();
|
||||
|
||||
var composedAt = _timeProvider.GetUtcNow();
|
||||
var scanId = options.ScanId ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
// Create the in-toto statement
|
||||
var statement = CreateStatement(scanResult, scanId, composedAt);
|
||||
|
||||
// Serialize to canonical JSON
|
||||
var statementJson = SerializeCanonical(statement);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(statementJson);
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
var payloadHash = ComputeHash(payloadBytes);
|
||||
|
||||
// Sign if requested
|
||||
var signatures = new List<DsseSignature>();
|
||||
if (options.Sign && _signingService != null)
|
||||
{
|
||||
var signature = await _signingService.SignAsync(
|
||||
payloadBytes,
|
||||
options.SigningKeyId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
signatures.Add(new DsseSignature
|
||||
{
|
||||
KeyId = signature.KeyId,
|
||||
Sig = signature.SignatureBase64
|
||||
});
|
||||
|
||||
_logger.LogDebug("Signed delta scan evidence with key {KeyId}", signature.KeyId);
|
||||
}
|
||||
else if (options.Sign)
|
||||
{
|
||||
_logger.LogWarning("Signing requested but no signing service available");
|
||||
}
|
||||
|
||||
var envelope = new DeltaScanDsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = payloadBase64,
|
||||
Signatures = signatures
|
||||
};
|
||||
|
||||
// Compute idempotency key
|
||||
var idempotencyKey = ComputeIdempotencyKey(
|
||||
scanResult.OldManifestDigest,
|
||||
scanResult.NewManifestDigest);
|
||||
|
||||
// Submit to Rekor if requested
|
||||
RekorEntryInfo? rekorEntry = null;
|
||||
if (options.SubmitToRekor && _rekorService != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
rekorEntry = await _rekorService.SubmitAsync(
|
||||
envelope,
|
||||
idempotencyKey,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Submitted delta scan evidence to Rekor, logIndex={LogIndex}",
|
||||
rekorEntry.LogIndex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to submit to Rekor, continuing without transparency log");
|
||||
}
|
||||
}
|
||||
|
||||
return new DeltaScanEvidence
|
||||
{
|
||||
Envelope = envelope,
|
||||
Statement = statement,
|
||||
PayloadHash = payloadHash,
|
||||
RekorEntry = rekorEntry,
|
||||
IdempotencyKey = idempotencyKey,
|
||||
ComposedAt = composedAt
|
||||
};
|
||||
}
|
||||
|
||||
public InTotoStatement CreateStatement(DeltaScanResult scanResult)
|
||||
{
|
||||
return CreateStatement(scanResult, Guid.NewGuid().ToString("N"), _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private InTotoStatement CreateStatement(
|
||||
DeltaScanResult scanResult,
|
||||
string scanId,
|
||||
DateTimeOffset scannedAt)
|
||||
{
|
||||
var predicate = CreatePredicate(scanResult, scanId, scannedAt);
|
||||
|
||||
// Create subjects for both old and new images
|
||||
var subjects = new List<InTotoSubject>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = scanResult.NewImage,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ExtractDigestValue(scanResult.NewManifestDigest)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add old image as second subject
|
||||
if (!string.IsNullOrWhiteSpace(scanResult.OldManifestDigest))
|
||||
{
|
||||
subjects.Add(new InTotoSubject
|
||||
{
|
||||
Name = scanResult.OldImage,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = ExtractDigestValue(scanResult.OldManifestDigest)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new InTotoStatement
|
||||
{
|
||||
Subject = subjects,
|
||||
PredicateType = DeltaScanPredicate.PredicateType,
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
private static DeltaScanPredicate CreatePredicate(
|
||||
DeltaScanResult scanResult,
|
||||
string scanId,
|
||||
DateTimeOffset scannedAt)
|
||||
{
|
||||
// Calculate layer reuse ratio
|
||||
var totalLayers = scanResult.AddedLayers.Count +
|
||||
scanResult.RemovedLayers.Count +
|
||||
scanResult.UnchangedLayers.Count;
|
||||
|
||||
var reuseRatio = totalLayers > 0
|
||||
? (double)scanResult.UnchangedLayers.Count / totalLayers
|
||||
: 0.0;
|
||||
|
||||
return new DeltaScanPredicate
|
||||
{
|
||||
ScanId = scanId,
|
||||
ScannedAt = scannedAt,
|
||||
Scanner = new ScannerInfo
|
||||
{
|
||||
Name = "StellaOps.Scanner.Delta",
|
||||
Version = ScannerVersion,
|
||||
SbomTool = "syft",
|
||||
SbomToolVersion = "0.100.0"
|
||||
},
|
||||
OldImage = new ImageSubject
|
||||
{
|
||||
Reference = scanResult.OldImage,
|
||||
ManifestDigest = scanResult.OldManifestDigest,
|
||||
LayerCount = scanResult.UnchangedLayers.Count + scanResult.RemovedLayers.Count
|
||||
},
|
||||
NewImage = new ImageSubject
|
||||
{
|
||||
Reference = scanResult.NewImage,
|
||||
ManifestDigest = scanResult.NewManifestDigest,
|
||||
LayerCount = scanResult.UnchangedLayers.Count + scanResult.AddedLayers.Count
|
||||
},
|
||||
LayerChanges = new LayerChangesInfo
|
||||
{
|
||||
Added = scanResult.AddedLayers.Count,
|
||||
Removed = scanResult.RemovedLayers.Count,
|
||||
Unchanged = scanResult.UnchangedLayers.Count,
|
||||
ReuseRatio = Math.Round(reuseRatio, 4),
|
||||
AddedDiffIds = scanResult.AddedLayers.Select(l => l.DiffId).ToList(),
|
||||
RemovedDiffIds = scanResult.RemovedLayers.Select(l => l.DiffId).ToList()
|
||||
},
|
||||
ComponentChanges = new ComponentChangesInfo
|
||||
{
|
||||
Added = scanResult.AddedComponentCount,
|
||||
Removed = 0, // Would need to track this in DeltaScanResult
|
||||
VersionChanged = 0,
|
||||
OtherModified = 0,
|
||||
Unchanged = scanResult.CachedComponentCount,
|
||||
TotalComponents = scanResult.AddedComponentCount + scanResult.CachedComponentCount,
|
||||
IsBreaking = false, // Would need to determine from SBOM diff
|
||||
CachedComponentCount = scanResult.CachedComponentCount,
|
||||
ScannedComponentCount = scanResult.AddedComponentCount
|
||||
},
|
||||
Metrics = new ScanMetrics
|
||||
{
|
||||
TotalDurationMs = (long)scanResult.ScanDuration.TotalMilliseconds,
|
||||
AddedLayersScanDurationMs = (long)scanResult.AddedLayersScanDuration.TotalMilliseconds,
|
||||
UsedCache = scanResult.UsedCache,
|
||||
CacheHitRatio = scanResult.CachedComponentCount > 0
|
||||
? (double)scanResult.CachedComponentCount /
|
||||
(scanResult.AddedComponentCount + scanResult.CachedComponentCount)
|
||||
: null
|
||||
},
|
||||
SbomFormat = scanResult.SbomFormat,
|
||||
SbomDigest = !string.IsNullOrWhiteSpace(scanResult.CompositeSbom)
|
||||
? "sha256:" + ComputeHash(Encoding.UTF8.GetBytes(scanResult.CompositeSbom))
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
public string ComputePredicateHash(DeltaScanPredicate predicate)
|
||||
{
|
||||
var json = SerializeCanonical(predicate);
|
||||
return ComputeHash(Encoding.UTF8.GetBytes(json));
|
||||
}
|
||||
|
||||
private static string SerializeCanonical<T>(T obj)
|
||||
{
|
||||
// For truly canonical JSON, we need sorted keys
|
||||
// System.Text.Json doesn't natively support this, but for our use case
|
||||
// the predictable property order from the record definitions is sufficient
|
||||
return JsonSerializer.Serialize(obj, CanonicalJsonOptions);
|
||||
}
|
||||
|
||||
private static string ComputeHash(byte[] data)
|
||||
{
|
||||
var hashBytes = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
private static string ComputeIdempotencyKey(string oldDigest, string newDigest)
|
||||
{
|
||||
var combined = $"{oldDigest}:{newDigest}";
|
||||
return ComputeHash(Encoding.UTF8.GetBytes(combined));
|
||||
}
|
||||
|
||||
private static string ExtractDigestValue(string digest)
|
||||
{
|
||||
// Extract the hex value from "sha256:abc123..."
|
||||
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return digest[7..];
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for evidence signing service.
|
||||
/// Implementations may use various signing backends (local key, KMS, HSM, etc.).
|
||||
/// </summary>
|
||||
public interface IEvidenceSigningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs the payload and returns the signature.
|
||||
/// </summary>
|
||||
/// <param name="payload">Payload bytes to sign.</param>
|
||||
/// <param name="keyId">Optional key ID (null for default).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Signature result.</returns>
|
||||
Task<SignatureResult> SignAsync(
|
||||
byte[] payload,
|
||||
string? keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing operation.
|
||||
/// </summary>
|
||||
public sealed record SignatureResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature.
|
||||
/// </summary>
|
||||
public required string SignatureBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature algorithm used.
|
||||
/// </summary>
|
||||
public string? Algorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Rekor transparency log submission.
|
||||
/// </summary>
|
||||
public interface IRekorSubmissionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Submits an envelope to Rekor.
|
||||
/// </summary>
|
||||
/// <param name="envelope">DSSE envelope to submit.</param>
|
||||
/// <param name="idempotencyKey">Idempotency key for deduplication.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Rekor entry information.</returns>
|
||||
Task<RekorEntryInfo> SubmitAsync(
|
||||
DeltaScanDsseEnvelope envelope,
|
||||
string idempotencyKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user