Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.VerifyOffline.cs
master 811f35cba7 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.
2025-12-18 16:19:16 +02:00

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