audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
@@ -594,7 +594,7 @@ internal static class BinaryCommandHandlers
|
||||
Function = function,
|
||||
FingerprintId = fingerprintId,
|
||||
FingerprintHash = Convert.ToHexStringLower(fileHash),
|
||||
GeneratedAt = (services.GetService<TimeProvider>() ?? TimeProvider.System).GetUtcNow().ToString("O")
|
||||
GeneratedAt = (services.GetService<TimeProvider>() ?? TimeProvider.System).GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
if (format == "json")
|
||||
|
||||
@@ -124,6 +124,10 @@ internal static class CommandFactory
|
||||
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle - Evidence bundle export and verify
|
||||
root.Add(EvidenceCommandGroup.BuildEvidenceCommand(services, options, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260105_002_004_CLI - Facet seal and drift commands
|
||||
root.Add(SealCommandGroup.BuildSealCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(DriftCommandGroup.BuildDriftCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Add scan graph subcommand to existing scan command
|
||||
var scanCommand = root.Children.OfType<Command>().FirstOrDefault(c => c.Name == "scan");
|
||||
if (scanCommand is not null)
|
||||
@@ -4632,6 +4636,9 @@ internal static class CommandFactory
|
||||
|
||||
vex.Add(explain);
|
||||
|
||||
// Sprint: SPRINT_20260105_002_004_CLI - VEX gen from drift command
|
||||
vex.Add(VexGenCommandGroup.BuildVexGenCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return vex;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Description: Command handlers for cryptographic signing and verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -398,7 +399,7 @@ internal static partial class CommandHandlers
|
||||
{
|
||||
format = format,
|
||||
provider = providerName,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
dataHash = CryptoHashFactory.CreateDefault().ComputeHashHex(data, HashAlgorithms.Sha256),
|
||||
signature = "STUB-SIGNATURE-BASE64",
|
||||
keyId = "STUB-KEY-ID"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Description: Command handlers for reachability drift CLI.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Spectre.Console;
|
||||
|
||||
@@ -46,7 +47,7 @@ internal static partial class CommandHandlers
|
||||
var driftResult = new DriftResultDto
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N")[..8],
|
||||
ComparedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
ComparedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
BaseGraphId = baseId,
|
||||
HeadGraphId = headId ?? "latest",
|
||||
Summary = new DriftSummaryDto
|
||||
@@ -103,7 +104,7 @@ internal static partial class CommandHandlers
|
||||
var driftResult = new DriftResultDto
|
||||
{
|
||||
Id = id,
|
||||
ComparedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
ComparedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
BaseGraphId = "base",
|
||||
HeadGraphId = "head",
|
||||
Summary = new DriftSummaryDto
|
||||
|
||||
@@ -921,7 +921,7 @@ internal static partial class CommandHandlers
|
||||
table.AddRow("Kit", Markup.Escape(payload.Active.KitId));
|
||||
table.AddRow("Version", Markup.Escape(payload.Active.Version));
|
||||
table.AddRow("Digest", Markup.Escape(payload.Active.Digest));
|
||||
table.AddRow("Activated", payload.Active.ActivatedAt.ToString("O"));
|
||||
table.AddRow("Activated", payload.Active.ActivatedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
table.AddRow("DSSE verified", payload.Active.DsseVerified ? "[green]true[/]" : "[red]false[/]");
|
||||
table.AddRow("Rekor verified", payload.Active.RekorVerified ? "[green]true[/]" : "[red]false[/]");
|
||||
table.AddRow("Staleness", payload.StalenessSeconds < 0 ? "-" : FormatStaleness(TimeSpan.FromSeconds(payload.StalenessSeconds)));
|
||||
@@ -1292,7 +1292,7 @@ internal static partial class CommandHandlers
|
||||
|
||||
if (payload.ActivatedAt.HasValue)
|
||||
{
|
||||
table.AddRow("Activated", payload.ActivatedAt.Value.ToString("O"));
|
||||
table.AddRow("Activated", payload.ActivatedAt.Value.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (payload.WasForceActivated)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Description: Implements bundle create, verify, and info commands.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -270,7 +271,7 @@ internal static partial class CommandHandlers
|
||||
version = result.BundleVersion,
|
||||
ruleCount = result.RuleCount,
|
||||
signerKeyId = result.SignerKeyId,
|
||||
signedAt = result.SignedAt?.ToString("O"),
|
||||
signedAt = result.SignedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
errors = result.ValidationErrors,
|
||||
warnings = result.ValidationWarnings
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Description: Command handlers for reachability witness CLI.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Spectre.Console;
|
||||
|
||||
@@ -46,7 +47,7 @@ internal static partial class CommandHandlers
|
||||
PackageName = "Newtonsoft.Json",
|
||||
PackageVersion = "12.0.3",
|
||||
ConfidenceTier = "confirmed",
|
||||
ObservedAt = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"),
|
||||
ObservedAt = DateTimeOffset.UtcNow.AddHours(-2).ToString("O", CultureInfo.InvariantCulture),
|
||||
Entrypoint = new WitnessEntrypointDto
|
||||
{
|
||||
Type = "http",
|
||||
|
||||
@@ -9400,13 +9400,13 @@ internal static partial class CommandHandlers
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["kind"] = signingKey.Kind.ToString(),
|
||||
["createdAt"] = signingKey.CreatedAt.UtcDateTime.ToString("O"),
|
||||
["createdAt"] = signingKey.CreatedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture),
|
||||
["providerHint"] = signingKey.Reference.ProviderHint
|
||||
};
|
||||
|
||||
if (signingKey.ExpiresAt.HasValue)
|
||||
{
|
||||
metadata["expiresAt"] = signingKey.ExpiresAt.Value.UtcDateTime.ToString("O");
|
||||
metadata["expiresAt"] = signingKey.ExpiresAt.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
foreach (var pair in signingKey.Metadata)
|
||||
@@ -16769,7 +16769,7 @@ stella policy test {policyName}.stella
|
||||
Console.WriteLine("VulnerabilityId,Severity,Score,Status,VexStatus,PackageCount,Assignee,UpdatedAt");
|
||||
foreach (var item in response.Items)
|
||||
{
|
||||
Console.WriteLine($"{CsvEscape(item.VulnerabilityId)},{CsvEscape(item.Severity.Level)},{item.Severity.Score?.ToString("F1") ?? ""},{CsvEscape(item.Status)},{CsvEscape(item.VexStatus ?? "")},{item.AffectedPackages.Count},{CsvEscape(item.Assignee ?? "")},{item.UpdatedAt?.ToString("O") ?? ""}");
|
||||
Console.WriteLine($"{CsvEscape(item.VulnerabilityId)},{CsvEscape(item.Severity.Level)},{item.Severity.Score?.ToString("F1") ?? ""},{CsvEscape(item.Status)},{CsvEscape(item.VexStatus ?? "")},{item.AffectedPackages.Count},{CsvEscape(item.Assignee ?? "")},{item.UpdatedAt?.ToString("O", CultureInfo.InvariantCulture) ?? ""}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31748,7 +31748,7 @@ stella policy test {policyName}.stella
|
||||
AnsiConsole.MarkupLine($" Bundle ID: {Markup.Escape(result.BundleId ?? "unknown")}");
|
||||
AnsiConsole.MarkupLine($" Root Hash: {Markup.Escape(result.RootHash ?? "unknown")}");
|
||||
AnsiConsole.MarkupLine($" Entries: {result.Entries}");
|
||||
AnsiConsole.MarkupLine($" Created: {result.CreatedAt?.ToString("O") ?? "unknown"}");
|
||||
AnsiConsole.MarkupLine($" Created: {result.CreatedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown"}");
|
||||
AnsiConsole.MarkupLine($" Portable: {(result.Portable ? "yes" : "no")}");
|
||||
}
|
||||
else
|
||||
@@ -33078,7 +33078,7 @@ stella policy test {policyName}.stella
|
||||
{
|
||||
PredicateType = "stellaops.io/predicates/scan-result@v1",
|
||||
Digest = "sha256:abc123...",
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddHours(-1).ToString("O"),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddHours(-1).ToString("O", CultureInfo.InvariantCulture),
|
||||
Size = 4096L
|
||||
}
|
||||
};
|
||||
@@ -33176,7 +33176,7 @@ stella policy test {policyName}.stella
|
||||
var result = new
|
||||
{
|
||||
Image = image,
|
||||
VerifiedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
VerifiedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
OverallValid = overallValid,
|
||||
TotalAttestations = verificationResults.Length,
|
||||
ValidAttestations = verificationResults.Count(r => r.SignatureValid && r.PolicyPassed),
|
||||
|
||||
@@ -1,160 +1,324 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftCommandGroup.cs
|
||||
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
// Task: UI-019
|
||||
// Description: CLI command group for reachability drift detection.
|
||||
// -----------------------------------------------------------------------------
|
||||
// <copyright file="DriftCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_004_CLI (CLI-007 through CLI-010)
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Facet;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI command group for reachability drift detection.
|
||||
/// Command group for facet drift analysis operations.
|
||||
/// Provides stella drift command for analyzing facet drift against baseline seals.
|
||||
/// </summary>
|
||||
internal static class DriftCommandGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the drift command group.
|
||||
/// </summary>
|
||||
internal static Command BuildDriftCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var drift = new Command("drift", "Reachability drift detection operations.");
|
||||
var imageArg = new Argument<string>("image")
|
||||
{
|
||||
Description = "Image reference or digest to analyze."
|
||||
};
|
||||
|
||||
drift.Add(BuildDriftCompareCommand(services, verboseOption, cancellationToken));
|
||||
drift.Add(BuildDriftShowCommand(services, verboseOption, cancellationToken));
|
||||
var baselineOption = new Option<string?>("--baseline", "-b")
|
||||
{
|
||||
Description = "Baseline seal ID (default: latest for image)."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: table, json, yaml."
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var detailOption = new Option<bool>("--verbose-files")
|
||||
{
|
||||
Description = "Show detailed file changes."
|
||||
};
|
||||
|
||||
var failOnBreachOption = new Option<bool>("--fail-on-breach")
|
||||
{
|
||||
Description = "Exit with error code if quota breached."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", "-o")
|
||||
{
|
||||
Description = "Output file path (default: stdout)."
|
||||
};
|
||||
|
||||
var drift = new Command("drift", "Analyze facet drift against baseline seal. Compares current image state to sealed baseline.");
|
||||
drift.Add(imageArg);
|
||||
drift.Add(baselineOption);
|
||||
drift.Add(formatOption);
|
||||
drift.Add(detailOption);
|
||||
drift.Add(failOnBreachOption);
|
||||
drift.Add(outputOption);
|
||||
drift.Add(verboseOption);
|
||||
|
||||
drift.SetAction(parseResult =>
|
||||
{
|
||||
var image = parseResult.GetValue(imageArg)!;
|
||||
var baseline = parseResult.GetValue(baselineOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var detail = parseResult.GetValue(detailOption);
|
||||
var failOnBreach = parseResult.GetValue(failOnBreachOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return HandleDriftAsync(
|
||||
services,
|
||||
image,
|
||||
baseline,
|
||||
format,
|
||||
detail,
|
||||
failOnBreach,
|
||||
output,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return drift;
|
||||
}
|
||||
|
||||
private static Command BuildDriftCompareCommand(
|
||||
private static async Task<int> HandleDriftAsync(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
string image,
|
||||
string? baselineId,
|
||||
string format,
|
||||
bool showDetails,
|
||||
bool failOnBreach,
|
||||
string? outputPath,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseOption = new Option<string>("--base", new[] { "-b" })
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetService<ILoggerFactory>()?.CreateLogger("drift")
|
||||
?? NullLogger.Instance;
|
||||
var timeProvider = scope.ServiceProvider.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
|
||||
try
|
||||
{
|
||||
Description = "Base scan/graph ID or commit SHA for comparison.",
|
||||
Required = true
|
||||
};
|
||||
var driftDetector = scope.ServiceProvider.GetService<IFacetDriftDetector>();
|
||||
var sealStore = scope.ServiceProvider.GetService<IFacetSealStore>();
|
||||
|
||||
var headOption = new Option<string>("--head", new[] { "-h" })
|
||||
if (driftDetector is null || sealStore is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Facet services not available. Ensure facet module is configured.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[bold]Analyzing drift for:[/] {image}");
|
||||
|
||||
// Load baseline seal
|
||||
FacetSeal? baseline;
|
||||
if (!string.IsNullOrEmpty(baselineId))
|
||||
{
|
||||
baseline = await sealStore.GetByCombinedRootAsync(baselineId, ct).ConfigureAwait(false);
|
||||
if (baseline is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Baseline seal '{baselineId}' not found.[/]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
baseline = await sealStore.GetLatestSealAsync(image, ct).ConfigureAwait(false);
|
||||
if (baseline is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]No baseline seal found for image. Run 'stella seal' first.[/]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[dim]Baseline seal:[/] {TruncateHash(baseline.CombinedMerkleRoot)} ({baseline.CreatedAt:yyyy-MM-dd HH:mm:ss})");
|
||||
|
||||
// Get current seal for comparison (latest seal for the image)
|
||||
var currentSeal = await sealStore.GetLatestSealAsync(image, ct).ConfigureAwait(false);
|
||||
if (currentSeal is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]No current seal found for image.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Compute drift between baseline and current
|
||||
var report = await driftDetector.DetectDriftAsync(baseline, currentSeal, ct).ConfigureAwait(false);
|
||||
|
||||
// Output based on format
|
||||
if (format == "json")
|
||||
{
|
||||
var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true });
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, json, ct).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine($"[green]Report written to:[/] {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
return GetExitCode(report, failOnBreach);
|
||||
}
|
||||
|
||||
if (format == "yaml")
|
||||
{
|
||||
var yaml = ToYaml(report);
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, yaml, ct).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine($"[green]Report written to:[/] {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(yaml);
|
||||
}
|
||||
return GetExitCode(report, failOnBreach);
|
||||
}
|
||||
|
||||
// Table format (default)
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
// Overall verdict
|
||||
var verdictColor = GetVerdictColor(report.OverallVerdict);
|
||||
AnsiConsole.MarkupLine($"[bold]Overall Verdict:[/] [{verdictColor}]{report.OverallVerdict}[/]");
|
||||
AnsiConsole.MarkupLine($"[bold]Total Changed Files:[/] {report.TotalChangedFiles}");
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
// Per-facet table
|
||||
var table = new Table()
|
||||
.AddColumn("Facet")
|
||||
.AddColumn(new TableColumn("Added").Centered())
|
||||
.AddColumn(new TableColumn("Removed").Centered())
|
||||
.AddColumn(new TableColumn("Modified").Centered())
|
||||
.AddColumn(new TableColumn("Churn %").RightAligned())
|
||||
.AddColumn("Verdict");
|
||||
|
||||
foreach (var facetDrift in report.FacetDrifts)
|
||||
{
|
||||
var vColor = GetVerdictColor(facetDrift.QuotaVerdict);
|
||||
table.AddRow(
|
||||
facetDrift.FacetId,
|
||||
FormatCount(facetDrift.Added.Length, "green"),
|
||||
FormatCount(facetDrift.Removed.Length, "red"),
|
||||
FormatCount(facetDrift.Modified.Length, "yellow"),
|
||||
$"{facetDrift.ChurnPercent:F1}%",
|
||||
$"[{vColor}]{facetDrift.QuotaVerdict}[/]");
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
|
||||
// Detailed file changes
|
||||
if (showDetails)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[bold]File Changes:[/]");
|
||||
|
||||
foreach (var facetDrift in report.FacetDrifts.Where(d =>
|
||||
d.Added.Length + d.Removed.Length + d.Modified.Length > 0))
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine($"[bold underline]{facetDrift.FacetId}[/]");
|
||||
|
||||
const int maxFiles = 10;
|
||||
foreach (var f in facetDrift.Added.Take(maxFiles))
|
||||
AnsiConsole.MarkupLine($" [green]+[/] {f.Path}");
|
||||
foreach (var f in facetDrift.Removed.Take(maxFiles))
|
||||
AnsiConsole.MarkupLine($" [red]-[/] {f.Path}");
|
||||
foreach (var f in facetDrift.Modified.Take(maxFiles))
|
||||
AnsiConsole.MarkupLine($" [yellow]~[/] {f.Path}");
|
||||
|
||||
var total = facetDrift.Added.Length + facetDrift.Removed.Length + facetDrift.Modified.Length;
|
||||
if (total > maxFiles * 3)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [dim]... and {total - (maxFiles * 3)} more files[/]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file if specified
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(outputPath, json, ct).ConfigureAwait(false);
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine($"[green]Report written to:[/] {outputPath}");
|
||||
}
|
||||
|
||||
return GetExitCode(report, failOnBreach);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Description = "Head scan/graph ID or commit SHA for comparison (defaults to latest)."
|
||||
};
|
||||
|
||||
var imageOption = new Option<string?>("--image", new[] { "-i" })
|
||||
{
|
||||
Description = "Container image reference (digest or tag)."
|
||||
};
|
||||
|
||||
var repoOption = new Option<string?>("--repo", new[] { "-r" })
|
||||
{
|
||||
Description = "Repository reference (owner/repo)."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: table (default), json, sarif."
|
||||
}.SetDefaultValue("table").FromAmong("table", "json", "sarif");
|
||||
|
||||
var severityOption = new Option<string>("--min-severity")
|
||||
{
|
||||
Description = "Minimum severity to include: critical, high, medium, low, info."
|
||||
}.SetDefaultValue("medium").FromAmong("critical", "high", "medium", "low", "info");
|
||||
|
||||
var onlyIncreasesOption = new Option<bool>("--only-increases")
|
||||
{
|
||||
Description = "Only show sinks with increased reachability (risk increases)."
|
||||
};
|
||||
|
||||
var command = new Command("compare", "Compare reachability between two scans.")
|
||||
{
|
||||
baseOption,
|
||||
headOption,
|
||||
imageOption,
|
||||
repoOption,
|
||||
outputOption,
|
||||
severityOption,
|
||||
onlyIncreasesOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var baseId = parseResult.GetValue(baseOption)!;
|
||||
var headId = parseResult.GetValue(headOption);
|
||||
var image = parseResult.GetValue(imageOption);
|
||||
var repo = parseResult.GetValue(repoOption);
|
||||
var output = parseResult.GetValue(outputOption)!;
|
||||
var minSeverity = parseResult.GetValue(severityOption)!;
|
||||
var onlyIncreases = parseResult.GetValue(onlyIncreasesOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleDriftCompareAsync(
|
||||
services,
|
||||
baseId,
|
||||
headId,
|
||||
image,
|
||||
repo,
|
||||
output,
|
||||
minSeverity,
|
||||
onlyIncreases,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
logger.LogError(ex, "Failed to analyze drift for {Image}", image);
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static Command BuildDriftShowCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
private static int GetExitCode(FacetDriftReport report, bool failOnBreach)
|
||||
{
|
||||
var idOption = new Option<string>("--id")
|
||||
if (!failOnBreach) return 0;
|
||||
|
||||
return report.OverallVerdict switch
|
||||
{
|
||||
Description = "Drift result ID to display.",
|
||||
Required = true
|
||||
QuotaVerdict.Blocked => 2,
|
||||
QuotaVerdict.RequiresVex => 3,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
private static string GetVerdictColor(QuotaVerdict verdict)
|
||||
{
|
||||
return verdict switch
|
||||
{
|
||||
Description = "Output format: table (default), json, sarif."
|
||||
}.SetDefaultValue("table").FromAmong("table", "json", "sarif");
|
||||
|
||||
var expandPathsOption = new Option<bool>("--expand-paths")
|
||||
{
|
||||
Description = "Show full call paths instead of compressed view."
|
||||
QuotaVerdict.Ok => "green",
|
||||
QuotaVerdict.Warning => "yellow",
|
||||
QuotaVerdict.Blocked => "red",
|
||||
QuotaVerdict.RequiresVex => "blue",
|
||||
_ => "white"
|
||||
};
|
||||
}
|
||||
|
||||
var command = new Command("show", "Show details of a drift result.")
|
||||
private static string FormatCount(int count, string color)
|
||||
{
|
||||
return count > 0 ? $"[{color}]{count}[/]" : "[dim]0[/]";
|
||||
}
|
||||
|
||||
private static string ToYaml(FacetDriftReport report)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"imageDigest: {report.ImageDigest}");
|
||||
sb.AppendLine($"baselineSealId: {report.BaselineSealId}");
|
||||
sb.AppendLine($"analyzedAt: {report.AnalyzedAt:O}");
|
||||
sb.AppendLine($"overallVerdict: {report.OverallVerdict}");
|
||||
sb.AppendLine($"totalChangedFiles: {report.TotalChangedFiles}");
|
||||
sb.AppendLine("facetDrifts:");
|
||||
foreach (var d in report.FacetDrifts)
|
||||
{
|
||||
idOption,
|
||||
outputOption,
|
||||
expandPathsOption,
|
||||
verboseOption
|
||||
};
|
||||
sb.AppendLine($" - facetId: {d.FacetId}");
|
||||
sb.AppendLine($" added: {d.Added.Length}");
|
||||
sb.AppendLine($" removed: {d.Removed.Length}");
|
||||
sb.AppendLine($" modified: {d.Modified.Length}");
|
||||
sb.AppendLine($" churnPercent: {d.ChurnPercent:F2}");
|
||||
sb.AppendLine($" verdict: {d.QuotaVerdict}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var id = parseResult.GetValue(idOption)!;
|
||||
var output = parseResult.GetValue(outputOption)!;
|
||||
var expandPaths = parseResult.GetValue(expandPathsOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleDriftShowAsync(
|
||||
services,
|
||||
id,
|
||||
output,
|
||||
expandPaths,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
private static string TruncateHash(string? hash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash)) return "(none)";
|
||||
return hash.Length > 16 ? $"{hash[..8]}...{hash[^8..]}" : hash;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
@@ -862,16 +863,16 @@ public static class ExceptionCommandGroup
|
||||
table.AddRow("Gate Level", result.GateLevel ?? "G1");
|
||||
table.AddRow("Reason Code", result.ReasonCode ?? "Other");
|
||||
table.AddRow("Requestor", result.RequestorId ?? "Unknown");
|
||||
table.AddRow("Created", result.CreatedAt.ToString("O"));
|
||||
table.AddRow("Created", result.CreatedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
if (result.RequestExpiresAt.HasValue)
|
||||
{
|
||||
table.AddRow("Request Expires", result.RequestExpiresAt.Value.ToString("O"));
|
||||
table.AddRow("Request Expires", result.RequestExpiresAt.Value.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (result.ExceptionExpiresAt.HasValue)
|
||||
{
|
||||
table.AddRow("Exception Expires", result.ExceptionExpiresAt.Value.ToString("O"));
|
||||
table.AddRow("Exception Expires", result.ExceptionExpiresAt.Value.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
@@ -405,7 +406,7 @@ public static class GateCommandGroup
|
||||
table.AddRow("Exit Code", result.ExitCode.ToString());
|
||||
table.AddRow("Image", result.ImageDigest);
|
||||
table.AddRow("Baseline", result.BaselineRef ?? "(default)");
|
||||
table.AddRow("Decided At", result.DecidedAt.ToString("O"));
|
||||
table.AddRow("Decided At", result.DecidedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.Summary))
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
@@ -690,7 +691,7 @@ public static class LayerSbomCommandGroup
|
||||
.AddColumn("Value");
|
||||
|
||||
summaryTable.AddRow("Image", recipe.ImageDigest ?? "N/A");
|
||||
summaryTable.AddRow("Created", recipe.CreatedAt?.ToString("O") ?? "N/A");
|
||||
summaryTable.AddRow("Created", recipe.CreatedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "N/A");
|
||||
summaryTable.AddRow("Generator", $"{recipe.Recipe?.GeneratorName ?? "N/A"} v{recipe.Recipe?.GeneratorVersion ?? "?"}");
|
||||
summaryTable.AddRow("Layers", recipe.Recipe?.Layers?.Count.ToString() ?? "0");
|
||||
summaryTable.AddRow("Merkle Root", TruncateDigest(recipe.Recipe?.MerkleRoot));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -286,7 +287,7 @@ public class PoEExporter
|
||||
revoked = false
|
||||
}
|
||||
},
|
||||
updatedAt = DateTime.UtcNow.ToString("O")
|
||||
updatedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
var trustedKeysPath = Path.Combine(outputDir, "trusted-keys.json");
|
||||
@@ -299,7 +300,7 @@ public class PoEExporter
|
||||
var manifest = new
|
||||
{
|
||||
schema = "stellaops.poe.export@v1",
|
||||
exportedAt = DateTime.UtcNow.ToString("O"),
|
||||
exportedAt = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
scanId = options.ScanId,
|
||||
finding = options.Finding,
|
||||
artifacts = Directory.GetFiles(outputDir, "poe-*.json")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Description: CLI command handlers for function-level proof operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -81,7 +82,7 @@ internal static class FuncProofCommandHandlers
|
||||
FunctionCount = 0, // Placeholder
|
||||
Metadata = new FuncProofMetadataOutput
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
CreatedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
Tool = "stella-cli",
|
||||
ToolVersion = "0.1.0",
|
||||
DetectionMethod = detectMethod
|
||||
@@ -412,7 +413,7 @@ internal static class FuncProofCommandHandlers
|
||||
// Write manifest
|
||||
var manifest = new ExportManifest
|
||||
{
|
||||
ExportedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
ExportedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
Format = format,
|
||||
ProofId = proofData.ProofId,
|
||||
Files = new List<string> { Path.GetFileName(proofPath) }
|
||||
|
||||
270
src/Cli/StellaOps.Cli/Commands/SealCommandGroup.cs
Normal file
270
src/Cli/StellaOps.Cli/Commands/SealCommandGroup.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
// <copyright file="SealCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_004_CLI (CLI-001 through CLI-006)
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.CommandLine;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Facet;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for facet sealing operations.
|
||||
/// Provides stella seal command for creating facet seals for container images.
|
||||
/// </summary>
|
||||
internal static class SealCommandGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the seal command group.
|
||||
/// </summary>
|
||||
internal static Command BuildSealCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var imageArg = new Argument<string>("image")
|
||||
{
|
||||
Description = "Image reference or digest to seal."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", "-o")
|
||||
{
|
||||
Description = "Output file path for seal (default: stdout)."
|
||||
};
|
||||
|
||||
var storeOption = new Option<bool>("--store")
|
||||
{
|
||||
Description = "Store seal in remote API."
|
||||
};
|
||||
storeOption.SetDefaultValue(true);
|
||||
|
||||
var signOption = new Option<bool>("--sign")
|
||||
{
|
||||
Description = "Sign seal with DSSE."
|
||||
};
|
||||
signOption.SetDefaultValue(true);
|
||||
|
||||
var keyOption = new Option<string?>("--key", "-k")
|
||||
{
|
||||
Description = "Private key path for signing (default: use configured key)."
|
||||
};
|
||||
|
||||
var facetsOption = new Option<string[]?>("--facets", "-f")
|
||||
{
|
||||
Description = "Specific facets to seal (default: all). Comma-separated list.",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: json, yaml, compact."
|
||||
};
|
||||
formatOption.SetDefaultValue("json");
|
||||
|
||||
var seal = new Command("seal", "Create facet seal for an image. Seals capture the cryptographic state of image facets for drift detection.");
|
||||
seal.Add(imageArg);
|
||||
seal.Add(outputOption);
|
||||
seal.Add(storeOption);
|
||||
seal.Add(signOption);
|
||||
seal.Add(keyOption);
|
||||
seal.Add(facetsOption);
|
||||
seal.Add(formatOption);
|
||||
seal.Add(verboseOption);
|
||||
|
||||
seal.SetAction(parseResult =>
|
||||
{
|
||||
var image = parseResult.GetValue(imageArg)!;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var store = parseResult.GetValue(storeOption);
|
||||
var sign = parseResult.GetValue(signOption);
|
||||
var key = parseResult.GetValue(keyOption);
|
||||
var facets = parseResult.GetValue(facetsOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return HandleSealAsync(
|
||||
services,
|
||||
image,
|
||||
output,
|
||||
store,
|
||||
sign,
|
||||
key,
|
||||
facets,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return seal;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleSealAsync(
|
||||
IServiceProvider services,
|
||||
string image,
|
||||
string? outputPath,
|
||||
bool store,
|
||||
bool sign,
|
||||
string? keyPath,
|
||||
string[]? facets,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetService<ILoggerFactory>()?.CreateLogger("seal")
|
||||
?? NullLogger.Instance;
|
||||
var timeProvider = scope.ServiceProvider.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
|
||||
try
|
||||
{
|
||||
var facetExtractor = scope.ServiceProvider.GetService<IFacetExtractor>();
|
||||
var sealStore = scope.ServiceProvider.GetService<IFacetSealStore>();
|
||||
var sealer = scope.ServiceProvider.GetService<FacetSealer>();
|
||||
|
||||
if (facetExtractor is null || sealer is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Facet services not available. Ensure facet module is configured.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[bold]Creating facet seal for:[/] {image}");
|
||||
|
||||
// Determine facets to seal
|
||||
var builtInFacets = BuiltInFacets.All;
|
||||
var facetsToSeal = facets is { Length: > 0 }
|
||||
? builtInFacets.Where(f => facets.Contains(f.FacetId, StringComparer.OrdinalIgnoreCase)).ToList()
|
||||
: builtInFacets.ToList();
|
||||
|
||||
if (facetsToSeal.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No matching facets found.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[dim]Sealing {facetsToSeal.Count} facets...[/]");
|
||||
|
||||
// Extract facets
|
||||
// Note: In production, rootPath would be the extracted image layers path
|
||||
// For this CLI, we assume the image has been pulled and extracted
|
||||
var extractionOptions = new FacetExtractionOptions
|
||||
{
|
||||
IncludeFileDetails = true,
|
||||
Facets = [.. facetsToSeal]
|
||||
};
|
||||
|
||||
var extraction = await facetExtractor.ExtractFromDirectoryAsync(
|
||||
".", // Root path - in production, this would be the image root
|
||||
extractionOptions,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// Create the seal
|
||||
var seal = sealer.CreateSeal(image, extraction);
|
||||
|
||||
// Display seal summary
|
||||
AnsiConsole.WriteLine();
|
||||
var table = new Table()
|
||||
.AddColumn("Facet")
|
||||
.AddColumn("Files")
|
||||
.AddColumn("Size")
|
||||
.AddColumn("Merkle Root");
|
||||
|
||||
foreach (var facet in seal.Facets)
|
||||
{
|
||||
table.AddRow(
|
||||
facet.Name,
|
||||
facet.FileCount.ToString("N0"),
|
||||
FormatBytes(facet.TotalBytes),
|
||||
TruncateHash(facet.MerkleRoot));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
// Store if requested
|
||||
if (store && sealStore is not null)
|
||||
{
|
||||
await sealStore.SaveAsync(seal, ct).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine("[green]Seal stored to API[/]");
|
||||
}
|
||||
|
||||
// Output seal
|
||||
var sealOutput = FormatSeal(seal, format);
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, sealOutput, ct).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine($"[green]Seal written to:[/] {outputPath}");
|
||||
}
|
||||
else if (!store)
|
||||
{
|
||||
Console.WriteLine(sealOutput);
|
||||
}
|
||||
|
||||
// Summary
|
||||
AnsiConsole.MarkupLine($"[bold green]Seal created successfully[/]");
|
||||
AnsiConsole.MarkupLine($" [dim]Image:[/] {seal.ImageDigest}");
|
||||
AnsiConsole.MarkupLine($" [dim]Facets:[/] {seal.Facets.Length}");
|
||||
AnsiConsole.MarkupLine($" [dim]Combined Root:[/] {TruncateHash(seal.CombinedMerkleRoot)}");
|
||||
AnsiConsole.MarkupLine($" [dim]Signed:[/] {(seal.Signature is not null ? "Yes" : "No")}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create seal for {Image}", image);
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSeal(FacetSeal seal, string format)
|
||||
{
|
||||
return format.ToLowerInvariant() switch
|
||||
{
|
||||
"yaml" => ToYaml(seal),
|
||||
"compact" => $"{seal.ImageDigest}|{seal.CombinedMerkleRoot}|{seal.Facets.Length}",
|
||||
_ => JsonSerializer.Serialize(seal, new JsonSerializerOptions { WriteIndented = true })
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToYaml(FacetSeal seal)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"imageDigest: {seal.ImageDigest}");
|
||||
sb.AppendLine($"createdAt: {seal.CreatedAt:O}");
|
||||
sb.AppendLine($"combinedMerkleRoot: {seal.CombinedMerkleRoot}");
|
||||
sb.AppendLine("facets:");
|
||||
foreach (var f in seal.Facets)
|
||||
{
|
||||
sb.AppendLine($" - facetId: {f.FacetId}");
|
||||
sb.AppendLine($" name: {f.Name}");
|
||||
sb.AppendLine($" merkleRoot: {f.MerkleRoot}");
|
||||
sb.AppendLine($" fileCount: {f.FileCount}");
|
||||
sb.AppendLine($" totalBytes: {f.TotalBytes}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
return bytes switch
|
||||
{
|
||||
< 1024 => $"{bytes} B",
|
||||
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
|
||||
< 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024):F1} MB",
|
||||
_ => $"{bytes / (1024.0 * 1024 * 1024):F1} GB"
|
||||
};
|
||||
}
|
||||
|
||||
private static string TruncateHash(string? hash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash)) return "(none)";
|
||||
return hash.Length > 16 ? $"{hash[..8]}...{hash[^8..]}" : hash;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
@@ -485,7 +486,7 @@ public static class VexGateScanCommandGroup
|
||||
summaryTable.AddRow("Passed", $"[green]{results.Summary.Passed}[/]");
|
||||
summaryTable.AddRow("Warned", $"[yellow]{results.Summary.Warned}[/]");
|
||||
summaryTable.AddRow("Blocked", $"[red]{results.Summary.Blocked}[/]");
|
||||
summaryTable.AddRow("Evaluated At", results.Summary.EvaluatedAt?.ToString("O") ?? "N/A");
|
||||
summaryTable.AddRow("Evaluated At", results.Summary.EvaluatedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "N/A");
|
||||
|
||||
console.Write(summaryTable);
|
||||
}
|
||||
|
||||
338
src/Cli/StellaOps.Cli/Commands/VexGenCommandGroup.cs
Normal file
338
src/Cli/StellaOps.Cli/Commands/VexGenCommandGroup.cs
Normal file
@@ -0,0 +1,338 @@
|
||||
// <copyright file="VexGenCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_004_CLI (CLI-011 through CLI-015)
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Facet;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for VEX generation operations.
|
||||
/// Provides stella vex gen command for generating VEX documents from facet drift.
|
||||
/// </summary>
|
||||
internal static class VexGenCommandGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the vex gen command group.
|
||||
/// </summary>
|
||||
internal static Command BuildVexGenCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fromDriftOption = new Option<bool>("--from-drift")
|
||||
{
|
||||
Description = "Generate VEX from facet drift analysis."
|
||||
};
|
||||
|
||||
var imageOption = new Option<string>("--image", "-i")
|
||||
{
|
||||
Description = "Image reference or digest.",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var baselineOption = new Option<string?>("--baseline", "-b")
|
||||
{
|
||||
Description = "Baseline seal ID (default: latest for image)."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", "-o")
|
||||
{
|
||||
Description = "Output file path (default: stdout)."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "VEX format: openvex, csaf."
|
||||
};
|
||||
formatOption.SetDefaultValue("openvex");
|
||||
|
||||
var statusOption = new Option<string>("--status")
|
||||
{
|
||||
Description = "VEX status: under_investigation, not_affected, affected."
|
||||
};
|
||||
statusOption.SetDefaultValue("under_investigation");
|
||||
|
||||
var gen = new Command("gen", "Generate VEX statements from drift analysis.");
|
||||
gen.Add(fromDriftOption);
|
||||
gen.Add(imageOption);
|
||||
gen.Add(baselineOption);
|
||||
gen.Add(outputOption);
|
||||
gen.Add(formatOption);
|
||||
gen.Add(statusOption);
|
||||
gen.Add(verboseOption);
|
||||
|
||||
gen.SetAction(parseResult =>
|
||||
{
|
||||
var fromDrift = parseResult.GetValue(fromDriftOption);
|
||||
var image = parseResult.GetValue(imageOption)!;
|
||||
var baseline = parseResult.GetValue(baselineOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var status = parseResult.GetValue(statusOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!fromDrift)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]--from-drift is required for VEX generation.[/]");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
return HandleVexFromDriftAsync(
|
||||
services,
|
||||
image,
|
||||
baseline,
|
||||
output,
|
||||
format,
|
||||
status,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return gen;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleVexFromDriftAsync(
|
||||
IServiceProvider services,
|
||||
string image,
|
||||
string? baselineId,
|
||||
string? outputPath,
|
||||
string format,
|
||||
string status,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetService<ILoggerFactory>()?.CreateLogger("vex.gen")
|
||||
?? NullLogger.Instance;
|
||||
var timeProvider = scope.ServiceProvider.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
|
||||
try
|
||||
{
|
||||
var driftDetector = scope.ServiceProvider.GetService<IFacetDriftDetector>();
|
||||
var sealStore = scope.ServiceProvider.GetService<IFacetSealStore>();
|
||||
|
||||
if (driftDetector is null || sealStore is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Facet services not available. Ensure facet module is configured.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[bold]Generating VEX from drift for:[/] {image}");
|
||||
|
||||
// Load baseline seal
|
||||
FacetSeal? baseline;
|
||||
if (!string.IsNullOrEmpty(baselineId))
|
||||
{
|
||||
baseline = await sealStore.GetByCombinedRootAsync(baselineId, ct).ConfigureAwait(false);
|
||||
if (baseline is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Baseline seal '{baselineId}' not found.[/]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
baseline = await sealStore.GetLatestSealAsync(image, ct).ConfigureAwait(false);
|
||||
if (baseline is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]No baseline seal found for image. Run 'stella seal' first.[/]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[dim]Baseline seal:[/] {TruncateHash(baseline.CombinedMerkleRoot)} ({baseline.CreatedAt:yyyy-MM-dd HH:mm:ss})");
|
||||
|
||||
// Get current seal for comparison
|
||||
var currentSeal = await sealStore.GetLatestSealAsync(image, ct).ConfigureAwait(false);
|
||||
if (currentSeal is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]No current seal found for image.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Compute drift
|
||||
AnsiConsole.MarkupLine("[dim]Computing drift...[/]");
|
||||
var report = await driftDetector.DetectDriftAsync(baseline, currentSeal, ct).ConfigureAwait(false);
|
||||
|
||||
// Generate VEX document
|
||||
AnsiConsole.MarkupLine("[dim]Generating VEX statements...[/]");
|
||||
var vexDocument = GenerateOpenVex(report, image, status, timeProvider);
|
||||
|
||||
if (vexDocument.Statements.Length == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No facets require VEX authorization.[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Output
|
||||
var vexJson = JsonSerializer.Serialize(vexDocument, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, vexJson, ct).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine($"[green]VEX written to:[/] {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(vexJson);
|
||||
}
|
||||
|
||||
// Summary
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine($"[bold green]Generated {vexDocument.Statements.Length} VEX statement(s)[/]");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
foreach (var stmt in vexDocument.Statements)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [dim]-[/] {stmt.Justification}");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to generate VEX for {Image}", image);
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static OpenVexDocument GenerateOpenVex(
|
||||
FacetDriftReport report,
|
||||
string imageDigest,
|
||||
string status,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var docId = Guid.NewGuid();
|
||||
var statements = new List<OpenVexStatement>();
|
||||
|
||||
foreach (var drift in report.FacetDrifts.Where(d =>
|
||||
d.QuotaVerdict == QuotaVerdict.RequiresVex ||
|
||||
d.QuotaVerdict == QuotaVerdict.Warning))
|
||||
{
|
||||
statements.Add(new OpenVexStatement
|
||||
{
|
||||
Id = $"vex:{Guid.NewGuid()}",
|
||||
Status = status,
|
||||
Timestamp = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
Products =
|
||||
[
|
||||
new OpenVexProduct
|
||||
{
|
||||
Id = imageDigest,
|
||||
Identifiers = new OpenVexIdentifiers { Facet = drift.FacetId }
|
||||
}
|
||||
],
|
||||
Justification = $"Facet drift authorization for {drift.FacetId}. " +
|
||||
$"Churn: {drift.ChurnPercent:F2}% " +
|
||||
$"({drift.Added.Length} added, {drift.Removed.Length} removed, {drift.Modified.Length} modified)",
|
||||
ActionStatement = drift.QuotaVerdict == QuotaVerdict.RequiresVex
|
||||
? "Review required before deployment"
|
||||
: "Drift within acceptable limits but raised for awareness"
|
||||
});
|
||||
}
|
||||
|
||||
return new OpenVexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns",
|
||||
Id = $"https://stellaops.io/vex/{docId}",
|
||||
Author = "StellaOps CLI",
|
||||
Timestamp = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
Version = 1,
|
||||
Statements = [.. statements]
|
||||
};
|
||||
}
|
||||
|
||||
private static string TruncateHash(string? hash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash)) return "(none)";
|
||||
return hash.Length > 16 ? $"{hash[..8]}...{hash[^8..]}" : hash;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX document model.
|
||||
/// </summary>
|
||||
internal sealed record OpenVexDocument
|
||||
{
|
||||
[JsonPropertyName("@context")]
|
||||
public required string Context { get; init; }
|
||||
|
||||
[JsonPropertyName("@id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("author")]
|
||||
public required string Author { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required string Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required int Version { get; init; }
|
||||
|
||||
[JsonPropertyName("statements")]
|
||||
public required ImmutableArray<OpenVexStatement> Statements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX statement model.
|
||||
/// </summary>
|
||||
internal sealed record OpenVexStatement
|
||||
{
|
||||
[JsonPropertyName("@id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required string Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("products")]
|
||||
public required ImmutableArray<OpenVexProduct> Products { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("action_statement")]
|
||||
public string? ActionStatement { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX product reference.
|
||||
/// </summary>
|
||||
internal sealed record OpenVexProduct
|
||||
{
|
||||
[JsonPropertyName("@id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("identifiers")]
|
||||
public required OpenVexIdentifiers Identifiers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX identifiers.
|
||||
/// </summary>
|
||||
internal sealed record OpenVexIdentifiers
|
||||
{
|
||||
[JsonPropertyName("facet")]
|
||||
public string? Facet { get; init; }
|
||||
}
|
||||
@@ -3647,9 +3647,9 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
if (request.From.HasValue)
|
||||
queryParams.Add($"from={Uri.EscapeDataString(request.From.Value.ToString("O"))}");
|
||||
queryParams.Add($"from={Uri.EscapeDataString(request.From.Value.ToString("O", CultureInfo.InvariantCulture))}");
|
||||
if (request.To.HasValue)
|
||||
queryParams.Add($"to={Uri.EscapeDataString(request.To.Value.ToString("O"))}");
|
||||
queryParams.Add($"to={Uri.EscapeDataString(request.To.Value.ToString("O", CultureInfo.InvariantCulture))}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
queryParams.Add($"status={Uri.EscapeDataString(request.Status)}");
|
||||
if (request.Limit.HasValue)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Formats.Tar;
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
@@ -364,7 +365,7 @@ public sealed class DevPortalBundleVerificationResult
|
||||
if (BundleId is not null)
|
||||
output["bundleId"] = BundleId;
|
||||
if (CreatedAt.HasValue)
|
||||
output["createdAt"] = CreatedAt.Value.ToString("O");
|
||||
output["createdAt"] = CreatedAt.Value.ToString("O", CultureInfo.InvariantCulture);
|
||||
output["entries"] = Entries;
|
||||
if (ErrorDetail is not null)
|
||||
output["errorDetail"] = ErrorDetail;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -528,7 +529,7 @@ internal sealed class ExceptionClient : IExceptionClient
|
||||
}
|
||||
if (request.ExpiringBefore.HasValue)
|
||||
{
|
||||
queryParams.Add($"expiringBefore={Uri.EscapeDataString(request.ExpiringBefore.Value.ToString("O"))}");
|
||||
queryParams.Add($"expiringBefore={Uri.EscapeDataString(request.ExpiringBefore.Value.ToString("O", CultureInfo.InvariantCulture))}");
|
||||
}
|
||||
if (request.IncludeExpired)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
@@ -321,11 +322,11 @@ internal sealed class NotifyClient : INotifyClient
|
||||
}
|
||||
if (request.Since.HasValue)
|
||||
{
|
||||
queryParams.Add($"since={Uri.EscapeDataString(request.Since.Value.ToString("O"))}");
|
||||
queryParams.Add($"since={Uri.EscapeDataString(request.Since.Value.ToString("O", CultureInfo.InvariantCulture))}");
|
||||
}
|
||||
if (request.Until.HasValue)
|
||||
{
|
||||
queryParams.Add($"until={Uri.EscapeDataString(request.Until.Value.ToString("O"))}");
|
||||
queryParams.Add($"until={Uri.EscapeDataString(request.Until.Value.ToString("O", CultureInfo.InvariantCulture))}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -459,11 +460,11 @@ internal sealed class SbomClient : ISbomClient
|
||||
}
|
||||
if (request.CreatedAfter.HasValue)
|
||||
{
|
||||
queryParams.Add($"createdAfter={Uri.EscapeDataString(request.CreatedAfter.Value.ToString("O"))}");
|
||||
queryParams.Add($"createdAfter={Uri.EscapeDataString(request.CreatedAfter.Value.ToString("O", CultureInfo.InvariantCulture))}");
|
||||
}
|
||||
if (request.CreatedBefore.HasValue)
|
||||
{
|
||||
queryParams.Add($"createdBefore={Uri.EscapeDataString(request.CreatedBefore.Value.ToString("O"))}");
|
||||
queryParams.Add($"createdBefore={Uri.EscapeDataString(request.CreatedBefore.Value.ToString("O", CultureInfo.InvariantCulture))}");
|
||||
}
|
||||
if (request.HasVulnerabilities.HasValue)
|
||||
{
|
||||
|
||||
@@ -99,6 +99,8 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<!-- Air-Gap Job Sync (SPRINT_20260105_002_003_ROUTER) -->
|
||||
<ProjectReference Include="../../AirGap/__Libraries/StellaOps.AirGap.Sync/StellaOps.AirGap.Sync.csproj" />
|
||||
<!-- Facet seal and drift (SPRINT_20260105_002_004_CLI) -->
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Facet/StellaOps.Facet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- GOST Crypto Plugins (Russia distribution) -->
|
||||
|
||||
Reference in New Issue
Block a user