353 lines
12 KiB
C#
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);
|
|
}
|