- 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.
550 lines
21 KiB
C#
550 lines
21 KiB
C#
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);
|
|
}
|