release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -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;
}

View File

@@ -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;

View 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)] + "...";
}
}

View 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;
}

View File

@@ -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;

View File

@@ -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.");
}

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

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

View File

@@ -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");

View File

@@ -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);

View File

@@ -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()
};
}

View File

@@ -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
};
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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) -->

View File

@@ -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;

View File

@@ -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);
}