Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Delta/Evidence/DeltaEvidenceComposer.cs

353 lines
12 KiB
C#

// -----------------------------------------------------------------------------
// 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);
}