Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs
master 491e883653 Add tests for SBOM generation determinism across multiple formats
- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism.
- Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions.
- Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests.
- Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
2025-12-24 00:36:14 +02:00

4913 lines
198 KiB
C#

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<string, object?> EmptyMetadata =
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(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<BackendOperationsClient> _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<BackendOperationsClient> 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<ScannerArtifactResult> 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<ScannerArtifactResult> 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<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> 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<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(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<JobRunResponse>(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<ExcititorOperationResult> 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<ExcititorExportDownloadResult> 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<RuntimePolicyEvaluationResult> 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<string, string>(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<RuntimePolicyEvaluationResponseDocument>(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<string, RuntimePolicyImageDecision>(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<string, RuntimePolicyImageDecision>(decisions);
return new RuntimePolicyEvaluationResult(
document.TtlSeconds ?? 0,
document.ExpiresAtUtc?.ToUniversalTime(),
string.IsNullOrWhiteSpace(document.PolicyRevision) ? null : document.PolicyRevision,
decisionsView);
}
public async Task<PolicyActivationResult> 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<PolicyActivationResponseDocument>(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<PolicySimulationResult> 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<string, JsonElement>(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<PolicySimulationResponseDocument>(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<TaskRunnerSimulationResult> 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<TaskRunnerSimulationResponseDocument>(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<PolicyFindingsPage> 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<PolicyFindingsResponseDocument>(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<PolicyFindingDocument> 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<PolicyFindingDocumentDocument>(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<PolicyFindingExplainResult> 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<PolicyFindingExplainResponseDocument>(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<EntryTraceResponseModel?> 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<EntryTraceResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (result is null)
{
throw new InvalidOperationException("EntryTrace response payload was empty.");
}
return result;
}
public async Task<RubyPackageInventoryModel?> 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<RubyPackageInventoryModel>(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<RubyPackageArtifactModel>();
return inventory with
{
ScanId = normalizedScanId,
ImageDigest = normalizedDigest,
Packages = packages
};
}
public async Task<BunPackageInventory?> 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<BunPackageInventory>(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<BunPackageItem>();
return inventory with
{
ScanId = normalizedScanId,
Packages = packages
};
}
public async Task<AdvisoryPipelinePlanResponseModel> 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<AdvisoryPipelinePlanResponseModel>(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<AdvisoryPipelineOutputModel?> 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<AdvisoryPipelineOutputModel>(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<IReadOnlyList<ExcititorProviderSummary>> 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<ExcititorProviderSummary>();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
if (stream is null || stream.Length == 0)
{
return Array.Empty<ExcititorProviderSummary>();
}
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<ExcititorProviderSummary>();
}
var list = new List<ExcititorProviderSummary>();
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<OfflineKitDownloadResult> 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<OfflineKitImportResult> 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<OfflineKitImportResponseTransport>(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<OfflineKitStatus> 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<OfflineKitComponentStatus>());
}
OfflineKitStatusTransport? document;
try
{
document = await response.Content.ReadFromJsonAsync<OfflineKitStatusTransport>(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<AocIngestDryRunResponse> 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<AocIngestDryRunResponse>(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<AocVerifyResponse> 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<AocVerifyResponse>(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<OfflineKitBundleDescriptor> 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<OfflineKitBundleDescriptorTransport>(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<OfflineKitComponentStatus> MapOfflineComponents(List<OfflineKitComponentStatusTransport>? transports)
{
if (transports is null || transports.Count == 0)
{
return Array.Empty<OfflineKitComponentStatus>();
}
var list = new List<OfflineKitComponentStatus>();
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<OfflineKitComponentStatus>() : 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<string> NormalizeImages(IReadOnlyList<string> images)
{
var normalized = new List<string>();
if (images is null)
{
return normalized;
}
var seen = new HashSet<string>(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<string> ExtractReasons(List<string>? reasons)
{
if (reasons is null || reasons.Count == 0)
{
return Array.Empty<string>();
}
var list = new List<string>();
foreach (var reason in reasons)
{
if (!string.IsNullOrWhiteSpace(reason))
{
list.Add(reason.Trim());
}
}
return list.Count == 0 ? Array.Empty<string>() : list;
}
private static IReadOnlyDictionary<string, object?> ExtractExtensionMetadata(Dictionary<string, JsonElement>? extensionData)
{
if (extensionData is null || extensionData.Count == 0)
{
return EmptyMetadata;
}
var metadata = new Dictionary<string, object?>(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<string, object?>(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<string, string>? 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<string, string>(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<string?> 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<object?>(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<string>();
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<PolicyFindingDocument>(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<PolicyFindingDocument>(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<string> advisoryIds;
if (document.AdvisoryIds is null || document.AdvisoryIds.Count == 0)
{
advisoryIds = Array.Empty<string>();
}
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<PolicyFindingUncertaintyState>? 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<PolicyFindingExplainStep>(capacity: 0)
: document.Steps
.Where(step => step is not null)
.Select(step => MapPolicyFindingExplainStep(step!))
.ToList();
var hints = document.SealedHints is null
? new List<PolicyFindingExplainHint>(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<PolicyFindingExplainStep>(steps),
new ReadOnlyCollection<PolicyFindingExplainHint>(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<string, string> inputs = document.Inputs is null
? new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.Ordinal))
: new ReadOnlyDictionary<string, string>(document.Inputs
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
.ToDictionary(
kvp => kvp.Key,
kvp => ConvertJsonElementToString(kvp.Value),
StringComparer.Ordinal));
IReadOnlyDictionary<string, string>? 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<string, string>(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<PolicyActivationApproval>();
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<PolicyActivationApproval>(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<string, PolicySimulationSeverityDelta>(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<string, PolicySimulationSeverityDelta>(severity);
var ruleHits = diffDocument.RuleHits is null
? new List<PolicySimulationRuleDelta>()
: 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<PolicySimulationHeatmapBucket>()
: 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<TaskRunnerSimulationStep>()
: document.Steps
.Where(step => step is not null)
.Select(step => MapTaskRunnerSimulationStep(step!))
.ToList();
var outputs = document.Outputs is null
? new List<TaskRunnerSimulationOutput>()
: 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<TaskRunnerSimulationStep>()
: 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<string> 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);
}
/// <summary>
/// Parses API error response supporting both standardized envelope and ProblemDetails.
/// CLI-SDK-62-002: Enhanced error parsing for standardized API error envelope.
/// </summary>
private async Task<ParsedApiError> 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<string, object?>? metadata = null;
IReadOnlyList<ApiErrorDetail>? 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<ApiErrorEnvelope>(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<ProblemDocument>(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<string, object?>(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
};
}
/// <summary>
/// Extracts error code from problem type URI.
/// </summary>
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;
}
/// <summary>
/// Gets default error code based on HTTP status code.
/// </summary>
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<string> 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<string> 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<VexConsensusListResponse> ListVexConsensusAsync(VexConsensusListRequest request, string? tenant, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
EnsureBackendConfigured();
var queryParams = new List<string>();
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<VexConsensusListResponse>(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<VexConsensusDetailResponse?> 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<VexConsensusDetailResponse>(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<VexSimulationResponse> 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<VexSimulationResponse>(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<VexExportResponse> 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<VexExportResponse>(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<Stream> 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<VulnListResponse> ListVulnerabilitiesAsync(VulnListRequest request, string? tenant, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var queryParams = new List<string>();
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<VulnListResponse>(json, SerializerOptions);
return result ?? new VulnListResponse(Array.Empty<VulnItem>(), 0, 0, 0, false);
}
// CLI-VULN-29-002: Vulnerability detail
public async Task<VulnDetailResponse?> 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<VulnDetailResponse>(json, SerializerOptions);
}
// CLI-VULN-29-003: Vulnerability workflow operations
public async Task<VulnWorkflowResponse> 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<VulnWorkflowResponse>(json, SerializerOptions);
return result ?? new VulnWorkflowResponse(false, request.Action, 0);
}
// CLI-VULN-29-004: Vulnerability simulation
public async Task<VulnSimulationResponse> 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<VulnSimulationResponse>(json, SerializerOptions);
return result ?? new VulnSimulationResponse(Array.Empty<VulnSimulationDelta>(), new VulnSimulationSummary(0, 0, 0, 0, 0));
}
// CLI-VULN-29-005: Vulnerability export
public async Task<VulnExportResponse> 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<VulnExportResponse>(json, SerializerOptions);
return result ?? throw new InvalidOperationException("Failed to parse export response.");
}
public async Task<Stream> 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<PolicyHistoryResponse> GetPolicyHistoryAsync(PolicyHistoryRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
var queryParams = new List<string>();
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<PolicyHistoryResponse>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicyHistoryResponse();
}
public async Task<PolicyExplainResult> GetPolicyExplainAsync(PolicyExplainRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
var queryParams = new List<string>();
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<PolicyExplainResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicyExplainResult();
}
// CLI-POLICY-27-002: Policy submission/review workflow
public async Task<PolicyVersionBumpResult> 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<PolicyVersionBumpResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicyVersionBumpResult();
}
public async Task<PolicySubmitResult> 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<PolicySubmitResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicySubmitResult();
}
public async Task<PolicyReviewCommentResult> 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<PolicyReviewCommentResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicyReviewCommentResult();
}
public async Task<PolicyApproveResult> 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<PolicyApproveResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicyApproveResult();
}
public async Task<PolicyRejectResult> 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<PolicyRejectResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicyRejectResult();
}
public async Task<PolicyReviewSummary?> 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<PolicyReviewSummary>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
// CLI-POLICY-27-004: Policy lifecycle (publish/promote/rollback/sign)
public async Task<PolicyPublishResult> 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<PolicyPublishResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicyPublishResult();
}
public async Task<PolicyPromoteResult> 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<PolicyPromoteResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicyPromoteResult();
}
public async Task<PolicyRollbackResult> 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<PolicyRollbackResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicyRollbackResult();
}
public async Task<PolicySignResult> 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<PolicySignResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicySignResult();
}
public async Task<PolicyVerifySignatureResult> 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<PolicyVerifySignatureResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new PolicyVerifySignatureResult();
}
// CLI-RISK-66-001: Risk profile list
public async Task<RiskProfileListResponse> ListRiskProfilesAsync(RiskProfileListRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
var queryParams = new List<string>();
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<RiskProfileListResponse>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new RiskProfileListResponse();
}
// CLI-RISK-66-002: Risk simulate
public async Task<RiskSimulateResult> 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<RiskSimulateResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new RiskSimulateResult();
}
// CLI-RISK-67-001: Risk results
public async Task<RiskResultsResponse> GetRiskResultsAsync(RiskResultsRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
var queryParams = new List<string>();
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<RiskResultsResponse>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new RiskResultsResponse();
}
// CLI-RISK-68-001: Risk bundle verify
public async Task<RiskBundleVerifyResult> 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<RiskBundleVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new RiskBundleVerifyResult();
}
// CLI-SIG-26-001: Reachability operations
public async Task<ReachabilityUploadCallGraphResult> 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<ReachabilityUploadCallGraphResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new ReachabilityUploadCallGraphResult();
}
public async Task<ReachabilityListResponse> ListReachabilityAnalysesAsync(ReachabilityListRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
OfflineModeGuard.ThrowIfOffline("reachability list");
var queryParams = new List<string>();
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<ReachabilityListResponse>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new ReachabilityListResponse();
}
public async Task<ReachabilityExplainResult> ExplainReachabilityAsync(ReachabilityExplainRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
OfflineModeGuard.ThrowIfOffline("reachability explain");
var queryParams = new List<string>();
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<ReachabilityExplainResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new ReachabilityExplainResult();
}
// UI-CLI-401-007: Graph explain with DSSE pointers, runtime hits, predicates, counterfactuals
public async Task<GraphExplainResult> ExplainGraphAsync(GraphExplainRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
OfflineModeGuard.ThrowIfOffline("graph explain");
var queryParams = new List<string>();
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<GraphExplainResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new GraphExplainResult();
}
// CLI-SDK-63-001: API spec operations
public async Task<ApiSpecListResponse> 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<ApiSpecListResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? new ApiSpecListResponse { Success = false, Error = "Empty response" };
}
public async Task<ApiSpecDownloadResult> 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<string> 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<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
OfflineModeGuard.ThrowIfOffline("sdk update");
var queryParams = new List<string>();
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<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
}
public async Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
OfflineModeGuard.ThrowIfOffline("sdk list");
var queryParams = new List<string>();
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<SdkListResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? new SdkListResponse { Success = false, Error = "Empty response" };
}
/// <summary>
/// Get SARIF 2.1.0 output for a scan.
/// Task: SDIFF-BIN-030 - CLI option --output-format sarif
/// </summary>
public async Task<string?> GetScanSarifAsync(
string scanId,
bool includeHardening,
bool includeReachability,
string? minSeverity,
CancellationToken cancellationToken)
{
EnsureBackendConfigured();
OfflineModeGuard.ThrowIfOffline("scan sarif");
var queryParams = new List<string>();
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);
}
/// <summary>
/// Exports VEX decisions as OpenVEX documents with optional DSSE signing.
/// </summary>
public async Task<DecisionExportResponse> ExportDecisionsAsync(
DecisionExportRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var queryParams = new List<string>();
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
};
}
}
}