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,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
}