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:
539
src/Cli/StellaOps.Cli/Commands/Scan/DeltaScanCommandGroup.cs
Normal file
539
src/Cli/StellaOps.Cli/Commands/Scan/DeltaScanCommandGroup.cs
Normal file
@@ -0,0 +1,539 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DeltaScanCommandGroup.cs
|
||||
// Sprint: SPRINT_20260118_026_Scanner_delta_scanning_engine
|
||||
// Task: TASK-026-06 - Delta Scan CLI Command
|
||||
// Description: CLI commands for delta scanning operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Delta;
|
||||
using StellaOps.Scanner.Delta.Evidence;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Scan;
|
||||
|
||||
/// <summary>
|
||||
/// CLI command group for delta scanning operations.
|
||||
/// Provides the `scan delta` command for efficient delta scanning between image versions.
|
||||
/// </summary>
|
||||
internal static class DeltaScanCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Exit codes for delta scan operations.
|
||||
/// </summary>
|
||||
public static class ExitCodes
|
||||
{
|
||||
/// <summary>No new CVEs or security issues found.</summary>
|
||||
public const int Success = 0;
|
||||
/// <summary>New CVEs or security issues found.</summary>
|
||||
public const int NewCvesFound = 1;
|
||||
/// <summary>Error during scan.</summary>
|
||||
public const int Error = 2;
|
||||
/// <summary>Invalid arguments.</summary>
|
||||
public const int InvalidArgs = 3;
|
||||
/// <summary>Registry authentication failure.</summary>
|
||||
public const int AuthFailure = 4;
|
||||
/// <summary>Network error.</summary>
|
||||
public const int NetworkError = 5;
|
||||
/// <summary>Timeout.</summary>
|
||||
public const int Timeout = 124;
|
||||
}
|
||||
|
||||
internal static Command BuildDeltaCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var oldOption = new Option<string>("--old", new[] { "-o" })
|
||||
{
|
||||
Description = "Old/baseline image reference (tag or @digest)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var newOption = new Option<string>("--new", new[] { "-n" })
|
||||
{
|
||||
Description = "New image reference to scan (tag or @digest)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Path to write full evidence file (JSON)"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: text, json, summary (default: text)"
|
||||
}.SetDefaultValue("text").FromAmong("text", "json", "summary");
|
||||
|
||||
var sbomFormatOption = new Option<string>("--sbom-format")
|
||||
{
|
||||
Description = "SBOM format: cyclonedx, spdx (default: cyclonedx)"
|
||||
}.SetDefaultValue("cyclonedx").FromAmong("cyclonedx", "spdx");
|
||||
|
||||
var platformOption = new Option<string?>("--platform", new[] { "-p" })
|
||||
{
|
||||
Description = "Platform filter for multi-arch images (e.g., linux/amd64)"
|
||||
};
|
||||
|
||||
var policyOption = new Option<string?>("--policy")
|
||||
{
|
||||
Description = "Path to policy file for CVE evaluation"
|
||||
};
|
||||
|
||||
var noCacheOption = new Option<bool>("--no-cache")
|
||||
{
|
||||
Description = "Skip cached per-layer SBOMs and force full scan"
|
||||
};
|
||||
|
||||
var signOption = new Option<bool>("--sign")
|
||||
{
|
||||
Description = "Sign the delta evidence"
|
||||
};
|
||||
|
||||
var rekorOption = new Option<bool>("--rekor")
|
||||
{
|
||||
Description = "Submit evidence to Rekor transparency log"
|
||||
};
|
||||
|
||||
var timeoutOption = new Option<int>("--timeout")
|
||||
{
|
||||
Description = "Timeout in seconds for scan operations (default: 300)"
|
||||
}.SetDefaultValue(300);
|
||||
|
||||
var command = new Command("delta", GetCommandDescription())
|
||||
{
|
||||
oldOption,
|
||||
newOption,
|
||||
outputOption,
|
||||
formatOption,
|
||||
sbomFormatOption,
|
||||
platformOption,
|
||||
policyOption,
|
||||
noCacheOption,
|
||||
signOption,
|
||||
rekorOption,
|
||||
timeoutOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var oldImage = parseResult.GetValue(oldOption) ?? string.Empty;
|
||||
var newImage = parseResult.GetValue(newOption) ?? string.Empty;
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
var formatValue = parseResult.GetValue(formatOption) ?? "text";
|
||||
var sbomFormat = parseResult.GetValue(sbomFormatOption) ?? "cyclonedx";
|
||||
var platformValue = parseResult.GetValue(platformOption);
|
||||
var policyPath = parseResult.GetValue(policyOption);
|
||||
var noCache = parseResult.GetValue(noCacheOption);
|
||||
var sign = parseResult.GetValue(signOption);
|
||||
var submitToRekor = parseResult.GetValue(rekorOption);
|
||||
var timeoutSeconds = parseResult.GetValue(timeoutOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(oldImage))
|
||||
{
|
||||
Console.Error.WriteLine("Error: --old option is required");
|
||||
return ExitCodes.InvalidArgs;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newImage))
|
||||
{
|
||||
Console.Error.WriteLine("Error: --new option is required");
|
||||
return ExitCodes.InvalidArgs;
|
||||
}
|
||||
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
|
||||
if (timeoutSeconds > 0)
|
||||
{
|
||||
linkedCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
}
|
||||
|
||||
var showProgress = formatValue != "json" || verbose;
|
||||
|
||||
try
|
||||
{
|
||||
var scanner = services.GetRequiredService<IDeltaLayerScanner>();
|
||||
var evidenceComposer = services.GetService<IDeltaEvidenceComposer>();
|
||||
|
||||
var options = new DeltaScanOptions
|
||||
{
|
||||
UseCachedSboms = !noCache,
|
||||
ForceFullScan = noCache,
|
||||
SbomFormat = sbomFormat,
|
||||
Platform = platformValue,
|
||||
IncludeLayerAttribution = true
|
||||
};
|
||||
|
||||
if (showProgress)
|
||||
{
|
||||
Console.Error.WriteLine($"Delta scanning: {oldImage} -> {newImage}");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var result = await scanner.ScanDeltaAsync(
|
||||
oldImage,
|
||||
newImage,
|
||||
options,
|
||||
linkedCts.Token).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
// Compose evidence if requested
|
||||
DeltaScanEvidence? evidence = null;
|
||||
if (evidenceComposer is not null && (!string.IsNullOrWhiteSpace(outputPath) || sign || submitToRekor))
|
||||
{
|
||||
evidence = await evidenceComposer.ComposeAsync(
|
||||
result,
|
||||
new EvidenceCompositionOptions
|
||||
{
|
||||
Sign = sign,
|
||||
SubmitToRekor = submitToRekor,
|
||||
IncludeLayerDetails = true
|
||||
},
|
||||
linkedCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Output based on format
|
||||
switch (formatValue.ToLowerInvariant())
|
||||
{
|
||||
case "json":
|
||||
await RenderJsonAsync(result, evidence, Console.Out, linkedCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case "summary":
|
||||
RenderSummary(result, evidence, verbose);
|
||||
break;
|
||||
|
||||
default:
|
||||
RenderText(result, evidence, verbose);
|
||||
break;
|
||||
}
|
||||
|
||||
// Write full evidence to file if requested
|
||||
if (!string.IsNullOrWhiteSpace(outputPath) && evidence is not null)
|
||||
{
|
||||
var evidenceJson = JsonSerializer.Serialize(evidence, JsonOptions);
|
||||
await File.WriteAllTextAsync(outputPath, evidenceJson, linkedCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (showProgress)
|
||||
{
|
||||
Console.Error.WriteLine($"Evidence written to: {outputPath}");
|
||||
}
|
||||
}
|
||||
|
||||
// Determine exit code based on CVE status
|
||||
// For now, return success - policy evaluation would determine if new CVEs are problematic
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Operation timed out after {timeoutSeconds}s");
|
||||
return ExitCodes.Timeout;
|
||||
}
|
||||
catch (InvalidOperationException ex) when (IsAuthFailure(ex))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Registry authentication failed: {ex.Message}");
|
||||
return ExitCodes.AuthFailure;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Network error: {ex.Message}");
|
||||
return ExitCodes.NetworkError;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine(ex.StackTrace);
|
||||
}
|
||||
return ExitCodes.Error;
|
||||
}
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static string GetCommandDescription()
|
||||
{
|
||||
return "Perform delta scanning between two image versions.\n\n" +
|
||||
"Scans only changed layers for efficiency, reducing scan time and CVE churn.\n\n" +
|
||||
"Examples:\n" +
|
||||
" stella scan delta --old myapp:1.0 --new myapp:1.1\n" +
|
||||
" stella scan delta --old registry.io/app:v1 --new registry.io/app:v2 --format=json\n" +
|
||||
" stella scan delta --old image:1.0@sha256:abc --new image:1.1@sha256:def --output=evidence.json\n" +
|
||||
" stella scan delta --old base:3.18 --new base:3.19 --platform=linux/amd64 --sign --rekor";
|
||||
}
|
||||
|
||||
private static async Task RenderJsonAsync(
|
||||
DeltaScanResult result,
|
||||
DeltaScanEvidence? evidence,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var jsonOutput = new DeltaScanJsonOutput
|
||||
{
|
||||
OldImage = result.OldImage,
|
||||
OldManifestDigest = result.OldManifestDigest,
|
||||
NewImage = result.NewImage,
|
||||
NewManifestDigest = result.NewManifestDigest,
|
||||
LayerChanges = new LayerChangesOutput
|
||||
{
|
||||
Added = result.AddedLayers.Length,
|
||||
Removed = result.RemovedLayers.Length,
|
||||
Unchanged = result.UnchangedLayers.Length,
|
||||
ReuseRatio = Math.Round(result.LayerReuseRatio, 4),
|
||||
AddedDiffIds = result.AddedLayers.Select(l => l.DiffId).ToList(),
|
||||
RemovedDiffIds = result.RemovedLayers.Select(l => l.DiffId).ToList()
|
||||
},
|
||||
ComponentChanges = new ComponentChangesOutput
|
||||
{
|
||||
Added = result.AddedComponentCount,
|
||||
Cached = result.CachedComponentCount,
|
||||
Total = result.AddedComponentCount + result.CachedComponentCount
|
||||
},
|
||||
Metrics = new MetricsOutput
|
||||
{
|
||||
TotalDurationMs = (long)result.ScanDuration.TotalMilliseconds,
|
||||
AddedLayersScanDurationMs = (long)result.AddedLayersScanDuration.TotalMilliseconds,
|
||||
UsedCache = result.UsedCache
|
||||
},
|
||||
SbomFormat = result.SbomFormat,
|
||||
ScannedAt = result.ScannedAt,
|
||||
Evidence = evidence is not null ? new EvidenceOutput
|
||||
{
|
||||
PayloadHash = evidence.PayloadHash,
|
||||
IdempotencyKey = evidence.IdempotencyKey,
|
||||
ComposedAt = evidence.ComposedAt,
|
||||
RekorLogIndex = evidence.RekorEntry?.LogIndex,
|
||||
RekorEntryUuid = evidence.RekorEntry?.EntryUuid
|
||||
} : null
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(jsonOutput, JsonOptions);
|
||||
await output.WriteLineAsync(json).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void RenderSummary(DeltaScanResult result, DeltaScanEvidence? evidence, bool verbose)
|
||||
{
|
||||
var status = result.AddedLayers.Length == 0 ? "[UNCHANGED]" : "[DELTA]";
|
||||
Console.WriteLine($"{status} Delta Scan Summary");
|
||||
Console.WriteLine($" Images: {result.OldImage} -> {result.NewImage}");
|
||||
Console.WriteLine($" Layer Reuse: {result.LayerReuseRatio:P1} ({result.UnchangedLayers.Length} unchanged, {result.AddedLayers.Length} added, {result.RemovedLayers.Length} removed)");
|
||||
Console.WriteLine($" Components: {result.AddedComponentCount + result.CachedComponentCount} total ({result.CachedComponentCount} cached, {result.AddedComponentCount} scanned)");
|
||||
Console.WriteLine($" Duration: {result.ScanDuration.TotalSeconds:N2}s total ({result.AddedLayersScanDuration.TotalSeconds:N2}s scanning)");
|
||||
|
||||
if (evidence?.RekorEntry is not null)
|
||||
{
|
||||
Console.WriteLine($" Rekor: logIndex={evidence.RekorEntry.LogIndex}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderText(DeltaScanResult result, DeltaScanEvidence? evidence, bool verbose)
|
||||
{
|
||||
Console.WriteLine("Delta Scan Report");
|
||||
Console.WriteLine("=================");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Old Image: {result.OldImage}");
|
||||
Console.WriteLine($" Digest: {result.OldManifestDigest}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"New Image: {result.NewImage}");
|
||||
Console.WriteLine($" Digest: {result.NewManifestDigest}");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("Layer Changes:");
|
||||
Console.WriteLine($" Added: {result.AddedLayers.Length}");
|
||||
Console.WriteLine($" Removed: {result.RemovedLayers.Length}");
|
||||
Console.WriteLine($" Unchanged: {result.UnchangedLayers.Length}");
|
||||
Console.WriteLine($" Reuse: {result.LayerReuseRatio:P1}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (verbose && result.AddedLayers.Length > 0)
|
||||
{
|
||||
Console.WriteLine("Added Layers:");
|
||||
foreach (var layer in result.AddedLayers)
|
||||
{
|
||||
Console.WriteLine($" - {TruncateDiffId(layer.DiffId)} ({FormatSize(layer.Size)}, {layer.ComponentCount} components)");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
if (verbose && result.RemovedLayers.Length > 0)
|
||||
{
|
||||
Console.WriteLine("Removed Layers:");
|
||||
foreach (var layer in result.RemovedLayers)
|
||||
{
|
||||
Console.WriteLine($" - {TruncateDiffId(layer.DiffId)} ({FormatSize(layer.Size)})");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine("Component Summary:");
|
||||
Console.WriteLine($" Total: {result.AddedComponentCount + result.CachedComponentCount}");
|
||||
Console.WriteLine($" Cached: {result.CachedComponentCount}");
|
||||
Console.WriteLine($" Scanned: {result.AddedComponentCount}");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("Performance:");
|
||||
Console.WriteLine($" Total Duration: {result.ScanDuration.TotalSeconds:N2}s");
|
||||
Console.WriteLine($" Added Layers Scan: {result.AddedLayersScanDuration.TotalSeconds:N2}s");
|
||||
Console.WriteLine($" Cache Used: {(result.UsedCache ? "Yes" : "No")}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (evidence is not null)
|
||||
{
|
||||
Console.WriteLine("Evidence:");
|
||||
Console.WriteLine($" Payload Hash: {evidence.PayloadHash}");
|
||||
Console.WriteLine($" Idempotency Key: {evidence.IdempotencyKey}");
|
||||
Console.WriteLine($" Composed At: {evidence.ComposedAt:O}");
|
||||
|
||||
if (evidence.RekorEntry is not null)
|
||||
{
|
||||
Console.WriteLine($" Rekor Log Index: {evidence.RekorEntry.LogIndex}");
|
||||
Console.WriteLine($" Rekor Entry UUID: {evidence.RekorEntry.EntryUuid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateDiffId(string diffId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(diffId))
|
||||
return "(unknown)";
|
||||
|
||||
if (diffId.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
diffId = diffId[7..];
|
||||
|
||||
return diffId.Length > 12 ? diffId[..12] : diffId;
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes < 1024)
|
||||
return $"{bytes} B";
|
||||
if (bytes < 1024 * 1024)
|
||||
return $"{bytes / 1024.0:N1} KB";
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return $"{bytes / (1024.0 * 1024):N1} MB";
|
||||
return $"{bytes / (1024.0 * 1024 * 1024):N1} GB";
|
||||
}
|
||||
|
||||
private static bool IsAuthFailure(InvalidOperationException ex)
|
||||
{
|
||||
return ex.Message.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) ||
|
||||
ex.Message.Contains("Forbidden", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#region JSON Output Models
|
||||
|
||||
private sealed record DeltaScanJsonOutput
|
||||
{
|
||||
[JsonPropertyName("oldImage")]
|
||||
public required string OldImage { get; init; }
|
||||
|
||||
[JsonPropertyName("oldManifestDigest")]
|
||||
public required string OldManifestDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("newImage")]
|
||||
public required string NewImage { get; init; }
|
||||
|
||||
[JsonPropertyName("newManifestDigest")]
|
||||
public required string NewManifestDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("layerChanges")]
|
||||
public required LayerChangesOutput LayerChanges { get; init; }
|
||||
|
||||
[JsonPropertyName("componentChanges")]
|
||||
public required ComponentChangesOutput ComponentChanges { get; init; }
|
||||
|
||||
[JsonPropertyName("metrics")]
|
||||
public required MetricsOutput Metrics { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomFormat")]
|
||||
public string? SbomFormat { get; init; }
|
||||
|
||||
[JsonPropertyName("scannedAt")]
|
||||
public DateTimeOffset ScannedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
public EvidenceOutput? Evidence { get; init; }
|
||||
}
|
||||
|
||||
private sealed record LayerChangesOutput
|
||||
{
|
||||
[JsonPropertyName("added")]
|
||||
public int Added { get; init; }
|
||||
|
||||
[JsonPropertyName("removed")]
|
||||
public int Removed { get; init; }
|
||||
|
||||
[JsonPropertyName("unchanged")]
|
||||
public int Unchanged { get; init; }
|
||||
|
||||
[JsonPropertyName("reuseRatio")]
|
||||
public double ReuseRatio { get; init; }
|
||||
|
||||
[JsonPropertyName("addedDiffIds")]
|
||||
public IReadOnlyList<string>? AddedDiffIds { get; init; }
|
||||
|
||||
[JsonPropertyName("removedDiffIds")]
|
||||
public IReadOnlyList<string>? RemovedDiffIds { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ComponentChangesOutput
|
||||
{
|
||||
[JsonPropertyName("added")]
|
||||
public int Added { get; init; }
|
||||
|
||||
[JsonPropertyName("cached")]
|
||||
public int Cached { get; init; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
private sealed record MetricsOutput
|
||||
{
|
||||
[JsonPropertyName("totalDurationMs")]
|
||||
public long TotalDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("addedLayersScanDurationMs")]
|
||||
public long AddedLayersScanDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("usedCache")]
|
||||
public bool UsedCache { get; init; }
|
||||
}
|
||||
|
||||
private sealed record EvidenceOutput
|
||||
{
|
||||
[JsonPropertyName("payloadHash")]
|
||||
public required string PayloadHash { get; init; }
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public required string IdempotencyKey { get; init; }
|
||||
|
||||
[JsonPropertyName("composedAt")]
|
||||
public DateTimeOffset ComposedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorEntryUuid")]
|
||||
public string? RekorEntryUuid { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user