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:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -0,0 +1,343 @@
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Manifest.Resolution;
namespace StellaOps.Scanner.Delta;
/// <summary>
/// Implementation of IDeltaLayerScanner that scans only changed layers between image versions.
/// Reduces scan time and CVE churn by reusing cached per-layer SBOMs.
/// </summary>
public sealed class DeltaLayerScanner : IDeltaLayerScanner
{
private readonly ILayerDigestResolver _layerResolver;
private readonly ILayerSbomCas _sbomCas;
private readonly ILayerScannerPipeline _scannerPipeline;
private readonly ISbomComposer _sbomComposer;
private readonly ILogger<DeltaLayerScanner> _logger;
public DeltaLayerScanner(
ILayerDigestResolver layerResolver,
ILayerSbomCas sbomCas,
ILayerScannerPipeline scannerPipeline,
ISbomComposer sbomComposer,
ILogger<DeltaLayerScanner> logger)
{
_layerResolver = layerResolver ?? throw new ArgumentNullException(nameof(layerResolver));
_sbomCas = sbomCas ?? throw new ArgumentNullException(nameof(sbomCas));
_scannerPipeline = scannerPipeline ?? throw new ArgumentNullException(nameof(scannerPipeline));
_sbomComposer = sbomComposer ?? throw new ArgumentNullException(nameof(sbomComposer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DeltaScanResult> ScanDeltaAsync(
string oldImage,
string newImage,
DeltaScanOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(oldImage);
ArgumentException.ThrowIfNullOrWhiteSpace(newImage);
options ??= new DeltaScanOptions();
var totalStopwatch = Stopwatch.StartNew();
_logger.LogInformation("Starting delta scan: {OldImage} -> {NewImage}", oldImage, newImage);
// Resolve layers for both images in parallel
var resolveOptions = new LayerResolutionOptions
{
ComputeDiffIds = true,
Platform = options.Platform
};
var oldResolvedTask = _layerResolver.ResolveLayersAsync(oldImage, resolveOptions, cancellationToken);
var newResolvedTask = _layerResolver.ResolveLayersAsync(newImage, resolveOptions, cancellationToken);
var oldResolved = await oldResolvedTask.ConfigureAwait(false);
var newResolved = await newResolvedTask.ConfigureAwait(false);
// Identify layer changes using diffID (content hash)
var oldDiffIds = oldResolved.Layers
.Where(l => !string.IsNullOrWhiteSpace(l.DiffId))
.Select(l => l.DiffId!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var addedLayers = new List<LayerChangeInfo>();
var unchangedLayers = new List<LayerChangeInfo>();
var removedLayers = new List<LayerChangeInfo>();
// Categorize new image layers
foreach (var layer in newResolved.Layers)
{
if (string.IsNullOrWhiteSpace(layer.DiffId))
{
// Treat as added if diffID unknown
addedLayers.Add(CreateLayerInfo(layer));
}
else if (oldDiffIds.Contains(layer.DiffId))
{
unchangedLayers.Add(CreateLayerInfo(layer));
}
else
{
addedLayers.Add(CreateLayerInfo(layer));
}
}
// Find removed layers
var newDiffIds = newResolved.Layers
.Where(l => !string.IsNullOrWhiteSpace(l.DiffId))
.Select(l => l.DiffId!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var layer in oldResolved.Layers)
{
if (!string.IsNullOrWhiteSpace(layer.DiffId) && !newDiffIds.Contains(layer.DiffId))
{
removedLayers.Add(CreateLayerInfo(layer));
}
}
_logger.LogInformation(
"Layer analysis: {Added} added, {Removed} removed, {Unchanged} unchanged",
addedLayers.Count, removedLayers.Count, unchangedLayers.Count);
// Collect SBOMs for unchanged layers from cache
var layerSboms = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var cachedComponentCount = 0;
if (options.UseCachedSboms && !options.ForceFullScan)
{
for (var i = 0; i < unchangedLayers.Count; i++)
{
var layer = unchangedLayers[i];
var cachedSbom = await _sbomCas.GetAsync(layer.DiffId, options.SbomFormat, cancellationToken)
.ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(cachedSbom))
{
layerSboms[layer.DiffId] = cachedSbom;
var componentCount = CountComponents(cachedSbom);
unchangedLayers[i] = layer with { FromCache = true, ComponentCount = componentCount };
cachedComponentCount += componentCount;
}
else
{
// Layer not in cache - need to scan it
_logger.LogDebug("Unchanged layer {DiffId} not in cache, will scan", layer.DiffId);
addedLayers.Add(layer);
unchangedLayers.RemoveAt(i);
i--;
}
}
_logger.LogDebug("Retrieved {Count} cached layer SBOMs", layerSboms.Count);
}
// Scan added layers
var addedScanStopwatch = Stopwatch.StartNew();
var addedComponentCount = 0;
for (var i = 0; i < addedLayers.Count; i++)
{
var layer = addedLayers[i];
cancellationToken.ThrowIfCancellationRequested();
var layerSbom = await _scannerPipeline.ScanLayerAsync(
newResolved.ImageReference,
layer.DiffId,
layer.LayerDigest,
options.SbomFormat,
cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(layerSbom))
{
layerSboms[layer.DiffId] = layerSbom;
// Store in cache for future use
await _sbomCas.StoreAsync(layer.DiffId, options.SbomFormat, layerSbom, cancellationToken)
.ConfigureAwait(false);
var componentCount = CountComponents(layerSbom);
addedLayers[i] = layer with { ComponentCount = componentCount };
addedComponentCount += componentCount;
}
}
addedScanStopwatch.Stop();
_logger.LogInformation("Scanned {Count} added layers in {Duration:N2}s",
addedLayers.Count, addedScanStopwatch.Elapsed.TotalSeconds);
// Compose final SBOM in layer order
var orderedSboms = newResolved.Layers
.Where(l => !string.IsNullOrWhiteSpace(l.DiffId) && layerSboms.ContainsKey(l.DiffId!))
.Select(l => layerSboms[l.DiffId!])
.ToList();
var compositeSbom = await _sbomComposer.ComposeAsync(
orderedSboms,
newResolved.ImageReference,
options.SbomFormat,
options.IncludeLayerAttribution,
cancellationToken).ConfigureAwait(false);
totalStopwatch.Stop();
var result = new DeltaScanResult
{
OldImage = oldImage,
OldManifestDigest = oldResolved.ManifestDigest,
NewImage = newImage,
NewManifestDigest = newResolved.ManifestDigest,
AddedLayers = [.. addedLayers],
RemovedLayers = [.. removedLayers],
UnchangedLayers = [.. unchangedLayers],
CompositeSbom = compositeSbom,
SbomFormat = options.SbomFormat,
ScanDuration = totalStopwatch.Elapsed,
AddedLayersScanDuration = addedScanStopwatch.Elapsed,
AddedComponentCount = addedComponentCount,
CachedComponentCount = cachedComponentCount,
UsedCache = options.UseCachedSboms && !options.ForceFullScan
};
_logger.LogInformation(
"Delta scan complete: {LayerReuse:P1} reuse, {Duration:N2}s total, {AddedDuration:N2}s scanning added layers",
result.LayerReuseRatio, result.ScanDuration.TotalSeconds, result.AddedLayersScanDuration.TotalSeconds);
return result;
}
public async Task<LayerChangeSummary> IdentifyLayerChangesAsync(
string oldImage,
string newImage,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(oldImage);
ArgumentException.ThrowIfNullOrWhiteSpace(newImage);
var resolveOptions = new LayerResolutionOptions { ComputeDiffIds = true };
var oldResolvedTask = _layerResolver.ResolveLayersAsync(oldImage, resolveOptions, cancellationToken);
var newResolvedTask = _layerResolver.ResolveLayersAsync(newImage, resolveOptions, cancellationToken);
var oldResolved = await oldResolvedTask.ConfigureAwait(false);
var newResolved = await newResolvedTask.ConfigureAwait(false);
var oldDiffIds = oldResolved.Layers
.Where(l => !string.IsNullOrWhiteSpace(l.DiffId))
.Select(l => l.DiffId!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var newDiffIds = newResolved.Layers
.Where(l => !string.IsNullOrWhiteSpace(l.DiffId))
.Select(l => l.DiffId!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var added = newDiffIds.Except(oldDiffIds, StringComparer.OrdinalIgnoreCase).ToList();
var removed = oldDiffIds.Except(newDiffIds, StringComparer.OrdinalIgnoreCase).ToList();
var unchanged = newDiffIds.Intersect(oldDiffIds, StringComparer.OrdinalIgnoreCase).ToList();
return new LayerChangeSummary
{
OldImage = oldImage,
NewImage = newImage,
OldLayerCount = oldResolved.LayerCount,
NewLayerCount = newResolved.LayerCount,
AddedCount = added.Count,
RemovedCount = removed.Count,
UnchangedCount = unchanged.Count,
AddedDiffIds = [.. added],
RemovedDiffIds = [.. removed],
UnchangedDiffIds = [.. unchanged]
};
}
private static LayerChangeInfo CreateLayerInfo(LayerProvenance layer) => new()
{
LayerDigest = layer.LayerDigest,
DiffId = layer.DiffId ?? string.Empty,
LayerIndex = layer.LayerIndex,
Size = layer.Size,
MediaType = layer.MediaType
};
private static int CountComponents(string sbom)
{
if (string.IsNullOrWhiteSpace(sbom))
return 0;
// Count component markers in SBOM
var cycloneDxCount = CountOccurrences(sbom, "\"bom-ref\"");
var spdxCount = CountOccurrences(sbom, "\"SPDXID\"");
return Math.Max(cycloneDxCount, spdxCount);
}
private static int CountOccurrences(string text, string pattern)
{
var count = 0;
var index = 0;
while ((index = text.IndexOf(pattern, index, StringComparison.Ordinal)) != -1)
{
count++;
index += pattern.Length;
}
return count;
}
}
/// <summary>
/// Interface for per-layer SBOM content-addressable storage.
/// </summary>
public interface ILayerSbomCas
{
/// <summary>
/// Stores a per-layer SBOM.
/// </summary>
Task StoreAsync(string diffId, string format, string sbom, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a per-layer SBOM.
/// </summary>
Task<string?> GetAsync(string diffId, string format, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a per-layer SBOM exists.
/// </summary>
Task<bool> ExistsAsync(string diffId, string format, CancellationToken cancellationToken = default);
}
/// <summary>
/// Interface for scanning individual layers.
/// </summary>
public interface ILayerScannerPipeline
{
/// <summary>
/// Scans a single layer and produces an SBOM.
/// </summary>
Task<string?> ScanLayerAsync(
string imageReference,
string diffId,
string layerDigest,
string sbomFormat,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Interface for composing multiple layer SBOMs into a single image SBOM.
/// </summary>
public interface ISbomComposer
{
/// <summary>
/// Composes multiple layer SBOMs into a single image SBOM.
/// </summary>
Task<string?> ComposeAsync(
IReadOnlyList<string> layerSboms,
string imageReference,
string sbomFormat,
bool includeLayerAttribution,
CancellationToken cancellationToken = default);
}

View File

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

View File

@@ -0,0 +1,293 @@
// -----------------------------------------------------------------------------
// DeltaScanPredicate.cs
// Sprint: SPRINT_20260118_026_Scanner_delta_scanning_engine
// Task: TASK-026-05 - Delta Evidence Composer
// Description: DSSE predicate type for delta scan attestations
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Delta.Evidence;
/// <summary>
/// DSSE predicate for delta scan attestations.
/// Predicate type: https://stellaops.io/attestations/delta-scan/v1
/// </summary>
public sealed record DeltaScanPredicate
{
/// <summary>
/// Predicate type URI.
/// </summary>
[JsonIgnore]
public const string PredicateType = "https://stellaops.io/attestations/delta-scan/v1";
/// <summary>
/// Unique scan ID.
/// </summary>
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
/// <summary>
/// When the scan was performed (UTC).
/// </summary>
[JsonPropertyName("scannedAt")]
public required DateTimeOffset ScannedAt { get; init; }
/// <summary>
/// Scanner tool information.
/// </summary>
[JsonPropertyName("scanner")]
public required ScannerInfo Scanner { get; init; }
/// <summary>
/// The old (baseline) image being compared.
/// </summary>
[JsonPropertyName("oldImage")]
public required ImageSubject OldImage { get; init; }
/// <summary>
/// The new image being scanned.
/// </summary>
[JsonPropertyName("newImage")]
public required ImageSubject NewImage { get; init; }
/// <summary>
/// Layer changes between images.
/// </summary>
[JsonPropertyName("layerChanges")]
public required LayerChangesInfo LayerChanges { get; init; }
/// <summary>
/// Component changes (SBOM diff).
/// </summary>
[JsonPropertyName("componentChanges")]
public required ComponentChangesInfo ComponentChanges { get; init; }
/// <summary>
/// Scan performance metrics.
/// </summary>
[JsonPropertyName("metrics")]
public required ScanMetrics Metrics { get; init; }
/// <summary>
/// SBOM format used.
/// </summary>
[JsonPropertyName("sbomFormat")]
public required string SbomFormat { get; init; }
/// <summary>
/// Content hash of the composite SBOM.
/// </summary>
[JsonPropertyName("sbomDigest")]
public string? SbomDigest { get; init; }
}
/// <summary>
/// Scanner tool information.
/// </summary>
public sealed record ScannerInfo
{
/// <summary>Scanner name.</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Scanner version.</summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>SBOM generation tool.</summary>
[JsonPropertyName("sbomTool")]
public string? SbomTool { get; init; }
/// <summary>SBOM tool version.</summary>
[JsonPropertyName("sbomToolVersion")]
public string? SbomToolVersion { get; init; }
}
/// <summary>
/// Image subject information.
/// </summary>
public sealed record ImageSubject
{
/// <summary>Image reference (registry/repository:tag).</summary>
[JsonPropertyName("reference")]
public required string Reference { get; init; }
/// <summary>Manifest digest (sha256:...).</summary>
[JsonPropertyName("manifestDigest")]
public required string ManifestDigest { get; init; }
/// <summary>Config digest (sha256:...).</summary>
[JsonPropertyName("configDigest")]
public string? ConfigDigest { get; init; }
/// <summary>Total number of layers.</summary>
[JsonPropertyName("layerCount")]
public required int LayerCount { get; init; }
/// <summary>Platform (os/arch).</summary>
[JsonPropertyName("platform")]
public string? Platform { get; init; }
}
/// <summary>
/// Summary of layer changes.
/// </summary>
public sealed record LayerChangesInfo
{
/// <summary>Number of layers added.</summary>
[JsonPropertyName("added")]
public required int Added { get; init; }
/// <summary>Number of layers removed.</summary>
[JsonPropertyName("removed")]
public required int Removed { get; init; }
/// <summary>Number of unchanged layers.</summary>
[JsonPropertyName("unchanged")]
public required int Unchanged { get; init; }
/// <summary>Layer reuse ratio (0.0 to 1.0).</summary>
[JsonPropertyName("reuseRatio")]
public required double ReuseRatio { get; init; }
/// <summary>DiffIDs of added layers.</summary>
[JsonPropertyName("addedDiffIds")]
public IReadOnlyList<string>? AddedDiffIds { get; init; }
/// <summary>DiffIDs of removed layers.</summary>
[JsonPropertyName("removedDiffIds")]
public IReadOnlyList<string>? RemovedDiffIds { get; init; }
}
/// <summary>
/// Summary of component changes.
/// </summary>
public sealed record ComponentChangesInfo
{
/// <summary>Number of components added.</summary>
[JsonPropertyName("added")]
public required int Added { get; init; }
/// <summary>Number of components removed.</summary>
[JsonPropertyName("removed")]
public required int Removed { get; init; }
/// <summary>Number of components with version changes.</summary>
[JsonPropertyName("versionChanged")]
public required int VersionChanged { get; init; }
/// <summary>Number of components with other modifications.</summary>
[JsonPropertyName("otherModified")]
public required int OtherModified { get; init; }
/// <summary>Number of unchanged components.</summary>
[JsonPropertyName("unchanged")]
public required int Unchanged { get; init; }
/// <summary>Total components in new image.</summary>
[JsonPropertyName("totalComponents")]
public required int TotalComponents { get; init; }
/// <summary>Whether this is a breaking change.</summary>
[JsonPropertyName("isBreaking")]
public required bool IsBreaking { get; init; }
/// <summary>Components added from cache (not scanned).</summary>
[JsonPropertyName("cachedComponentCount")]
public int CachedComponentCount { get; init; }
/// <summary>Components added from fresh scans.</summary>
[JsonPropertyName("scannedComponentCount")]
public int ScannedComponentCount { get; init; }
}
/// <summary>
/// Scan performance metrics.
/// </summary>
public sealed record ScanMetrics
{
/// <summary>Total scan duration in milliseconds.</summary>
[JsonPropertyName("totalDurationMs")]
public required long TotalDurationMs { get; init; }
/// <summary>Time spent scanning added layers.</summary>
[JsonPropertyName("addedLayersScanDurationMs")]
public required long AddedLayersScanDurationMs { get; init; }
/// <summary>Whether cached SBOMs were used.</summary>
[JsonPropertyName("usedCache")]
public required bool UsedCache { get; init; }
/// <summary>Cache hit ratio for unchanged layers.</summary>
[JsonPropertyName("cacheHitRatio")]
public double? CacheHitRatio { get; init; }
}
/// <summary>
/// DSSE envelope structure for delta scan attestations.
/// </summary>
public sealed record DeltaScanDsseEnvelope
{
/// <summary>Payload type.</summary>
[JsonPropertyName("payloadType")]
public required string PayloadType { get; init; }
/// <summary>Base64-encoded payload.</summary>
[JsonPropertyName("payload")]
public required string Payload { get; init; }
/// <summary>Signatures over the payload.</summary>
[JsonPropertyName("signatures")]
public required IReadOnlyList<DsseSignature> Signatures { get; init; }
}
/// <summary>
/// Signature in a DSSE envelope.
/// </summary>
public sealed record DsseSignature
{
/// <summary>Key ID.</summary>
[JsonPropertyName("keyid")]
public string? KeyId { get; init; }
/// <summary>Base64-encoded signature.</summary>
[JsonPropertyName("sig")]
public required string Sig { get; init; }
}
/// <summary>
/// In-toto statement wrapper for the predicate.
/// </summary>
public sealed record InTotoStatement
{
/// <summary>Statement type (always in-toto statement).</summary>
[JsonPropertyName("_type")]
public string Type { get; init; } = "https://in-toto.io/Statement/v1";
/// <summary>Subjects (what the attestation is about).</summary>
[JsonPropertyName("subject")]
public required IReadOnlyList<InTotoSubject> Subject { get; init; }
/// <summary>Predicate type URI.</summary>
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
/// <summary>The predicate payload.</summary>
[JsonPropertyName("predicate")]
public required DeltaScanPredicate Predicate { get; init; }
}
/// <summary>
/// In-toto subject (artifact being attested).
/// </summary>
public sealed record InTotoSubject
{
/// <summary>Subject name (image reference).</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Subject digests.</summary>
[JsonPropertyName("digest")]
public required IReadOnlyDictionary<string, string> Digest { get; init; }
}

View File

@@ -0,0 +1,137 @@
// -----------------------------------------------------------------------------
// IDeltaEvidenceComposer.cs
// Sprint: SPRINT_20260118_026_Scanner_delta_scanning_engine
// Task: TASK-026-05 - Delta Evidence Composer
// Description: Interface for composing signed delta scan evidence
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Delta.Evidence;
/// <summary>
/// Composes signed, DSSE-wrapped delta evidence for policy gates.
/// Evidence is deterministic and can be submitted to Rekor for transparency logging.
/// </summary>
public interface IDeltaEvidenceComposer
{
/// <summary>
/// Composes a DSSE-wrapped attestation from a delta scan result.
/// </summary>
/// <param name="scanResult">The delta scan result to attest.</param>
/// <param name="options">Optional composition options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Composed evidence with optional Rekor entry.</returns>
Task<DeltaScanEvidence> ComposeAsync(
DeltaScanResult scanResult,
EvidenceCompositionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates an unsigned in-toto statement for the delta scan.
/// Useful for preview or when signing is handled separately.
/// </summary>
/// <param name="scanResult">The delta scan result.</param>
/// <returns>Unsigned in-toto statement.</returns>
InTotoStatement CreateStatement(DeltaScanResult scanResult);
/// <summary>
/// Computes the canonical hash of a delta scan predicate.
/// Used for idempotency key generation.
/// </summary>
/// <param name="predicate">The predicate to hash.</param>
/// <returns>SHA256 hash as hex string.</returns>
string ComputePredicateHash(DeltaScanPredicate predicate);
}
/// <summary>
/// Options for evidence composition.
/// </summary>
public sealed class EvidenceCompositionOptions
{
/// <summary>
/// Whether to sign the evidence.
/// </summary>
public bool Sign { get; set; } = true;
/// <summary>
/// Whether to submit to Rekor transparency log.
/// </summary>
public bool SubmitToRekor { get; set; } = true;
/// <summary>
/// Key ID for signing (null for default key).
/// </summary>
public string? SigningKeyId { get; set; }
/// <summary>
/// Custom scan ID (auto-generated if null).
/// </summary>
public string? ScanId { get; set; }
/// <summary>
/// Include detailed layer diffIDs in evidence.
/// </summary>
public bool IncludeLayerDetails { get; set; } = true;
}
/// <summary>
/// Composed delta scan evidence with metadata.
/// </summary>
public sealed record DeltaScanEvidence
{
/// <summary>
/// The DSSE-wrapped attestation envelope.
/// </summary>
public required DeltaScanDsseEnvelope Envelope { get; init; }
/// <summary>
/// The in-toto statement (for reference).
/// </summary>
public required InTotoStatement Statement { get; init; }
/// <summary>
/// SHA256 hash of the canonical JSON payload.
/// </summary>
public required string PayloadHash { get; init; }
/// <summary>
/// Rekor log entry information (if submitted).
/// </summary>
public RekorEntryInfo? RekorEntry { get; init; }
/// <summary>
/// Idempotency key for deduplication.
/// Based on old+new image digests.
/// </summary>
public required string IdempotencyKey { get; init; }
/// <summary>
/// When the evidence was composed.
/// </summary>
public required DateTimeOffset ComposedAt { get; init; }
}
/// <summary>
/// Information about the Rekor log entry.
/// </summary>
public sealed record RekorEntryInfo
{
/// <summary>
/// Log index in Rekor.
/// </summary>
public required long LogIndex { get; init; }
/// <summary>
/// Entry UUID.
/// </summary>
public required string EntryUuid { get; init; }
/// <summary>
/// Integrated timestamp from Rekor.
/// </summary>
public required DateTimeOffset IntegratedTime { get; init; }
/// <summary>
/// Rekor log ID.
/// </summary>
public string? LogId { get; init; }
}

View File

@@ -0,0 +1,270 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Delta;
/// <summary>
/// Scans only changed layers between two image versions for efficient delta scanning.
/// </summary>
public interface IDeltaLayerScanner
{
/// <summary>
/// Performs a delta scan comparing two image versions.
/// </summary>
/// <param name="oldImage">Reference to the old/baseline image.</param>
/// <param name="newImage">Reference to the new image to scan.</param>
/// <param name="options">Delta scan options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Delta scan result with changed layers and composite SBOM.</returns>
Task<DeltaScanResult> ScanDeltaAsync(
string oldImage,
string newImage,
DeltaScanOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Identifies layer changes between two images without scanning.
/// </summary>
/// <param name="oldImage">Reference to the old image.</param>
/// <param name="newImage">Reference to the new image.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Layer change summary.</returns>
Task<LayerChangeSummary> IdentifyLayerChangesAsync(
string oldImage,
string newImage,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for delta scanning.
/// </summary>
public sealed record DeltaScanOptions
{
/// <summary>
/// Whether to force a full scan even for unchanged layers.
/// Default is false.
/// </summary>
public bool ForceFullScan { get; init; } = false;
/// <summary>
/// Whether to use cached per-layer SBOMs for unchanged layers.
/// Default is true.
/// </summary>
public bool UseCachedSboms { get; init; } = true;
/// <summary>
/// Maximum age of cached SBOMs to consider valid (in days).
/// Default is 30 days.
/// </summary>
public int MaxCacheAgeDays { get; init; } = 30;
/// <summary>
/// SBOM format to produce (CycloneDX or SPDX).
/// Default is CycloneDX.
/// </summary>
public string SbomFormat { get; init; } = "cyclonedx";
/// <summary>
/// Whether to include layer attribution in the SBOM.
/// Default is true.
/// </summary>
public bool IncludeLayerAttribution { get; init; } = true;
/// <summary>
/// Target platform for multi-arch images.
/// If null, uses default platform.
/// </summary>
public string? Platform { get; init; }
}
/// <summary>
/// Result of a delta scan operation.
/// </summary>
public sealed record DeltaScanResult
{
/// <summary>
/// Old image reference.
/// </summary>
public required string OldImage { get; init; }
/// <summary>
/// Old image manifest digest.
/// </summary>
public required string OldManifestDigest { get; init; }
/// <summary>
/// New image reference.
/// </summary>
public required string NewImage { get; init; }
/// <summary>
/// New image manifest digest.
/// </summary>
public required string NewManifestDigest { get; init; }
/// <summary>
/// Layers that were added in the new image.
/// </summary>
public ImmutableArray<LayerChangeInfo> AddedLayers { get; init; } = [];
/// <summary>
/// Layers that were removed from the old image.
/// </summary>
public ImmutableArray<LayerChangeInfo> RemovedLayers { get; init; } = [];
/// <summary>
/// Layers that are unchanged between images.
/// </summary>
public ImmutableArray<LayerChangeInfo> UnchangedLayers { get; init; } = [];
/// <summary>
/// Composite SBOM for the new image (combining cached + newly scanned).
/// </summary>
public string? CompositeSbom { get; init; }
/// <summary>
/// SBOM format (cyclonedx or spdx).
/// </summary>
public string? SbomFormat { get; init; }
/// <summary>
/// Total scan duration.
/// </summary>
public TimeSpan ScanDuration { get; init; }
/// <summary>
/// Duration spent scanning only added layers.
/// </summary>
public TimeSpan AddedLayersScanDuration { get; init; }
/// <summary>
/// Number of components found in added layers.
/// </summary>
public int AddedComponentCount { get; init; }
/// <summary>
/// Number of components from cached layers.
/// </summary>
public int CachedComponentCount { get; init; }
/// <summary>
/// Whether the scan used cached SBOMs.
/// </summary>
public bool UsedCache { get; init; }
/// <summary>
/// Percentage of layers that were reused from cache.
/// </summary>
public double LayerReuseRatio =>
(AddedLayers.Length + UnchangedLayers.Length) > 0
? (double)UnchangedLayers.Length / (AddedLayers.Length + UnchangedLayers.Length)
: 0;
/// <summary>
/// When the scan was performed.
/// </summary>
public DateTimeOffset ScannedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Information about a layer change.
/// </summary>
public sealed record LayerChangeInfo
{
/// <summary>
/// Compressed layer digest.
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// Uncompressed content hash (diffID).
/// </summary>
public required string DiffId { get; init; }
/// <summary>
/// Layer index in the image.
/// </summary>
public int LayerIndex { get; init; }
/// <summary>
/// Size of the layer in bytes.
/// </summary>
public long Size { get; init; }
/// <summary>
/// Media type of the layer.
/// </summary>
public string? MediaType { get; init; }
/// <summary>
/// Whether this layer's SBOM was retrieved from cache.
/// </summary>
public bool FromCache { get; init; }
/// <summary>
/// Number of components found in this layer.
/// </summary>
public int ComponentCount { get; init; }
}
/// <summary>
/// Summary of layer changes between two images.
/// </summary>
public sealed record LayerChangeSummary
{
/// <summary>
/// Old image reference.
/// </summary>
public required string OldImage { get; init; }
/// <summary>
/// New image reference.
/// </summary>
public required string NewImage { get; init; }
/// <summary>
/// Total layers in old image.
/// </summary>
public int OldLayerCount { get; init; }
/// <summary>
/// Total layers in new image.
/// </summary>
public int NewLayerCount { get; init; }
/// <summary>
/// Number of layers added.
/// </summary>
public int AddedCount { get; init; }
/// <summary>
/// Number of layers removed.
/// </summary>
public int RemovedCount { get; init; }
/// <summary>
/// Number of layers unchanged.
/// </summary>
public int UnchangedCount { get; init; }
/// <summary>
/// Estimated scan savings (layers we don't need to scan).
/// </summary>
public double EstimatedSavingsRatio => NewLayerCount > 0
? (double)UnchangedCount / NewLayerCount
: 0;
/// <summary>
/// DiffIDs of added layers.
/// </summary>
public ImmutableArray<string> AddedDiffIds { get; init; } = [];
/// <summary>
/// DiffIDs of removed layers.
/// </summary>
public ImmutableArray<string> RemovedDiffIds { get; init; } = [];
/// <summary>
/// DiffIDs of unchanged layers.
/// </summary>
public ImmutableArray<string> UnchangedDiffIds { get; init; } = [];
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Contracts\StellaOps.Scanner.Contracts.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Manifest\StellaOps.Scanner.Manifest.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Emit\StellaOps.Scanner.Emit.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
</ItemGroup>
</Project>