feat(telemetry): add telemetry client and services for tracking events
- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint. - Created TtfsTelemetryService for emitting specific telemetry events related to TTFS. - Added tests for TelemetryClient to ensure event queuing and flushing functionality. - Introduced models for reachability drift detection, including DriftResult and DriftedSink. - Developed DriftApiService for interacting with the drift detection API. - Updated FirstSignalCardComponent to emit telemetry events on signal appearance. - Enhanced localization support for first signal component with i18n strings.
This commit is contained in:
549
src/Cli/StellaOps.Cli/Commands/CommandHandlers.VerifyOffline.cs
Normal file
549
src/Cli/StellaOps.Cli/Commands/CommandHandlers.VerifyOffline.cs
Normal file
@@ -0,0 +1,549 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Policy;
|
||||
using StellaOps.AirGap.Importer.Reconciliation;
|
||||
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static partial class CommandHandlers
|
||||
{
|
||||
public static async Task HandleVerifyOfflineAsync(
|
||||
IServiceProvider services,
|
||||
string evidenceDirectory,
|
||||
string artifactDigest,
|
||||
string policyPath,
|
||||
string? outputDirectory,
|
||||
string outputFormat,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var logger = loggerFactory.CreateLogger("verify-offline");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.verify.offline", ActivityKind.Client);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("verify offline");
|
||||
|
||||
var emitJson = string.Equals(outputFormat, "json", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(evidenceDirectory))
|
||||
{
|
||||
await WriteVerifyOfflineErrorAsync(emitJson, "--evidence-dir is required.", OfflineExitCodes.ValidationFailed, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Environment.ExitCode = OfflineExitCodes.ValidationFailed;
|
||||
return;
|
||||
}
|
||||
|
||||
evidenceDirectory = Path.GetFullPath(evidenceDirectory);
|
||||
if (!Directory.Exists(evidenceDirectory))
|
||||
{
|
||||
await WriteVerifyOfflineErrorAsync(emitJson, $"Evidence directory not found: {evidenceDirectory}", OfflineExitCodes.FileNotFound, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Environment.ExitCode = OfflineExitCodes.FileNotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedArtifact;
|
||||
try
|
||||
{
|
||||
normalizedArtifact = ArtifactIndex.NormalizeDigest(artifactDigest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await WriteVerifyOfflineErrorAsync(emitJson, $"Invalid --artifact: {ex.Message}", OfflineExitCodes.ValidationFailed, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Environment.ExitCode = OfflineExitCodes.ValidationFailed;
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedPolicyPath = ResolvePolicyPath(evidenceDirectory, policyPath);
|
||||
if (resolvedPolicyPath is null)
|
||||
{
|
||||
await WriteVerifyOfflineErrorAsync(emitJson, $"Policy file not found: {policyPath}", OfflineExitCodes.FileNotFound, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Environment.ExitCode = OfflineExitCodes.FileNotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
OfflineVerificationPolicy policy;
|
||||
try
|
||||
{
|
||||
policy = await OfflineVerificationPolicyLoader.LoadAsync(resolvedPolicyPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await WriteVerifyOfflineErrorAsync(emitJson, $"Failed to load policy: {ex.Message}", OfflineExitCodes.PolicyLoadFailed, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Environment.ExitCode = OfflineExitCodes.PolicyLoadFailed;
|
||||
return;
|
||||
}
|
||||
|
||||
var violations = new List<VerifyOfflineViolation>();
|
||||
|
||||
if (policy.Keys.Count == 0)
|
||||
{
|
||||
violations.Add(new VerifyOfflineViolation("policy.keys.missing", "Policy 'keys' must contain at least one trust-root public key path."));
|
||||
}
|
||||
|
||||
var trustRootFiles = policy.Keys
|
||||
.Select(key => ResolveEvidencePath(evidenceDirectory, key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var trustRoots = await TryBuildTrustRootsAsync(evidenceDirectory, trustRootFiles, violations, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var verifyRekor = string.Equals(policy.Tlog?.Mode, "offline", StringComparison.OrdinalIgnoreCase);
|
||||
var rekorPublicKeyPath = verifyRekor ? ResolveRekorPublicKeyPath(evidenceDirectory) : null;
|
||||
if (verifyRekor && rekorPublicKeyPath is null)
|
||||
{
|
||||
violations.Add(new VerifyOfflineViolation(
|
||||
"policy.tlog.rekor_key.missing",
|
||||
"Policy requires offline tlog verification, but Rekor public key was not found (expected under evidence/keys/tlog-root/rekor-pub.pem)."));
|
||||
}
|
||||
|
||||
var outputRoot = string.IsNullOrWhiteSpace(outputDirectory)
|
||||
? Path.Combine(Environment.CurrentDirectory, ".stellaops", "verify-offline")
|
||||
: Path.GetFullPath(outputDirectory);
|
||||
|
||||
var outputDir = Path.Combine(outputRoot, normalizedArtifact.Replace(':', '_'));
|
||||
|
||||
var reconciler = new EvidenceReconciler();
|
||||
EvidenceGraph graph;
|
||||
try
|
||||
{
|
||||
graph = await reconciler.ReconcileAsync(
|
||||
evidenceDirectory,
|
||||
outputDir,
|
||||
new ReconciliationOptions
|
||||
{
|
||||
VerifySignatures = true,
|
||||
VerifyRekorProofs = verifyRekor,
|
||||
TrustRoots = trustRoots,
|
||||
RekorPublicKeyPath = rekorPublicKeyPath
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await WriteVerifyOfflineErrorAsync(emitJson, $"Evidence reconciliation failed: {ex.Message}", OfflineExitCodes.VerificationFailed, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Environment.ExitCode = OfflineExitCodes.VerificationFailed;
|
||||
return;
|
||||
}
|
||||
|
||||
var artifactNode = graph.Nodes.FirstOrDefault(node => string.Equals(node.Id, normalizedArtifact, StringComparison.Ordinal));
|
||||
if (artifactNode is null)
|
||||
{
|
||||
violations.Add(new VerifyOfflineViolation("artifact.not_found", $"Artifact not found in evidence set: {normalizedArtifact}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyPolicyChecks(policy, artifactNode, verifyRekor, violations);
|
||||
}
|
||||
|
||||
var graphSerializer = new EvidenceGraphSerializer();
|
||||
var graphHash = graphSerializer.ComputeHash(graph);
|
||||
|
||||
var attestationsFound = artifactNode?.Attestations?.Count ?? 0;
|
||||
var attestationsVerified = artifactNode?.Attestations?
|
||||
.Count(att => att.SignatureValid && (!verifyRekor || att.RekorVerified)) ?? 0;
|
||||
var sbomsFound = artifactNode?.Sboms?.Count ?? 0;
|
||||
|
||||
var passed = violations.Count == 0;
|
||||
var exitCode = passed ? OfflineExitCodes.Success : OfflineExitCodes.VerificationFailed;
|
||||
|
||||
await WriteVerifyOfflineResultAsync(
|
||||
emitJson,
|
||||
new VerifyOfflineResultPayload(
|
||||
Status: passed ? "passed" : "failed",
|
||||
ExitCode: exitCode,
|
||||
Artifact: normalizedArtifact,
|
||||
EvidenceDir: evidenceDirectory,
|
||||
PolicyPath: resolvedPolicyPath,
|
||||
OutputDir: outputDir,
|
||||
EvidenceGraphHash: graphHash,
|
||||
SbomsFound: sbomsFound,
|
||||
AttestationsFound: attestationsFound,
|
||||
AttestationsVerified: attestationsVerified,
|
||||
Violations: violations),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = exitCode;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await WriteVerifyOfflineErrorAsync(emitJson, "Cancelled.", OfflineExitCodes.Cancelled, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Environment.ExitCode = OfflineExitCodes.Cancelled;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyPolicyChecks(
|
||||
OfflineVerificationPolicy policy,
|
||||
EvidenceNode node,
|
||||
bool verifyRekor,
|
||||
List<VerifyOfflineViolation> violations)
|
||||
{
|
||||
var subjectAlg = policy.Constraints?.Subjects?.Algorithm;
|
||||
if (!string.IsNullOrWhiteSpace(subjectAlg) && !string.Equals(subjectAlg, "sha256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
violations.Add(new VerifyOfflineViolation("policy.subjects.alg.unsupported", $"Unsupported subjects.alg '{subjectAlg}'. Only sha256 is supported."));
|
||||
}
|
||||
|
||||
var attestations = node.Attestations ?? Array.Empty<AttestationNodeRef>();
|
||||
foreach (var attestation in attestations.OrderBy(static att => att.PredicateType, StringComparer.Ordinal))
|
||||
{
|
||||
if (!attestation.SignatureValid)
|
||||
{
|
||||
violations.Add(new VerifyOfflineViolation(
|
||||
"attestation.signature.invalid",
|
||||
$"DSSE signature not verified for predicateType '{attestation.PredicateType}' (path: {attestation.Path})."));
|
||||
}
|
||||
|
||||
if (verifyRekor && !attestation.RekorVerified)
|
||||
{
|
||||
violations.Add(new VerifyOfflineViolation(
|
||||
"attestation.rekor.invalid",
|
||||
$"Rekor inclusion proof not verified for predicateType '{attestation.PredicateType}' (path: {attestation.Path})."));
|
||||
}
|
||||
}
|
||||
|
||||
var required = policy.Attestations?.Required ?? Array.Empty<OfflineAttestationRequirement>();
|
||||
foreach (var requirement in required.OrderBy(static req => req.Type ?? string.Empty, StringComparer.Ordinal))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(requirement.Type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsRequirementSatisfied(requirement.Type, node, verifyRekor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
violations.Add(new VerifyOfflineViolation(
|
||||
"policy.attestations.required.missing",
|
||||
$"Required evidence missing or unverified: {requirement.Type}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRequirementSatisfied(string requirementType, EvidenceNode node, bool verifyRekor)
|
||||
{
|
||||
requirementType = requirementType.Trim().ToLowerInvariant();
|
||||
var attestations = node.Attestations ?? Array.Empty<AttestationNodeRef>();
|
||||
var sboms = node.Sboms ?? Array.Empty<SbomNodeRef>();
|
||||
|
||||
bool Verified(AttestationNodeRef att) => att.SignatureValid && (!verifyRekor || att.RekorVerified);
|
||||
|
||||
if (requirementType is "slsa-provenance" or "slsa")
|
||||
{
|
||||
return attestations.Any(att =>
|
||||
Verified(att) && IsSlsaProvenance(att.PredicateType));
|
||||
}
|
||||
|
||||
if (requirementType is "cyclonedx-sbom" or "cyclonedx")
|
||||
{
|
||||
return sboms.Any(sbom => string.Equals(sbom.Format, SbomFormat.CycloneDx.ToString(), StringComparison.OrdinalIgnoreCase)) ||
|
||||
attestations.Any(att => Verified(att) && string.Equals(att.PredicateType, PredicateTypes.CycloneDx, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (requirementType is "spdx-sbom" or "spdx")
|
||||
{
|
||||
return sboms.Any(sbom => string.Equals(sbom.Format, SbomFormat.Spdx.ToString(), StringComparison.OrdinalIgnoreCase)) ||
|
||||
attestations.Any(att => Verified(att) && string.Equals(att.PredicateType, PredicateTypes.Spdx, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (requirementType is "vex")
|
||||
{
|
||||
return attestations.Any(att =>
|
||||
Verified(att) &&
|
||||
(string.Equals(att.PredicateType, PredicateTypes.OpenVex, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(att.PredicateType, PredicateTypes.Csaf, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
if (requirementType.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
requirementType.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return attestations.Any(att =>
|
||||
Verified(att) && string.Equals(att.PredicateType, requirementType, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return attestations.Any(att =>
|
||||
Verified(att) && att.PredicateType.Contains(requirementType, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsSlsaProvenance(string predicateType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(predicateType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(predicateType, PredicateTypes.SlsaProvenanceV1, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(predicateType, PredicateTypes.SlsaProvenanceV02, StringComparison.OrdinalIgnoreCase) ||
|
||||
predicateType.Contains("slsa.dev/provenance", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? ResolvePolicyPath(string evidenceDir, string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = input.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
{
|
||||
var full = Path.GetFullPath(trimmed);
|
||||
return File.Exists(full) ? full : null;
|
||||
}
|
||||
|
||||
var candidate1 = Path.GetFullPath(Path.Combine(evidenceDir, trimmed));
|
||||
if (File.Exists(candidate1))
|
||||
{
|
||||
return candidate1;
|
||||
}
|
||||
|
||||
var candidate2 = Path.GetFullPath(Path.Combine(evidenceDir, "policy", trimmed));
|
||||
if (File.Exists(candidate2))
|
||||
{
|
||||
return candidate2;
|
||||
}
|
||||
|
||||
var candidate3 = Path.GetFullPath(trimmed);
|
||||
return File.Exists(candidate3) ? candidate3 : null;
|
||||
}
|
||||
|
||||
private static string ResolveEvidencePath(string evidenceDir, string raw)
|
||||
{
|
||||
raw = raw.Trim();
|
||||
|
||||
if (Path.IsPathRooted(raw))
|
||||
{
|
||||
return Path.GetFullPath(raw);
|
||||
}
|
||||
|
||||
var normalized = raw.Replace('\\', '/');
|
||||
if (normalized.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized[2..];
|
||||
}
|
||||
|
||||
if (normalized.StartsWith("evidence/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized["evidence/".Length..];
|
||||
}
|
||||
|
||||
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
return Path.GetFullPath(Path.Combine(new[] { evidenceDir }.Concat(segments).ToArray()));
|
||||
}
|
||||
|
||||
private static string? ResolveRekorPublicKeyPath(string evidenceDir)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(evidenceDir, "keys", "tlog-root", "rekor-pub.pem"),
|
||||
Path.Combine(evidenceDir, "tlog", "rekor-pub.pem"),
|
||||
Path.Combine(evidenceDir, "rekor-pub.pem")
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<TrustRootConfig?> TryBuildTrustRootsAsync(
|
||||
string evidenceDir,
|
||||
IReadOnlyList<string> keyFiles,
|
||||
List<VerifyOfflineViolation> violations,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (keyFiles.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var publicKeys = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
var fingerprints = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var keyFile in keyFiles)
|
||||
{
|
||||
if (!File.Exists(keyFile))
|
||||
{
|
||||
violations.Add(new VerifyOfflineViolation("policy.keys.missing_file", $"Trust-root public key not found: {keyFile}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var keyBytes = await LoadPublicKeyDerBytesAsync(keyFile, ct).ConfigureAwait(false);
|
||||
var fingerprint = ComputeKeyFingerprint(keyBytes);
|
||||
publicKeys[fingerprint] = keyBytes;
|
||||
fingerprints.Add(fingerprint);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
violations.Add(new VerifyOfflineViolation("policy.keys.load_failed", $"Failed to load trust-root key '{keyFile}': {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
if (publicKeys.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TrustRootConfig(
|
||||
RootBundlePath: evidenceDir,
|
||||
TrustedKeyFingerprints: fingerprints.ToArray(),
|
||||
AllowedSignatureAlgorithms: new[] { "rsassa-pss-sha256" },
|
||||
NotBeforeUtc: null,
|
||||
NotAfterUtc: null,
|
||||
PublicKeys: publicKeys);
|
||||
}
|
||||
|
||||
private static async Task<byte[]> LoadPublicKeyDerBytesAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
|
||||
var text = Encoding.UTF8.GetString(bytes);
|
||||
|
||||
const string Begin = "-----BEGIN PUBLIC KEY-----";
|
||||
const string End = "-----END PUBLIC KEY-----";
|
||||
|
||||
var begin = text.IndexOf(Begin, StringComparison.Ordinal);
|
||||
var end = text.IndexOf(End, StringComparison.Ordinal);
|
||||
if (begin >= 0 && end > begin)
|
||||
{
|
||||
var base64 = text
|
||||
.Substring(begin + Begin.Length, end - (begin + Begin.Length))
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal)
|
||||
.Trim();
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
|
||||
// Allow raw base64 (SPKI).
|
||||
var trimmed = text.Trim();
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(trimmed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new InvalidDataException("Unsupported public key format (expected PEM or raw base64 SPKI).");
|
||||
}
|
||||
}
|
||||
|
||||
private static Task WriteVerifyOfflineErrorAsync(
|
||||
bool emitJson,
|
||||
string message,
|
||||
int exitCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
status = "error",
|
||||
exitCode,
|
||||
message
|
||||
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
|
||||
|
||||
AnsiConsole.Console.WriteLine(json);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task WriteVerifyOfflineResultAsync(
|
||||
bool emitJson,
|
||||
VerifyOfflineResultPayload payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
|
||||
AnsiConsole.Console.WriteLine(json);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var headline = payload.Status switch
|
||||
{
|
||||
"passed" => "[green]Verification PASSED[/]",
|
||||
"failed" => "[red]Verification FAILED[/]",
|
||||
_ => "[yellow]Verification result unknown[/]"
|
||||
};
|
||||
|
||||
AnsiConsole.MarkupLine(headline);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var table = new Table().AddColumns("Field", "Value");
|
||||
table.AddRow("Artifact", Markup.Escape(payload.Artifact));
|
||||
table.AddRow("Evidence dir", Markup.Escape(payload.EvidenceDir));
|
||||
table.AddRow("Policy", Markup.Escape(payload.PolicyPath));
|
||||
table.AddRow("Output dir", Markup.Escape(payload.OutputDir));
|
||||
table.AddRow("Evidence graph hash", Markup.Escape(payload.EvidenceGraphHash));
|
||||
table.AddRow("SBOMs found", payload.SbomsFound.ToString());
|
||||
table.AddRow("Attestations found", payload.AttestationsFound.ToString());
|
||||
table.AddRow("Attestations verified", payload.AttestationsVerified.ToString());
|
||||
AnsiConsole.Write(table);
|
||||
|
||||
if (payload.Violations.Count > 0)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[red]Violations:[/]");
|
||||
foreach (var violation in payload.Violations.OrderBy(static violation => violation.Rule, StringComparer.Ordinal))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" - {Markup.Escape(violation.Rule)}: {Markup.Escape(violation.Message)}");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record VerifyOfflineViolation(string Rule, string Message);
|
||||
|
||||
private sealed record VerifyOfflineResultPayload(
|
||||
string Status,
|
||||
int ExitCode,
|
||||
string Artifact,
|
||||
string EvidenceDir,
|
||||
string PolicyPath,
|
||||
string OutputDir,
|
||||
string EvidenceGraphHash,
|
||||
int SbomsFound,
|
||||
int AttestationsFound,
|
||||
int AttestationsVerified,
|
||||
IReadOnlyList<VerifyOfflineViolation> Violations);
|
||||
}
|
||||
Reference in New Issue
Block a user