using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Net; using System.Net.Http; using System.Linq; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Auth.Abstractions; using StellaOps.Auth.Client; using StellaOps.Cli.Configuration; using StellaOps.Cli.Services.Models; using StellaOps.Cli.Services.Models.AdvisoryAi; using StellaOps.Cli.Services.Models.Bun; using StellaOps.Cli.Services.Models.Ruby; using StellaOps.Cli.Services.Models.Transport; using StellaOps.Cryptography; using StellaOps.Cryptography.Digests; namespace StellaOps.Cli.Services; internal sealed class BackendOperationsClient : IBackendOperationsClient { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOptions = SerializerOptions; private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30); private static readonly IReadOnlyDictionary EmptyMetadata = new ReadOnlyDictionary(new Dictionary(0, StringComparer.OrdinalIgnoreCase)); private const string OperatorReasonParameterName = "operator_reason"; private const string OperatorTicketParameterName = "operator_ticket"; private const string BackfillReasonParameterName = "backfill_reason"; private const string BackfillTicketParameterName = "backfill_ticket"; private const string AdvisoryScopesHeader = "X-StellaOps-Scopes"; private const string AdvisoryRunScope = "advisory:run"; private readonly HttpClient _httpClient; private readonly StellaOpsCliOptions _options; private readonly ILogger _logger; private readonly ICryptoHash _cryptoHash; private readonly IStellaOpsTokenClient? _tokenClient; private readonly object _tokenSync = new(); private string? _cachedAccessToken; private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue; public BackendOperationsClient( HttpClient httpClient, StellaOpsCliOptions options, ILogger logger, ICryptoHash cryptoHash, IStellaOpsTokenClient? tokenClient = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); _tokenClient = tokenClient; if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && httpClient.BaseAddress is null) { if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri)) { httpClient.BaseAddress = baseUri; } } } public async Task DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken) { EnsureBackendConfigured(); channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim(); outputPath = ResolveArtifactPath(outputPath, channel); Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); if (!overwrite && File.Exists(outputPath)) { var existing = new FileInfo(outputPath); _logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length); return new ScannerArtifactResult(outputPath, existing.Length, true); } var attempt = 0; var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts); while (true) { attempt++; try { using var request = CreateRequest(HttpMethod.Get, $"api/scanner/artifacts/{channel}"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (attempt < maxAttempts) { var backoffSeconds = Math.Pow(2, attempt); _logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds); await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false); } } } private async Task ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken) { var tempFile = outputPath + ".tmp"; await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) await using (var fileStream = File.Create(tempFile)) { await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest"); var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature"); var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false); await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false); if (verbose) { var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated"; _logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote); } if (File.Exists(outputPath)) { File.Delete(outputPath); } File.Move(tempFile, outputPath); PersistMetadata(outputPath, channel, digestHex, signatureHeader, response); var downloaded = new FileInfo(outputPath); _logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length); return new ScannerArtifactResult(outputPath, downloaded.Length, false); } public async Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (!File.Exists(filePath)) { throw new FileNotFoundException("Scan result file not found.", filePath); } var maxAttempts = Math.Max(1, _options.ScanUploadAttempts); var attempt = 0; while (true) { attempt++; try { using var content = new MultipartFormDataContent(); await using var fileStream = File.OpenRead(filePath); var streamContent = new StreamContent(fileStream); streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); content.Add(streamContent, "file", Path.GetFileName(filePath)); using var request = CreateRequest(HttpMethod.Post, "api/scanner/results"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); request.Content = content; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { _logger.LogInformation("Scan results uploaded from {Path}.", filePath); return; } var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); if (attempt >= maxAttempts) { throw new InvalidOperationException(failure); } var delay = GetRetryDelay(response, attempt); _logger.LogWarning( "Scan upload attempt {Attempt}/{MaxAttempts} failed ({Reason}). Retrying in {Delay:F1}s...", attempt, maxAttempts, failure, delay.TotalSeconds); await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (attempt < maxAttempts) { var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); _logger.LogWarning( ex, "Scan upload attempt {Attempt}/{MaxAttempts} threw an exception. Retrying in {Delay:F1}s...", attempt, maxAttempts, delay.TotalSeconds); await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } } } public async Task TriggerJobAsync(string jobKind, IDictionary parameters, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(jobKind)) { throw new ArgumentException("Job kind must be provided.", nameof(jobKind)); } var requestBody = new JobTriggerRequest { Trigger = "cli", Parameters = parameters is null ? new Dictionary(StringComparer.Ordinal) : new Dictionary(parameters, StringComparer.Ordinal) }; var request = CreateRequest(HttpMethod.Post, $"jobs/{jobKind}"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); request.Content = JsonContent.Create(requestBody, options: SerializerOptions); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Accepted) { JobRunResponse? run = null; if (response.Content.Headers.ContentLength is > 0) { try { run = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind); } } var location = response.Headers.Location?.ToString(); return new JobTriggerResult(true, "Accepted", location, run); } var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); return new JobTriggerResult(false, failureMessage, null, null); } public async Task ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(route)) { throw new ArgumentException("Route must be provided.", nameof(route)); } var relative = route.TrimStart('/'); using var request = CreateRequest(method, $"excititor/{relative}"); if (payload is not null && method != HttpMethod.Get && method != HttpMethod.Delete) { request.Content = JsonContent.Create(payload, options: SerializerOptions); } await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { var (message, payloadElement) = await ExtractExcititorResponseAsync(response, cancellationToken).ConfigureAwait(false); var location = response.Headers.Location?.ToString(); return new ExcititorOperationResult(true, message, location, payloadElement); } var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); return new ExcititorOperationResult(false, failure, null, null); } public async Task DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(exportId)) { throw new ArgumentException("Export id must be provided.", nameof(exportId)); } if (string.IsNullOrWhiteSpace(destinationPath)) { throw new ArgumentException("Destination path must be provided.", nameof(destinationPath)); } var fullPath = Path.GetFullPath(destinationPath); var directory = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } var normalizedAlgorithm = string.IsNullOrWhiteSpace(expectedDigestAlgorithm) ? null : expectedDigestAlgorithm.Trim(); var expectedDigestRaw = string.IsNullOrWhiteSpace(expectedDigest) ? null : expectedDigest.Trim(); string? expectedSha256Hex = null; if (string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase) && expectedDigestRaw is not null) { expectedSha256Hex = Sha256Digest.ExtractHex(expectedDigestRaw, requirePrefix: false, parameterName: nameof(expectedDigest)); } if (File.Exists(fullPath) && string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase) && expectedSha256Hex is not null) { var existingDigest = await ComputeSha256Async(fullPath, cancellationToken).ConfigureAwait(false); if (string.Equals(existingDigest, expectedSha256Hex, StringComparison.OrdinalIgnoreCase)) { var info = new FileInfo(fullPath); _logger.LogDebug("Export {ExportId} already present at {Path}; digest matches.", exportId, fullPath); return new ExcititorExportDownloadResult(fullPath, info.Length, true); } } var encodedId = Uri.EscapeDataString(exportId); using var request = CreateRequest(HttpMethod.Get, $"excititor/export/{encodedId}/download"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); var tempPath = fullPath + ".tmp"; if (File.Exists(tempPath)) { File.Delete(tempPath); } using (var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false)) { if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using (var fileStream = File.Create(tempPath)) { await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } } if (!string.IsNullOrWhiteSpace(normalizedAlgorithm) && expectedDigestRaw is not null) { if (string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase)) { var computed = await ComputeSha256Async(tempPath, cancellationToken).ConfigureAwait(false); if (expectedSha256Hex is null || !string.Equals(computed, expectedSha256Hex, StringComparison.OrdinalIgnoreCase)) { File.Delete(tempPath); throw new InvalidOperationException($"Export digest mismatch. Expected sha256:{expectedSha256Hex}, computed sha256:{computed}."); } } else { _logger.LogWarning("Export digest verification skipped. Unsupported algorithm {Algorithm}.", normalizedAlgorithm); } } if (File.Exists(fullPath)) { File.Delete(fullPath); } File.Move(tempPath, fullPath); var downloaded = new FileInfo(fullPath); return new ExcititorExportDownloadResult(fullPath, downloaded.Length, false); } public async Task EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (request is null) { throw new ArgumentNullException(nameof(request)); } var images = NormalizeImages(request.Images); if (images.Count == 0) { throw new ArgumentException("At least one image digest must be provided.", nameof(request)); } var payload = new RuntimePolicyEvaluationRequestDocument { Namespace = string.IsNullOrWhiteSpace(request.Namespace) ? null : request.Namespace.Trim(), Images = images }; if (request.Labels.Count > 0) { payload.Labels = new Dictionary(StringComparer.Ordinal); foreach (var label in request.Labels) { if (!string.IsNullOrWhiteSpace(label.Key)) { payload.Labels[label.Key] = label.Value ?? string.Empty; } } } using var message = CreateRequest(HttpMethod.Post, "api/scanner/policy/runtime"); await AuthorizeRequestAsync(message, cancellationToken).ConfigureAwait(false); message.Content = JsonContent.Create(payload, options: SerializerOptions); using var response = await _httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } RuntimePolicyEvaluationResponseDocument? document; try { document = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = response.Content is null ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse runtime policy response. {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (document is null) { throw new InvalidOperationException("Runtime policy response was empty."); } var decisions = new Dictionary(StringComparer.Ordinal); if (document.Results is not null) { foreach (var kvp in document.Results) { var image = kvp.Key; var decision = kvp.Value; if (string.IsNullOrWhiteSpace(image) || decision is null) { continue; } var verdict = string.IsNullOrWhiteSpace(decision.PolicyVerdict) ? "unknown" : decision.PolicyVerdict!.Trim(); var reasons = ExtractReasons(decision.Reasons); var metadata = ExtractExtensionMetadata(decision.ExtensionData); var hasSbom = decision.HasSbomReferrers ?? decision.HasSbomLegacy; RuntimePolicyRekorReference? rekor = null; if (decision.Rekor is not null && (!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) || !string.IsNullOrWhiteSpace(decision.Rekor.Url) || decision.Rekor.Verified.HasValue)) { rekor = new RuntimePolicyRekorReference( NormalizeOptionalString(decision.Rekor.Uuid), NormalizeOptionalString(decision.Rekor.Url), decision.Rekor.Verified); } decisions[image] = new RuntimePolicyImageDecision( verdict, decision.Signed, hasSbom, reasons, rekor, metadata); } } var decisionsView = new ReadOnlyDictionary(decisions); return new RuntimePolicyEvaluationResult( document.TtlSeconds ?? 0, document.ExpiresAtUtc?.ToUniversalTime(), string.IsNullOrWhiteSpace(document.PolicyRevision) ? null : document.PolicyRevision, decisionsView); } public async Task ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(policyId)) { throw new ArgumentException("Policy identifier must be provided.", nameof(policyId)); } if (version <= 0) { throw new ArgumentOutOfRangeException(nameof(version), "Version must be greater than zero."); } if (request is null) { throw new ArgumentNullException(nameof(request)); } var requestDocument = new PolicyActivationRequestDocument { Comment = NormalizeOptionalString(request.Comment), RunNow = request.RunNow ? true : null, ScheduledAt = request.ScheduledAt, Priority = NormalizeOptionalString(request.Priority), Rollback = request.Rollback ? true : null, IncidentId = NormalizeOptionalString(request.IncidentId) }; var encodedPolicyId = Uri.EscapeDataString(policyId.Trim()); using var httpRequest = CreateRequest(HttpMethod.Post, $"api/policy/policies/{encodedPolicyId}/versions/{version}:activate"); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); httpRequest.Content = JsonContent.Create(requestDocument, options: SerializerOptions); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new PolicyApiException(message, response.StatusCode, errorCode); } PolicyActivationResponseDocument? responseDocument; try { responseDocument = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = response.Content is null ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse policy activation response: {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (responseDocument is null) { throw new InvalidOperationException("Policy activation response was empty."); } if (string.IsNullOrWhiteSpace(responseDocument.Status)) { throw new InvalidOperationException("Policy activation response missing status."); } if (responseDocument.Revision is null) { throw new InvalidOperationException("Policy activation response missing revision."); } return MapPolicyActivation(responseDocument); } public async Task SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(policyId)) { throw new ArgumentException("Policy identifier must be provided.", nameof(policyId)); } if (input is null) { throw new ArgumentNullException(nameof(input)); } var requestDocument = new PolicySimulationRequestDocument { BaseVersion = input.BaseVersion, CandidateVersion = input.CandidateVersion, Explain = input.Explain ? true : null }; if (input.SbomSet.Count > 0) { requestDocument.SbomSet = input.SbomSet; } if (input.Environment.Count > 0) { var environment = new Dictionary(StringComparer.Ordinal); foreach (var pair in input.Environment) { if (string.IsNullOrWhiteSpace(pair.Key)) { continue; } environment[pair.Key] = SerializeEnvironmentValue(pair.Value); } if (environment.Count > 0) { requestDocument.Env = environment; } } // CLI-POLICY-27-003: Enhanced simulation options if (input.Mode.HasValue) { requestDocument.Mode = input.Mode.Value switch { PolicySimulationMode.Quick => "quick", PolicySimulationMode.Batch => "batch", _ => null }; } if (input.SbomSelectors is not null && input.SbomSelectors.Count > 0) { requestDocument.SbomSelectors = input.SbomSelectors; } if (input.IncludeHeatmap) { requestDocument.IncludeHeatmap = true; } if (input.IncludeManifest) { requestDocument.IncludeManifest = true; } var encodedPolicyId = Uri.EscapeDataString(policyId); using var request = CreateRequest(HttpMethod.Post, $"api/policy/policies/{encodedPolicyId}/simulate"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); request.Content = JsonContent.Create(requestDocument, options: SerializerOptions); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new PolicyApiException(message, response.StatusCode, errorCode); } if (response.Content is null || response.Content.Headers.ContentLength is 0) { throw new InvalidOperationException("Policy simulation response was empty."); } PolicySimulationResponseDocument? document; try { document = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse policy simulation response: {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (document is null) { throw new InvalidOperationException("Policy simulation response was empty."); } if (document.Diff is null) { throw new InvalidOperationException("Policy simulation response missing diff summary."); } return MapPolicySimulation(document); } public async Task SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (request is null) { throw new ArgumentNullException(nameof(request)); } if (string.IsNullOrWhiteSpace(request.Manifest)) { throw new ArgumentException("Manifest must be provided.", nameof(request)); } var requestDocument = new TaskRunnerSimulationRequestDocument { Manifest = request.Manifest, Inputs = request.Inputs }; using var httpRequest = CreateRequest(HttpMethod.Post, "api/task-runner/simulations"); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); httpRequest.Content = JsonContent.Create(requestDocument, options: SerializerOptions); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } TaskRunnerSimulationResponseDocument? document; try { document = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse task runner simulation response: {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (document is null) { throw new InvalidOperationException("Task runner simulation response was empty."); } if (document.FailurePolicy is null) { throw new InvalidOperationException("Task runner simulation response missing failure policy."); } return MapTaskRunnerSimulation(document); } public async Task GetPolicyFindingsAsync(PolicyFindingsQuery query, CancellationToken cancellationToken) { if (query is null) { throw new ArgumentNullException(nameof(query)); } EnsureBackendConfigured(); var policyId = query.PolicyId; if (string.IsNullOrWhiteSpace(policyId)) { throw new ArgumentException("Policy identifier must be provided.", nameof(query)); } var encodedPolicyId = Uri.EscapeDataString(policyId.Trim()); var relative = $"api/policy/findings/{encodedPolicyId}{BuildPolicyFindingsQueryString(query)}"; using var request = CreateRequest(HttpMethod.Get, relative); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new PolicyApiException(message, response.StatusCode, errorCode); } PolicyFindingsResponseDocument? document; try { document = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse policy findings response: {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (document is null) { throw new InvalidOperationException("Policy findings response was empty."); } return MapPolicyFindings(document); } public async Task GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(policyId)) { throw new ArgumentException("Policy identifier must be provided.", nameof(policyId)); } if (string.IsNullOrWhiteSpace(findingId)) { throw new ArgumentException("Finding identifier must be provided.", nameof(findingId)); } var encodedPolicyId = Uri.EscapeDataString(policyId.Trim()); var encodedFindingId = Uri.EscapeDataString(findingId.Trim()); using var request = CreateRequest(HttpMethod.Get, $"api/policy/findings/{encodedPolicyId}/{encodedFindingId}"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new PolicyApiException(message, response.StatusCode, errorCode); } PolicyFindingDocumentDocument? document; try { document = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse policy finding response: {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (document is null) { throw new InvalidOperationException("Policy finding response was empty."); } return MapPolicyFinding(document); } public async Task GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(policyId)) { throw new ArgumentException("Policy identifier must be provided.", nameof(policyId)); } if (string.IsNullOrWhiteSpace(findingId)) { throw new ArgumentException("Finding identifier must be provided.", nameof(findingId)); } var encodedPolicyId = Uri.EscapeDataString(policyId.Trim()); var encodedFindingId = Uri.EscapeDataString(findingId.Trim()); var query = string.IsNullOrWhiteSpace(mode) ? string.Empty : $"?mode={Uri.EscapeDataString(mode.Trim())}"; using var request = CreateRequest(HttpMethod.Get, $"api/policy/findings/{encodedPolicyId}/{encodedFindingId}/explain{query}"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new PolicyApiException(message, response.StatusCode, errorCode); } PolicyFindingExplainResponseDocument? document; try { document = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse policy finding explain response: {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (document is null) { throw new InvalidOperationException("Policy finding explain response was empty."); } return MapPolicyFindingExplain(document); } public async Task GetEntryTraceAsync(string scanId, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(scanId)) { throw new ArgumentException("Scan identifier is required.", nameof(scanId)); } using var request = CreateRequest(HttpMethod.Get, $"api/scans/{scanId}/entrytrace"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { return null; } if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } var result = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); if (result is null) { throw new InvalidOperationException("EntryTrace response payload was empty."); } return result; } public async Task GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(scanId)) { throw new ArgumentException("Scan identifier is required.", nameof(scanId)); } var encodedScanId = Uri.EscapeDataString(scanId); using var request = CreateRequest(HttpMethod.Get, $"api/scans/{encodedScanId}/ruby-packages"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { return null; } if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } var inventory = await response.Content .ReadFromJsonAsync(SerializerOptions, cancellationToken) .ConfigureAwait(false); if (inventory is null) { throw new InvalidOperationException("Ruby package response payload was empty."); } var normalizedScanId = string.IsNullOrWhiteSpace(inventory.ScanId) ? scanId : inventory.ScanId; var normalizedDigest = inventory.ImageDigest ?? string.Empty; var packages = inventory.Packages ?? Array.Empty(); return inventory with { ScanId = normalizedScanId, ImageDigest = normalizedDigest, Packages = packages }; } public async Task GetBunPackagesAsync(string scanId, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(scanId)) { throw new ArgumentException("Scan identifier is required.", nameof(scanId)); } var encodedScanId = Uri.EscapeDataString(scanId); using var request = CreateRequest(HttpMethod.Get, $"api/scans/{encodedScanId}/bun-packages"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { return null; } if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } var inventory = await response.Content .ReadFromJsonAsync(SerializerOptions, cancellationToken) .ConfigureAwait(false); if (inventory is null) { throw new InvalidOperationException("Bun package response payload was empty."); } var normalizedScanId = string.IsNullOrWhiteSpace(inventory.ScanId) ? scanId : inventory.ScanId; var packages = inventory.Packages ?? Array.Empty(); return inventory with { ScanId = normalizedScanId, Packages = packages }; } public async Task CreateAdvisoryPipelinePlanAsync( AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); var taskSegment = taskType.ToString().ToLowerInvariant(); var relative = $"v1/advisory-ai/pipeline/{taskSegment}"; var payload = new AdvisoryPipelinePlanRequestModel { TaskType = taskType, AdvisoryKey = string.IsNullOrWhiteSpace(request.AdvisoryKey) ? string.Empty : request.AdvisoryKey.Trim(), ArtifactId = string.IsNullOrWhiteSpace(request.ArtifactId) ? null : request.ArtifactId!.Trim(), ArtifactPurl = string.IsNullOrWhiteSpace(request.ArtifactPurl) ? null : request.ArtifactPurl!.Trim(), PolicyVersion = string.IsNullOrWhiteSpace(request.PolicyVersion) ? null : request.PolicyVersion!.Trim(), Profile = string.IsNullOrWhiteSpace(request.Profile) ? "default" : request.Profile!.Trim(), PreferredSections = request.PreferredSections is null ? null : request.PreferredSections .Where(static section => !string.IsNullOrWhiteSpace(section)) .Select(static section => section.Trim()) .ToArray(), ForceRefresh = request.ForceRefresh }; using var httpRequest = CreateRequest(HttpMethod.Post, relative); ApplyAdvisoryAiEndpoint(httpRequest, taskType); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } try { var plan = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); if (plan is null) { throw new InvalidOperationException("Advisory AI plan response was empty."); } return plan; } catch (JsonException ex) { var raw = response.Content is null ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse advisory plan response. {ex.Message}", ex) { Data = { ["payload"] = raw } }; } } public async Task TryGetAdvisoryPipelineOutputAsync( string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(cacheKey)) { throw new ArgumentException("Cache key is required.", nameof(cacheKey)); } var encodedKey = Uri.EscapeDataString(cacheKey); var taskSegment = Uri.EscapeDataString(taskType.ToString().ToLowerInvariant()); var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim(); var relative = $"v1/advisory-ai/outputs/{encodedKey}?taskType={taskSegment}&profile={Uri.EscapeDataString(resolvedProfile)}"; using var request = CreateRequest(HttpMethod.Get, relative); ApplyAdvisoryAiEndpoint(request, taskType); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { return null; } if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } try { return await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = response.Content is null ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse advisory output response. {ex.Message}", ex) { Data = { ["payload"] = raw } }; } } public async Task> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken) { EnsureBackendConfigured(); var query = includeDisabled ? "?includeDisabled=true" : string.Empty; using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } if (response.Content is null || response.Content.Headers.ContentLength is 0) { return Array.Empty(); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); if (stream is null || stream.Length == 0) { return Array.Empty(); } using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); var root = document.RootElement; if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("providers", out var providersProperty)) { root = providersProperty; } if (root.ValueKind != JsonValueKind.Array) { return Array.Empty(); } var list = new List(); foreach (var item in root.EnumerateArray()) { var id = GetStringProperty(item, "id") ?? string.Empty; if (string.IsNullOrWhiteSpace(id)) { continue; } var kind = GetStringProperty(item, "kind") ?? "unknown"; var displayName = GetStringProperty(item, "displayName") ?? id; var trustTier = GetStringProperty(item, "trustTier") ?? string.Empty; var enabled = GetBooleanProperty(item, "enabled", defaultValue: true); var lastIngested = GetDateTimeOffsetProperty(item, "lastIngestedAt"); list.Add(new ExcititorProviderSummary(id, kind, displayName, trustTier, enabled, lastIngested)); } return list; } public async Task DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken) { EnsureBackendConfigured(); var rootDirectory = ResolveOfflineDirectory(destinationDirectory); Directory.CreateDirectory(rootDirectory); var descriptor = await FetchOfflineKitDescriptorAsync(bundleId, cancellationToken).ConfigureAwait(false); var bundlePath = Path.Combine(rootDirectory, descriptor.BundleName); var metadataPath = bundlePath + ".metadata.json"; var manifestPath = Path.Combine(rootDirectory, descriptor.ManifestName); var bundleSignaturePath = descriptor.BundleSignatureName is not null ? Path.Combine(rootDirectory, descriptor.BundleSignatureName) : null; var manifestSignaturePath = descriptor.ManifestSignatureName is not null ? Path.Combine(rootDirectory, descriptor.ManifestSignatureName) : null; var fromCache = false; if (!overwrite && File.Exists(bundlePath)) { var digest = await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false); if (string.Equals(digest, descriptor.BundleSha256, StringComparison.OrdinalIgnoreCase)) { fromCache = true; } else if (resume) { var partial = bundlePath + ".partial"; File.Move(bundlePath, partial, overwrite: true); } else { File.Delete(bundlePath); } } if (!fromCache) { await DownloadFileWithResumeAsync(descriptor.BundleDownloadUri, bundlePath, descriptor.BundleSha256, descriptor.BundleSize, resume, cancellationToken).ConfigureAwait(false); } await DownloadFileWithResumeAsync(descriptor.ManifestDownloadUri, manifestPath, descriptor.ManifestSha256, descriptor.ManifestSize ?? 0, resume: false, cancellationToken).ConfigureAwait(false); if (descriptor.BundleSignatureDownloadUri is not null && bundleSignaturePath is not null) { await DownloadAuxiliaryFileAsync(descriptor.BundleSignatureDownloadUri, bundleSignaturePath, cancellationToken).ConfigureAwait(false); } if (descriptor.ManifestSignatureDownloadUri is not null && manifestSignaturePath is not null) { await DownloadAuxiliaryFileAsync(descriptor.ManifestSignatureDownloadUri, manifestSignaturePath, cancellationToken).ConfigureAwait(false); } await WriteOfflineKitMetadataAsync(metadataPath, descriptor, bundlePath, manifestPath, bundleSignaturePath, manifestSignaturePath, cancellationToken).ConfigureAwait(false); return new OfflineKitDownloadResult( descriptor, bundlePath, manifestPath, bundleSignaturePath, manifestSignaturePath, metadataPath, fromCache); } public async Task ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (request is null) { throw new ArgumentNullException(nameof(request)); } var bundlePath = Path.GetFullPath(request.BundlePath); if (!File.Exists(bundlePath)) { throw new FileNotFoundException("Offline kit bundle not found.", bundlePath); } string? manifestPath = null; if (!string.IsNullOrWhiteSpace(request.ManifestPath)) { manifestPath = Path.GetFullPath(request.ManifestPath); if (!File.Exists(manifestPath)) { throw new FileNotFoundException("Offline kit manifest not found.", manifestPath); } } string? bundleSignaturePath = null; if (!string.IsNullOrWhiteSpace(request.BundleSignaturePath)) { bundleSignaturePath = Path.GetFullPath(request.BundleSignaturePath); if (!File.Exists(bundleSignaturePath)) { throw new FileNotFoundException("Offline kit bundle signature not found.", bundleSignaturePath); } } string? manifestSignaturePath = null; if (!string.IsNullOrWhiteSpace(request.ManifestSignaturePath)) { manifestSignaturePath = Path.GetFullPath(request.ManifestSignaturePath); if (!File.Exists(manifestSignaturePath)) { throw new FileNotFoundException("Offline kit manifest signature not found.", manifestSignaturePath); } } var bundleSize = request.BundleSize ?? new FileInfo(bundlePath).Length; var bundleSha = string.IsNullOrWhiteSpace(request.BundleSha256) ? await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false) : NormalizeSha(request.BundleSha256) ?? throw new InvalidOperationException("Bundle digest must not be empty."); string? manifestSha = null; long? manifestSize = null; if (manifestPath is not null) { manifestSize = request.ManifestSize ?? new FileInfo(manifestPath).Length; manifestSha = string.IsNullOrWhiteSpace(request.ManifestSha256) ? await ComputeSha256Async(manifestPath, cancellationToken).ConfigureAwait(false) : NormalizeSha(request.ManifestSha256); } var metadata = new OfflineKitImportMetadataPayload { BundleId = request.BundleId, BundleSha256 = bundleSha, BundleSize = bundleSize, CapturedAt = request.CapturedAt, Channel = request.Channel, Kind = request.Kind, IsDelta = request.IsDelta, BaseBundleId = request.BaseBundleId, ManifestSha256 = manifestSha, ManifestSize = manifestSize }; using var message = CreateRequest(HttpMethod.Post, "api/offline-kit/import"); await AuthorizeRequestAsync(message, cancellationToken).ConfigureAwait(false); using var content = new MultipartFormDataContent(); var metadataOptions = new JsonSerializerOptions(SerializerOptions) { WriteIndented = false }; var metadataJson = JsonSerializer.Serialize(metadata, metadataOptions); var metadataContent = new StringContent(metadataJson, Encoding.UTF8, "application/json"); content.Add(metadataContent, "metadata"); var bundleStream = File.OpenRead(bundlePath); var bundleContent = new StreamContent(bundleStream); bundleContent.Headers.ContentType = new MediaTypeHeaderValue("application/gzip"); content.Add(bundleContent, "bundle", Path.GetFileName(bundlePath)); if (manifestPath is not null) { var manifestStream = File.OpenRead(manifestPath); var manifestContent = new StreamContent(manifestStream); manifestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); content.Add(manifestContent, "manifest", Path.GetFileName(manifestPath)); } if (bundleSignaturePath is not null) { var signatureStream = File.OpenRead(bundleSignaturePath); var signatureContent = new StreamContent(signatureStream); signatureContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); content.Add(signatureContent, "bundleSignature", Path.GetFileName(bundleSignaturePath)); } if (manifestSignaturePath is not null) { var manifestSignatureStream = File.OpenRead(manifestSignaturePath); var manifestSignatureContent = new StreamContent(manifestSignatureStream); manifestSignatureContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); content.Add(manifestSignatureContent, "manifestSignature", Path.GetFileName(manifestSignaturePath)); } message.Content = content; using var response = await _httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } OfflineKitImportResponseTransport? document; try { document = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse offline kit import response. {ex.Message}", ex) { Data = { ["payload"] = raw } }; } var submittedAt = document?.SubmittedAt ?? DateTimeOffset.UtcNow; return new OfflineKitImportResult( document?.ImportId, document?.Status, submittedAt, document?.Message); } public async Task GetOfflineKitStatusAsync(CancellationToken cancellationToken) { EnsureBackendConfigured(); using var request = CreateRequest(HttpMethod.Get, "api/offline-kit/status"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } if (response.Content is null || response.Content.Headers.ContentLength is 0) { return new OfflineKitStatus(null, null, null, false, null, null, null, null, null, Array.Empty()); } OfflineKitStatusTransport? document; try { document = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse offline kit status response. {ex.Message}", ex) { Data = { ["payload"] = raw } }; } var current = document?.Current; var components = MapOfflineComponents(document?.Components); if (current is null) { return new OfflineKitStatus(null, null, null, false, null, null, null, null, null, components); } return new OfflineKitStatus( NormalizeOptionalString(current.BundleId), NormalizeOptionalString(current.Channel), NormalizeOptionalString(current.Kind), current.IsDelta ?? false, NormalizeOptionalString(current.BaseBundleId), current.CapturedAt?.ToUniversalTime(), current.ImportedAt?.ToUniversalTime(), NormalizeSha(current.BundleSha256), current.BundleSize, components); } public async Task ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest requestBody, CancellationToken cancellationToken) { EnsureBackendConfigured(); ArgumentNullException.ThrowIfNull(requestBody); using var request = CreateRequest(HttpMethod.Post, "api/aoc/ingest/dry-run"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); request.Content = JsonContent.Create(requestBody, options: SerializerOptions); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } try { var result = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); return result ?? new AocIngestDryRunResponse(); } catch (JsonException ex) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse ingest dry-run response. {ex.Message}", ex) { Data = { ["payload"] = payload } }; } } public async Task ExecuteAocVerifyAsync(AocVerifyRequest requestBody, CancellationToken cancellationToken) { EnsureBackendConfigured(); ArgumentNullException.ThrowIfNull(requestBody); using var request = CreateRequest(HttpMethod.Post, "api/aoc/verify"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); request.Content = JsonContent.Create(requestBody, options: SerializerOptions); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } try { var result = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); return result ?? new AocVerifyResponse(); } catch (JsonException ex) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse AOC verification response. {ex.Message}", ex) { Data = { ["payload"] = payload } }; } } private string ResolveOfflineDirectory(string destinationDirectory) { if (!string.IsNullOrWhiteSpace(destinationDirectory)) { return Path.GetFullPath(destinationDirectory); } var configured = _options.Offline?.KitsDirectory; if (!string.IsNullOrWhiteSpace(configured)) { return Path.GetFullPath(configured); } return Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "offline-kits")); } private async Task FetchOfflineKitDescriptorAsync(string? bundleId, CancellationToken cancellationToken) { var route = string.IsNullOrWhiteSpace(bundleId) ? "api/offline-kit/bundles/latest" : $"api/offline-kit/bundles/{Uri.EscapeDataString(bundleId)}"; using var request = CreateRequest(HttpMethod.Get, route); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } OfflineKitBundleDescriptorTransport? payload; try { payload = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse offline kit metadata. {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (payload is null) { throw new InvalidOperationException("Offline kit metadata response was empty."); } return MapOfflineKitDescriptor(payload); } private OfflineKitBundleDescriptor MapOfflineKitDescriptor(OfflineKitBundleDescriptorTransport transport) { if (transport is null) { throw new ArgumentNullException(nameof(transport)); } var bundleName = string.IsNullOrWhiteSpace(transport.BundleName) ? throw new InvalidOperationException("Offline kit metadata missing bundleName.") : transport.BundleName!.Trim(); var bundleId = string.IsNullOrWhiteSpace(transport.BundleId) ? bundleName : transport.BundleId!.Trim(); var bundleSha = NormalizeSha(transport.BundleSha256) ?? throw new InvalidOperationException("Offline kit metadata missing bundleSha256."); var bundleSize = transport.BundleSize; if (bundleSize <= 0) { throw new InvalidOperationException("Offline kit metadata missing bundle size."); } var manifestName = string.IsNullOrWhiteSpace(transport.ManifestName) ? "offline-manifest.json" : transport.ManifestName!.Trim(); var manifestSha = NormalizeSha(transport.ManifestSha256) ?? throw new InvalidOperationException("Offline kit metadata missing manifestSha256."); var capturedAt = transport.CapturedAt?.ToUniversalTime() ?? DateTimeOffset.UtcNow; var bundleDownloadUri = ResolveDownloadUri(transport.BundleUrl, transport.BundlePath, bundleName); var manifestDownloadUri = ResolveDownloadUri(transport.ManifestUrl, transport.ManifestPath, manifestName); var bundleSignatureUri = ResolveOptionalDownloadUri(transport.BundleSignatureUrl, transport.BundleSignaturePath, transport.BundleSignatureName); var manifestSignatureUri = ResolveOptionalDownloadUri(transport.ManifestSignatureUrl, transport.ManifestSignaturePath, transport.ManifestSignatureName); var bundleSignatureName = ResolveArtifactName(transport.BundleSignatureName, bundleSignatureUri); var manifestSignatureName = ResolveArtifactName(transport.ManifestSignatureName, manifestSignatureUri); return new OfflineKitBundleDescriptor( bundleId, bundleName, bundleSha, bundleSize, bundleDownloadUri, manifestName, manifestSha, manifestDownloadUri, capturedAt, NormalizeOptionalString(transport.Channel), NormalizeOptionalString(transport.Kind), transport.IsDelta ?? false, NormalizeOptionalString(transport.BaseBundleId), bundleSignatureName, bundleSignatureUri, manifestSignatureName, manifestSignatureUri, transport.ManifestSize); } private static string? ResolveArtifactName(string? explicitName, Uri? uri) { if (!string.IsNullOrWhiteSpace(explicitName)) { return explicitName.Trim(); } if (uri is not null) { var name = Path.GetFileName(uri.LocalPath); return string.IsNullOrWhiteSpace(name) ? null : name; } return null; } private Uri ResolveDownloadUri(string? absoluteOrRelativeUrl, string? relativePath, string fallbackFileName) { if (!string.IsNullOrWhiteSpace(absoluteOrRelativeUrl)) { var candidate = new Uri(absoluteOrRelativeUrl, UriKind.RelativeOrAbsolute); if (candidate.IsAbsoluteUri) { return candidate; } if (_httpClient.BaseAddress is not null) { return new Uri(_httpClient.BaseAddress, candidate); } return BuildUriFromRelative(candidate.ToString()); } if (!string.IsNullOrWhiteSpace(relativePath)) { return BuildUriFromRelative(relativePath); } if (!string.IsNullOrWhiteSpace(fallbackFileName)) { return BuildUriFromRelative(fallbackFileName); } throw new InvalidOperationException("Offline kit metadata did not include a download URL."); } private Uri BuildUriFromRelative(string relative) { var normalized = relative.TrimStart('/'); if (!string.IsNullOrWhiteSpace(_options.Offline?.MirrorUrl) && Uri.TryCreate(_options.Offline.MirrorUrl, UriKind.Absolute, out var mirrorBase)) { if (!mirrorBase.AbsoluteUri.EndsWith("/")) { mirrorBase = new Uri(mirrorBase.AbsoluteUri + "/"); } return new Uri(mirrorBase, normalized); } if (_httpClient.BaseAddress is not null) { return new Uri(_httpClient.BaseAddress, normalized); } throw new InvalidOperationException($"Cannot resolve offline kit URI for '{relative}' because no mirror or backend base address is configured."); } private Uri? ResolveOptionalDownloadUri(string? absoluteOrRelativeUrl, string? relativePath, string? fallbackName) { var hasData = !string.IsNullOrWhiteSpace(absoluteOrRelativeUrl) || !string.IsNullOrWhiteSpace(relativePath) || !string.IsNullOrWhiteSpace(fallbackName); if (!hasData) { return null; } try { return ResolveDownloadUri(absoluteOrRelativeUrl, relativePath, fallbackName ?? string.Empty); } catch { return null; } } private async Task DownloadFileWithResumeAsync(Uri downloadUri, string targetPath, string expectedSha256, long expectedSize, bool resume, CancellationToken cancellationToken) { var directory = Path.GetDirectoryName(targetPath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } var partialPath = resume ? targetPath + ".partial" : targetPath + ".tmp"; if (!resume && File.Exists(targetPath)) { File.Delete(targetPath); } if (resume && File.Exists(targetPath)) { File.Move(targetPath, partialPath, overwrite: true); } long existingLength = 0; if (resume && File.Exists(partialPath)) { existingLength = new FileInfo(partialPath).Length; if (expectedSize > 0 && existingLength >= expectedSize) { existingLength = expectedSize; } } while (true) { using var request = new HttpRequestMessage(HttpMethod.Get, downloadUri); if (resume && existingLength > 0 && expectedSize > 0 && existingLength < expectedSize) { request.Headers.Range = new RangeHeaderValue(existingLength, null); } using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (resume && existingLength > 0 && expectedSize > 0 && existingLength < expectedSize && response.StatusCode == HttpStatusCode.OK) { existingLength = 0; if (File.Exists(partialPath)) { File.Delete(partialPath); } continue; } if (!response.IsSuccessStatusCode && !(resume && existingLength > 0 && response.StatusCode == HttpStatusCode.PartialContent)) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } var destination = resume ? partialPath : targetPath; var mode = resume && existingLength > 0 ? FileMode.Append : FileMode.Create; await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using (var file = new FileStream(destination, mode, FileAccess.Write, FileShare.None, 81920, useAsync: true)) { await stream.CopyToAsync(file, cancellationToken).ConfigureAwait(false); } break; } if (resume && File.Exists(partialPath)) { File.Move(partialPath, targetPath, overwrite: true); } var digest = await ComputeSha256Async(targetPath, cancellationToken).ConfigureAwait(false); if (!string.Equals(digest, expectedSha256, StringComparison.OrdinalIgnoreCase)) { File.Delete(targetPath); throw new InvalidOperationException($"Digest mismatch for {Path.GetFileName(targetPath)}. Expected {expectedSha256} but computed {digest}."); } if (expectedSize > 0) { var actualSize = new FileInfo(targetPath).Length; if (actualSize != expectedSize) { File.Delete(targetPath); throw new InvalidOperationException($"Size mismatch for {Path.GetFileName(targetPath)}. Expected {expectedSize:N0} bytes but downloaded {actualSize:N0} bytes."); } } } private async Task DownloadAuxiliaryFileAsync(Uri downloadUri, string targetPath, CancellationToken cancellationToken) { var directory = Path.GetDirectoryName(targetPath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } using var request = new HttpRequestMessage(HttpMethod.Get, downloadUri); using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException(failure); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var file = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); await stream.CopyToAsync(file, cancellationToken).ConfigureAwait(false); } private static async Task WriteOfflineKitMetadataAsync( string metadataPath, OfflineKitBundleDescriptor descriptor, string bundlePath, string manifestPath, string? bundleSignaturePath, string? manifestSignaturePath, CancellationToken cancellationToken) { var document = new OfflineKitMetadataDocument { BundleId = descriptor.BundleId, BundleName = descriptor.BundleName, BundleSha256 = descriptor.BundleSha256, BundleSize = descriptor.BundleSize, BundlePath = Path.GetFullPath(bundlePath), CapturedAt = descriptor.CapturedAt, DownloadedAt = DateTimeOffset.UtcNow, Channel = descriptor.Channel, Kind = descriptor.Kind, IsDelta = descriptor.IsDelta, BaseBundleId = descriptor.BaseBundleId, ManifestName = descriptor.ManifestName, ManifestSha256 = descriptor.ManifestSha256, ManifestSize = descriptor.ManifestSize, ManifestPath = Path.GetFullPath(manifestPath), BundleSignaturePath = bundleSignaturePath is null ? null : Path.GetFullPath(bundleSignaturePath), ManifestSignaturePath = manifestSignaturePath is null ? null : Path.GetFullPath(manifestSignaturePath) }; var options = new JsonSerializerOptions(SerializerOptions) { WriteIndented = true }; var payload = JsonSerializer.Serialize(document, options); await File.WriteAllTextAsync(metadataPath, payload, cancellationToken).ConfigureAwait(false); } private static IReadOnlyList MapOfflineComponents(List? transports) { if (transports is null || transports.Count == 0) { return Array.Empty(); } var list = new List(); foreach (var transport in transports) { if (transport is null || string.IsNullOrWhiteSpace(transport.Name)) { continue; } list.Add(new OfflineKitComponentStatus( transport.Name.Trim(), NormalizeOptionalString(transport.Version), NormalizeSha(transport.Digest), transport.CapturedAt?.ToUniversalTime(), transport.SizeBytes)); } return list.Count == 0 ? Array.Empty() : list; } private static string? NormalizeSha(string? digest) { if (string.IsNullOrWhiteSpace(digest)) { return null; } var value = digest.Trim(); if (value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)) { value = value.Substring("sha256:".Length); } return value.ToLowerInvariant(); } private sealed class OfflineKitImportMetadataPayload { public string? BundleId { get; set; } public string BundleSha256 { get; set; } = string.Empty; public long BundleSize { get; set; } public DateTimeOffset? CapturedAt { get; set; } public string? Channel { get; set; } public string? Kind { get; set; } public bool? IsDelta { get; set; } public string? BaseBundleId { get; set; } public string? ManifestSha256 { get; set; } public long? ManifestSize { get; set; } } private static List NormalizeImages(IReadOnlyList images) { var normalized = new List(); if (images is null) { return normalized; } var seen = new HashSet(StringComparer.Ordinal); foreach (var entry in images) { if (string.IsNullOrWhiteSpace(entry)) { continue; } var trimmed = entry.Trim(); if (seen.Add(trimmed)) { normalized.Add(trimmed); } } return normalized; } private static IReadOnlyList ExtractReasons(List? reasons) { if (reasons is null || reasons.Count == 0) { return Array.Empty(); } var list = new List(); foreach (var reason in reasons) { if (!string.IsNullOrWhiteSpace(reason)) { list.Add(reason.Trim()); } } return list.Count == 0 ? Array.Empty() : list; } private static IReadOnlyDictionary ExtractExtensionMetadata(Dictionary? extensionData) { if (extensionData is null || extensionData.Count == 0) { return EmptyMetadata; } var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kvp in extensionData) { var value = ConvertJsonElementToObject(kvp.Value); if (value is not null) { metadata[kvp.Key] = value; } } if (metadata.Count == 0) { return EmptyMetadata; } return new ReadOnlyDictionary(metadata); } private static object? ConvertJsonElementToObject(JsonElement element) { return element.ValueKind switch { JsonValueKind.String => element.GetString(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Number when element.TryGetInt64(out var integer) => integer, JsonValueKind.Number when element.TryGetDouble(out var @double) => @double, JsonValueKind.Null or JsonValueKind.Undefined => null, _ => element.GetRawText() }; } private static string? NormalizeOptionalString(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, AdvisoryAiTaskType taskType) { if (request is null) { throw new ArgumentNullException(nameof(request)); } var requestUri = request.RequestUri ?? throw new InvalidOperationException("Request URI was not initialized."); if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl) && Uri.TryCreate(_options.AdvisoryAiUrl, UriKind.Absolute, out var advisoryBase)) { if (!requestUri.IsAbsoluteUri) { request.RequestUri = new Uri(advisoryBase, requestUri.ToString()); } } else if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl)) { throw new InvalidOperationException($"Advisory AI URL '{_options.AdvisoryAiUrl}' is not a valid absolute URI."); } else { EnsureBackendConfigured(); } var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}"; var combined = $"{AdvisoryRunScope} {taskScope}"; if (request.Headers.Contains(AdvisoryScopesHeader)) { request.Headers.Remove(AdvisoryScopesHeader); } request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, combined); } private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri) { if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri)) { throw new InvalidOperationException($"Invalid request URI '{relativeUri}'."); } if (requestUri.IsAbsoluteUri) { // Nothing to normalize. } else { requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative); } return new HttpRequestMessage(method, requestUri); } private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(token)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } } private IReadOnlyDictionary? ResolveOrchestratorMetadataIfNeeded(string? scope) { if (string.IsNullOrWhiteSpace(scope)) { return null; } var requiresOperate = scope.Contains("orch:operate", StringComparison.OrdinalIgnoreCase); var requiresBackfill = scope.Contains("orch:backfill", StringComparison.OrdinalIgnoreCase); if (!requiresOperate && !requiresBackfill) { return null; } var metadata = new Dictionary(StringComparer.Ordinal); if (requiresOperate) { var reason = _options.Authority.OperatorReason?.Trim(); var ticket = _options.Authority.OperatorTicket?.Trim(); if (string.IsNullOrWhiteSpace(reason) || string.IsNullOrWhiteSpace(ticket)) { throw new InvalidOperationException("Authority.OperatorReason and Authority.OperatorTicket must be configured when requesting orch:operate tokens. Set STELLAOPS_ORCH_REASON and STELLAOPS_ORCH_TICKET or the corresponding configuration values."); } metadata[OperatorReasonParameterName] = reason; metadata[OperatorTicketParameterName] = ticket; } if (requiresBackfill) { var reason = _options.Authority.BackfillReason?.Trim(); var ticket = _options.Authority.BackfillTicket?.Trim(); if (string.IsNullOrWhiteSpace(reason) || string.IsNullOrWhiteSpace(ticket)) { throw new InvalidOperationException("Authority.BackfillReason and Authority.BackfillTicket must be configured when requesting orch:backfill tokens. Set STELLAOPS_ORCH_BACKFILL_REASON and STELLAOPS_ORCH_BACKFILL_TICKET or the corresponding configuration values."); } metadata[BackfillReasonParameterName] = reason; metadata[BackfillTicketParameterName] = ticket; } return metadata; } private async Task ResolveAccessTokenAsync(CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(_options.ApiKey)) { return _options.ApiKey; } if (_tokenClient is null || string.IsNullOrWhiteSpace(_options.Authority.Url)) { return null; } var now = DateTimeOffset.UtcNow; lock (_tokenSync) { if (!string.IsNullOrEmpty(_cachedAccessToken) && now < _cachedAccessTokenExpiresAt - TokenRefreshSkew) { return _cachedAccessToken; } } var cacheKey = AuthorityTokenUtilities.BuildCacheKey(_options); var cachedEntry = await _tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew) { lock (_tokenSync) { _cachedAccessToken = cachedEntry.AccessToken; _cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc; return _cachedAccessToken; } } var scope = AuthorityTokenUtilities.ResolveScope(_options); var orchestratorMetadata = ResolveOrchestratorMetadataIfNeeded(scope); StellaOpsTokenResult token; if (!string.IsNullOrWhiteSpace(_options.Authority.Username)) { if (string.IsNullOrWhiteSpace(_options.Authority.Password)) { throw new InvalidOperationException("Authority password must be configured when username is provided."); } token = await _tokenClient.RequestPasswordTokenAsync( _options.Authority.Username, _options.Authority.Password!, scope, null, cancellationToken).ConfigureAwait(false); } else { token = await _tokenClient.RequestClientCredentialsTokenAsync(scope, orchestratorMetadata, cancellationToken).ConfigureAwait(false); } await _tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false); lock (_tokenSync) { _cachedAccessToken = token.AccessToken; _cachedAccessTokenExpiresAt = token.ExpiresAtUtc; return _cachedAccessToken; } } private async Task<(string Message, JsonElement? Payload)> ExtractExcititorResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) { if (response.Content is null || response.Content.Headers.ContentLength is 0) { return ($"HTTP {(int)response.StatusCode}", null); } try { await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); if (stream is null || stream.Length == 0) { return ($"HTTP {(int)response.StatusCode}", null); } using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); var root = document.RootElement.Clone(); string? message = null; if (root.ValueKind == JsonValueKind.Object) { message = GetStringProperty(root, "message") ?? GetStringProperty(root, "status"); } if (string.IsNullOrWhiteSpace(message)) { message = root.ValueKind == JsonValueKind.Object || root.ValueKind == JsonValueKind.Array ? root.ToString() : root.GetRawText(); } return (message ?? $"HTTP {(int)response.StatusCode}", root); } catch (JsonException) { var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return (string.IsNullOrWhiteSpace(text) ? $"HTTP {(int)response.StatusCode}" : text.Trim(), null); } } private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement property) { if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property)) { return true; } if (element.ValueKind == JsonValueKind.Object) { foreach (var candidate in element.EnumerateObject()) { if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase)) { property = candidate.Value; return true; } } } property = default; return false; } private static string? GetStringProperty(JsonElement element, string propertyName) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) { if (property.ValueKind == JsonValueKind.String) { return property.GetString(); } } return null; } private static bool GetBooleanProperty(JsonElement element, string propertyName, bool defaultValue) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) { return property.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed, _ => defaultValue }; } return defaultValue; } private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property) && property.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) { return parsed.ToUniversalTime(); } } return null; } private static JsonElement SerializeEnvironmentValue(object? value) { if (value is JsonElement element) { return element; } return JsonSerializer.SerializeToElement(value, SerializerOptions); } private static string? ExtractProblemErrorCode(ProblemDocument? problem) { if (problem?.Extensions is null || problem.Extensions.Count == 0) { return null; } if (problem.Extensions.TryGetValue("code", out var value)) { switch (value) { case string code when !string.IsNullOrWhiteSpace(code): return code; case JsonElement element when element.ValueKind == JsonValueKind.String: var text = element.GetString(); return string.IsNullOrWhiteSpace(text) ? null : text; } } return null; } private static string? ExtractProblemExtensionString(ProblemDocument? problem, params string[] keys) { if (problem?.Extensions is null || problem.Extensions.Count == 0 || keys.Length == 0) { return null; } foreach (var key in keys) { if (!problem.Extensions.TryGetValue(key, out var value) || value is null) { continue; } switch (value) { case string text when !string.IsNullOrWhiteSpace(text): return text; case JsonElement element when element.ValueKind == JsonValueKind.String: var parsed = element.GetString(); if (!string.IsNullOrWhiteSpace(parsed)) { return parsed; } break; } } return null; } private static string BuildPolicyFindingsQueryString(PolicyFindingsQuery query) { var parameters = new List(); if (query.SbomIds is not null) { foreach (var sbom in query.SbomIds) { if (!string.IsNullOrWhiteSpace(sbom)) { parameters.Add($"sbomId={Uri.EscapeDataString(sbom)}"); } } } if (query.Statuses is not null && query.Statuses.Count > 0) { var joined = string.Join(",", query.Statuses.Where(s => !string.IsNullOrWhiteSpace(s))); if (!string.IsNullOrWhiteSpace(joined)) { parameters.Add($"status={Uri.EscapeDataString(joined)}"); } } if (query.Severities is not null && query.Severities.Count > 0) { var joined = string.Join(",", query.Severities.Where(s => !string.IsNullOrWhiteSpace(s))); if (!string.IsNullOrWhiteSpace(joined)) { parameters.Add($"severity={Uri.EscapeDataString(joined)}"); } } if (!string.IsNullOrWhiteSpace(query.Cursor)) { parameters.Add($"cursor={Uri.EscapeDataString(query.Cursor)}"); } if (query.Page.HasValue) { parameters.Add($"page={query.Page.Value}"); } if (query.PageSize.HasValue) { parameters.Add($"pageSize={query.PageSize.Value}"); } if (query.Since.HasValue) { var value = query.Since.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); parameters.Add($"since={Uri.EscapeDataString(value)}"); } if (parameters.Count == 0) { return string.Empty; } return "?" + string.Join("&", parameters); } private static PolicyFindingsPage MapPolicyFindings(PolicyFindingsResponseDocument document) { var items = document.Items is null ? new List(capacity: 0) : document.Items .Where(item => item is not null) .Select(item => MapPolicyFinding(item!)) .ToList(); var nextCursor = string.IsNullOrWhiteSpace(document.NextCursor) ? null : document.NextCursor; var view = new ReadOnlyCollection(items); return new PolicyFindingsPage(view, nextCursor, document.TotalCount); } private static PolicyFindingDocument MapPolicyFinding(PolicyFindingDocumentDocument document) { var findingId = document.FindingId; if (string.IsNullOrWhiteSpace(findingId)) { throw new InvalidOperationException("Policy finding response missing findingId."); } var status = string.IsNullOrWhiteSpace(document.Status) ? "unknown" : document.Status!; var severityNormalized = document.Severity?.Normalized; if (string.IsNullOrWhiteSpace(severityNormalized)) { severityNormalized = "unknown"; } var severity = new PolicyFindingSeverity(severityNormalized!, document.Severity?.Score); var sbomId = string.IsNullOrWhiteSpace(document.SbomId) ? "(unknown)" : document.SbomId!; IReadOnlyList advisoryIds; if (document.AdvisoryIds is null || document.AdvisoryIds.Count == 0) { advisoryIds = Array.Empty(); } else { advisoryIds = document.AdvisoryIds .Where(id => !string.IsNullOrWhiteSpace(id)) .ToArray(); } PolicyFindingVexMetadata? vex = null; if (document.Vex is not null) { if (!string.IsNullOrWhiteSpace(document.Vex.WinningStatementId) || !string.IsNullOrWhiteSpace(document.Vex.Source) || !string.IsNullOrWhiteSpace(document.Vex.Status)) { vex = new PolicyFindingVexMetadata( string.IsNullOrWhiteSpace(document.Vex.WinningStatementId) ? null : document.Vex.WinningStatementId, string.IsNullOrWhiteSpace(document.Vex.Source) ? null : document.Vex.Source, string.IsNullOrWhiteSpace(document.Vex.Status) ? null : document.Vex.Status); } } var updatedAt = document.UpdatedAt ?? DateTimeOffset.MinValue; PolicyFindingUncertainty? uncertainty = null; if (document.Uncertainty is not null) { IReadOnlyList? states = null; if (document.Uncertainty.States is not null) { states = document.Uncertainty.States .Where(s => s is not null) .Select(s => new PolicyFindingUncertaintyState( string.IsNullOrWhiteSpace(s!.Code) ? null : s.Code, string.IsNullOrWhiteSpace(s.Name) ? null : s.Name, s.Entropy, string.IsNullOrWhiteSpace(s.Tier) ? null : s.Tier)) .ToList(); } uncertainty = new PolicyFindingUncertainty( string.IsNullOrWhiteSpace(document.Uncertainty.AggregateTier) ? null : document.Uncertainty.AggregateTier, document.Uncertainty.RiskScore, states, document.Uncertainty.ComputedAt); } return new PolicyFindingDocument( findingId, status, severity, sbomId, advisoryIds, vex, uncertainty, document.PolicyVersion ?? 0, updatedAt, string.IsNullOrWhiteSpace(document.RunId) ? null : document.RunId); } private static PolicyFindingExplainResult MapPolicyFindingExplain(PolicyFindingExplainResponseDocument document) { var findingId = document.FindingId; if (string.IsNullOrWhiteSpace(findingId)) { throw new InvalidOperationException("Policy finding explain response missing findingId."); } var steps = document.Steps is null ? new List(capacity: 0) : document.Steps .Where(step => step is not null) .Select(step => MapPolicyFindingExplainStep(step!)) .ToList(); var hints = document.SealedHints is null ? new List(capacity: 0) : document.SealedHints .Where(hint => hint is not null && !string.IsNullOrWhiteSpace(hint!.Message)) .Select(hint => new PolicyFindingExplainHint(hint!.Message!.Trim())) .ToList(); return new PolicyFindingExplainResult( findingId, document.PolicyVersion ?? 0, new ReadOnlyCollection(steps), new ReadOnlyCollection(hints)); } private static PolicyFindingExplainStep MapPolicyFindingExplainStep(PolicyFindingExplainStepDocument document) { var rule = string.IsNullOrWhiteSpace(document.Rule) ? "(unknown)" : document.Rule!; var status = string.IsNullOrWhiteSpace(document.Status) ? null : document.Status; var action = string.IsNullOrWhiteSpace(document.Action) ? null : document.Action; IReadOnlyDictionary inputs = document.Inputs is null ? new ReadOnlyDictionary(new Dictionary(0, StringComparer.Ordinal)) : new ReadOnlyDictionary(document.Inputs .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) .ToDictionary( kvp => kvp.Key, kvp => ConvertJsonElementToString(kvp.Value), StringComparer.Ordinal)); IReadOnlyDictionary? evidence = null; if (document.Evidence is not null && document.Evidence.Count > 0) { var evidenceDict = document.Evidence .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) .ToDictionary( kvp => kvp.Key, kvp => ConvertJsonElementToString(kvp.Value), StringComparer.Ordinal); evidence = new ReadOnlyDictionary(evidenceDict); } return new PolicyFindingExplainStep( rule, status, action, document.Score, inputs, evidence); } private static string ConvertJsonElementToString(JsonElement element) { return element.ValueKind switch { JsonValueKind.String => element.GetString() ?? string.Empty, JsonValueKind.Number => element.TryGetInt64(out var longValue) ? longValue.ToString(CultureInfo.InvariantCulture) : element.GetDouble().ToString(CultureInfo.InvariantCulture), JsonValueKind.True => "true", JsonValueKind.False => "false", JsonValueKind.Null => "null", JsonValueKind.Array => string.Join(", ", element.EnumerateArray().Select(ConvertJsonElementToString)), JsonValueKind.Object => element.GetRawText(), _ => element.GetRawText() }; } private static PolicyActivationResult MapPolicyActivation(PolicyActivationResponseDocument document) { if (document.Revision is null) { throw new InvalidOperationException("Policy activation response missing revision data."); } var revisionDocument = document.Revision; if (string.IsNullOrWhiteSpace(revisionDocument.PackId)) { throw new InvalidOperationException("Policy activation revision missing policy identifier."); } if (!revisionDocument.Version.HasValue) { throw new InvalidOperationException("Policy activation revision missing version number."); } var approvals = new List(); if (revisionDocument.Approvals is not null) { foreach (var approval in revisionDocument.Approvals) { if (approval is null || string.IsNullOrWhiteSpace(approval.ActorId) || !approval.ApprovedAt.HasValue) { continue; } approvals.Add(new PolicyActivationApproval( approval.ActorId, approval.ApprovedAt.Value.ToUniversalTime(), NormalizeOptionalString(approval.Comment))); } } var revision = new PolicyActivationRevision( revisionDocument.PackId, revisionDocument.Version.Value, NormalizeOptionalString(revisionDocument.Status) ?? "unknown", revisionDocument.RequiresTwoPersonApproval ?? false, revisionDocument.CreatedAt?.ToUniversalTime() ?? DateTimeOffset.MinValue, revisionDocument.ActivatedAt?.ToUniversalTime(), new ReadOnlyCollection(approvals)); return new PolicyActivationResult( NormalizeOptionalString(document.Status) ?? "unknown", revision); } private static PolicySimulationResult MapPolicySimulation(PolicySimulationResponseDocument document) { var diffDocument = document.Diff ?? throw new InvalidOperationException("Policy simulation response missing diff summary."); var severity = diffDocument.BySeverity is null ? new Dictionary(0, StringComparer.Ordinal) : diffDocument.BySeverity .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) && kvp.Value is not null) .ToDictionary( kvp => kvp.Key, kvp => new PolicySimulationSeverityDelta(kvp.Value!.Up, kvp.Value.Down), StringComparer.Ordinal); var severityView = new ReadOnlyDictionary(severity); var ruleHits = diffDocument.RuleHits is null ? new List() : diffDocument.RuleHits .Where(hit => hit is not null) .Select(hit => new PolicySimulationRuleDelta( hit!.RuleId ?? string.Empty, hit.RuleName ?? string.Empty, hit.Up, hit.Down)) .ToList(); var ruleHitsView = ruleHits.AsReadOnly(); var diff = new PolicySimulationDiff( string.IsNullOrWhiteSpace(diffDocument.SchemaVersion) ? null : diffDocument.SchemaVersion, diffDocument.Added ?? 0, diffDocument.Removed ?? 0, diffDocument.Unchanged ?? 0, severityView, ruleHitsView); // CLI-POLICY-27-003: Map heatmap if present PolicySimulationHeatmap? heatmap = null; if (document.Heatmap is not null) { var buckets = document.Heatmap.Buckets is null ? new List() : document.Heatmap.Buckets .Where(b => b is not null) .Select(b => new PolicySimulationHeatmapBucket( b!.Label ?? string.Empty, b.Count ?? 0, string.IsNullOrWhiteSpace(b.Color) ? null : b.Color)) .ToList(); heatmap = new PolicySimulationHeatmap( document.Heatmap.Critical ?? 0, document.Heatmap.High ?? 0, document.Heatmap.Medium ?? 0, document.Heatmap.Low ?? 0, document.Heatmap.Info ?? 0, buckets.AsReadOnly()); } return new PolicySimulationResult( diff, string.IsNullOrWhiteSpace(document.ExplainUri) ? null : document.ExplainUri, heatmap, string.IsNullOrWhiteSpace(document.ManifestDownloadUri) ? null : document.ManifestDownloadUri, string.IsNullOrWhiteSpace(document.ManifestDigest) ? null : document.ManifestDigest); } private static TaskRunnerSimulationResult MapTaskRunnerSimulation(TaskRunnerSimulationResponseDocument document) { var failurePolicyDocument = document.FailurePolicy ?? throw new InvalidOperationException("Task runner simulation response missing failure policy."); var steps = document.Steps is null ? new List() : document.Steps .Where(step => step is not null) .Select(step => MapTaskRunnerSimulationStep(step!)) .ToList(); var outputs = document.Outputs is null ? new List() : document.Outputs .Where(output => output is not null) .Select(output => new TaskRunnerSimulationOutput( output!.Name ?? string.Empty, output.Type ?? string.Empty, output.RequiresRuntimeValue, NormalizeOptionalString(output.PathExpression), NormalizeOptionalString(output.ValueExpression))) .ToList(); return new TaskRunnerSimulationResult( document.PlanHash ?? string.Empty, new TaskRunnerSimulationFailurePolicy( failurePolicyDocument.MaxAttempts, failurePolicyDocument.BackoffSeconds, failurePolicyDocument.ContinueOnError), steps, outputs, document.HasPendingApprovals); } private static TaskRunnerSimulationStep MapTaskRunnerSimulationStep(TaskRunnerSimulationStepDocument document) { var children = document.Children is null ? new List() : document.Children .Where(child => child is not null) .Select(child => MapTaskRunnerSimulationStep(child!)) .ToList(); return new TaskRunnerSimulationStep( document.Id ?? string.Empty, document.TemplateId ?? string.Empty, document.Kind ?? string.Empty, document.Enabled, document.Status ?? string.Empty, NormalizeOptionalString(document.StatusReason), NormalizeOptionalString(document.Uses), NormalizeOptionalString(document.ApprovalId), NormalizeOptionalString(document.GateMessage), document.MaxParallel, document.ContinueOnError, children); } private void EnsureBackendConfigured() { if (_httpClient.BaseAddress is null) { throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings."); } } private string ResolveArtifactPath(string outputPath, string channel) { if (!string.IsNullOrWhiteSpace(outputPath)) { return Path.GetFullPath(outputPath); } var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory) ? Directory.GetCurrentDirectory() : Path.GetFullPath(_options.ScannerCacheDirectory); Directory.CreateDirectory(directory); var fileName = $"stellaops-scanner-{channel}.tar.gz"; return Path.Combine(directory, fileName); } private async Task CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); return message; } private async Task<(string Message, string? ErrorCode)> CreateFailureDetailsAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var parsed = await ParseApiErrorAsync(response, cancellationToken).ConfigureAwait(false); return (parsed.Message, parsed.Code); } /// /// Parses API error response supporting both standardized envelope and ProblemDetails. /// CLI-SDK-62-002: Enhanced error parsing for standardized API error envelope. /// private async Task ParseApiErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var statusCode = (int)response.StatusCode; var builder = new StringBuilder(); builder.Append("Backend request failed with status "); builder.Append(statusCode); builder.Append(' '); builder.Append(response.ReasonPhrase ?? "Unknown"); // Extract trace/request IDs from headers var traceId = ExtractHeaderValue(response.Headers, "X-Trace-Id") ?? ExtractHeaderValue(response.Headers, "traceparent"); var requestId = ExtractHeaderValue(response.Headers, "X-Request-Id") ?? ExtractHeaderValue(response.Headers, "x-request-id"); ProblemDocument? problem = null; ApiErrorEnvelope? envelope = null; string? errorCode = null; string? errorDetail = null; string? target = null; string? helpUrl = null; int? retryAfter = null; Dictionary? metadata = null; IReadOnlyList? innerErrors = null; if (response.Content is not null && response.Content.Headers.ContentLength is > 0) { string? raw = null; try { raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(raw)) { // Try to parse as standardized error envelope first try { envelope = JsonSerializer.Deserialize(raw, SerializerOptions); if (envelope?.Error is not null) { errorCode = envelope.Error.Code; if (!string.IsNullOrWhiteSpace(envelope.Error.Message)) { builder.Clear().Append(envelope.Error.Message); } errorDetail = envelope.Error.Detail; target = envelope.Error.Target; helpUrl = envelope.Error.HelpUrl; retryAfter = envelope.Error.RetryAfter; metadata = envelope.Error.Metadata; innerErrors = envelope.Error.InnerErrors; // Prefer envelope trace_id over header if (!string.IsNullOrWhiteSpace(envelope.TraceId)) { traceId = envelope.TraceId; } if (!string.IsNullOrWhiteSpace(envelope.RequestId)) { requestId = envelope.RequestId; } } } catch (JsonException) { envelope = null; } // If envelope didn't have error details, try ProblemDetails format if (envelope?.Error is null) { try { problem = JsonSerializer.Deserialize(raw, SerializerOptions); if (problem is not null) { // Extract error code from problem type URI errorCode = ExtractErrorCodeFromProblemType(problem.Type); errorCode ??= ExtractProblemErrorCode(problem); if (!string.IsNullOrWhiteSpace(problem.Title)) { builder.AppendLine().Append(problem.Title); } if (!string.IsNullOrWhiteSpace(problem.Detail)) { builder.AppendLine().Append(problem.Detail); errorDetail = problem.Detail; } // Check for trace_id in extensions if (problem.Extensions is not null) { var extensionTraceId = ExtractProblemExtensionString(problem, "trace_id", "traceId"); if (!string.IsNullOrWhiteSpace(extensionTraceId)) { traceId ??= extensionTraceId; } var extensionErrorCode = ExtractProblemExtensionString(problem, "error_code", "errorCode"); if (!string.IsNullOrWhiteSpace(extensionErrorCode)) { errorCode ??= extensionErrorCode; } var reasonCode = ExtractProblemExtensionString(problem, "reason_code", "reasonCode"); if (!string.IsNullOrWhiteSpace(reasonCode)) { metadata ??= new Dictionary(StringComparer.Ordinal); metadata["reason_code"] = reasonCode; } } } } catch (JsonException) { problem = null; } } // If neither format parsed, include raw content if (envelope?.Error is null && problem is null && !string.IsNullOrWhiteSpace(raw)) { builder.AppendLine().Append(raw); } } } catch (Exception) { // Ignore content read errors } } // Parse Retry-After header if not in envelope if (retryAfter is null && response.Headers.RetryAfter?.Delta is not null) { retryAfter = (int)response.Headers.RetryAfter.Delta.Value.TotalSeconds; } // Default error code based on HTTP status errorCode ??= GetDefaultErrorCode(statusCode); return new ParsedApiError { Code = errorCode, Message = builder.ToString(), Detail = errorDetail, TraceId = traceId, RequestId = requestId, HttpStatus = statusCode, Target = target, HelpUrl = helpUrl, RetryAfter = retryAfter, InnerErrors = innerErrors, Metadata = metadata, ProblemDocument = problem, ErrorEnvelope = envelope }; } /// /// Extracts error code from problem type URI. /// private static string? ExtractErrorCodeFromProblemType(string? type) { if (string.IsNullOrWhiteSpace(type)) return null; // Handle URN format: urn:stellaops:error:ERR_AUTH_INVALID_SCOPE if (type.StartsWith("urn:stellaops:error:", StringComparison.OrdinalIgnoreCase)) { return type[20..]; } // Handle URL format: https://docs.stellaops.org/errors/ERR_AUTH_INVALID_SCOPE if (type.Contains("/errors/", StringComparison.OrdinalIgnoreCase)) { var idx = type.LastIndexOf("/errors/", StringComparison.OrdinalIgnoreCase); return type[(idx + 8)..]; } return null; } /// /// Gets default error code based on HTTP status code. /// private static string GetDefaultErrorCode(int statusCode) => statusCode switch { 400 => "ERR_VALIDATION_BAD_REQUEST", 401 => "ERR_AUTH_UNAUTHORIZED", 403 => "ERR_AUTH_FORBIDDEN", 404 => "ERR_NOT_FOUND", 409 => "ERR_CONFLICT", 422 => "ERR_VALIDATION_UNPROCESSABLE", 429 => "ERR_RATE_LIMIT", 500 => "ERR_SERVER_INTERNAL", 502 => "ERR_SERVER_BAD_GATEWAY", 503 => "ERR_SERVER_UNAVAILABLE", 504 => "ERR_SERVER_TIMEOUT", _ => $"ERR_HTTP_{statusCode}" }; private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name) { if (headers.TryGetValues(name, out var values)) { return values.FirstOrDefault(); } return null; } private async Task ValidateDigestAsync(string filePath, string? expectedDigest, CancellationToken cancellationToken) { string digestHex; await using (var stream = File.OpenRead(filePath)) { digestHex = await _cryptoHash.ComputeHashHexAsync(stream, HashAlgorithms.Sha256, cancellationToken).ConfigureAwait(false); } if (!string.IsNullOrWhiteSpace(expectedDigest)) { string expectedHex; try { expectedHex = Sha256Digest.ExtractHex(expectedDigest, requirePrefix: false, parameterName: "X-StellaOps-Digest"); } catch (Exception ex) when (ex is ArgumentException or FormatException) { File.Delete(filePath); throw new InvalidOperationException($"Scanner digest header is invalid: {ex.Message}", ex); } if (!expectedHex.Equals(digestHex, StringComparison.OrdinalIgnoreCase)) { File.Delete(filePath); throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{expectedHex}, calculated sha256:{digestHex}."); } } else { _logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only."); } return digestHex; } private async Task ComputeSha256Async(string filePath, CancellationToken cancellationToken) { await using var stream = File.OpenRead(filePath); return await _cryptoHash.ComputeHashHexAsync(stream, HashAlgorithms.Sha256, cancellationToken).ConfigureAwait(false); } private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath)) { if (!string.IsNullOrWhiteSpace(signatureHeader)) { _logger.LogDebug("Signature header present but no public key configured; skipping validation."); } return; } if (string.IsNullOrWhiteSpace(signatureHeader)) { throw new InvalidOperationException("Scanner signature missing while a public key is configured."); } var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath); if (!File.Exists(publicKeyPath)) { throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath); } var signatureBytes = Convert.FromBase64String(signatureHeader); var digestBytes = Convert.FromHexString(digestHex); var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false); using var rsa = RSA.Create(); rsa.ImportFromPem(pem); var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); if (!valid) { throw new InvalidOperationException("Scanner signature validation failed."); } if (verbose) { _logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath); } } private void PersistMetadata(string outputPath, string channel, string digestHex, string? signatureHeader, HttpResponseMessage response) { var metadata = new { channel, digest = $"sha256:{digestHex}", signature = signatureHeader, downloadedAt = DateTimeOffset.UtcNow, source = response.RequestMessage?.RequestUri?.ToString(), sizeBytes = new FileInfo(outputPath).Length, headers = new { etag = response.Headers.ETag?.Tag, lastModified = response.Content.Headers.LastModified, contentType = response.Content.Headers.ContentType?.ToString() } }; var metadataPath = outputPath + ".metadata.json"; var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(metadataPath, json); } private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) { if (response.Headers.TryGetValues("Retry-After", out var retryValues)) { var value = retryValues.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) { if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0) { return TimeSpan.FromSeconds(Math.Min(seconds, 300)); } if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var when)) { var delta = when - DateTimeOffset.UtcNow; if (delta > TimeSpan.Zero) { return delta < TimeSpan.FromMinutes(5) ? delta : TimeSpan.FromMinutes(5); } } } } var fallbackSeconds = Math.Min(60, Math.Pow(2, attempt)); return TimeSpan.FromSeconds(fallbackSeconds); } // CLI-VEX-30-001: VEX consensus list public async Task ListVexConsensusAsync(VexConsensusListRequest request, string? tenant, CancellationToken cancellationToken) { if (request is null) { throw new ArgumentNullException(nameof(request)); } EnsureBackendConfigured(); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.VulnerabilityId)) queryParams.Add($"vulnerabilityId={Uri.EscapeDataString(request.VulnerabilityId)}"); if (!string.IsNullOrWhiteSpace(request.ProductKey)) queryParams.Add($"productKey={Uri.EscapeDataString(request.ProductKey)}"); if (!string.IsNullOrWhiteSpace(request.Purl)) queryParams.Add($"purl={Uri.EscapeDataString(request.Purl)}"); if (!string.IsNullOrWhiteSpace(request.Status)) queryParams.Add($"status={Uri.EscapeDataString(request.Status)}"); if (!string.IsNullOrWhiteSpace(request.PolicyVersion)) queryParams.Add($"policyVersion={Uri.EscapeDataString(request.PolicyVersion)}"); if (request.Limit.HasValue) queryParams.Add($"limit={request.Limit.Value}"); if (request.Offset.HasValue) queryParams.Add($"offset={request.Offset.Value}"); var queryString = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty; var relative = $"api/vex/consensus{queryString}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"VEX consensus list failed: {message}"); } VexConsensusListResponse? result; try { result = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse VEX consensus list response: {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (result is null) { throw new InvalidOperationException("VEX consensus list response was empty."); } return result; } // CLI-VEX-30-002: VEX consensus detail public async Task GetVexConsensusAsync(string vulnerabilityId, string productKey, string? tenant, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(vulnerabilityId)) { throw new ArgumentException("Vulnerability ID must be provided.", nameof(vulnerabilityId)); } if (string.IsNullOrWhiteSpace(productKey)) { throw new ArgumentException("Product key must be provided.", nameof(productKey)); } EnsureBackendConfigured(); var encodedVulnId = Uri.EscapeDataString(vulnerabilityId.Trim()); var encodedProductKey = Uri.EscapeDataString(productKey.Trim()); var relative = $"api/vex/consensus/{encodedVulnId}/{encodedProductKey}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { return null; } if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"VEX consensus get failed: {message}"); } VexConsensusDetailResponse? result; try { result = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse VEX consensus detail response: {ex.Message}", ex) { Data = { ["payload"] = raw } }; } return result; } // CLI-VEX-30-003: VEX simulation public async Task SimulateVexConsensusAsync(VexSimulationRequest request, string? tenant, CancellationToken cancellationToken) { if (request is null) { throw new ArgumentNullException(nameof(request)); } EnsureBackendConfigured(); var relative = "api/vex/consensus/simulate"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } var jsonContent = JsonSerializer.Serialize(request, SerializerOptions); httpRequest.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"VEX consensus simulation failed: {message}"); } VexSimulationResponse? result; try { result = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse VEX simulation response: {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (result is null) { throw new InvalidOperationException("VEX simulation response was empty."); } return result; } // CLI-VEX-30-004: VEX export public async Task ExportVexConsensusAsync(VexExportRequest request, string? tenant, CancellationToken cancellationToken) { if (request is null) { throw new ArgumentNullException(nameof(request)); } EnsureBackendConfigured(); var relative = "api/vex/consensus/export"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } var jsonContent = JsonSerializer.Serialize(request, SerializerOptions); httpRequest.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"VEX consensus export failed: {message}"); } VexExportResponse? result; try { result = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse VEX export response: {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (result is null) { throw new InvalidOperationException("VEX export response was empty."); } return result; } public async Task DownloadVexExportAsync(string exportId, string? tenant, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(exportId)) { throw new ArgumentException("Export ID must be provided.", nameof(exportId)); } EnsureBackendConfigured(); var encodedExportId = Uri.EscapeDataString(exportId.Trim()); var relative = $"api/vex/consensus/export/{encodedExportId}/download"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"VEX export download failed: {message}"); } return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); } // CLI-VULN-29-001: Vulnerability explorer list public async Task ListVulnerabilitiesAsync(VulnListRequest request, string? tenant, CancellationToken cancellationToken) { EnsureBackendConfigured(); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.VulnerabilityId)) queryParams.Add($"vulnerabilityId={Uri.EscapeDataString(request.VulnerabilityId)}"); if (!string.IsNullOrWhiteSpace(request.Severity)) queryParams.Add($"severity={Uri.EscapeDataString(request.Severity)}"); if (!string.IsNullOrWhiteSpace(request.Status)) queryParams.Add($"status={Uri.EscapeDataString(request.Status)}"); if (!string.IsNullOrWhiteSpace(request.Purl)) queryParams.Add($"purl={Uri.EscapeDataString(request.Purl)}"); if (!string.IsNullOrWhiteSpace(request.Cpe)) queryParams.Add($"cpe={Uri.EscapeDataString(request.Cpe)}"); if (!string.IsNullOrWhiteSpace(request.SbomId)) queryParams.Add($"sbomId={Uri.EscapeDataString(request.SbomId)}"); if (!string.IsNullOrWhiteSpace(request.PolicyId)) queryParams.Add($"policyId={Uri.EscapeDataString(request.PolicyId)}"); if (request.PolicyVersion.HasValue) queryParams.Add($"policyVersion={request.PolicyVersion.Value}"); if (!string.IsNullOrWhiteSpace(request.GroupBy)) queryParams.Add($"groupBy={Uri.EscapeDataString(request.GroupBy)}"); if (request.Limit.HasValue) queryParams.Add($"limit={request.Limit.Value}"); if (request.Offset.HasValue) queryParams.Add($"offset={request.Offset.Value}"); if (!string.IsNullOrWhiteSpace(request.Cursor)) queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}"); var relative = "api/vuln"; if (queryParams.Count > 0) relative += "?" + string.Join("&", queryParams); using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to list vulnerabilities: {message}"); } var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var result = JsonSerializer.Deserialize(json, SerializerOptions); return result ?? new VulnListResponse(Array.Empty(), 0, 0, 0, false); } // CLI-VULN-29-002: Vulnerability detail public async Task GetVulnerabilityAsync(string vulnerabilityId, string? tenant, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(vulnerabilityId)) { throw new ArgumentException("Vulnerability ID must be provided.", nameof(vulnerabilityId)); } EnsureBackendConfigured(); var encodedVulnId = Uri.EscapeDataString(vulnerabilityId.Trim()); var relative = $"api/vuln/{encodedVulnId}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to get vulnerability details: {message}"); } var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return JsonSerializer.Deserialize(json, SerializerOptions); } // CLI-VULN-29-003: Vulnerability workflow operations public async Task ExecuteVulnWorkflowAsync(VulnWorkflowRequest request, string? tenant, CancellationToken cancellationToken) { EnsureBackendConfigured(); var relative = "api/vuln/workflow"; var jsonPayload = JsonSerializer.Serialize(request, SerializerOptions); using var httpRequest = CreateRequest(HttpMethod.Post, relative); httpRequest.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Workflow operation failed: {message}"); } var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var result = JsonSerializer.Deserialize(json, SerializerOptions); return result ?? new VulnWorkflowResponse(false, request.Action, 0); } // CLI-VULN-29-004: Vulnerability simulation public async Task SimulateVulnerabilitiesAsync(VulnSimulationRequest request, string? tenant, CancellationToken cancellationToken) { EnsureBackendConfigured(); var relative = "api/vuln/simulate"; var jsonPayload = JsonSerializer.Serialize(request, SerializerOptions); using var httpRequest = CreateRequest(HttpMethod.Post, relative); httpRequest.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Vulnerability simulation failed: {message}"); } var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var result = JsonSerializer.Deserialize(json, SerializerOptions); return result ?? new VulnSimulationResponse(Array.Empty(), new VulnSimulationSummary(0, 0, 0, 0, 0)); } // CLI-VULN-29-005: Vulnerability export public async Task ExportVulnerabilitiesAsync(VulnExportRequest request, string? tenant, CancellationToken cancellationToken) { EnsureBackendConfigured(); var relative = "api/vuln/export"; var jsonPayload = JsonSerializer.Serialize(request, SerializerOptions); using var httpRequest = CreateRequest(HttpMethod.Post, relative); httpRequest.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Vulnerability export failed: {message}"); } var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var result = JsonSerializer.Deserialize(json, SerializerOptions); return result ?? throw new InvalidOperationException("Failed to parse export response."); } public async Task DownloadVulnExportAsync(string exportId, string? tenant, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(exportId)) { throw new ArgumentException("Export ID must be provided.", nameof(exportId)); } EnsureBackendConfigured(); var encodedExportId = Uri.EscapeDataString(exportId.Trim()); var relative = $"api/vuln/export/{encodedExportId}/download"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Vulnerability export download failed: {message}"); } return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); } // CLI-POLICY-23-006: Policy history and explain public async Task GetPolicyHistoryAsync(PolicyHistoryRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.Tenant)) queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}"); if (request.From.HasValue) queryParams.Add($"from={Uri.EscapeDataString(request.From.Value.ToString("O"))}"); if (request.To.HasValue) queryParams.Add($"to={Uri.EscapeDataString(request.To.Value.ToString("O"))}"); if (!string.IsNullOrWhiteSpace(request.Status)) queryParams.Add($"status={Uri.EscapeDataString(request.Status)}"); if (request.Limit.HasValue) queryParams.Add($"limit={request.Limit.Value}"); if (!string.IsNullOrWhiteSpace(request.Cursor)) queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/runs{query}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy history request failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicyHistoryResponse(); } public async Task GetPolicyExplainAsync(PolicyExplainRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.RunId)) queryParams.Add($"runId={Uri.EscapeDataString(request.RunId)}"); if (!string.IsNullOrWhiteSpace(request.FindingId)) queryParams.Add($"findingId={Uri.EscapeDataString(request.FindingId)}"); if (!string.IsNullOrWhiteSpace(request.SbomId)) queryParams.Add($"sbomId={Uri.EscapeDataString(request.SbomId)}"); if (!string.IsNullOrWhiteSpace(request.ComponentPurl)) queryParams.Add($"purl={Uri.EscapeDataString(request.ComponentPurl)}"); if (!string.IsNullOrWhiteSpace(request.AdvisoryId)) queryParams.Add($"advisoryId={Uri.EscapeDataString(request.AdvisoryId)}"); if (!string.IsNullOrWhiteSpace(request.Tenant)) queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}"); if (request.Depth.HasValue) queryParams.Add($"depth={request.Depth.Value}"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/explain{query}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy explain request failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicyExplainResult(); } // CLI-POLICY-27-002: Policy submission/review workflow public async Task BumpPolicyVersionAsync(PolicyVersionBumpRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/version"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); httpRequest.Content = JsonContent.Create(request, options: JsonOptions); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy version bump failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicyVersionBumpResult(); } public async Task SubmitPolicyForReviewAsync(PolicySubmitRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/submit"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); httpRequest.Content = JsonContent.Create(request, options: JsonOptions); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy submission failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicySubmitResult(); } public async Task AddPolicyReviewCommentAsync(PolicyReviewCommentRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/review/{Uri.EscapeDataString(request.ReviewId)}/comment"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); httpRequest.Content = JsonContent.Create(request, options: JsonOptions); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Add review comment failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicyReviewCommentResult(); } public async Task ApprovePolicyReviewAsync(PolicyApproveRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/review/{Uri.EscapeDataString(request.ReviewId)}/approve"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); httpRequest.Content = JsonContent.Create(request, options: JsonOptions); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy approval failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicyApproveResult(); } public async Task RejectPolicyReviewAsync(PolicyRejectRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/review/{Uri.EscapeDataString(request.ReviewId)}/reject"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); httpRequest.Content = JsonContent.Create(request, options: JsonOptions); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy rejection failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicyRejectResult(); } public async Task GetPolicyReviewStatusAsync(PolicyReviewStatusRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var reviewPart = string.IsNullOrWhiteSpace(request.ReviewId) ? "latest" : Uri.EscapeDataString(request.ReviewId); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/review/{reviewPart}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Get policy review status failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); } // CLI-POLICY-27-004: Policy lifecycle (publish/promote/rollback/sign) public async Task PublishPolicyAsync(PolicyPublishRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/versions/{request.Version}/publish"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } httpRequest.Content = JsonContent.Create(new { sign = request.Sign, signatureAlgorithm = request.SignatureAlgorithm, keyId = request.KeyId, note = request.Note }, options: JsonOptions); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy publish failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicyPublishResult(); } public async Task PromotePolicyAsync(PolicyPromoteRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/versions/{request.Version}/promote"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } httpRequest.Content = JsonContent.Create(new { targetEnvironment = request.TargetEnvironment, canary = request.Canary, canaryPercentage = request.CanaryPercentage, note = request.Note }, options: JsonOptions); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy promote failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicyPromoteResult(); } public async Task RollbackPolicyAsync(PolicyRollbackRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/rollback"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } httpRequest.Content = JsonContent.Create(new { targetVersion = request.TargetVersion, environment = request.Environment, reason = request.Reason, incidentId = request.IncidentId }, options: JsonOptions); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy rollback failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicyRollbackResult(); } public async Task SignPolicyAsync(PolicySignRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/versions/{request.Version}/sign"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } httpRequest.Content = JsonContent.Create(new { keyId = request.KeyId, signatureAlgorithm = request.SignatureAlgorithm, rekorUpload = request.RekorUpload }, options: JsonOptions); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy sign failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicySignResult(); } public async Task VerifyPolicySignatureAsync(PolicyVerifySignatureRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var signaturePart = string.IsNullOrWhiteSpace(request.SignatureId) ? "latest" : Uri.EscapeDataString(request.SignatureId); var relative = $"api/policy/{Uri.EscapeDataString(request.PolicyId)}/versions/{request.Version}/signatures/{signaturePart}/verify"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } httpRequest.Content = JsonContent.Create(new { checkRekor = request.CheckRekor }, options: JsonOptions); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Policy signature verification failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new PolicyVerifySignatureResult(); } // CLI-RISK-66-001: Risk profile list public async Task ListRiskProfilesAsync(RiskProfileListRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var queryParams = new List(); if (request.IncludeDisabled) queryParams.Add("includeDisabled=true"); if (!string.IsNullOrWhiteSpace(request.Category)) queryParams.Add($"category={Uri.EscapeDataString(request.Category)}"); if (request.Limit.HasValue) queryParams.Add($"limit={request.Limit.Value}"); if (request.Offset.HasValue) queryParams.Add($"offset={request.Offset.Value}"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var relative = $"api/risk/profiles{query}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"List risk profiles failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new RiskProfileListResponse(); } // CLI-RISK-66-002: Risk simulate public async Task SimulateRiskAsync(RiskSimulateRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = "api/risk/simulate"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } httpRequest.Content = JsonContent.Create(new { profileId = request.ProfileId, sbomId = request.SbomId, sbomPath = request.SbomPath, assetId = request.AssetId, diffMode = request.DiffMode, baselineProfileId = request.BaselineProfileId }, options: JsonOptions); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Risk simulate failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new RiskSimulateResult(); } // CLI-RISK-67-001: Risk results public async Task GetRiskResultsAsync(RiskResultsRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.AssetId)) queryParams.Add($"assetId={Uri.EscapeDataString(request.AssetId)}"); if (!string.IsNullOrWhiteSpace(request.SbomId)) queryParams.Add($"sbomId={Uri.EscapeDataString(request.SbomId)}"); if (!string.IsNullOrWhiteSpace(request.ProfileId)) queryParams.Add($"profileId={Uri.EscapeDataString(request.ProfileId)}"); if (!string.IsNullOrWhiteSpace(request.MinSeverity)) queryParams.Add($"minSeverity={Uri.EscapeDataString(request.MinSeverity)}"); if (request.MaxScore.HasValue) queryParams.Add($"maxScore={request.MaxScore.Value}"); if (request.IncludeExplain) queryParams.Add("includeExplain=true"); if (request.Limit.HasValue) queryParams.Add($"limit={request.Limit.Value}"); if (request.Offset.HasValue) queryParams.Add($"offset={request.Offset.Value}"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var relative = $"api/risk/results{query}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Get risk results failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new RiskResultsResponse(); } // CLI-RISK-68-001: Risk bundle verify public async Task VerifyRiskBundleAsync(RiskBundleVerifyRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); var relative = "api/risk/bundles/verify"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } httpRequest.Content = JsonContent.Create(new { bundlePath = request.BundlePath, signaturePath = request.SignaturePath, checkRekor = request.CheckRekor }, options: JsonOptions); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Risk bundle verify failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new RiskBundleVerifyResult(); } // CLI-SIG-26-001: Reachability operations public async Task UploadCallGraphAsync(ReachabilityUploadCallGraphRequest request, Stream callGraphStream, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(callGraphStream); EnsureBackendConfigured(); OfflineModeGuard.ThrowIfOffline("reachability upload-callgraph"); var relative = "api/reachability/callgraphs"; using var httpRequest = CreateRequest(HttpMethod.Post, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } var content = new MultipartFormDataContent(); content.Add(new StreamContent(callGraphStream), "callGraph", Path.GetFileName(request.CallGraphPath)); if (!string.IsNullOrWhiteSpace(request.ScanId)) { content.Add(new StringContent(request.ScanId), "scanId"); } if (!string.IsNullOrWhiteSpace(request.AssetId)) { content.Add(new StringContent(request.AssetId), "assetId"); } if (!string.IsNullOrWhiteSpace(request.Format)) { content.Add(new StringContent(request.Format), "format"); } httpRequest.Content = content; await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Call graph upload failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new ReachabilityUploadCallGraphResult(); } public async Task ListReachabilityAnalysesAsync(ReachabilityListRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); OfflineModeGuard.ThrowIfOffline("reachability list"); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.ScanId)) queryParams.Add($"scanId={Uri.EscapeDataString(request.ScanId)}"); if (!string.IsNullOrWhiteSpace(request.AssetId)) queryParams.Add($"assetId={Uri.EscapeDataString(request.AssetId)}"); if (!string.IsNullOrWhiteSpace(request.Status)) queryParams.Add($"status={Uri.EscapeDataString(request.Status)}"); if (request.Limit.HasValue) queryParams.Add($"limit={request.Limit.Value}"); if (request.Offset.HasValue) queryParams.Add($"offset={request.Offset.Value}"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var relative = $"api/reachability/analyses{query}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"List reachability analyses failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new ReachabilityListResponse(); } public async Task ExplainReachabilityAsync(ReachabilityExplainRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); OfflineModeGuard.ThrowIfOffline("reachability explain"); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.VulnerabilityId)) queryParams.Add($"vulnerabilityId={Uri.EscapeDataString(request.VulnerabilityId)}"); if (!string.IsNullOrWhiteSpace(request.PackagePurl)) queryParams.Add($"packagePurl={Uri.EscapeDataString(request.PackagePurl)}"); if (request.IncludeCallPaths) queryParams.Add("includeCallPaths=true"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var relative = $"api/reachability/analyses/{Uri.EscapeDataString(request.AnalysisId)}/explain{query}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Explain reachability failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new ReachabilityExplainResult(); } // UI-CLI-401-007: Graph explain with DSSE pointers, runtime hits, predicates, counterfactuals public async Task ExplainGraphAsync(GraphExplainRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); OfflineModeGuard.ThrowIfOffline("graph explain"); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.VulnerabilityId)) queryParams.Add($"vulnerabilityId={Uri.EscapeDataString(request.VulnerabilityId)}"); if (!string.IsNullOrWhiteSpace(request.PackagePurl)) queryParams.Add($"packagePurl={Uri.EscapeDataString(request.PackagePurl)}"); if (request.IncludeCallPaths) queryParams.Add("includeCallPaths=true"); if (request.IncludeRuntimeHits) queryParams.Add("includeRuntimeHits=true"); if (request.IncludePredicates) queryParams.Add("includePredicates=true"); if (request.IncludeDsseEnvelopes) queryParams.Add("includeDsseEnvelopes=true"); if (request.IncludeCounterfactuals) queryParams.Add("includeCounterfactuals=true"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var relative = $"api/graphs/{Uri.EscapeDataString(request.GraphId)}/explain{query}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); throw new HttpRequestException($"Explain graph failed: {message}", null, response.StatusCode); } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false) ?? new GraphExplainResult(); } // CLI-SDK-63-001: API spec operations public async Task ListApiSpecsAsync(string? tenant, CancellationToken cancellationToken) { EnsureBackendConfigured(); OfflineModeGuard.ThrowIfOffline("api spec list"); using var httpRequest = CreateRequest(HttpMethod.Get, "api/openapi/specs"); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); return new ApiSpecListResponse { Success = false, Error = message }; } var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); return result ?? new ApiSpecListResponse { Success = false, Error = "Empty response" }; } public async Task DownloadApiSpecAsync(ApiSpecDownloadRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); OfflineModeGuard.ThrowIfOffline("api spec download"); // Determine output file path var outputPath = request.OutputPath; var extension = request.Format.Equals("openapi-yaml", StringComparison.OrdinalIgnoreCase) ? ".yaml" : ".json"; var fileName = string.IsNullOrWhiteSpace(request.Service) ? $"stellaops-openapi{extension}" : $"stellaops-{request.Service.ToLowerInvariant()}-openapi{extension}"; if (Directory.Exists(outputPath)) { outputPath = Path.Combine(outputPath, fileName); } else if (string.IsNullOrWhiteSpace(Path.GetExtension(outputPath))) { outputPath = outputPath + extension; } // Check for existing file if (!request.Overwrite && File.Exists(outputPath)) { // Compute checksum of existing file var existingChecksum = await ComputeChecksumAsync(outputPath, request.ChecksumAlgorithm, cancellationToken).ConfigureAwait(false); return new ApiSpecDownloadResult { Success = true, Path = outputPath, SizeBytes = new FileInfo(outputPath).Length, FromCache = true, Checksum = existingChecksum, ChecksumAlgorithm = request.ChecksumAlgorithm }; } // Build the endpoint URL var serviceSegment = string.IsNullOrWhiteSpace(request.Service) ? "aggregate" : Uri.EscapeDataString(request.Service.Trim().ToLowerInvariant()); var formatQuery = request.Format.Equals("openapi-yaml", StringComparison.OrdinalIgnoreCase) ? "?format=yaml" : ""; var relative = $"api/openapi/specs/{serviceSegment}{formatQuery}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } // Add conditional headers if (!string.IsNullOrWhiteSpace(request.ExpectedETag)) { httpRequest.Headers.IfNoneMatch.Add(new EntityTagHeaderValue($"\"{request.ExpectedETag}\"")); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); // Handle 304 Not Modified if (response.StatusCode == HttpStatusCode.NotModified) { if (File.Exists(outputPath)) { var cachedChecksum = await ComputeChecksumAsync(outputPath, request.ChecksumAlgorithm, cancellationToken).ConfigureAwait(false); return new ApiSpecDownloadResult { Success = true, Path = outputPath, SizeBytes = new FileInfo(outputPath).Length, FromCache = true, ETag = request.ExpectedETag, Checksum = cachedChecksum, ChecksumAlgorithm = request.ChecksumAlgorithm }; } } if (!response.IsSuccessStatusCode) { var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); return new ApiSpecDownloadResult { Success = false, Error = message, ErrorCode = errorCode }; } // Ensure output directory exists var outputDir = Path.GetDirectoryName(outputPath); if (!string.IsNullOrWhiteSpace(outputDir) && !Directory.Exists(outputDir)) { Directory.CreateDirectory(outputDir); } // Download and save the spec await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var fileStream = File.Create(outputPath); await contentStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false); var fileInfo = new FileInfo(outputPath); // Get ETag from response var etag = response.Headers.ETag?.Tag?.Trim('"'); // Compute checksum var checksum = await ComputeChecksumAsync(outputPath, request.ChecksumAlgorithm, cancellationToken).ConfigureAwait(false); // Verify checksum if expected bool? checksumVerified = null; if (!string.IsNullOrWhiteSpace(request.ExpectedChecksum)) { checksumVerified = string.Equals(checksum, request.ExpectedChecksum, StringComparison.OrdinalIgnoreCase); if (!checksumVerified.Value) { return new ApiSpecDownloadResult { Success = false, Path = outputPath, SizeBytes = fileInfo.Length, ETag = etag, Checksum = checksum, ChecksumAlgorithm = request.ChecksumAlgorithm, ChecksumVerified = false, Error = $"Checksum mismatch: expected {request.ExpectedChecksum}, got {checksum}", ErrorCode = "ERR_API_CHECKSUM_MISMATCH" }; } } // Try to extract API version from spec string? apiVersion = null; DateTimeOffset? generatedAt = null; try { var specContent = await File.ReadAllTextAsync(outputPath, cancellationToken).ConfigureAwait(false); if (specContent.Contains("\"info\"")) { var specJson = JsonDocument.Parse(specContent); if (specJson.RootElement.TryGetProperty("info", out var info)) { if (info.TryGetProperty("version", out var version)) { apiVersion = version.GetString(); } } } } catch { // Ignore version extraction errors } return new ApiSpecDownloadResult { Success = true, Path = outputPath, SizeBytes = fileInfo.Length, FromCache = false, ETag = etag, Checksum = checksum, ChecksumAlgorithm = request.ChecksumAlgorithm, ChecksumVerified = checksumVerified, ApiVersion = apiVersion, GeneratedAt = generatedAt }; } private async Task ComputeChecksumAsync(string filePath, string algorithm, CancellationToken cancellationToken) { using var hasher = algorithm.ToLowerInvariant() switch { "sha384" => (HashAlgorithm)SHA384.Create(), "sha512" => SHA512.Create(), _ => SHA256.Create() }; await using var stream = File.OpenRead(filePath); var hashBytes = await hasher.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); return Convert.ToHexString(hashBytes).ToLowerInvariant(); } // CLI-SDK-64-001: SDK update operations public async Task CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); EnsureBackendConfigured(); OfflineModeGuard.ThrowIfOffline("sdk update"); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.Language)) queryParams.Add($"language={Uri.EscapeDataString(request.Language)}"); if (request.IncludeChangelog) queryParams.Add("includeChangelog=true"); if (request.IncludeDeprecations) queryParams.Add("includeDeprecations=true"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var relative = $"api/sdk/updates{query}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(request.Tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.Tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); return new SdkUpdateResponse { Success = false, Error = message }; } var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" }; } public async Task ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken) { EnsureBackendConfigured(); OfflineModeGuard.ThrowIfOffline("sdk list"); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(language)) queryParams.Add($"language={Uri.EscapeDataString(language)}"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var relative = $"api/sdk/installed{query}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); if (!string.IsNullOrWhiteSpace(tenant)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim()); } await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); return new SdkListResponse { Success = false, Error = message }; } var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); return result ?? new SdkListResponse { Success = false, Error = "Empty response" }; } /// /// Get SARIF 2.1.0 output for a scan. /// Task: SDIFF-BIN-030 - CLI option --output-format sarif /// public async Task GetScanSarifAsync( string scanId, bool includeHardening, bool includeReachability, string? minSeverity, CancellationToken cancellationToken) { EnsureBackendConfigured(); OfflineModeGuard.ThrowIfOffline("scan sarif"); var queryParams = new List(); if (includeHardening) queryParams.Add("includeHardening=true"); if (includeReachability) queryParams.Add("includeReachability=true"); if (!string.IsNullOrWhiteSpace(minSeverity)) queryParams.Add($"minSeverity={Uri.EscapeDataString(minSeverity)}"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var relative = $"api/scans/{Uri.EscapeDataString(scanId)}/sarif{query}"; using var httpRequest = CreateRequest(HttpMethod.Get, relative); httpRequest.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/sarif+json")); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } /// /// Exports VEX decisions as OpenVEX documents with optional DSSE signing. /// public async Task ExportDecisionsAsync( DecisionExportRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try { var queryParams = new List(); if (!string.IsNullOrEmpty(request.ScanId)) { queryParams.Add($"scanId={Uri.EscapeDataString(request.ScanId)}"); } if (request.VulnIds is { Count: > 0 }) { foreach (var vulnId in request.VulnIds) { queryParams.Add($"vulnId={Uri.EscapeDataString(vulnId)}"); } } if (request.Purls is { Count: > 0 }) { foreach (var purl in request.Purls) { queryParams.Add($"purl={Uri.EscapeDataString(purl)}"); } } if (request.Statuses is { Count: > 0 }) { foreach (var status in request.Statuses) { queryParams.Add($"status={Uri.EscapeDataString(status)}"); } } queryParams.Add($"format={Uri.EscapeDataString(request.Format)}"); queryParams.Add($"sign={request.Sign.ToString().ToLowerInvariant()}"); queryParams.Add($"rekor={request.SubmitToRekor.ToString().ToLowerInvariant()}"); queryParams.Add($"includeEvidence={request.IncludeEvidence.ToString().ToLowerInvariant()}"); var queryString = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var url = $"{_options.BackendUrl}/api/v1/decisions/export{queryString}"; using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.TenantId); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false); return new DecisionExportResponse { Success = false, Error = message }; } var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); // Extract metadata from response headers response.Headers.TryGetValues("X-VEX-Digest", out var digestValues); response.Headers.TryGetValues("X-VEX-Rekor-Index", out var rekorIndexValues); response.Headers.TryGetValues("X-VEX-Rekor-UUID", out var rekorUuidValues); response.Headers.TryGetValues("X-VEX-Statement-Count", out var countValues); response.Headers.TryGetValues("X-VEX-Signed", out var signedValues); var digest = digestValues?.FirstOrDefault(); var rekorUuid = rekorUuidValues?.FirstOrDefault(); long? rekorIndex = null; int statementCount = 0; bool signed = false; if (rekorIndexValues?.FirstOrDefault() is { } indexStr && long.TryParse(indexStr, out var idx)) { rekorIndex = idx; } if (countValues?.FirstOrDefault() is { } countStr && int.TryParse(countStr, out var cnt)) { statementCount = cnt; } if (signedValues?.FirstOrDefault() is { } signedStr) { signed = signedStr.Equals("true", StringComparison.OrdinalIgnoreCase); } return new DecisionExportResponse { Success = true, Content = content, Digest = digest, RekorLogIndex = rekorIndex, RekorUuid = rekorUuid, StatementCount = statementCount, Signed = signed }; } catch (HttpRequestException ex) { return new DecisionExportResponse { Success = false, Error = ex.Message }; } } }