- 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.
4913 lines
198 KiB
C#
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
|
|
};
|
|
}
|
|
}
|
|
}
|