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,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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user