Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.VerdictVerify.cs
StellaOps Bot 5146204f1b feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
2025-12-22 23:21:21 +02:00

622 lines
22 KiB
C#

// -----------------------------------------------------------------------------
// CommandHandlers.VerdictVerify.cs
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
// Description: Command handlers for verdict verification operations.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services;
using StellaOps.Cli.Telemetry;
using Spectre.Console;
namespace StellaOps.Cli.Commands;
internal static partial class CommandHandlers
{
private static readonly JsonSerializerOptions VerdictJsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
internal static async Task<int> HandleVerdictVerifyAsync(
IServiceProvider services,
string reference,
string? sbomDigest,
string? feedsDigest,
string? policyDigest,
string? expectedDecision,
bool strict,
string? trustPolicy,
string output,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("verdict-verify");
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
using var activity = CliActivitySource.Instance.StartActivity("cli.verdict.verify", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("verdict verify");
if (!OfflineModeGuard.IsNetworkAllowed(options, "verdict verify"))
{
WriteVerdictVerifyError("Offline mode enabled. Use offline evidence verification instead.", output);
Environment.ExitCode = 2;
return 2;
}
if (string.IsNullOrWhiteSpace(reference))
{
WriteVerdictVerifyError("Image reference is required.", output);
Environment.ExitCode = 2;
return 2;
}
try
{
var verifier = scope.ServiceProvider.GetRequiredService<IVerdictAttestationVerifier>();
var request = new VerdictVerificationRequest
{
Reference = reference,
ExpectedSbomDigest = sbomDigest,
ExpectedFeedsDigest = feedsDigest,
ExpectedPolicyDigest = policyDigest,
ExpectedDecision = expectedDecision,
Strict = strict,
TrustPolicyPath = trustPolicy
};
var result = await verifier.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
WriteVerdictVerifyResult(result, output, verbose);
var exitCode = result.IsValid ? 0 : 1;
Environment.ExitCode = exitCode;
return exitCode;
}
catch (Exception ex)
{
logger.LogError(ex, "Verdict verify failed for {Reference}", reference);
WriteVerdictVerifyError($"Verification failed: {ex.Message}", output);
Environment.ExitCode = 2;
return 2;
}
}
internal static async Task<int> HandleVerdictListAsync(
IServiceProvider services,
string reference,
string output,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("verdict-list");
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
using var activity = CliActivitySource.Instance.StartActivity("cli.verdict.list", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("verdict list");
if (!OfflineModeGuard.IsNetworkAllowed(options, "verdict list"))
{
WriteVerdictListError("Offline mode enabled. Use offline evidence verification instead.", output);
Environment.ExitCode = 2;
return 2;
}
if (string.IsNullOrWhiteSpace(reference))
{
WriteVerdictListError("Image reference is required.", output);
Environment.ExitCode = 2;
return 2;
}
try
{
var verifier = scope.ServiceProvider.GetRequiredService<IVerdictAttestationVerifier>();
var verdicts = await verifier.ListAsync(reference, cancellationToken).ConfigureAwait(false);
WriteVerdictListResult(reference, verdicts, output, verbose);
Environment.ExitCode = 0;
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Verdict list failed for {Reference}", reference);
WriteVerdictListError($"Failed to list verdicts: {ex.Message}", output);
Environment.ExitCode = 2;
return 2;
}
}
/// <summary>
/// Handle verdict push command.
/// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-013
/// </summary>
internal static async Task<int> HandleVerdictPushAsync(
IServiceProvider services,
string reference,
string? verdictFile,
string? registry,
bool insecure,
bool dryRun,
bool force,
int timeout,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("verdict-push");
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
var console = AnsiConsole.Console;
using var activity = CliActivitySource.Instance.StartActivity("cli.verdict.push", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("verdict push");
if (!OfflineModeGuard.IsNetworkAllowed(options, "verdict push"))
{
console.MarkupLine("[red]Error:[/] Offline mode enabled. Cannot push verdicts.");
Environment.ExitCode = 2;
return 2;
}
if (string.IsNullOrWhiteSpace(reference))
{
console.MarkupLine("[red]Error:[/] Image reference is required.");
Environment.ExitCode = 2;
return 2;
}
if (string.IsNullOrWhiteSpace(verdictFile))
{
console.MarkupLine("[red]Error:[/] Verdict file path is required (--verdict-file).");
Environment.ExitCode = 2;
return 2;
}
if (!File.Exists(verdictFile))
{
console.MarkupLine($"[red]Error:[/] Verdict file not found: {Markup.Escape(verdictFile)}");
Environment.ExitCode = 2;
return 2;
}
try
{
var verifier = scope.ServiceProvider.GetRequiredService<IVerdictAttestationVerifier>();
if (verbose)
{
console.MarkupLine($"Reference: [bold]{Markup.Escape(reference)}[/]");
console.MarkupLine($"Verdict file: [bold]{Markup.Escape(verdictFile)}[/]");
if (!string.IsNullOrWhiteSpace(registry))
{
console.MarkupLine($"Registry override: [bold]{Markup.Escape(registry)}[/]");
}
if (dryRun)
{
console.MarkupLine("[yellow]Dry run mode - no changes will be made[/]");
}
}
var request = new VerdictPushRequest
{
Reference = reference,
VerdictFilePath = verdictFile,
Registry = registry,
Insecure = insecure,
DryRun = dryRun,
Force = force,
TimeoutSeconds = timeout
};
var result = await verifier.PushAsync(request, cancellationToken).ConfigureAwait(false);
if (result.Success)
{
if (result.DryRun)
{
console.MarkupLine("[green]Dry run:[/] Verdict would be pushed successfully.");
}
else
{
console.MarkupLine("[green]Success:[/] Verdict pushed successfully.");
}
if (!string.IsNullOrWhiteSpace(result.VerdictDigest))
{
console.MarkupLine($"Verdict digest: [bold]{Markup.Escape(result.VerdictDigest)}[/]");
}
if (!string.IsNullOrWhiteSpace(result.ManifestDigest))
{
console.MarkupLine($"Manifest digest: [bold]{Markup.Escape(result.ManifestDigest)}[/]");
}
Environment.ExitCode = 0;
return 0;
}
else
{
console.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Push failed")}");
Environment.ExitCode = 1;
return 1;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Verdict push failed for {Reference}", reference);
console.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 2;
return 2;
}
}
private static void WriteVerdictVerifyResult(VerdictVerificationResult result, string output, bool verbose)
{
var console = AnsiConsole.Console;
switch (output)
{
case "json":
console.WriteLine(JsonSerializer.Serialize(result, VerdictJsonOptions));
break;
case "sarif":
console.WriteLine(JsonSerializer.Serialize(BuildVerdictSarif(result), VerdictJsonOptions));
break;
default:
WriteVerdictVerifyTable(console, result, verbose);
break;
}
}
private static void WriteVerdictVerifyError(string message, string output)
{
var console = AnsiConsole.Console;
if (string.Equals(output, "json", StringComparison.OrdinalIgnoreCase))
{
var payload = new { status = "error", message };
console.WriteLine(JsonSerializer.Serialize(payload, VerdictJsonOptions));
return;
}
if (string.Equals(output, "sarif", StringComparison.OrdinalIgnoreCase))
{
var sarif = new
{
version = "2.1.0",
schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
runs = new[]
{
new
{
tool = new { driver = new { name = "StellaOps Verdict Verify", version = "1.0.0" } },
results = new[]
{
new { level = "error", message = new { text = message } }
}
}
}
};
console.WriteLine(JsonSerializer.Serialize(sarif, VerdictJsonOptions));
return;
}
console.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}");
}
private static void WriteVerdictVerifyTable(IAnsiConsole console, VerdictVerificationResult result, bool verbose)
{
console.MarkupLine($"Image: [bold]{Markup.Escape(result.ImageReference)}[/]");
console.MarkupLine($"Image Digest: [bold]{Markup.Escape(result.ImageDigest)}[/]");
console.WriteLine();
if (result.VerdictFound)
{
console.MarkupLine($"Verdict Found: [green]Yes[/]");
console.MarkupLine($"Verdict Digest: {Markup.Escape(result.VerdictDigest ?? "-")}");
console.MarkupLine($"Decision: {FormatDecision(result.Decision)}");
console.WriteLine();
var table = new Table().AddColumns("Input", "Expected", "Actual", "Match");
table.AddRow("SBOM Digest", result.ExpectedSbomDigest ?? "-", result.ActualSbomDigest ?? "-", FormatMatch(result.SbomDigestMatches));
table.AddRow("Feeds Digest", result.ExpectedFeedsDigest ?? "-", result.ActualFeedsDigest ?? "-", FormatMatch(result.FeedsDigestMatches));
table.AddRow("Policy Digest", result.ExpectedPolicyDigest ?? "-", result.ActualPolicyDigest ?? "-", FormatMatch(result.PolicyDigestMatches));
table.AddRow("Decision", result.ExpectedDecision ?? "-", result.Decision ?? "-", FormatMatch(result.DecisionMatches));
console.Write(table);
console.WriteLine();
if (result.SignatureValid.HasValue)
{
console.MarkupLine($"Signature: {(result.SignatureValid.Value ? "[green]VALID[/]" : "[red]INVALID[/]")}");
if (!string.IsNullOrWhiteSpace(result.SignerIdentity))
{
console.MarkupLine($"Signer: {Markup.Escape(result.SignerIdentity)}");
}
}
}
else
{
console.MarkupLine($"Verdict Found: [yellow]No[/]");
}
console.WriteLine();
var headline = result.IsValid ? "[green]Verification PASSED[/]" : "[red]Verification FAILED[/]";
console.MarkupLine(headline);
if (verbose && result.Errors.Count > 0)
{
console.MarkupLine("[red]Errors:[/]");
foreach (var error in result.Errors)
{
console.MarkupLine($" - {Markup.Escape(error)}");
}
}
}
private static void WriteVerdictListResult(string reference, IReadOnlyList<VerdictSummary> verdicts, string output, bool verbose)
{
var console = AnsiConsole.Console;
if (string.Equals(output, "json", StringComparison.OrdinalIgnoreCase))
{
var payload = new { imageReference = reference, verdicts };
console.WriteLine(JsonSerializer.Serialize(payload, VerdictJsonOptions));
return;
}
console.MarkupLine($"Image: [bold]{Markup.Escape(reference)}[/]");
console.WriteLine();
if (verdicts.Count == 0)
{
console.MarkupLine("[yellow]No verdict attestations found.[/]");
return;
}
var table = new Table().AddColumns("Digest", "Decision", "Created", "SBOM Digest", "Feeds Digest");
foreach (var verdict in verdicts)
{
table.AddRow(
TruncateDigest(verdict.Digest),
FormatDecision(verdict.Decision),
verdict.CreatedAt?.ToString("u") ?? "-",
TruncateDigest(verdict.SbomDigest),
TruncateDigest(verdict.FeedsDigest));
}
console.Write(table);
console.MarkupLine($"\nTotal: [bold]{verdicts.Count}[/] verdict(s)");
}
private static void WriteVerdictListError(string message, string output)
{
var console = AnsiConsole.Console;
if (string.Equals(output, "json", StringComparison.OrdinalIgnoreCase))
{
var payload = new { status = "error", message };
console.WriteLine(JsonSerializer.Serialize(payload, VerdictJsonOptions));
return;
}
console.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}");
}
private static string FormatDecision(string? decision) => decision?.ToLowerInvariant() switch
{
"pass" => "[green]PASS[/]",
"warn" => "[yellow]WARN[/]",
"block" => "[red]BLOCK[/]",
_ => decision ?? "-"
};
private static string FormatMatch(bool? matches) => matches switch
{
true => "[green]PASS[/]",
false => "[red]FAIL[/]",
null => "[dim]-[/]"
};
private static string TruncateDigest(string? digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return "-";
}
if (digest.Length > 20)
{
return $"{digest[..17]}...";
}
return digest;
}
private static object BuildVerdictSarif(VerdictVerificationResult result)
{
var results = new List<object>();
if (result.VerdictFound)
{
results.Add(new
{
ruleId = "stellaops.verdict.found",
level = "note",
message = new { text = $"Verdict found with decision: {result.Decision}" },
properties = new
{
verdict_digest = result.VerdictDigest,
decision = result.Decision
}
});
if (!result.SbomDigestMatches.GetValueOrDefault(true))
{
results.Add(new
{
ruleId = "stellaops.verdict.sbom_mismatch",
level = "error",
message = new { text = "SBOM digest does not match expected value" }
});
}
if (!result.FeedsDigestMatches.GetValueOrDefault(true))
{
results.Add(new
{
ruleId = "stellaops.verdict.feeds_mismatch",
level = "error",
message = new { text = "Feeds digest does not match expected value" }
});
}
if (!result.PolicyDigestMatches.GetValueOrDefault(true))
{
results.Add(new
{
ruleId = "stellaops.verdict.policy_mismatch",
level = "error",
message = new { text = "Policy digest does not match expected value" }
});
}
}
else
{
results.Add(new
{
ruleId = "stellaops.verdict.missing",
level = "error",
message = new { text = "No verdict attestation found for image" }
});
}
return new
{
version = "2.1.0",
schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
runs = new[]
{
new
{
tool = new { driver = new { name = "StellaOps Verdict Verify", version = "1.0.0" } },
results = results.ToArray()
}
}
};
}
}
/// <summary>
/// Request for verdict verification.
/// </summary>
public sealed record VerdictVerificationRequest
{
public required string Reference { get; init; }
public string? ExpectedSbomDigest { get; init; }
public string? ExpectedFeedsDigest { get; init; }
public string? ExpectedPolicyDigest { get; init; }
public string? ExpectedDecision { get; init; }
public bool Strict { get; init; }
public string? TrustPolicyPath { get; init; }
}
/// <summary>
/// Result of verdict verification.
/// </summary>
public sealed record VerdictVerificationResult
{
public required string ImageReference { get; init; }
public required string ImageDigest { get; init; }
public required bool VerdictFound { get; init; }
public required bool IsValid { get; init; }
public string? VerdictDigest { get; init; }
public string? Decision { get; init; }
public string? ExpectedSbomDigest { get; init; }
public string? ActualSbomDigest { get; init; }
public bool? SbomDigestMatches { get; init; }
public string? ExpectedFeedsDigest { get; init; }
public string? ActualFeedsDigest { get; init; }
public bool? FeedsDigestMatches { get; init; }
public string? ExpectedPolicyDigest { get; init; }
public string? ActualPolicyDigest { get; init; }
public bool? PolicyDigestMatches { get; init; }
public string? ExpectedDecision { get; init; }
public bool? DecisionMatches { get; init; }
public bool? SignatureValid { get; init; }
public string? SignerIdentity { get; init; }
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Summary information about a verdict attestation.
/// </summary>
public sealed record VerdictSummary
{
public required string Digest { get; init; }
public string? Decision { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
public string? SbomDigest { get; init; }
public string? FeedsDigest { get; init; }
public string? PolicyDigest { get; init; }
public string? GraphRevisionId { get; init; }
}
/// <summary>
/// Interface for verdict attestation verification.
/// </summary>
public interface IVerdictAttestationVerifier
{
Task<VerdictVerificationResult> VerifyAsync(
VerdictVerificationRequest request,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<VerdictSummary>> ListAsync(
string reference,
CancellationToken cancellationToken = default);
/// <summary>
/// Push a verdict attestation to an OCI registry.
/// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-013
/// </summary>
Task<VerdictPushResult> PushAsync(
VerdictPushRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for verdict push.
/// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-013
/// </summary>
public sealed record VerdictPushRequest
{
public required string Reference { get; init; }
public string? VerdictFilePath { get; init; }
public byte[]? VerdictBytes { get; init; }
public string? Registry { get; init; }
public bool Insecure { get; init; }
public bool DryRun { get; init; }
public bool Force { get; init; }
public int TimeoutSeconds { get; init; } = 300;
}
/// <summary>
/// Result of verdict push.
/// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-013
/// </summary>
public sealed record VerdictPushResult
{
public required bool Success { get; init; }
public string? VerdictDigest { get; init; }
public string? ManifestDigest { get; init; }
public string? Error { get; init; }
public bool DryRun { get; init; }
}