up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
@@ -9,11 +10,12 @@ using StellaOps.Cli.Services.Models;
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for Authority console endpoints (CLI-TEN-47-001).
|
||||
/// HTTP client for Authority console endpoints (CLI-TEN-47-001, CLI-TEN-49-001).
|
||||
/// </summary>
|
||||
internal sealed class AuthorityConsoleClient : IAuthorityConsoleClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public AuthorityConsoleClient(HttpClient httpClient)
|
||||
{
|
||||
@@ -38,4 +40,73 @@ internal sealed class AuthorityConsoleClient : IAuthorityConsoleClient
|
||||
|
||||
return result?.Tenants ?? Array.Empty<TenantInfo>();
|
||||
}
|
||||
|
||||
public async Task<TokenMintResponse> MintTokenAsync(TokenMintRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "console/token/mint")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: JsonOptions)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
httpRequest.Headers.Add("X-StellaOps-Tenant", request.Tenant.Trim().ToLowerInvariant());
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content
|
||||
.ReadFromJsonAsync<TokenMintResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Token mint response was empty.");
|
||||
}
|
||||
|
||||
public async Task<TokenDelegateResponse> DelegateTokenAsync(TokenDelegateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "console/token/delegate")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: JsonOptions)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
httpRequest.Headers.Add("X-StellaOps-Tenant", request.Tenant.Trim().ToLowerInvariant());
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content
|
||||
.ReadFromJsonAsync<TokenDelegateResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Token delegation response was empty.");
|
||||
}
|
||||
|
||||
public async Task<TokenIntrospectionResponse?> IntrospectTokenAsync(string? tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "console/token/introspect");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
httpRequest.Headers.Add("X-StellaOps-Tenant", tenant.Trim().ToLowerInvariant());
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content
|
||||
.ReadFromJsonAsync<TokenIntrospectionResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ 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.Ruby;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
@@ -32,12 +32,12 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
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 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;
|
||||
@@ -859,9 +859,9 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
return MapPolicyFindingExplain(document);
|
||||
}
|
||||
|
||||
public async Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
public async Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
@@ -883,174 +883,174 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
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<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 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<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);
|
||||
@@ -1937,44 +1937,44 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
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)
|
||||
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))
|
||||
{
|
||||
@@ -2857,4 +2857,469 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using StellaOps.Cli.Services.Models;
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Authority console endpoints (CLI-TEN-47-001).
|
||||
/// Client for Authority console endpoints (CLI-TEN-47-001, CLI-TEN-49-001).
|
||||
/// </summary>
|
||||
internal interface IAuthorityConsoleClient
|
||||
{
|
||||
@@ -14,4 +14,19 @@ internal interface IAuthorityConsoleClient
|
||||
/// Lists available tenants for the authenticated principal.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TenantInfo>> ListTenantsAsync(string tenant, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Mints a service account token (CLI-TEN-49-001).
|
||||
/// </summary>
|
||||
Task<TokenMintResponse> MintTokenAsync(TokenMintRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Delegates a token to another principal (CLI-TEN-49-001).
|
||||
/// </summary>
|
||||
Task<TokenDelegateResponse> DelegateTokenAsync(TokenDelegateRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Introspects the current token for impersonation/delegation info (CLI-TEN-49-001).
|
||||
/// </summary>
|
||||
Task<TokenIntrospectionResponse?> IntrospectTokenAsync(string? tenant, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IBackendOperationsClient
|
||||
{
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IBackendOperationsClient
|
||||
{
|
||||
Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken);
|
||||
|
||||
Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken);
|
||||
@@ -54,4 +54,33 @@ internal interface IBackendOperationsClient
|
||||
Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VEX-30-001: VEX consensus operations
|
||||
Task<VexConsensusListResponse> ListVexConsensusAsync(VexConsensusListRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VEX-30-002: VEX consensus detail
|
||||
Task<VexConsensusDetailResponse?> GetVexConsensusAsync(string vulnerabilityId, string productKey, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VEX-30-003: VEX simulation
|
||||
Task<VexSimulationResponse> SimulateVexConsensusAsync(VexSimulationRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VEX-30-004: VEX export
|
||||
Task<VexExportResponse> ExportVexConsensusAsync(VexExportRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
Task<Stream> DownloadVexExportAsync(string exportId, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VULN-29-001: Vulnerability explorer list
|
||||
Task<VulnListResponse> ListVulnerabilitiesAsync(VulnListRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VULN-29-002: Vulnerability detail
|
||||
Task<VulnDetailResponse?> GetVulnerabilityAsync(string vulnerabilityId, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VULN-29-003: Vulnerability workflow operations
|
||||
Task<VulnWorkflowResponse> ExecuteVulnWorkflowAsync(VulnWorkflowRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VULN-29-004: Vulnerability simulation
|
||||
Task<VulnSimulationResponse> SimulateVulnerabilitiesAsync(VulnSimulationRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VULN-29-005: Vulnerability export
|
||||
Task<VulnExportResponse> ExportVulnerabilitiesAsync(VulnExportRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
Task<Stream> DownloadVulnExportAsync(string exportId, string? tenant, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -35,3 +35,60 @@ internal sealed record TenantProfile
|
||||
[JsonPropertyName("lastUpdated")]
|
||||
public DateTimeOffset? LastUpdated { get; init; }
|
||||
}
|
||||
|
||||
// CLI-TEN-49-001: Token minting and delegation models
|
||||
|
||||
/// <summary>
|
||||
/// Request to mint a service account token.
|
||||
/// </summary>
|
||||
internal sealed record TokenMintRequest(
|
||||
[property: JsonPropertyName("serviceAccountId")] string ServiceAccountId,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes,
|
||||
[property: JsonPropertyName("expiresInSeconds")] int? ExpiresInSeconds = null,
|
||||
[property: JsonPropertyName("tenant")] string? Tenant = null,
|
||||
[property: JsonPropertyName("reason")] string? Reason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response from token minting.
|
||||
/// </summary>
|
||||
internal sealed record TokenMintResponse(
|
||||
[property: JsonPropertyName("accessToken")] string AccessToken,
|
||||
[property: JsonPropertyName("tokenType")] string TokenType,
|
||||
[property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes,
|
||||
[property: JsonPropertyName("tokenId")] string? TokenId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to delegate a token to another principal.
|
||||
/// </summary>
|
||||
internal sealed record TokenDelegateRequest(
|
||||
[property: JsonPropertyName("delegateTo")] string DelegateTo,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes,
|
||||
[property: JsonPropertyName("expiresInSeconds")] int? ExpiresInSeconds = null,
|
||||
[property: JsonPropertyName("tenant")] string? Tenant = null,
|
||||
[property: JsonPropertyName("reason")] string? Reason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response from token delegation.
|
||||
/// </summary>
|
||||
internal sealed record TokenDelegateResponse(
|
||||
[property: JsonPropertyName("accessToken")] string AccessToken,
|
||||
[property: JsonPropertyName("tokenType")] string TokenType,
|
||||
[property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt,
|
||||
[property: JsonPropertyName("delegationId")] string DelegationId,
|
||||
[property: JsonPropertyName("originalSubject")] string OriginalSubject,
|
||||
[property: JsonPropertyName("delegatedSubject")] string DelegatedSubject,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes);
|
||||
|
||||
/// <summary>
|
||||
/// Token introspection response for impersonation banner.
|
||||
/// </summary>
|
||||
internal sealed record TokenIntrospectionResponse(
|
||||
[property: JsonPropertyName("active")] bool Active,
|
||||
[property: JsonPropertyName("sub")] string? Subject = null,
|
||||
[property: JsonPropertyName("clientId")] string? ClientId = null,
|
||||
[property: JsonPropertyName("scope")] string? Scope = null,
|
||||
[property: JsonPropertyName("exp")] long? ExpiresAt = null,
|
||||
[property: JsonPropertyName("iat")] long? IssuedAt = null,
|
||||
[property: JsonPropertyName("delegatedBy")] string? DelegatedBy = null,
|
||||
[property: JsonPropertyName("delegationReason")] string? DelegationReason = null);
|
||||
|
||||
258
src/Cli/StellaOps.Cli/Services/Models/VexModels.cs
Normal file
258
src/Cli/StellaOps.Cli/Services/Models/VexModels.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-VEX-30-001: VEX consensus models for CLI
|
||||
|
||||
/// <summary>
|
||||
/// VEX consensus list request parameters.
|
||||
/// </summary>
|
||||
internal sealed record VexConsensusListRequest(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string? VulnerabilityId = null,
|
||||
[property: JsonPropertyName("productKey")] string? ProductKey = null,
|
||||
[property: JsonPropertyName("purl")] string? Purl = null,
|
||||
[property: JsonPropertyName("status")] string? Status = null,
|
||||
[property: JsonPropertyName("policyVersion")] string? PolicyVersion = null,
|
||||
[property: JsonPropertyName("limit")] int? Limit = null,
|
||||
[property: JsonPropertyName("offset")] int? Offset = null);
|
||||
|
||||
/// <summary>
|
||||
/// Paginated VEX consensus list response.
|
||||
/// </summary>
|
||||
internal sealed record VexConsensusListResponse(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<VexConsensusItem> Items,
|
||||
[property: JsonPropertyName("total")] int Total,
|
||||
[property: JsonPropertyName("limit")] int Limit,
|
||||
[property: JsonPropertyName("offset")] int Offset,
|
||||
[property: JsonPropertyName("hasMore")] bool HasMore);
|
||||
|
||||
/// <summary>
|
||||
/// VEX consensus item from the API.
|
||||
/// </summary>
|
||||
internal sealed record VexConsensusItem(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("product")] VexProductInfo Product,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("calculatedAt")] DateTimeOffset CalculatedAt,
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<VexConsensusSourceInfo> Sources,
|
||||
[property: JsonPropertyName("conflicts")] IReadOnlyList<VexConsensusConflictInfo>? Conflicts = null,
|
||||
[property: JsonPropertyName("policyVersion")] string? PolicyVersion = null,
|
||||
[property: JsonPropertyName("policyDigest")] string? PolicyDigest = null,
|
||||
[property: JsonPropertyName("summary")] string? Summary = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX product information.
|
||||
/// </summary>
|
||||
internal sealed record VexProductInfo(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
[property: JsonPropertyName("name")] string? Name = null,
|
||||
[property: JsonPropertyName("version")] string? Version = null,
|
||||
[property: JsonPropertyName("purl")] string? Purl = null,
|
||||
[property: JsonPropertyName("cpe")] string? Cpe = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX consensus source (accepted claim).
|
||||
/// </summary>
|
||||
internal sealed record VexConsensusSourceInfo(
|
||||
[property: JsonPropertyName("providerId")] string ProviderId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("documentDigest")] string? DocumentDigest = null,
|
||||
[property: JsonPropertyName("weight")] double Weight = 1.0,
|
||||
[property: JsonPropertyName("justification")] string? Justification = null,
|
||||
[property: JsonPropertyName("detail")] string? Detail = null,
|
||||
[property: JsonPropertyName("confidence")] VexConfidenceInfo? Confidence = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX consensus conflict (rejected claim).
|
||||
/// </summary>
|
||||
internal sealed record VexConsensusConflictInfo(
|
||||
[property: JsonPropertyName("providerId")] string ProviderId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("documentDigest")] string? DocumentDigest = null,
|
||||
[property: JsonPropertyName("justification")] string? Justification = null,
|
||||
[property: JsonPropertyName("detail")] string? Detail = null,
|
||||
[property: JsonPropertyName("reason")] string? Reason = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX confidence information.
|
||||
/// </summary>
|
||||
internal sealed record VexConfidenceInfo(
|
||||
[property: JsonPropertyName("level")] string? Level = null,
|
||||
[property: JsonPropertyName("score")] double? Score = null,
|
||||
[property: JsonPropertyName("method")] string? Method = null);
|
||||
|
||||
// CLI-VEX-30-002: VEX consensus detail models
|
||||
|
||||
/// <summary>
|
||||
/// Detailed VEX consensus response including quorum, evidence, rationale, and signature status.
|
||||
/// </summary>
|
||||
internal sealed record VexConsensusDetailResponse(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("product")] VexProductInfo Product,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("calculatedAt")] DateTimeOffset CalculatedAt,
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<VexConsensusSourceInfo> Sources,
|
||||
[property: JsonPropertyName("conflicts")] IReadOnlyList<VexConsensusConflictInfo>? Conflicts = null,
|
||||
[property: JsonPropertyName("policyVersion")] string? PolicyVersion = null,
|
||||
[property: JsonPropertyName("policyDigest")] string? PolicyDigest = null,
|
||||
[property: JsonPropertyName("summary")] string? Summary = null,
|
||||
[property: JsonPropertyName("quorum")] VexQuorumInfo? Quorum = null,
|
||||
[property: JsonPropertyName("rationale")] VexRationaleInfo? Rationale = null,
|
||||
[property: JsonPropertyName("signature")] VexSignatureInfo? Signature = null,
|
||||
[property: JsonPropertyName("evidence")] IReadOnlyList<VexEvidenceInfo>? Evidence = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX quorum information showing how consensus was reached.
|
||||
/// </summary>
|
||||
internal sealed record VexQuorumInfo(
|
||||
[property: JsonPropertyName("required")] int Required,
|
||||
[property: JsonPropertyName("achieved")] int Achieved,
|
||||
[property: JsonPropertyName("threshold")] double Threshold,
|
||||
[property: JsonPropertyName("totalWeight")] double TotalWeight,
|
||||
[property: JsonPropertyName("weightAchieved")] double WeightAchieved,
|
||||
[property: JsonPropertyName("participatingProviders")] IReadOnlyList<string>? ParticipatingProviders = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX rationale explaining the consensus decision.
|
||||
/// </summary>
|
||||
internal sealed record VexRationaleInfo(
|
||||
[property: JsonPropertyName("text")] string? Text = null,
|
||||
[property: JsonPropertyName("justifications")] IReadOnlyList<string>? Justifications = null,
|
||||
[property: JsonPropertyName("policyRules")] IReadOnlyList<string>? PolicyRules = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX signature status information.
|
||||
/// </summary>
|
||||
internal sealed record VexSignatureInfo(
|
||||
[property: JsonPropertyName("signed")] bool Signed,
|
||||
[property: JsonPropertyName("algorithm")] string? Algorithm = null,
|
||||
[property: JsonPropertyName("keyId")] string? KeyId = null,
|
||||
[property: JsonPropertyName("signedAt")] DateTimeOffset? SignedAt = null,
|
||||
[property: JsonPropertyName("verificationStatus")] string? VerificationStatus = null,
|
||||
[property: JsonPropertyName("certificateChain")] IReadOnlyList<string>? CertificateChain = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX evidence supporting the consensus decision.
|
||||
/// </summary>
|
||||
internal sealed record VexEvidenceInfo(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("providerId")] string ProviderId,
|
||||
[property: JsonPropertyName("documentId")] string? DocumentId = null,
|
||||
[property: JsonPropertyName("documentDigest")] string? DocumentDigest = null,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset? Timestamp = null,
|
||||
[property: JsonPropertyName("content")] string? Content = null);
|
||||
|
||||
// CLI-VEX-30-003: VEX simulation models
|
||||
|
||||
/// <summary>
|
||||
/// VEX simulation request with trust/threshold overrides.
|
||||
/// </summary>
|
||||
internal sealed record VexSimulationRequest(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string? VulnerabilityId = null,
|
||||
[property: JsonPropertyName("productKey")] string? ProductKey = null,
|
||||
[property: JsonPropertyName("purl")] string? Purl = null,
|
||||
[property: JsonPropertyName("trustOverrides")] IReadOnlyDictionary<string, double>? TrustOverrides = null,
|
||||
[property: JsonPropertyName("thresholdOverride")] double? ThresholdOverride = null,
|
||||
[property: JsonPropertyName("quorumOverride")] int? QuorumOverride = null,
|
||||
[property: JsonPropertyName("excludeProviders")] IReadOnlyList<string>? ExcludeProviders = null,
|
||||
[property: JsonPropertyName("includeOnly")] IReadOnlyList<string>? IncludeOnly = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX simulation response showing before/after comparison.
|
||||
/// </summary>
|
||||
internal sealed record VexSimulationResponse(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<VexSimulationResultItem> Items,
|
||||
[property: JsonPropertyName("parameters")] VexSimulationParameters Parameters,
|
||||
[property: JsonPropertyName("summary")] VexSimulationSummary Summary);
|
||||
|
||||
/// <summary>
|
||||
/// Individual VEX simulation result showing the delta.
|
||||
/// </summary>
|
||||
internal sealed record VexSimulationResultItem(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("product")] VexProductInfo Product,
|
||||
[property: JsonPropertyName("before")] VexSimulationState Before,
|
||||
[property: JsonPropertyName("after")] VexSimulationState After,
|
||||
[property: JsonPropertyName("changed")] bool Changed,
|
||||
[property: JsonPropertyName("changeType")] string? ChangeType = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX state for simulation comparison.
|
||||
/// </summary>
|
||||
internal sealed record VexSimulationState(
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("quorumAchieved")] int QuorumAchieved,
|
||||
[property: JsonPropertyName("weightAchieved")] double WeightAchieved,
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<string>? Sources = null);
|
||||
|
||||
/// <summary>
|
||||
/// Parameters used in the simulation.
|
||||
/// </summary>
|
||||
internal sealed record VexSimulationParameters(
|
||||
[property: JsonPropertyName("threshold")] double Threshold,
|
||||
[property: JsonPropertyName("quorum")] int Quorum,
|
||||
[property: JsonPropertyName("trustWeights")] IReadOnlyDictionary<string, double>? TrustWeights = null,
|
||||
[property: JsonPropertyName("excludedProviders")] IReadOnlyList<string>? ExcludedProviders = null);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of simulation results.
|
||||
/// </summary>
|
||||
internal sealed record VexSimulationSummary(
|
||||
[property: JsonPropertyName("totalEvaluated")] int TotalEvaluated,
|
||||
[property: JsonPropertyName("totalChanged")] int TotalChanged,
|
||||
[property: JsonPropertyName("statusUpgrades")] int StatusUpgrades,
|
||||
[property: JsonPropertyName("statusDowngrades")] int StatusDowngrades,
|
||||
[property: JsonPropertyName("noChange")] int NoChange);
|
||||
|
||||
// CLI-VEX-30-004: VEX export models
|
||||
|
||||
/// <summary>
|
||||
/// VEX export request parameters.
|
||||
/// </summary>
|
||||
internal sealed record VexExportRequest(
|
||||
[property: JsonPropertyName("vulnerabilityIds")] IReadOnlyList<string>? VulnerabilityIds = null,
|
||||
[property: JsonPropertyName("productKeys")] IReadOnlyList<string>? ProductKeys = null,
|
||||
[property: JsonPropertyName("purls")] IReadOnlyList<string>? Purls = null,
|
||||
[property: JsonPropertyName("statuses")] IReadOnlyList<string>? Statuses = null,
|
||||
[property: JsonPropertyName("policyVersion")] string? PolicyVersion = null,
|
||||
[property: JsonPropertyName("signed")] bool Signed = true,
|
||||
[property: JsonPropertyName("format")] string Format = "ndjson");
|
||||
|
||||
/// <summary>
|
||||
/// VEX export response with download information.
|
||||
/// </summary>
|
||||
internal sealed record VexExportResponse(
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("downloadUrl")] string? DownloadUrl = null,
|
||||
[property: JsonPropertyName("format")] string Format = "ndjson",
|
||||
[property: JsonPropertyName("itemCount")] int ItemCount = 0,
|
||||
[property: JsonPropertyName("signed")] bool Signed = false,
|
||||
[property: JsonPropertyName("signatureAlgorithm")] string? SignatureAlgorithm = null,
|
||||
[property: JsonPropertyName("signatureKeyId")] string? SignatureKeyId = null,
|
||||
[property: JsonPropertyName("digest")] string? Digest = null,
|
||||
[property: JsonPropertyName("digestAlgorithm")] string? DigestAlgorithm = null,
|
||||
[property: JsonPropertyName("expiresAt")] DateTimeOffset? ExpiresAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX export signature verification request.
|
||||
/// </summary>
|
||||
internal sealed record VexExportVerifyRequest(
|
||||
[property: JsonPropertyName("filePath")] string FilePath,
|
||||
[property: JsonPropertyName("signaturePath")] string? SignaturePath = null,
|
||||
[property: JsonPropertyName("expectedDigest")] string? ExpectedDigest = null,
|
||||
[property: JsonPropertyName("publicKeyPath")] string? PublicKeyPath = null);
|
||||
|
||||
/// <summary>
|
||||
/// VEX export signature verification result.
|
||||
/// </summary>
|
||||
internal sealed record VexExportVerifyResult(
|
||||
[property: JsonPropertyName("valid")] bool Valid,
|
||||
[property: JsonPropertyName("signatureStatus")] string SignatureStatus,
|
||||
[property: JsonPropertyName("digestMatch")] bool? DigestMatch = null,
|
||||
[property: JsonPropertyName("actualDigest")] string? ActualDigest = null,
|
||||
[property: JsonPropertyName("expectedDigest")] string? ExpectedDigest = null,
|
||||
[property: JsonPropertyName("keyId")] string? KeyId = null,
|
||||
[property: JsonPropertyName("signedAt")] DateTimeOffset? SignedAt = null,
|
||||
[property: JsonPropertyName("errors")] IReadOnlyList<string>? Errors = null);
|
||||
291
src/Cli/StellaOps.Cli/Services/Models/VulnModels.cs
Normal file
291
src/Cli/StellaOps.Cli/Services/Models/VulnModels.cs
Normal file
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-VULN-29-001: Vulnerability Explorer models for CLI
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability list request parameters.
|
||||
/// </summary>
|
||||
internal sealed record VulnListRequest(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string? VulnerabilityId = null,
|
||||
[property: JsonPropertyName("severity")] string? Severity = null,
|
||||
[property: JsonPropertyName("status")] string? Status = null,
|
||||
[property: JsonPropertyName("purl")] string? Purl = null,
|
||||
[property: JsonPropertyName("cpe")] string? Cpe = null,
|
||||
[property: JsonPropertyName("sbomId")] string? SbomId = null,
|
||||
[property: JsonPropertyName("policyId")] string? PolicyId = null,
|
||||
[property: JsonPropertyName("policyVersion")] int? PolicyVersion = null,
|
||||
[property: JsonPropertyName("groupBy")] string? GroupBy = null,
|
||||
[property: JsonPropertyName("limit")] int? Limit = null,
|
||||
[property: JsonPropertyName("offset")] int? Offset = null,
|
||||
[property: JsonPropertyName("cursor")] string? Cursor = null);
|
||||
|
||||
/// <summary>
|
||||
/// Paginated vulnerability list response.
|
||||
/// </summary>
|
||||
internal sealed record VulnListResponse(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<VulnItem> Items,
|
||||
[property: JsonPropertyName("total")] int Total,
|
||||
[property: JsonPropertyName("limit")] int Limit,
|
||||
[property: JsonPropertyName("offset")] int Offset,
|
||||
[property: JsonPropertyName("hasMore")] bool HasMore,
|
||||
[property: JsonPropertyName("nextCursor")] string? NextCursor = null,
|
||||
[property: JsonPropertyName("grouping")] VulnGroupingInfo? Grouping = null);
|
||||
|
||||
/// <summary>
|
||||
/// Individual vulnerability item from the explorer.
|
||||
/// </summary>
|
||||
internal sealed record VulnItem(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("severity")] VulnSeverityInfo Severity,
|
||||
[property: JsonPropertyName("affectedPackages")] IReadOnlyList<VulnAffectedPackage> AffectedPackages,
|
||||
[property: JsonPropertyName("vexStatus")] string? VexStatus = null,
|
||||
[property: JsonPropertyName("policyFindingId")] string? PolicyFindingId = null,
|
||||
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases = null,
|
||||
[property: JsonPropertyName("summary")] string? Summary = null,
|
||||
[property: JsonPropertyName("publishedAt")] DateTimeOffset? PublishedAt = null,
|
||||
[property: JsonPropertyName("updatedAt")] DateTimeOffset? UpdatedAt = null,
|
||||
[property: JsonPropertyName("assignee")] string? Assignee = null,
|
||||
[property: JsonPropertyName("dueDate")] DateTimeOffset? DueDate = null,
|
||||
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags = null);
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability severity information.
|
||||
/// </summary>
|
||||
internal sealed record VulnSeverityInfo(
|
||||
[property: JsonPropertyName("level")] string Level,
|
||||
[property: JsonPropertyName("score")] double? Score = null,
|
||||
[property: JsonPropertyName("vector")] string? Vector = null,
|
||||
[property: JsonPropertyName("source")] string? Source = null);
|
||||
|
||||
/// <summary>
|
||||
/// Affected package information.
|
||||
/// </summary>
|
||||
internal sealed record VulnAffectedPackage(
|
||||
[property: JsonPropertyName("purl")] string? Purl = null,
|
||||
[property: JsonPropertyName("cpe")] string? Cpe = null,
|
||||
[property: JsonPropertyName("name")] string? Name = null,
|
||||
[property: JsonPropertyName("version")] string? Version = null,
|
||||
[property: JsonPropertyName("fixedIn")] string? FixedIn = null,
|
||||
[property: JsonPropertyName("sbomId")] string? SbomId = null,
|
||||
[property: JsonPropertyName("pathCount")] int? PathCount = null);
|
||||
|
||||
/// <summary>
|
||||
/// Grouping information for aggregated results.
|
||||
/// </summary>
|
||||
internal sealed record VulnGroupingInfo(
|
||||
[property: JsonPropertyName("field")] string Field,
|
||||
[property: JsonPropertyName("groups")] IReadOnlyList<VulnGroup> Groups);
|
||||
|
||||
/// <summary>
|
||||
/// A group in aggregated results.
|
||||
/// </summary>
|
||||
internal sealed record VulnGroup(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("criticalCount")] int? CriticalCount = null,
|
||||
[property: JsonPropertyName("highCount")] int? HighCount = null,
|
||||
[property: JsonPropertyName("mediumCount")] int? MediumCount = null,
|
||||
[property: JsonPropertyName("lowCount")] int? LowCount = null);
|
||||
|
||||
// CLI-VULN-29-002: Vulnerability detail models
|
||||
|
||||
/// <summary>
|
||||
/// Detailed vulnerability response including evidence, rationale, paths, and ledger.
|
||||
/// </summary>
|
||||
internal sealed record VulnDetailResponse(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("severity")] VulnSeverityInfo Severity,
|
||||
[property: JsonPropertyName("affectedPackages")] IReadOnlyList<VulnAffectedPackage> AffectedPackages,
|
||||
[property: JsonPropertyName("vexStatus")] string? VexStatus = null,
|
||||
[property: JsonPropertyName("policyFindingId")] string? PolicyFindingId = null,
|
||||
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases = null,
|
||||
[property: JsonPropertyName("summary")] string? Summary = null,
|
||||
[property: JsonPropertyName("description")] string? Description = null,
|
||||
[property: JsonPropertyName("publishedAt")] DateTimeOffset? PublishedAt = null,
|
||||
[property: JsonPropertyName("updatedAt")] DateTimeOffset? UpdatedAt = null,
|
||||
[property: JsonPropertyName("assignee")] string? Assignee = null,
|
||||
[property: JsonPropertyName("dueDate")] DateTimeOffset? DueDate = null,
|
||||
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags = null,
|
||||
[property: JsonPropertyName("evidence")] IReadOnlyList<VulnEvidenceInfo>? Evidence = null,
|
||||
[property: JsonPropertyName("policyRationale")] VulnPolicyRationale? PolicyRationale = null,
|
||||
[property: JsonPropertyName("dependencyPaths")] IReadOnlyList<VulnDependencyPath>? DependencyPaths = null,
|
||||
[property: JsonPropertyName("ledger")] IReadOnlyList<VulnLedgerEntry>? Ledger = null,
|
||||
[property: JsonPropertyName("references")] IReadOnlyList<VulnReference>? References = null);
|
||||
|
||||
/// <summary>
|
||||
/// Evidence supporting the vulnerability assessment.
|
||||
/// </summary>
|
||||
internal sealed record VulnEvidenceInfo(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("documentId")] string? DocumentId = null,
|
||||
[property: JsonPropertyName("documentDigest")] string? DocumentDigest = null,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset? Timestamp = null,
|
||||
[property: JsonPropertyName("content")] string? Content = null);
|
||||
|
||||
/// <summary>
|
||||
/// Policy rationale explaining the status decision.
|
||||
/// </summary>
|
||||
internal sealed record VulnPolicyRationale(
|
||||
[property: JsonPropertyName("policyId")] string PolicyId,
|
||||
[property: JsonPropertyName("policyVersion")] int PolicyVersion,
|
||||
[property: JsonPropertyName("rules")] IReadOnlyList<VulnPolicyRuleResult>? Rules = null,
|
||||
[property: JsonPropertyName("summary")] string? Summary = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a policy rule evaluation.
|
||||
/// </summary>
|
||||
internal sealed record VulnPolicyRuleResult(
|
||||
[property: JsonPropertyName("rule")] string Rule,
|
||||
[property: JsonPropertyName("result")] string Result,
|
||||
[property: JsonPropertyName("weight")] double? Weight = null,
|
||||
[property: JsonPropertyName("reason")] string? Reason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Dependency path showing how the vulnerable package is included.
|
||||
/// </summary>
|
||||
internal sealed record VulnDependencyPath(
|
||||
[property: JsonPropertyName("path")] IReadOnlyList<string> Path,
|
||||
[property: JsonPropertyName("sbomId")] string? SbomId = null,
|
||||
[property: JsonPropertyName("depth")] int? Depth = null);
|
||||
|
||||
/// <summary>
|
||||
/// Ledger entry tracking vulnerability workflow history.
|
||||
/// </summary>
|
||||
internal sealed record VulnLedgerEntry(
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("action")] string Action,
|
||||
[property: JsonPropertyName("actor")] string? Actor = null,
|
||||
[property: JsonPropertyName("fromStatus")] string? FromStatus = null,
|
||||
[property: JsonPropertyName("toStatus")] string? ToStatus = null,
|
||||
[property: JsonPropertyName("comment")] string? Comment = null,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reference link for the vulnerability.
|
||||
/// </summary>
|
||||
internal sealed record VulnReference(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("title")] string? Title = null);
|
||||
|
||||
// CLI-VULN-29-003: Vulnerability workflow models
|
||||
|
||||
/// <summary>
|
||||
/// Workflow action request for vulnerability operations.
|
||||
/// </summary>
|
||||
internal sealed record VulnWorkflowRequest(
|
||||
[property: JsonPropertyName("action")] string Action,
|
||||
[property: JsonPropertyName("vulnerabilityIds")] IReadOnlyList<string>? VulnerabilityIds = null,
|
||||
[property: JsonPropertyName("filter")] VulnFilterSpec? Filter = null,
|
||||
[property: JsonPropertyName("assignee")] string? Assignee = null,
|
||||
[property: JsonPropertyName("comment")] string? Comment = null,
|
||||
[property: JsonPropertyName("dueDate")] DateTimeOffset? DueDate = null,
|
||||
[property: JsonPropertyName("justification")] string? Justification = null,
|
||||
[property: JsonPropertyName("fixVersion")] string? FixVersion = null,
|
||||
[property: JsonPropertyName("idempotencyKey")] string? IdempotencyKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Filter specification for bulk workflow operations.
|
||||
/// </summary>
|
||||
internal sealed record VulnFilterSpec(
|
||||
[property: JsonPropertyName("severity")] string? Severity = null,
|
||||
[property: JsonPropertyName("status")] string? Status = null,
|
||||
[property: JsonPropertyName("purl")] string? Purl = null,
|
||||
[property: JsonPropertyName("sbomId")] string? SbomId = null,
|
||||
[property: JsonPropertyName("policyId")] string? PolicyId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Workflow action response with affected items.
|
||||
/// </summary>
|
||||
internal sealed record VulnWorkflowResponse(
|
||||
[property: JsonPropertyName("success")] bool Success,
|
||||
[property: JsonPropertyName("action")] string Action,
|
||||
[property: JsonPropertyName("affectedCount")] int AffectedCount,
|
||||
[property: JsonPropertyName("affectedIds")] IReadOnlyList<string>? AffectedIds = null,
|
||||
[property: JsonPropertyName("errors")] IReadOnlyList<VulnWorkflowError>? Errors = null,
|
||||
[property: JsonPropertyName("idempotencyKey")] string? IdempotencyKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Error detail for workflow operations.
|
||||
/// </summary>
|
||||
internal sealed record VulnWorkflowError(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("message")] string Message);
|
||||
|
||||
// CLI-VULN-29-004: Vulnerability simulation models
|
||||
|
||||
/// <summary>
|
||||
/// Simulation request for policy/VEX changes.
|
||||
/// </summary>
|
||||
internal sealed record VulnSimulationRequest(
|
||||
[property: JsonPropertyName("policyId")] string? PolicyId = null,
|
||||
[property: JsonPropertyName("policyVersion")] int? PolicyVersion = null,
|
||||
[property: JsonPropertyName("vexOverrides")] IReadOnlyDictionary<string, string>? VexOverrides = null,
|
||||
[property: JsonPropertyName("severityThreshold")] string? SeverityThreshold = null,
|
||||
[property: JsonPropertyName("sbomIds")] IReadOnlyList<string>? SbomIds = null,
|
||||
[property: JsonPropertyName("outputMarkdown")] bool OutputMarkdown = false);
|
||||
|
||||
/// <summary>
|
||||
/// Simulation response showing deltas.
|
||||
/// </summary>
|
||||
internal sealed record VulnSimulationResponse(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<VulnSimulationDelta> Items,
|
||||
[property: JsonPropertyName("summary")] VulnSimulationSummary Summary,
|
||||
[property: JsonPropertyName("markdownReport")] string? MarkdownReport = null);
|
||||
|
||||
/// <summary>
|
||||
/// Individual delta in simulation results.
|
||||
/// </summary>
|
||||
internal sealed record VulnSimulationDelta(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("beforeStatus")] string BeforeStatus,
|
||||
[property: JsonPropertyName("afterStatus")] string AfterStatus,
|
||||
[property: JsonPropertyName("changed")] bool Changed,
|
||||
[property: JsonPropertyName("changeReason")] string? ChangeReason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of simulation results.
|
||||
/// </summary>
|
||||
internal sealed record VulnSimulationSummary(
|
||||
[property: JsonPropertyName("totalEvaluated")] int TotalEvaluated,
|
||||
[property: JsonPropertyName("totalChanged")] int TotalChanged,
|
||||
[property: JsonPropertyName("statusUpgrades")] int StatusUpgrades,
|
||||
[property: JsonPropertyName("statusDowngrades")] int StatusDowngrades,
|
||||
[property: JsonPropertyName("noChange")] int NoChange);
|
||||
|
||||
// CLI-VULN-29-005: Vulnerability export models
|
||||
|
||||
/// <summary>
|
||||
/// Export request for vulnerability evidence bundles.
|
||||
/// </summary>
|
||||
internal sealed record VulnExportRequest(
|
||||
[property: JsonPropertyName("vulnerabilityIds")] IReadOnlyList<string>? VulnerabilityIds = null,
|
||||
[property: JsonPropertyName("sbomIds")] IReadOnlyList<string>? SbomIds = null,
|
||||
[property: JsonPropertyName("policyId")] string? PolicyId = null,
|
||||
[property: JsonPropertyName("format")] string Format = "ndjson",
|
||||
[property: JsonPropertyName("includeEvidence")] bool IncludeEvidence = true,
|
||||
[property: JsonPropertyName("includeLedger")] bool IncludeLedger = true,
|
||||
[property: JsonPropertyName("signed")] bool Signed = true);
|
||||
|
||||
/// <summary>
|
||||
/// Export response with download information.
|
||||
/// </summary>
|
||||
internal sealed record VulnExportResponse(
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("downloadUrl")] string? DownloadUrl = null,
|
||||
[property: JsonPropertyName("format")] string Format = "ndjson",
|
||||
[property: JsonPropertyName("itemCount")] int ItemCount = 0,
|
||||
[property: JsonPropertyName("signed")] bool Signed = false,
|
||||
[property: JsonPropertyName("signatureAlgorithm")] string? SignatureAlgorithm = null,
|
||||
[property: JsonPropertyName("signatureKeyId")] string? SignatureKeyId = null,
|
||||
[property: JsonPropertyName("digest")] string? Digest = null,
|
||||
[property: JsonPropertyName("digestAlgorithm")] string? DigestAlgorithm = null,
|
||||
[property: JsonPropertyName("expiresAt")] DateTimeOffset? ExpiresAt = null);
|
||||
@@ -27,6 +27,7 @@ internal static class CliMetrics
|
||||
private static readonly Counter<long> RubyInspectCounter = Meter.CreateCounter<long>("stellaops.cli.ruby.inspect.count");
|
||||
private static readonly Counter<long> RubyResolveCounter = Meter.CreateCounter<long>("stellaops.cli.ruby.resolve.count");
|
||||
private static readonly Counter<long> PhpInspectCounter = Meter.CreateCounter<long>("stellaops.cli.php.inspect.count");
|
||||
private static readonly Counter<long> PythonInspectCounter = Meter.CreateCounter<long>("stellaops.cli.python.inspect.count");
|
||||
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
|
||||
|
||||
public static void RecordScannerDownload(string channel, bool fromCache)
|
||||
@@ -150,6 +151,12 @@ internal static class CliMetrics
|
||||
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
|
||||
});
|
||||
|
||||
public static void RecordPythonInspect(string outcome)
|
||||
=> PythonInspectCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
|
||||
});
|
||||
|
||||
public static IDisposable MeasureCommandDuration(string command)
|
||||
{
|
||||
var start = DateTime.UtcNow;
|
||||
|
||||
Reference in New Issue
Block a user