release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -38,6 +38,9 @@ public static class AttestCommandGroup
|
||||
attest.Add(FixChainCommandGroup.BuildFixChainCommand(verboseOption, cancellationToken));
|
||||
attest.Add(FixChainCommandGroup.BuildFixChainVerifyCommand(verboseOption, cancellationToken));
|
||||
|
||||
// Patch attestation command (Sprint 20260111_001_005)
|
||||
attest.Add(PatchAttestCommandGroup.BuildPatchAttestCommand(verboseOption, cancellationToken));
|
||||
|
||||
return attest;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.CallGraph.Binary;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Binary;
|
||||
|
||||
|
||||
450
src/Cli/StellaOps.Cli/Commands/ChangeTraceCommandGroup.cs
Normal file
450
src/Cli/StellaOps.Cli/Commands/ChangeTraceCommandGroup.cs
Normal file
@@ -0,0 +1,450 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChangeTraceCommandGroup.cs
|
||||
// Sprint: SPRINT_20260112_200_006_CLI_commands
|
||||
// Description: CLI commands for building, exporting, and verifying change traces.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.ChangeTrace.Builder;
|
||||
using StellaOps.Scanner.ChangeTrace.CycloneDx;
|
||||
using StellaOps.Scanner.ChangeTrace.Models;
|
||||
using StellaOps.Scanner.ChangeTrace.Validation;
|
||||
|
||||
using ChangeTraceModel = StellaOps.Scanner.ChangeTrace.Models.ChangeTrace;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for building, exporting, and verifying change traces.
|
||||
/// </summary>
|
||||
public static class ChangeTraceCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the change-trace command group.
|
||||
/// </summary>
|
||||
public static Command BuildChangeTraceCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var changeTrace = new Command("change-trace", "Build and export change traces between scans");
|
||||
|
||||
changeTrace.Add(BuildBuildCommand(services, verboseOption, cancellationToken));
|
||||
changeTrace.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
changeTrace.Add(BuildVerifyCommand(verboseOption, cancellationToken));
|
||||
|
||||
return changeTrace;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'build' subcommand for creating change traces.
|
||||
/// </summary>
|
||||
private static Command BuildBuildCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fromOption = new Option<string>("--from") { Description = "Source scan ID or binary file path", Required = true };
|
||||
var toOption = new Option<string>("--to") { Description = "Target scan ID or binary file path", Required = true };
|
||||
var includeByteOption = new Option<bool>("--include-byte-diff") { Description = "Include byte-level diffing (slower, more detailed)" };
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" }) { Description = "Output file path (default: stdout)" };
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" }) { Description = "Output format: json, table, summary" };
|
||||
formatOption.SetDefaultValue("json");
|
||||
|
||||
var build = new Command("build", "Build a change trace comparing two scans or binaries");
|
||||
build.Add(fromOption);
|
||||
build.Add(toOption);
|
||||
build.Add(includeByteOption);
|
||||
build.Add(outputOption);
|
||||
build.Add(formatOption);
|
||||
build.Add(verboseOption);
|
||||
|
||||
build.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var from = parseResult.GetValue(fromOption) ?? string.Empty;
|
||||
var to = parseResult.GetValue(toOption) ?? string.Empty;
|
||||
var includeByteDiff = parseResult.GetValue(includeByteOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
try
|
||||
{
|
||||
// Build the change trace
|
||||
var builder = services.GetService(typeof(IChangeTraceBuilder)) as IChangeTraceBuilder
|
||||
?? new ChangeTraceBuilder(NullLogger<ChangeTraceBuilder>.Instance, TimeProvider.System);
|
||||
|
||||
var options = new ChangeTraceBuilderOptions
|
||||
{
|
||||
IncludeByteDiff = includeByteDiff
|
||||
};
|
||||
|
||||
ChangeTraceModel trace;
|
||||
|
||||
// Check if inputs are files or scan IDs
|
||||
if (File.Exists(from) && File.Exists(to))
|
||||
{
|
||||
// Binary file comparison
|
||||
trace = await builder.FromBinaryComparisonAsync(from, to, options, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scan ID comparison
|
||||
trace = await builder.FromScanComparisonAsync(from, to, options, cancellationToken);
|
||||
}
|
||||
|
||||
// Format output
|
||||
var result = format.ToLowerInvariant() switch
|
||||
{
|
||||
"json" => JsonSerializer.Serialize(trace, JsonOptions),
|
||||
"table" => FormatAsTable(trace),
|
||||
"summary" => FormatAsSummary(trace),
|
||||
_ => JsonSerializer.Serialize(trace, JsonOptions)
|
||||
};
|
||||
|
||||
// Write output
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
await File.WriteAllTextAsync(output, result, cancellationToken);
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Change trace written to {output}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(result);
|
||||
}
|
||||
|
||||
// Return exit code based on verdict
|
||||
return trace.Summary.Verdict switch
|
||||
{
|
||||
ChangeTraceVerdict.RiskDown => ChangeTraceExitCodes.Success,
|
||||
ChangeTraceVerdict.Neutral => ChangeTraceExitCodes.Success,
|
||||
ChangeTraceVerdict.RiskUp => ChangeTraceExitCodes.RiskUp,
|
||||
_ => ChangeTraceExitCodes.Inconclusive
|
||||
};
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: File not found - {ex.FileName}");
|
||||
return ChangeTraceExitCodes.FileNotFound;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Invalid JSON - {ex.Message}");
|
||||
return ChangeTraceExitCodes.ValidationFailed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return ChangeTraceExitCodes.Error;
|
||||
}
|
||||
});
|
||||
|
||||
return build;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'export' subcommand for exporting change traces.
|
||||
/// </summary>
|
||||
private static Command BuildExportCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inputOption = new Option<string>("--input", new[] { "-i" }) { Description = "Input change trace JSON file", Required = true };
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" }) { Description = "Export format: json, cyclonedx, bundle" };
|
||||
formatOption.SetDefaultValue("json");
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" }) { Description = "Output file path" };
|
||||
var cdxEmbeddedOption = new Option<bool>("--cdx-embedded") { Description = "Embed in CycloneDX as component-evidence extension" };
|
||||
var cdxBomOption = new Option<string?>("--cdx-bom") { Description = "Existing CycloneDX BOM to embed the trace in" };
|
||||
|
||||
var export = new Command("export", "Export a change trace in various formats");
|
||||
export.Add(inputOption);
|
||||
export.Add(formatOption);
|
||||
export.Add(outputOption);
|
||||
export.Add(cdxEmbeddedOption);
|
||||
export.Add(cdxBomOption);
|
||||
export.Add(verboseOption);
|
||||
|
||||
export.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var input = parseResult.GetValue(inputOption) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var cdxEmbedded = parseResult.GetValue(cdxEmbeddedOption);
|
||||
var cdxBom = parseResult.GetValue(cdxBomOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(input))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Input file not found - {input}");
|
||||
return ChangeTraceExitCodes.FileNotFound;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(input, cancellationToken);
|
||||
var trace = JsonSerializer.Deserialize<ChangeTraceModel>(content, JsonOptions);
|
||||
|
||||
if (trace is null)
|
||||
{
|
||||
Console.Error.WriteLine("Error: Failed to parse change trace");
|
||||
return ChangeTraceExitCodes.ValidationFailed;
|
||||
}
|
||||
|
||||
string result;
|
||||
string defaultExtension;
|
||||
|
||||
if (cdxEmbedded || format.Equals("cyclonedx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var evidenceExtension = new ChangeTraceEvidenceExtension(TimeProvider.System);
|
||||
|
||||
if (!string.IsNullOrEmpty(cdxBom) && File.Exists(cdxBom))
|
||||
{
|
||||
// Embed in existing BOM
|
||||
var bomContent = await File.ReadAllTextAsync(cdxBom, cancellationToken);
|
||||
using var bomDoc = JsonDocument.Parse(bomContent);
|
||||
using var resultDoc = evidenceExtension.EmbedInCycloneDx(bomDoc, trace);
|
||||
result = JsonSerializer.Serialize(resultDoc, JsonOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standalone export
|
||||
using var resultDoc = evidenceExtension.ExportAsStandalone(trace);
|
||||
result = JsonSerializer.Serialize(resultDoc, JsonOptions);
|
||||
}
|
||||
|
||||
defaultExtension = ".cdx.json";
|
||||
}
|
||||
else
|
||||
{
|
||||
result = format.ToLowerInvariant() switch
|
||||
{
|
||||
"json" => JsonSerializer.Serialize(trace, JsonOptions),
|
||||
"table" => FormatAsTable(trace),
|
||||
"summary" => FormatAsSummary(trace),
|
||||
_ => JsonSerializer.Serialize(trace, JsonOptions)
|
||||
};
|
||||
|
||||
defaultExtension = format.Equals("json", StringComparison.OrdinalIgnoreCase)
|
||||
? ".cdxchange.json"
|
||||
: ".txt";
|
||||
}
|
||||
|
||||
// Write output
|
||||
var outputPath = output ?? $"trace-export{defaultExtension}";
|
||||
await File.WriteAllTextAsync(outputPath, result, cancellationToken);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Exported to {outputPath}");
|
||||
}
|
||||
|
||||
return ChangeTraceExitCodes.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return ChangeTraceExitCodes.Error;
|
||||
}
|
||||
});
|
||||
|
||||
return export;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'verify' subcommand for verifying change trace files.
|
||||
/// </summary>
|
||||
private static Command BuildVerifyCommand(
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fileArg = new Argument<string>("file")
|
||||
{
|
||||
Description = "Path to change trace file (.cdxchange.json)"
|
||||
};
|
||||
|
||||
var strictOption = new Option<bool>("--strict")
|
||||
{
|
||||
Description = "Fail on any warnings"
|
||||
};
|
||||
|
||||
var verify = new Command("verify", "Verify a change trace file");
|
||||
verify.Add(fileArg);
|
||||
verify.Add(strictOption);
|
||||
verify.Add(verboseOption);
|
||||
|
||||
verify.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var file = parseResult.GetValue(fileArg) ?? string.Empty;
|
||||
var strict = parseResult.GetValue(strictOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: File not found - {file}");
|
||||
return ChangeTraceExitCodes.FileNotFound;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
|
||||
// Validate JSON structure
|
||||
ChangeTraceModel? trace;
|
||||
try
|
||||
{
|
||||
trace = JsonSerializer.Deserialize<ChangeTraceModel>(content, JsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Invalid JSON - {ex.Message}");
|
||||
return ChangeTraceExitCodes.ValidationFailed;
|
||||
}
|
||||
|
||||
if (trace is null)
|
||||
{
|
||||
Console.Error.WriteLine("Error: Failed to parse change trace");
|
||||
return ChangeTraceExitCodes.ValidationFailed;
|
||||
}
|
||||
|
||||
// Validate trace
|
||||
var validator = new ChangeTraceValidator();
|
||||
var result = validator.Validate(trace);
|
||||
|
||||
// Display results
|
||||
if (result.Errors.Count > 0)
|
||||
{
|
||||
Console.Error.WriteLine("Errors:");
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
Console.Error.WriteLine($" - {error}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Warnings.Count > 0)
|
||||
{
|
||||
Console.WriteLine("Warnings:");
|
||||
foreach (var warning in result.Warnings)
|
||||
{
|
||||
Console.WriteLine($" - {warning}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.IsValid && (!strict || result.Warnings.Count == 0))
|
||||
{
|
||||
Console.WriteLine("Change trace is valid");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(FormatAsSummary(trace));
|
||||
}
|
||||
|
||||
return ChangeTraceExitCodes.Success;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("Change trace validation failed");
|
||||
return ChangeTraceExitCodes.ValidationFailed;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return ChangeTraceExitCodes.Error;
|
||||
}
|
||||
});
|
||||
|
||||
return verify;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format a change trace as a table.
|
||||
/// </summary>
|
||||
private static string FormatAsTable(ChangeTraceModel trace)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
string.Format("{0,-50} {1,-15} {2,-15} {3,-15} {4,-10}",
|
||||
"Component", "From", "To", "Change Type", "Trust Delta"),
|
||||
new string('-', 105)
|
||||
};
|
||||
|
||||
foreach (var delta in trace.Deltas)
|
||||
{
|
||||
var trustDelta = delta.TrustDelta?.Score ?? 0;
|
||||
var trustSign = trustDelta < 0 ? "" : (trustDelta > 0 ? "+" : " ");
|
||||
|
||||
lines.Add(string.Format("{0,-50} {1,-15} {2,-15} {3,-15} {4}{5:0.00}",
|
||||
TruncatePurl(delta.Purl, 50),
|
||||
TruncateVersion(delta.FromVersion, 15),
|
||||
TruncateVersion(delta.ToVersion, 15),
|
||||
delta.ChangeType.ToString(),
|
||||
trustSign,
|
||||
trustDelta));
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format a change trace as a summary.
|
||||
/// </summary>
|
||||
private static string FormatAsSummary(ChangeTraceModel trace)
|
||||
{
|
||||
var trustDelta = trace.Summary.RiskDelta;
|
||||
var trustSign = trustDelta < 0 ? "" : (trustDelta > 0 ? "+" : " ");
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"Change Trace: {trace.Subject.Digest}",
|
||||
$"Generated: {trace.Basis.AnalyzedAt:O}",
|
||||
$"Packages Changed: {trace.Summary.ChangedPackages}",
|
||||
$"Symbols Changed: {trace.Summary.ChangedSymbols}",
|
||||
$"Bytes Changed: {trace.Summary.ChangedBytes:N0}",
|
||||
$"Trust Delta: {trustSign}{trustDelta:0.00}",
|
||||
$"Verdict: {trace.Summary.Verdict}"
|
||||
};
|
||||
|
||||
if (trace.Commitment is not null)
|
||||
{
|
||||
lines.Add($"Commitment: {trace.Commitment.Sha256}");
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static string TruncatePurl(string purl, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(purl) || purl.Length <= maxLength)
|
||||
return purl ?? "-";
|
||||
|
||||
return purl[..(maxLength - 3)] + "...";
|
||||
}
|
||||
|
||||
private static string TruncateVersion(string? version, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version))
|
||||
return "-";
|
||||
|
||||
if (version.Length <= maxLength)
|
||||
return version;
|
||||
|
||||
return version[..(maxLength - 3)] + "...";
|
||||
}
|
||||
}
|
||||
49
src/Cli/StellaOps.Cli/Commands/ChangeTraceExitCodes.cs
Normal file
49
src/Cli/StellaOps.Cli/Commands/ChangeTraceExitCodes.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChangeTraceExitCodes.cs
|
||||
// Sprint: SPRINT_20260112_200_006_CLI_commands
|
||||
// Description: Exit codes for change-trace CLI commands.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Exit codes for change-trace CLI commands.
|
||||
/// Designed for CI/CD pipeline integration.
|
||||
/// </summary>
|
||||
public static class ChangeTraceExitCodes
|
||||
{
|
||||
/// <summary>
|
||||
/// Operation completed successfully (or risk_down/neutral verdict).
|
||||
/// </summary>
|
||||
public const int Success = 0;
|
||||
|
||||
/// <summary>
|
||||
/// General error (file not found, validation failed, etc.).
|
||||
/// </summary>
|
||||
public const int Error = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Risk up verdict - trust delta indicates increased risk.
|
||||
/// </summary>
|
||||
public const int RiskUp = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Inconclusive - unable to determine verdict.
|
||||
/// </summary>
|
||||
public const int Inconclusive = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Input file not found.
|
||||
/// </summary>
|
||||
public const int FileNotFound = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Validation failed.
|
||||
/// </summary>
|
||||
public const int ValidationFailed = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Service not available.
|
||||
/// </summary>
|
||||
public const int ServiceUnavailable = 6;
|
||||
}
|
||||
@@ -135,6 +135,9 @@ internal static class CommandFactory
|
||||
root.Add(GoldenSet.GoldenSetCommandGroup.BuildGoldenCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(GoldenSet.VerifyFixCommandGroup.BuildVerifyFixCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260112_200_006_CLI - Change Trace Commands
|
||||
root.Add(ChangeTraceCommandGroup.BuildChangeTraceCommand(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)
|
||||
@@ -417,6 +420,10 @@ internal static class CommandFactory
|
||||
var recipe = LayerSbomCommandGroup.BuildRecipeCommand(services, options, verboseOption, cancellationToken);
|
||||
scan.Add(recipe);
|
||||
|
||||
// Patch verification command (Sprint: SPRINT_20260111_001_004_CLI_verify_patches)
|
||||
var verifyPatches = PatchVerifyCommandGroup.BuildVerifyPatchesCommand(services, verboseOption, cancellationToken);
|
||||
scan.Add(verifyPatches);
|
||||
|
||||
scan.Add(run);
|
||||
scan.Add(upload);
|
||||
return scan;
|
||||
|
||||
@@ -2971,7 +2971,7 @@ internal static partial class CommandHandlers
|
||||
|
||||
try
|
||||
{
|
||||
await TenantProfileStore.SetActiveTenantAsync(normalizedTenant, displayName, cancellationToken).ConfigureAwait(false);
|
||||
await TenantProfileStore.SetActiveTenantAsync(normalizedTenant, displayName, asOf: null, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Active tenant set to '{TenantId}'.", normalizedTenant);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(displayName))
|
||||
@@ -3043,7 +3043,7 @@ internal static partial class CommandHandlers
|
||||
|
||||
try
|
||||
{
|
||||
await TenantProfileStore.ClearActiveTenantAsync(cancellationToken).ConfigureAwait(false);
|
||||
await TenantProfileStore.ClearActiveTenantAsync(asOf: null, cancellationToken).ConfigureAwait(false);
|
||||
Console.WriteLine("Active tenant cleared.");
|
||||
Console.WriteLine("Subsequent commands will require --tenant or STELLAOPS_TENANT environment variable.");
|
||||
}
|
||||
|
||||
580
src/Cli/StellaOps.Cli/Commands/PatchAttestCommandGroup.cs
Normal file
580
src/Cli/StellaOps.Cli/Commands/PatchAttestCommandGroup.cs
Normal file
@@ -0,0 +1,580 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PatchAttestCommandGroup.cs
|
||||
// Sprint: SPRINT_20260111_001_005_CLI_attest_patch
|
||||
// Task: Patch attestation command for creating DSSE-signed patch evidence
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Feedser.BinaryAnalysis.Models;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for patch attestation operations.
|
||||
/// Creates DSSE-signed attestations from before/after binary analysis.
|
||||
/// </summary>
|
||||
public static class PatchAttestCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 'attest patch' command.
|
||||
/// Creates a patch verification attestation from before/after binaries.
|
||||
/// </summary>
|
||||
public static Command BuildPatchAttestCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var cveOption = new Option<string>("--cve", "-c")
|
||||
{
|
||||
Description = "CVE identifier being attested (e.g., CVE-2024-1234)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var fromOption = new Option<FileInfo>("--from", "-f")
|
||||
{
|
||||
Description = "Path to vulnerable binary (before patch)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var toOption = new Option<FileInfo>("--to", "-t")
|
||||
{
|
||||
Description = "Path to patched binary (after patch)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var outputOption = new Option<FileInfo?>("--out", "-o")
|
||||
{
|
||||
Description = "Output DSSE envelope file (prints to stdout if not specified)"
|
||||
};
|
||||
|
||||
var purlOption = new Option<string?>("--purl", "-p")
|
||||
{
|
||||
Description = "Package URL for the component (e.g., pkg:rpm/openssl@1.1.1k-123.el8)"
|
||||
};
|
||||
|
||||
var keyOption = new Option<string?>("--key", "-k")
|
||||
{
|
||||
Description = "Path to private key for signing (PEM or PKCS#8)"
|
||||
};
|
||||
|
||||
var keylessOption = new Option<bool>("--sign-keyless")
|
||||
{
|
||||
Description = "Use Sigstore keyless signing (OIDC)"
|
||||
};
|
||||
|
||||
var noSignOption = new Option<bool>("--no-sign")
|
||||
{
|
||||
Description = "Skip signing (output unsigned attestation payload)"
|
||||
};
|
||||
|
||||
var noRekorOption = new Option<bool>("--no-rekor")
|
||||
{
|
||||
Description = "Skip Rekor transparency log publication"
|
||||
};
|
||||
|
||||
var publishOption = new Option<bool>("--publish")
|
||||
{
|
||||
Description = "Publish attestation to Authority service"
|
||||
};
|
||||
|
||||
var manifestOption = new Option<FileInfo?>("--manifest", "-m")
|
||||
{
|
||||
Description = "Patch manifest file for batch attestation (YAML)"
|
||||
};
|
||||
|
||||
var outDirOption = new Option<DirectoryInfo?>("--out-dir")
|
||||
{
|
||||
Description = "Output directory for batch attestations"
|
||||
};
|
||||
|
||||
var issuerOption = new Option<string?>("--issuer")
|
||||
{
|
||||
Description = "Issuer identifier for the attestation"
|
||||
};
|
||||
|
||||
var descriptionOption = new Option<string?>("--description")
|
||||
{
|
||||
Description = "Human-readable description of the patch"
|
||||
};
|
||||
|
||||
var patch = new Command("patch", "Create DSSE-signed patch verification attestation")
|
||||
{
|
||||
cveOption,
|
||||
fromOption,
|
||||
toOption,
|
||||
outputOption,
|
||||
purlOption,
|
||||
keyOption,
|
||||
keylessOption,
|
||||
noSignOption,
|
||||
noRekorOption,
|
||||
publishOption,
|
||||
manifestOption,
|
||||
outDirOption,
|
||||
issuerOption,
|
||||
descriptionOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
patch.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var cve = parseResult.GetValue(cveOption) ?? string.Empty;
|
||||
var from = parseResult.GetValue(fromOption)!;
|
||||
var to = parseResult.GetValue(toOption)!;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var purl = parseResult.GetValue(purlOption);
|
||||
var keyPath = parseResult.GetValue(keyOption);
|
||||
var keyless = parseResult.GetValue(keylessOption);
|
||||
var noSign = parseResult.GetValue(noSignOption);
|
||||
var noRekor = parseResult.GetValue(noRekorOption);
|
||||
var publish = parseResult.GetValue(publishOption);
|
||||
var manifest = parseResult.GetValue(manifestOption);
|
||||
var outDir = parseResult.GetValue(outDirOption);
|
||||
var issuer = parseResult.GetValue(issuerOption);
|
||||
var description = parseResult.GetValue(descriptionOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await ExecutePatchAttestAsync(
|
||||
cve,
|
||||
from,
|
||||
to,
|
||||
output,
|
||||
purl,
|
||||
keyPath,
|
||||
keyless,
|
||||
noSign,
|
||||
noRekor,
|
||||
publish,
|
||||
manifest,
|
||||
outDir,
|
||||
issuer,
|
||||
description,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecutePatchAttestAsync(
|
||||
string cve,
|
||||
FileInfo fromFile,
|
||||
FileInfo toFile,
|
||||
FileInfo? outputFile,
|
||||
string? purl,
|
||||
string? keyPath,
|
||||
bool keyless,
|
||||
bool noSign,
|
||||
bool noRekor,
|
||||
bool publish,
|
||||
FileInfo? manifest,
|
||||
DirectoryInfo? outDir,
|
||||
string? issuer,
|
||||
string? description,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate input files
|
||||
if (!fromFile.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Vulnerable binary not found: {fromFile.FullName}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!toFile.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Patched binary not found: {toFile.FullName}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine("Creating patch attestation...");
|
||||
Console.WriteLine($" CVE: {cve}");
|
||||
Console.WriteLine($" From (vulnerable): {fromFile.FullName}");
|
||||
Console.WriteLine($" To (patched): {toFile.FullName}");
|
||||
if (purl is not null)
|
||||
Console.WriteLine($" PURL: {purl}");
|
||||
if (outputFile is not null)
|
||||
Console.WriteLine($" Output: {outputFile.FullName}");
|
||||
Console.WriteLine($" Sign: {(noSign ? "disabled" : (keyless ? "keyless" : (keyPath is not null ? keyPath : "default")))}");
|
||||
Console.WriteLine($" Rekor: {(noRekor ? "disabled" : "enabled")}");
|
||||
}
|
||||
|
||||
// Read binary files
|
||||
var fromBytes = await File.ReadAllBytesAsync(fromFile.FullName, ct);
|
||||
var toBytes = await File.ReadAllBytesAsync(toFile.FullName, ct);
|
||||
|
||||
// Compute digests
|
||||
var fromDigest = ComputeSha256(fromBytes);
|
||||
var toDigest = ComputeSha256(toBytes);
|
||||
|
||||
// Extract basic binary information
|
||||
var fromSize = fromBytes.Length;
|
||||
var toSize = toBytes.Length;
|
||||
|
||||
// Compute simple section fingerprints (placeholder - real impl would use IBinaryFingerprinter)
|
||||
var sectionFingerprints = ComputeSimpleSectionFingerprints(fromBytes, toBytes);
|
||||
|
||||
// Build attestation predicate
|
||||
var attestedAt = DateTimeOffset.UtcNow;
|
||||
var predicate = new PatchVerificationPredicateDto
|
||||
{
|
||||
Cve = cve,
|
||||
VulnerableBinaryDigest = $"sha256:{fromDigest}",
|
||||
PatchedBinaryDigest = $"sha256:{toDigest}",
|
||||
VulnerableBinaryPath = fromFile.Name,
|
||||
PatchedBinaryPath = toFile.Name,
|
||||
Purl = purl,
|
||||
Fingerprints = new PatchFingerprintsDto
|
||||
{
|
||||
Sections = sectionFingerprints.Sections.ToList(),
|
||||
Functions = sectionFingerprints.Functions?.ToList(),
|
||||
Deltas = sectionFingerprints.Deltas?.ToList()
|
||||
},
|
||||
Issuer = issuer,
|
||||
Description = description,
|
||||
AttestedAt = attestedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
AttestorVersion = "1.0.0"
|
||||
};
|
||||
|
||||
// Build in-toto statement
|
||||
var statement = new InTotoStatementDto
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v0.1",
|
||||
PredicateType = "https://stellaops.org/patch-verification/v1",
|
||||
Subject = new List<SubjectDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = toFile.Name,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = toDigest
|
||||
}
|
||||
}
|
||||
},
|
||||
Predicate = predicate
|
||||
};
|
||||
|
||||
// Serialize statement
|
||||
var statementJson = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
|
||||
if (noSign)
|
||||
{
|
||||
// Output unsigned statement
|
||||
if (outputFile is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputFile.FullName, statementJson, ct);
|
||||
Console.WriteLine($"Unsigned attestation written to {outputFile.FullName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(statementJson);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Create DSSE envelope (placeholder - real impl would use actual signing)
|
||||
var envelope = CreateDsseEnvelope(statementJson, keyPath, keyless);
|
||||
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
|
||||
|
||||
if (outputFile is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputFile.FullName, envelopeJson, ct);
|
||||
Console.WriteLine($"DSSE attestation written to {outputFile.FullName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(envelopeJson);
|
||||
}
|
||||
|
||||
if (publish)
|
||||
{
|
||||
Console.WriteLine("[yellow]Warning:[/] --publish not yet implemented. Use 'stella attest attach' to publish.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Attestation Summary:");
|
||||
Console.WriteLine($" CVE: {cve}");
|
||||
Console.WriteLine($" Vulnerable digest: sha256:{fromDigest[..16]}...");
|
||||
Console.WriteLine($" Patched digest: sha256:{toDigest[..16]}...");
|
||||
Console.WriteLine($" Section fingerprints: {sectionFingerprints.Sections.Count}");
|
||||
Console.WriteLine($" Attested at: {attestedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error creating patch attestation: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<SectionFingerprintDto> Sections, IReadOnlyList<FunctionFingerprintDto>? Functions, IReadOnlyList<DeltaDto>? Deltas)
|
||||
ComputeSimpleSectionFingerprints(byte[] fromBytes, byte[] toBytes)
|
||||
{
|
||||
// This is a simplified implementation that creates section-level fingerprints
|
||||
// Real implementation would use IBinaryFingerprinter from Feedser.BinaryAnalysis
|
||||
|
||||
var sections = new List<SectionFingerprintDto>();
|
||||
|
||||
// Create a simple fingerprint based on file sections
|
||||
// In reality, we'd parse ELF/PE headers and extract actual sections
|
||||
var chunkSize = 4096;
|
||||
var fromChunks = (int)Math.Ceiling(fromBytes.Length / (double)chunkSize);
|
||||
var toChunks = (int)Math.Ceiling(toBytes.Length / (double)chunkSize);
|
||||
|
||||
// Compare chunks to identify changed sections
|
||||
for (int i = 0; i < Math.Max(fromChunks, toChunks); i++)
|
||||
{
|
||||
var fromStart = i * chunkSize;
|
||||
var toStart = i * chunkSize;
|
||||
|
||||
byte[]? fromChunk = fromStart < fromBytes.Length
|
||||
? fromBytes.Skip(fromStart).Take(Math.Min(chunkSize, fromBytes.Length - fromStart)).ToArray()
|
||||
: null;
|
||||
|
||||
byte[]? toChunk = toStart < toBytes.Length
|
||||
? toBytes.Skip(toStart).Take(Math.Min(chunkSize, toBytes.Length - toStart)).ToArray()
|
||||
: null;
|
||||
|
||||
var status = (fromChunk, toChunk) switch
|
||||
{
|
||||
(null, not null) => "added",
|
||||
(not null, null) => "removed",
|
||||
(not null, not null) when !fromChunk.SequenceEqual(toChunk) => "modified",
|
||||
_ => "unchanged"
|
||||
};
|
||||
|
||||
if (status != "unchanged")
|
||||
{
|
||||
sections.Add(new SectionFingerprintDto
|
||||
{
|
||||
Name = $".section_{i}",
|
||||
Offset = (ulong)(i * chunkSize),
|
||||
VulnerableHash = fromChunk is not null ? ComputeSha256(fromChunk)[..16] : null,
|
||||
PatchedHash = toChunk is not null ? ComputeSha256(toChunk)[..16] : null,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no sections differ, add a summary section
|
||||
if (sections.Count == 0)
|
||||
{
|
||||
sections.Add(new SectionFingerprintDto
|
||||
{
|
||||
Name = ".text",
|
||||
Offset = 0,
|
||||
VulnerableHash = ComputeSha256(fromBytes)[..16],
|
||||
PatchedHash = ComputeSha256(toBytes)[..16],
|
||||
Status = "identical"
|
||||
});
|
||||
}
|
||||
|
||||
return (sections, null, null);
|
||||
}
|
||||
|
||||
private static DsseEnvelopeDto CreateDsseEnvelope(string payload, string? keyPath, bool keyless)
|
||||
{
|
||||
// This is a placeholder implementation
|
||||
// Real implementation would use actual DSSE signing via Attestor.Envelope
|
||||
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
// Create placeholder signature
|
||||
// In production, this would use cryptographic signing
|
||||
var signatureData = $"placeholder-sig-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
||||
var signatureBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(signatureData));
|
||||
|
||||
var keyId = keyless
|
||||
? "sigstore-keyless"
|
||||
: keyPath ?? "local-key";
|
||||
|
||||
return new DsseEnvelopeDto
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = payloadBase64,
|
||||
Signatures = new List<DsseSignatureDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
KeyId = keyId,
|
||||
Sig = signatureBase64
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed record InTotoStatementDto
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required List<SubjectDto> Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required PatchVerificationPredicateDto Predicate { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SubjectDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required Dictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
private sealed record PatchVerificationPredicateDto
|
||||
{
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerableBinaryDigest")]
|
||||
public required string VulnerableBinaryDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("patchedBinaryDigest")]
|
||||
public required string PatchedBinaryDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerableBinaryPath")]
|
||||
public string? VulnerableBinaryPath { get; init; }
|
||||
|
||||
[JsonPropertyName("patchedBinaryPath")]
|
||||
public string? PatchedBinaryPath { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("fingerprints")]
|
||||
public required PatchFingerprintsDto Fingerprints { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("attestedAt")]
|
||||
public required string AttestedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("attestorVersion")]
|
||||
public required string AttestorVersion { get; init; }
|
||||
}
|
||||
|
||||
private sealed record PatchFingerprintsDto
|
||||
{
|
||||
[JsonPropertyName("sections")]
|
||||
public required List<SectionFingerprintDto> Sections { get; init; }
|
||||
|
||||
[JsonPropertyName("functions")]
|
||||
public List<FunctionFingerprintDto>? Functions { get; init; }
|
||||
|
||||
[JsonPropertyName("deltas")]
|
||||
public List<DeltaDto>? Deltas { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SectionFingerprintDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public ulong Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerableHash")]
|
||||
public string? VulnerableHash { get; init; }
|
||||
|
||||
[JsonPropertyName("patchedHash")]
|
||||
public string? PatchedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
private sealed record FunctionFingerprintDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("address")]
|
||||
public ulong Address { get; init; }
|
||||
|
||||
[JsonPropertyName("cfgHash")]
|
||||
public string? CfgHash { get; init; }
|
||||
|
||||
[JsonPropertyName("instructionHash")]
|
||||
public string? InstructionHash { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DeltaDto
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("location")]
|
||||
public required string Location { get; init; }
|
||||
|
||||
[JsonPropertyName("before")]
|
||||
public string? Before { get; init; }
|
||||
|
||||
[JsonPropertyName("after")]
|
||||
public string? After { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DsseEnvelopeDto
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public required List<DsseSignatureDto> Signatures { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DsseSignatureDto
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
461
src/Cli/StellaOps.Cli/Commands/PatchVerifyCommandGroup.cs
Normal file
461
src/Cli/StellaOps.Cli/Commands/PatchVerifyCommandGroup.cs
Normal file
@@ -0,0 +1,461 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PatchVerifyCommandGroup.cs
|
||||
// Sprint: SPRINT_20260111_001_004_CLI_verify_patches
|
||||
// Task: CLI integration for patch verification
|
||||
// Description: CLI commands for patch verification under scan command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.PatchVerification;
|
||||
using StellaOps.Scanner.PatchVerification.Models;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for patch verification operations under the scan command.
|
||||
/// Implements `stella scan verify-patches` for on-demand patch verification.
|
||||
/// </summary>
|
||||
public static class PatchVerifyCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the verify-patches command for scan command group.
|
||||
/// </summary>
|
||||
public static Command BuildVerifyPatchesCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scanIdOption = new Option<string?>("--scan-id", "-s")
|
||||
{
|
||||
Description = "Scan ID to verify patches for (retrieves CVEs from existing scan)"
|
||||
};
|
||||
|
||||
var cveOption = new Option<string[]>("--cve", "-c")
|
||||
{
|
||||
Description = "Specific CVE IDs to verify (comma-separated or multiple --cve flags)",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
var binaryPathOption = new Option<string?>("--binary", "-b")
|
||||
{
|
||||
Description = "Path to binary file to verify"
|
||||
};
|
||||
|
||||
var imageOption = new Option<string?>("--image", "-i")
|
||||
{
|
||||
Description = "OCI image reference to verify patches in"
|
||||
};
|
||||
|
||||
var confidenceThresholdOption = new Option<double>("--confidence-threshold")
|
||||
{
|
||||
Description = "Minimum confidence threshold (0.0-1.0, default: 0.7)"
|
||||
};
|
||||
confidenceThresholdOption.SetDefaultValue(0.7);
|
||||
|
||||
var similarityThresholdOption = new Option<double>("--similarity-threshold")
|
||||
{
|
||||
Description = "Minimum similarity threshold for fingerprint match (0.0-1.0, default: 0.85)"
|
||||
};
|
||||
similarityThresholdOption.SetDefaultValue(0.85);
|
||||
|
||||
var outputOption = new Option<string>("--output", "-o")
|
||||
{
|
||||
Description = "Output format: table (default), json, summary"
|
||||
};
|
||||
outputOption.SetDefaultValue("table");
|
||||
|
||||
var outputFileOption = new Option<string?>("--output-file", "-f")
|
||||
{
|
||||
Description = "Write output to file instead of stdout"
|
||||
};
|
||||
|
||||
var includeEvidenceOption = new Option<bool>("--include-evidence")
|
||||
{
|
||||
Description = "Include detailed fingerprint evidence in output"
|
||||
};
|
||||
|
||||
var verifyPatches = new Command("verify-patches", "Verify that security patches are present in binaries")
|
||||
{
|
||||
scanIdOption,
|
||||
cveOption,
|
||||
binaryPathOption,
|
||||
imageOption,
|
||||
confidenceThresholdOption,
|
||||
similarityThresholdOption,
|
||||
outputOption,
|
||||
outputFileOption,
|
||||
includeEvidenceOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
verifyPatches.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var scanId = parseResult.GetValue(scanIdOption);
|
||||
var cves = parseResult.GetValue(cveOption) ?? Array.Empty<string>();
|
||||
var binaryPath = parseResult.GetValue(binaryPathOption);
|
||||
var image = parseResult.GetValue(imageOption);
|
||||
var confidenceThreshold = parseResult.GetValue(confidenceThresholdOption);
|
||||
var similarityThreshold = parseResult.GetValue(similarityThresholdOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var outputFile = parseResult.GetValue(outputFileOption);
|
||||
var includeEvidence = parseResult.GetValue(includeEvidenceOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleVerifyPatchesAsync(
|
||||
services,
|
||||
scanId,
|
||||
cves,
|
||||
binaryPath,
|
||||
image,
|
||||
confidenceThreshold,
|
||||
similarityThreshold,
|
||||
output,
|
||||
outputFile,
|
||||
includeEvidence,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return verifyPatches;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleVerifyPatchesAsync(
|
||||
IServiceProvider services,
|
||||
string? scanId,
|
||||
string[] cves,
|
||||
string? binaryPath,
|
||||
string? image,
|
||||
double confidenceThreshold,
|
||||
double similarityThreshold,
|
||||
string output,
|
||||
string? outputFile,
|
||||
bool includeEvidence,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var loggerFactory = scope.ServiceProvider.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(PatchVerifyCommandGroup));
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
try
|
||||
{
|
||||
// Validate input
|
||||
if (string.IsNullOrWhiteSpace(scanId) && cves.Length == 0)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Either --scan-id or at least one --cve must be specified.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(binaryPath) && string.IsNullOrWhiteSpace(image) && string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Either --binary, --image, or --scan-id must be specified.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine("[dim]Patch Verification Options:[/]");
|
||||
if (!string.IsNullOrWhiteSpace(scanId))
|
||||
console.MarkupLine($"[dim] Scan ID: {scanId}[/]");
|
||||
if (cves.Length > 0)
|
||||
console.MarkupLine($"[dim] CVEs: {string.Join(", ", cves)}[/]");
|
||||
if (!string.IsNullOrWhiteSpace(binaryPath))
|
||||
console.MarkupLine($"[dim] Binary: {binaryPath}[/]");
|
||||
if (!string.IsNullOrWhiteSpace(image))
|
||||
console.MarkupLine($"[dim] Image: {image}[/]");
|
||||
console.MarkupLine($"[dim] Confidence threshold: {confidenceThreshold:P0}[/]");
|
||||
console.MarkupLine($"[dim] Similarity threshold: {similarityThreshold:P0}[/]");
|
||||
}
|
||||
|
||||
// Get the patch verification orchestrator
|
||||
var orchestrator = scope.ServiceProvider.GetService<IPatchVerificationOrchestrator>();
|
||||
if (orchestrator is null)
|
||||
{
|
||||
console.MarkupLine("[yellow]Warning:[/] Patch verification service not available.");
|
||||
console.MarkupLine("[dim]Patch verification requires the Scanner.PatchVerification library to be configured.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Create verification options
|
||||
var options = new PatchVerificationOptions
|
||||
{
|
||||
MinConfidenceThreshold = confidenceThreshold,
|
||||
MinSimilarityThreshold = similarityThreshold
|
||||
};
|
||||
|
||||
// Perform verification
|
||||
PatchVerificationResult? result = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
// TODO: Fetch CVEs and binary paths from scan results via backend API
|
||||
// For now, show a placeholder message
|
||||
console.MarkupLine($"[dim]Fetching scan results for {scanId}...[/]");
|
||||
|
||||
// This would normally fetch from the backend
|
||||
var context = new PatchVerificationContext
|
||||
{
|
||||
ScanId = scanId,
|
||||
TenantId = "default",
|
||||
ImageDigest = "sha256:placeholder",
|
||||
ArtifactPurl = "pkg:oci/placeholder",
|
||||
CveIds = cves.Length > 0 ? cves : new[] { "CVE-2024-0001" },
|
||||
BinaryPaths = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
result = await orchestrator.VerifyAsync(context, ct);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(binaryPath))
|
||||
{
|
||||
// Verify single binary
|
||||
if (!File.Exists(binaryPath))
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Binary file not found: {binaryPath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var evidenceList = new List<PatchVerificationEvidence>();
|
||||
foreach (var cve in cves)
|
||||
{
|
||||
var evidence = await orchestrator.VerifySingleAsync(
|
||||
cve,
|
||||
binaryPath,
|
||||
"pkg:generic/binary",
|
||||
options,
|
||||
ct);
|
||||
evidenceList.Add(evidence);
|
||||
}
|
||||
|
||||
result = new PatchVerificationResult
|
||||
{
|
||||
ScanId = $"cli-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}",
|
||||
Evidence = evidenceList,
|
||||
PatchedCves = evidenceList
|
||||
.Where(e => e.Status == PatchVerificationStatus.Verified)
|
||||
.Select(e => e.CveId)
|
||||
.ToHashSet(),
|
||||
UnpatchedCves = evidenceList
|
||||
.Where(e => e.Status == PatchVerificationStatus.NotPatched)
|
||||
.Select(e => e.CveId)
|
||||
.ToHashSet(),
|
||||
InconclusiveCves = evidenceList
|
||||
.Where(e => e.Status == PatchVerificationStatus.Inconclusive)
|
||||
.Select(e => e.CveId)
|
||||
.ToHashSet(),
|
||||
NoPatchDataCves = evidenceList
|
||||
.Where(e => e.Status == PatchVerificationStatus.NoPatchData)
|
||||
.Select(e => e.CveId)
|
||||
.ToHashSet(),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0"
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Image-based verification not yet implemented. Use --binary instead.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Verification failed to produce results.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Output results
|
||||
var outputText = output.ToLowerInvariant() switch
|
||||
{
|
||||
"json" => FormatJsonOutput(result, includeEvidence),
|
||||
"summary" => FormatSummaryOutput(result),
|
||||
_ => FormatTableOutput(result, includeEvidence, console)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outputFile))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputFile, outputText, ct);
|
||||
console.MarkupLine($"[green]Output written to {outputFile}[/]");
|
||||
}
|
||||
else if (output.ToLowerInvariant() != "table")
|
||||
{
|
||||
console.WriteLine(outputText);
|
||||
}
|
||||
|
||||
// Return exit code based on results
|
||||
if (result.UnpatchedCves.Count > 0)
|
||||
{
|
||||
return 2; // Unpatched vulnerabilities found
|
||||
}
|
||||
|
||||
if (result.InconclusiveCves.Count > 0 && result.PatchedCves.Count == 0)
|
||||
{
|
||||
return 3; // Only inconclusive results
|
||||
}
|
||||
|
||||
return 0; // Success
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Patch verification failed");
|
||||
console.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatJsonOutput(PatchVerificationResult result, bool includeEvidence)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
scanId = result.ScanId,
|
||||
verifiedAt = result.VerifiedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
verifierVersion = result.VerifierVersion,
|
||||
summary = new
|
||||
{
|
||||
totalCves = result.Evidence.Count,
|
||||
patched = result.PatchedCves.Count,
|
||||
unpatched = result.UnpatchedCves.Count,
|
||||
inconclusive = result.InconclusiveCves.Count,
|
||||
noPatchData = result.NoPatchDataCves.Count
|
||||
},
|
||||
patchedCves = result.PatchedCves,
|
||||
unpatchedCves = result.UnpatchedCves,
|
||||
inconclusiveCves = result.InconclusiveCves,
|
||||
noPatchDataCves = result.NoPatchDataCves,
|
||||
evidence = includeEvidence ? result.Evidence.Select(e => new
|
||||
{
|
||||
evidenceId = e.EvidenceId,
|
||||
cveId = e.CveId,
|
||||
binaryPath = e.BinaryPath,
|
||||
status = e.Status.ToString(),
|
||||
similarity = e.Similarity,
|
||||
confidence = e.Confidence,
|
||||
method = e.Method.ToString(),
|
||||
reason = e.Reason,
|
||||
trustScore = e.ComputeTrustScore(),
|
||||
verifiedAt = e.VerifiedAt.ToString("O", CultureInfo.InvariantCulture)
|
||||
}) : null
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(output, JsonOptions);
|
||||
}
|
||||
|
||||
private static string FormatSummaryOutput(PatchVerificationResult result)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("Patch Verification Summary");
|
||||
sb.AppendLine("==========================");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"Scan ID: {result.ScanId}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"Verified at: {result.VerifiedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"Verifier version: {result.VerifierVersion}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"Total CVEs checked: {result.Evidence.Count}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" Patched: {result.PatchedCves.Count}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" Unpatched: {result.UnpatchedCves.Count}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" Inconclusive: {result.InconclusiveCves.Count}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" No patch data: {result.NoPatchDataCves.Count}");
|
||||
|
||||
if (result.PatchedCves.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Patched CVEs:");
|
||||
foreach (var cve in result.PatchedCves.OrderBy(c => c))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" [PATCHED] {cve}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.UnpatchedCves.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Unpatched CVEs:");
|
||||
foreach (var cve in result.UnpatchedCves.OrderBy(c => c))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" [UNPATCHED] {cve}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatTableOutput(PatchVerificationResult result, bool includeEvidence, IAnsiConsole console)
|
||||
{
|
||||
// Header
|
||||
var header = new Panel(new Markup($"[bold]Patch Verification Results[/] - {result.ScanId}"))
|
||||
.Border(BoxBorder.Rounded)
|
||||
.Padding(1, 0);
|
||||
console.Write(header);
|
||||
|
||||
// Summary table
|
||||
var summaryTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Summary[/]")
|
||||
.AddColumn("Metric")
|
||||
.AddColumn("Count");
|
||||
|
||||
summaryTable.AddRow("Total CVEs", result.Evidence.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summaryTable.AddRow("[green]Patched[/]", result.PatchedCves.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summaryTable.AddRow("[red]Unpatched[/]", result.UnpatchedCves.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summaryTable.AddRow("[yellow]Inconclusive[/]", result.InconclusiveCves.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summaryTable.AddRow("[dim]No patch data[/]", result.NoPatchDataCves.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
console.Write(summaryTable);
|
||||
|
||||
// Evidence table
|
||||
if (result.Evidence.Count > 0)
|
||||
{
|
||||
console.WriteLine();
|
||||
var evidenceTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Verification Evidence[/]")
|
||||
.AddColumn("CVE")
|
||||
.AddColumn("Status")
|
||||
.AddColumn("Similarity")
|
||||
.AddColumn("Confidence")
|
||||
.AddColumn("Method")
|
||||
.AddColumn("Trust Score");
|
||||
|
||||
foreach (var evidence in result.Evidence.OrderBy(e => e.CveId))
|
||||
{
|
||||
var statusColor = evidence.Status switch
|
||||
{
|
||||
PatchVerificationStatus.Verified => "green",
|
||||
PatchVerificationStatus.NotPatched => "red",
|
||||
PatchVerificationStatus.PartialMatch => "yellow",
|
||||
PatchVerificationStatus.Inconclusive => "yellow",
|
||||
_ => "dim"
|
||||
};
|
||||
|
||||
evidenceTable.AddRow(
|
||||
evidence.CveId,
|
||||
$"[{statusColor}]{evidence.Status}[/]",
|
||||
evidence.Similarity.ToString("P0", CultureInfo.InvariantCulture),
|
||||
evidence.Confidence.ToString("P0", CultureInfo.InvariantCulture),
|
||||
evidence.Method.ToString(),
|
||||
evidence.ComputeTrustScore().ToString("P0", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
console.Write(evidenceTable);
|
||||
}
|
||||
|
||||
// Verified timestamp
|
||||
console.WriteLine();
|
||||
console.MarkupLine($"[dim]Verified at: {result.VerifiedAt:yyyy-MM-dd HH:mm:ss} UTC[/]");
|
||||
console.MarkupLine($"[dim]Verifier version: {result.VerifierVersion}[/]");
|
||||
|
||||
return string.Empty; // Table output is written directly to console
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.ExportCenter.Client;
|
||||
using StellaOps.ExportCenter.Core.EvidenceCache;
|
||||
using StellaOps.Verdict;
|
||||
using StellaOps.Scanner.PatchVerification.DependencyInjection;
|
||||
#if DEBUG || STELLAOPS_ENABLE_SIMULATOR
|
||||
using StellaOps.Cryptography.Plugin.SimRemote.DependencyInjection;
|
||||
#endif
|
||||
@@ -321,6 +322,9 @@ internal static class Program
|
||||
// CLI-CRYPTO-4100-001: Crypto profile validator
|
||||
services.AddSingleton<CryptoProfileValidator>();
|
||||
|
||||
// CLI-PATCHVERIFY-001-004: Patch verification services (SPRINT_20260111_001_004)
|
||||
services.AddPatchVerification();
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var startupLogger = loggerFactory.CreateLogger("StellaOps.Cli.Startup");
|
||||
|
||||
@@ -30,11 +30,16 @@ internal sealed class AttestationReader : IAttestationReader
|
||||
|
||||
private readonly ILogger<AttestationReader> _logger;
|
||||
private readonly IForensicVerifier _verifier;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AttestationReader(ILogger<AttestationReader> logger, IForensicVerifier verifier)
|
||||
public AttestationReader(
|
||||
ILogger<AttestationReader> logger,
|
||||
IForensicVerifier verifier,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<AttestationShowResult> ReadAttestationAsync(
|
||||
@@ -127,7 +132,7 @@ internal sealed class AttestationReader : IAttestationReader
|
||||
if (matchingRoot is not null)
|
||||
{
|
||||
var isValid = VerifySignature(envelope, sig, matchingRoot);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
|
||||
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
private readonly IStellaOpsTokenClient _tokenClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<OrchestratorClient> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -37,12 +38,14 @@ internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
HttpClient httpClient,
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
IOptions<StellaOpsCliOptions> options,
|
||||
ILogger<OrchestratorClient> logger)
|
||||
ILogger<OrchestratorClient> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_tokenClient = tokenClient;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SourceListResponse> ListSourcesAsync(
|
||||
@@ -171,7 +174,7 @@ internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
SourceId = request.SourceId,
|
||||
Reachable = false,
|
||||
ErrorMessage = $"Failed to test source: {response.StatusCode} - {errorContent}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,7 +184,7 @@ internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
Success = true,
|
||||
SourceId = request.SourceId,
|
||||
Reachable = true,
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,18 @@ internal sealed partial class PromotionAssembler : IPromotionAssembler
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly ILogger<PromotionAssembler> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PromotionAssembler(HttpClient httpClient, ICryptoHash cryptoHash, ILogger<PromotionAssembler> logger)
|
||||
public PromotionAssembler(
|
||||
HttpClient httpClient,
|
||||
ICryptoHash cryptoHash,
|
||||
ILogger<PromotionAssembler> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PromotionAssembleResult> AssembleAsync(
|
||||
@@ -171,7 +177,7 @@ internal sealed partial class PromotionAssembler : IPromotionAssembler
|
||||
From = request.FromEnvironment,
|
||||
To = request.ToEnvironment,
|
||||
Actor = request.Actor ?? Environment.UserName,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
Pipeline = request.Pipeline,
|
||||
Ticket = request.Ticket,
|
||||
Notes = request.Notes
|
||||
@@ -527,7 +533,7 @@ internal sealed partial class PromotionAssembler : IPromotionAssembler
|
||||
RekorEntry = rekorEntry,
|
||||
AuditId = auditId,
|
||||
SignerKeyId = signerKeyId,
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SignedAt = _timeProvider.GetUtcNow(),
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ namespace StellaOps.Cli.Services;
|
||||
internal sealed class ScannerExecutor : IScannerExecutor
|
||||
{
|
||||
private readonly ILogger<ScannerExecutor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ScannerExecutor(ILogger<ScannerExecutor> logger)
|
||||
public ScannerExecutor(ILogger<ScannerExecutor> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ScannerExecutionResult> RunAsync(
|
||||
@@ -47,7 +49,7 @@ internal sealed class ScannerExecutor : IScannerExecutor
|
||||
: Path.GetFullPath(resultsDirectory);
|
||||
|
||||
Directory.CreateDirectory(resultsDirectory);
|
||||
var executionTimestamp = DateTimeOffset.UtcNow;
|
||||
var executionTimestamp = _timeProvider.GetUtcNow();
|
||||
var baselineFiles = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
||||
var baseline = new HashSet<string>(baselineFiles, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -92,7 +94,7 @@ internal sealed class ScannerExecutor : IScannerExecutor
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var completionTimestamp = DateTimeOffset.UtcNow;
|
||||
var completionTimestamp = _timeProvider.GetUtcNow();
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
@@ -279,9 +281,9 @@ internal sealed class ScannerExecutor : IScannerExecutor
|
||||
return newest ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string CreatePlaceholderResult(string resultsDirectory)
|
||||
private string CreatePlaceholderResult(string resultsDirectory)
|
||||
{
|
||||
var fileName = $"scan-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
||||
var fileName = $"scan-{_timeProvider.GetUtcNow():yyyyMMddHHmmss}.json";
|
||||
var path = Path.Combine(resultsDirectory, fileName);
|
||||
File.WriteAllText(path, "{\"status\":\"placeholder\"}");
|
||||
return path;
|
||||
|
||||
@@ -94,25 +94,29 @@ internal static class TenantProfileStore
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task SetActiveTenantAsync(string tenantId, string? displayName = null, CancellationToken cancellationToken = default)
|
||||
public static async Task SetActiveTenantAsync(
|
||||
string tenantId,
|
||||
string? displayName = null,
|
||||
DateTimeOffset? asOf = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
ActiveTenant = tenantId?.Trim().ToLowerInvariant(),
|
||||
ActiveTenantDisplayName = displayName?.Trim(),
|
||||
LastUpdated = DateTimeOffset.UtcNow
|
||||
LastUpdated = asOf ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await SaveAsync(profile, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task ClearActiveTenantAsync(CancellationToken cancellationToken = default)
|
||||
public static async Task ClearActiveTenantAsync(DateTimeOffset? asOf = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
ActiveTenant = null,
|
||||
ActiveTenantDisplayName = null,
|
||||
LastUpdated = DateTimeOffset.UtcNow
|
||||
LastUpdated = asOf ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await SaveAsync(profile, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -103,6 +103,10 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Facet/StellaOps.Facet.csproj" />
|
||||
<!-- GitHub Code Scanning Integration (SPRINT_20260109_010_002) -->
|
||||
<ProjectReference Include="../../Integrations/__Plugins/StellaOps.Integrations.Plugin.GitHubApp/StellaOps.Integrations.Plugin.GitHubApp.csproj" />
|
||||
<!-- Patch Verification (SPRINT_20260111_001_004) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.PatchVerification/StellaOps.Scanner.PatchVerification.csproj" />
|
||||
<!-- Change Trace (SPRINT_20260112_200_006) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.ChangeTrace/StellaOps.Scanner.ChangeTrace.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- GOST Crypto Plugins (Russia distribution) -->
|
||||
|
||||
@@ -4449,7 +4449,7 @@ spec:
|
||||
_entries = entries;
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) => null;
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
Assert.Equal(DevPortalVerifyExitCode.Success, result.ExitCode);
|
||||
Assert.Equal("a1b2c3d4-e5f6-7890-abcd-ef1234567890", result.BundleId);
|
||||
Assert.NotNull(result.RootHash);
|
||||
Assert.True(result.RootHash!.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.StartsWith("sha256:", result.RootHash!, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(1, result.Entries);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user