Restructure solution layout by module
This commit is contained in:
123
src/Cli/StellaOps.Cli/Services/AuthorityDiagnosticsReporter.cs
Normal file
123
src/Cli/StellaOps.Cli/Services/AuthorityDiagnosticsReporter.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Emits Authority configuration diagnostics discovered during CLI startup.
|
||||
/// </summary>
|
||||
internal static class AuthorityDiagnosticsReporter
|
||||
{
|
||||
public static void Emit(IConfiguration configuration, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var basePath = Directory.GetCurrentDirectory();
|
||||
EmitInternal(configuration, logger, basePath);
|
||||
}
|
||||
|
||||
internal static void Emit(IConfiguration configuration, ILogger logger, string basePath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(basePath);
|
||||
|
||||
EmitInternal(configuration, logger, basePath);
|
||||
}
|
||||
|
||||
private static void EmitInternal(IConfiguration configuration, ILogger logger, string basePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(basePath))
|
||||
{
|
||||
basePath = Directory.GetCurrentDirectory();
|
||||
}
|
||||
|
||||
var authoritySection = configuration.GetSection("Authority");
|
||||
if (!authoritySection.Exists())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var authorityOptions = new StellaOpsAuthorityOptions();
|
||||
authoritySection.Bind(authorityOptions);
|
||||
|
||||
if (authorityOptions.Plugins.Descriptors.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedBasePath = Path.GetFullPath(basePath);
|
||||
IReadOnlyList<AuthorityPluginContext> contexts;
|
||||
|
||||
try
|
||||
{
|
||||
contexts = AuthorityPluginConfigurationLoader.Load(authorityOptions, resolvedBasePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Failed to load Authority plug-in configuration for diagnostics.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (contexts.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<AuthorityConfigurationDiagnostic> diagnostics;
|
||||
try
|
||||
{
|
||||
diagnostics = AuthorityPluginConfigurationAnalyzer.Analyze(contexts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Failed to analyze Authority plug-in configuration for diagnostics.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (diagnostics.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var contextLookup = new Dictionary<string, AuthorityPluginContext>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var context in contexts)
|
||||
{
|
||||
if (context?.Manifest?.Name is { Length: > 0 } name && !contextLookup.ContainsKey(name))
|
||||
{
|
||||
contextLookup[name] = context;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var diagnostic in diagnostics)
|
||||
{
|
||||
var level = diagnostic.Severity switch
|
||||
{
|
||||
AuthorityConfigurationDiagnosticSeverity.Error => LogLevel.Error,
|
||||
AuthorityConfigurationDiagnosticSeverity.Warning => LogLevel.Warning,
|
||||
_ => LogLevel.Information
|
||||
};
|
||||
|
||||
if (!logger.IsEnabled(level))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contextLookup.TryGetValue(diagnostic.PluginName, out var context) &&
|
||||
context?.Manifest?.ConfigPath is { Length: > 0 } configPath)
|
||||
{
|
||||
logger.Log(level, "{DiagnosticMessage} (config: {ConfigPath})", diagnostic.Message, configPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Log(level, "{DiagnosticMessage}", diagnostic.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
223
src/Cli/StellaOps.Cli/Services/AuthorityRevocationClient.cs
Normal file
223
src/Cli/StellaOps.Cli/Services/AuthorityRevocationClient.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Buffers.Text;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class AuthorityRevocationClient : IAuthorityRevocationClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<AuthorityRevocationClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public AuthorityRevocationClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<AuthorityRevocationClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority?.Url) && httpClient.BaseAddress is null && Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
httpClient.BaseAddress = authorityUri;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureAuthorityConfigured();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "internal/revocations/export");
|
||||
var accessToken = await AcquireAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var message = $"Authority export request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}";
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
|
||||
var payload = await JsonSerializer.DeserializeAsync<ExportResponseDto>(
|
||||
await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
|
||||
SerializerOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authority export response payload was empty.");
|
||||
}
|
||||
|
||||
var bundleBytes = Convert.FromBase64String(payload.Bundle.Data);
|
||||
var digest = payload.Digest?.Value ?? string.Empty;
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Received revocation export sequence {Sequence} (sha256:{Digest}, signing key {KeyId}, provider {Provider}).",
|
||||
payload.Sequence,
|
||||
digest,
|
||||
payload.SigningKeyId ?? "<unspecified>",
|
||||
string.IsNullOrWhiteSpace(payload.Signature?.Provider) ? "default" : payload.Signature!.Provider);
|
||||
}
|
||||
|
||||
return new AuthorityRevocationExportResult
|
||||
{
|
||||
BundleBytes = bundleBytes,
|
||||
Signature = payload.Signature?.Value ?? string.Empty,
|
||||
Digest = digest,
|
||||
Sequence = payload.Sequence,
|
||||
IssuedAt = payload.IssuedAt,
|
||||
SigningKeyId = payload.SigningKeyId,
|
||||
SigningProvider = payload.Signature?.Provider
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> AcquireAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(cachedAccessToken) && cachedAccessTokenExpiresAt - TokenRefreshSkew > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var scope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var token = await RequestAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = token.AccessToken;
|
||||
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<StellaOpsTokenResult> RequestAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.Authority is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authority credentials are not configured.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority.Password))
|
||||
{
|
||||
throw new InvalidOperationException("Authority password must be configured or run 'auth login'.");
|
||||
}
|
||||
|
||||
return await tokenClient!.RequestPasswordTokenAsync(
|
||||
options.Authority.Username,
|
||||
options.Authority.Password!,
|
||||
scope,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await tokenClient!.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void EnsureAuthorityConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
||||
{
|
||||
throw new InvalidOperationException("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update stellaops.yaml.");
|
||||
}
|
||||
|
||||
if (httpClient.BaseAddress is null)
|
||||
{
|
||||
if (!Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("Authority URL is invalid.");
|
||||
}
|
||||
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ExportResponseDto
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string BundleId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sequence")]
|
||||
public long Sequence { get; set; }
|
||||
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public DateTimeOffset IssuedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("signingKeyId")]
|
||||
public string? SigningKeyId { get; set; }
|
||||
|
||||
[JsonPropertyName("bundle")]
|
||||
public ExportPayloadDto Bundle { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public ExportSignatureDto? Signature { get; set; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public ExportDigestDto? Digest { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ExportPayloadDto
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public string Data { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ExportSignatureDto
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("provider")]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ExportDigestDto
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
2486
src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs
Normal file
2486
src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs
Normal file
File diff suppressed because it is too large
Load Diff
250
src/Cli/StellaOps.Cli/Services/ConcelierObservationsClient.cs
Normal file
250
src/Cli/StellaOps.Cli/Services/ConcelierObservationsClient.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
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;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<ConcelierObservationsClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ConcelierObservationsClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ConcelierObservationsClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
||||
AdvisoryObservationsQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildRequestUri(query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to query observations (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryObservationsResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new AdvisoryObservationsResponse();
|
||||
}
|
||||
|
||||
private static string BuildRequestUri(AdvisoryObservationsQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/concelier/observations?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
AppendValues(builder, "observationId", query.ObservationIds);
|
||||
AppendValues(builder, "alias", query.Aliases);
|
||||
AppendValues(builder, "purl", query.Purls);
|
||||
AppendValues(builder, "cpe", query.Cpes);
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append('&');
|
||||
builder.Append("limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
builder.Append('&');
|
||||
builder.Append("cursor=");
|
||||
builder.Append(Uri.EscapeDataString(query.Cursor));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
|
||||
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('&');
|
||||
builder.Append(name);
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"ConcelierUrl is not configured. Set StellaOps:ConcelierUrl or STELLAOPS_CONCELIER_URL.");
|
||||
}
|
||||
|
||||
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 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 (scope, cacheKey) = BuildScopeAndCacheKey(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;
|
||||
}
|
||||
}
|
||||
|
||||
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, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = token.AccessToken;
|
||||
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
|
||||
{
|
||||
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var finalScope = EnsureScope(baseScope, StellaOpsScopes.VulnRead);
|
||||
|
||||
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
|
||||
? $"user:{options.Authority.Username}"
|
||||
: $"client:{options.Authority.ClientId}";
|
||||
|
||||
var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}";
|
||||
return (finalScope, cacheKey);
|
||||
}
|
||||
|
||||
private static string EnsureScope(string scopes, string required)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scopes))
|
||||
{
|
||||
return required;
|
||||
}
|
||||
|
||||
var parts = scopes
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static scope => scope.ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (!parts.Contains(required, StringComparer.Ordinal))
|
||||
{
|
||||
parts.Add(required);
|
||||
}
|
||||
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
}
|
||||
10
src/Cli/StellaOps.Cli/Services/IAuthorityRevocationClient.cs
Normal file
10
src/Cli/StellaOps.Cli/Services/IAuthorityRevocationClient.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IAuthorityRevocationClient
|
||||
{
|
||||
Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken);
|
||||
}
|
||||
45
src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs
Normal file
45
src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
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);
|
||||
|
||||
Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken);
|
||||
|
||||
Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken);
|
||||
|
||||
Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken);
|
||||
|
||||
Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyActivationResult> ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken);
|
||||
|
||||
Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<AocVerifyResponse> ExecuteAocVerifyAsync(AocVerifyRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyFindingsPage> GetPolicyFindingsAsync(PolicyFindingsQuery query, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyFindingDocument> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IConcelierObservationsClient
|
||||
{
|
||||
Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
||||
AdvisoryObservationsQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
17
src/Cli/StellaOps.Cli/Services/IScannerExecutor.cs
Normal file
17
src/Cli/StellaOps.Cli/Services/IScannerExecutor.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerExecutor
|
||||
{
|
||||
Task<ScannerExecutionResult> RunAsync(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
9
src/Cli/StellaOps.Cli/Services/IScannerInstaller.cs
Normal file
9
src/Cli/StellaOps.Cli/Services/IScannerInstaller.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerInstaller
|
||||
{
|
||||
Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record AdvisoryObservationsQuery(
|
||||
string Tenant,
|
||||
IReadOnlyList<string> ObservationIds,
|
||||
IReadOnlyList<string> Aliases,
|
||||
IReadOnlyList<string> Purls,
|
||||
IReadOnlyList<string> Cpes,
|
||||
int? Limit,
|
||||
string? Cursor);
|
||||
|
||||
internal sealed class AdvisoryObservationsResponse
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<AdvisoryObservationDocument> Observations { get; init; } =
|
||||
Array.Empty<AdvisoryObservationDocument>();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryObservationLinksetAggregate Linkset { get; init; } =
|
||||
new();
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationDocument
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public AdvisoryObservationSource Source { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("upstream")]
|
||||
public AdvisoryObservationUpstream Upstream { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryObservationLinkset Linkset { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationSource
|
||||
{
|
||||
[JsonPropertyName("vendor")]
|
||||
public string Vendor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("stream")]
|
||||
public string Stream { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("api")]
|
||||
public string Api { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("collectorVersion")]
|
||||
public string? CollectorVersion { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationUpstream
|
||||
{
|
||||
[JsonPropertyName("upstreamId")]
|
||||
public string UpstreamId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("documentVersion")]
|
||||
public string? DocumentVersion { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationLinkset
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
|
||||
Array.Empty<AdvisoryObservationReference>();
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationReference
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationLinksetAggregate
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
|
||||
Array.Empty<AdvisoryObservationReference>();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed class AocIngestDryRunRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public AocIngestDryRunDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunDocument
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("contentType")]
|
||||
public string ContentType { get; init; } = "application/json";
|
||||
|
||||
[JsonPropertyName("contentEncoding")]
|
||||
public string? ContentEncoding { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunResponse
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("guardVersion")]
|
||||
public string? GuardVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public AocIngestDryRunDocumentResult Document { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("violations")]
|
||||
public IReadOnlyList<AocIngestDryRunViolation> Violations { get; init; } =
|
||||
Array.Empty<AocIngestDryRunViolation>();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunDocumentResult
|
||||
{
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("supersedes")]
|
||||
public string? Supersedes { get; init; }
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public AocIngestDryRunProvenance Provenance { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunProvenance
|
||||
{
|
||||
[JsonPropertyName("signature")]
|
||||
public AocIngestDryRunSignature Signature { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunSignature
|
||||
{
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("present")]
|
||||
public bool Present { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunViolation
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
100
src/Cli/StellaOps.Cli/Services/Models/AocVerifyModels.cs
Normal file
100
src/Cli/StellaOps.Cli/Services/Models/AocVerifyModels.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed class AocVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public string? Since { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string>? Sources { get; init; }
|
||||
|
||||
[JsonPropertyName("codes")]
|
||||
public IReadOnlyList<string>? Codes { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyResponse
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("window")]
|
||||
public AocVerifyWindow Window { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("checked")]
|
||||
public AocVerifyChecked Checked { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("violations")]
|
||||
public IReadOnlyList<AocVerifyViolation> Violations { get; init; } =
|
||||
Array.Empty<AocVerifyViolation>();
|
||||
|
||||
[JsonPropertyName("metrics")]
|
||||
public AocVerifyMetrics Metrics { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("truncated")]
|
||||
public bool? Truncated { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyWindow
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset? From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset? To { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyChecked
|
||||
{
|
||||
[JsonPropertyName("advisories")]
|
||||
public int Advisories { get; init; }
|
||||
|
||||
[JsonPropertyName("vex")]
|
||||
public int Vex { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyViolation
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
|
||||
[JsonPropertyName("examples")]
|
||||
public IReadOnlyList<AocVerifyViolationExample> Examples { get; init; } =
|
||||
Array.Empty<AocVerifyViolationExample>();
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyViolationExample
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("documentId")]
|
||||
public string? DocumentId { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyMetrics
|
||||
{
|
||||
[JsonPropertyName("ingestion_write_total")]
|
||||
public int? IngestionWriteTotal { get; init; }
|
||||
|
||||
[JsonPropertyName("aoc_violation_total")]
|
||||
public int? AocViolationTotal { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed class AuthorityRevocationExportResult
|
||||
{
|
||||
public required byte[] BundleBytes { get; init; }
|
||||
|
||||
public required string Signature { get; init; }
|
||||
|
||||
public required string Digest { get; init; }
|
||||
|
||||
public required long Sequence { get; init; }
|
||||
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
public string? SigningProvider { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorExportDownloadResult(
|
||||
string Path,
|
||||
long SizeBytes,
|
||||
bool FromCache);
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorOperationResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Location,
|
||||
JsonElement? Payload);
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorProviderSummary(
|
||||
string Id,
|
||||
string Kind,
|
||||
string DisplayName,
|
||||
string TrustTier,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastIngestedAt);
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record JobTriggerResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Location,
|
||||
JobRunResponse? Run);
|
||||
111
src/Cli/StellaOps.Cli/Services/Models/OfflineKitModels.cs
Normal file
111
src/Cli/StellaOps.Cli/Services/Models/OfflineKitModels.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record OfflineKitBundleDescriptor(
|
||||
string BundleId,
|
||||
string BundleName,
|
||||
string BundleSha256,
|
||||
long BundleSize,
|
||||
Uri BundleDownloadUri,
|
||||
string ManifestName,
|
||||
string ManifestSha256,
|
||||
Uri ManifestDownloadUri,
|
||||
DateTimeOffset CapturedAt,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool IsDelta,
|
||||
string? BaseBundleId,
|
||||
string? BundleSignatureName,
|
||||
Uri? BundleSignatureDownloadUri,
|
||||
string? ManifestSignatureName,
|
||||
Uri? ManifestSignatureDownloadUri,
|
||||
long? ManifestSize);
|
||||
|
||||
internal sealed record OfflineKitDownloadResult(
|
||||
OfflineKitBundleDescriptor Descriptor,
|
||||
string BundlePath,
|
||||
string ManifestPath,
|
||||
string? BundleSignaturePath,
|
||||
string? ManifestSignaturePath,
|
||||
string MetadataPath,
|
||||
bool FromCache);
|
||||
|
||||
internal sealed record OfflineKitImportRequest(
|
||||
string BundlePath,
|
||||
string? ManifestPath,
|
||||
string? BundleSignaturePath,
|
||||
string? ManifestSignaturePath,
|
||||
string? BundleId,
|
||||
string? BundleSha256,
|
||||
long? BundleSize,
|
||||
DateTimeOffset? CapturedAt,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool? IsDelta,
|
||||
string? BaseBundleId,
|
||||
string? ManifestSha256,
|
||||
long? ManifestSize);
|
||||
|
||||
internal sealed record OfflineKitImportResult(
|
||||
string? ImportId,
|
||||
string? Status,
|
||||
DateTimeOffset SubmittedAt,
|
||||
string? Message);
|
||||
|
||||
internal sealed record OfflineKitStatus(
|
||||
string? BundleId,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool IsDelta,
|
||||
string? BaseBundleId,
|
||||
DateTimeOffset? CapturedAt,
|
||||
DateTimeOffset? ImportedAt,
|
||||
string? BundleSha256,
|
||||
long? BundleSize,
|
||||
IReadOnlyList<OfflineKitComponentStatus> Components);
|
||||
|
||||
internal sealed record OfflineKitComponentStatus(
|
||||
string Name,
|
||||
string? Version,
|
||||
string? Digest,
|
||||
DateTimeOffset? CapturedAt,
|
||||
long? SizeBytes);
|
||||
|
||||
internal sealed record OfflineKitMetadataDocument
|
||||
{
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
public string BundleName { get; init; } = string.Empty;
|
||||
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long BundleSize { get; init; }
|
||||
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
public DateTimeOffset DownloadedAt { get; init; }
|
||||
|
||||
public string? Channel { get; init; }
|
||||
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public bool IsDelta { get; init; }
|
||||
|
||||
public string? BaseBundleId { get; init; }
|
||||
|
||||
public string ManifestName { get; init; } = string.Empty;
|
||||
|
||||
public string ManifestSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long? ManifestSize { get; init; }
|
||||
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
|
||||
public string? BundleSignaturePath { get; init; }
|
||||
|
||||
public string? ManifestSignaturePath { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record PolicyActivationRequest(
|
||||
bool RunNow,
|
||||
DateTimeOffset? ScheduledAt,
|
||||
string? Priority,
|
||||
bool Rollback,
|
||||
string? IncidentId,
|
||||
string? Comment);
|
||||
|
||||
internal sealed record PolicyActivationResult(
|
||||
string Status,
|
||||
PolicyActivationRevision Revision);
|
||||
|
||||
internal sealed record PolicyActivationRevision(
|
||||
string PolicyId,
|
||||
int Version,
|
||||
string Status,
|
||||
bool RequiresTwoPersonApproval,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
IReadOnlyList<PolicyActivationApproval> Approvals);
|
||||
|
||||
internal sealed record PolicyActivationApproval(
|
||||
string ActorId,
|
||||
DateTimeOffset ApprovedAt,
|
||||
string? Comment);
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record PolicyFindingsQuery(
|
||||
string PolicyId,
|
||||
IReadOnlyList<string> SbomIds,
|
||||
IReadOnlyList<string> Statuses,
|
||||
IReadOnlyList<string> Severities,
|
||||
string? Cursor,
|
||||
int? Page,
|
||||
int? PageSize,
|
||||
DateTimeOffset? Since);
|
||||
|
||||
internal sealed record PolicyFindingsPage(
|
||||
IReadOnlyList<PolicyFindingDocument> Items,
|
||||
string? NextCursor,
|
||||
int? TotalCount);
|
||||
|
||||
internal sealed record PolicyFindingDocument(
|
||||
string FindingId,
|
||||
string Status,
|
||||
PolicyFindingSeverity Severity,
|
||||
string SbomId,
|
||||
IReadOnlyList<string> AdvisoryIds,
|
||||
PolicyFindingVexMetadata? Vex,
|
||||
int PolicyVersion,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? RunId);
|
||||
|
||||
internal sealed record PolicyFindingSeverity(string Normalized, double? Score);
|
||||
|
||||
internal sealed record PolicyFindingVexMetadata(string? WinningStatementId, string? Source, string? Status);
|
||||
|
||||
internal sealed record PolicyFindingExplainResult(
|
||||
string FindingId,
|
||||
int PolicyVersion,
|
||||
IReadOnlyList<PolicyFindingExplainStep> Steps,
|
||||
IReadOnlyList<PolicyFindingExplainHint> SealedHints);
|
||||
|
||||
internal sealed record PolicyFindingExplainStep(
|
||||
string Rule,
|
||||
string? Status,
|
||||
string? Action,
|
||||
double? Score,
|
||||
IReadOnlyDictionary<string, string> Inputs,
|
||||
IReadOnlyDictionary<string, string>? Evidence);
|
||||
|
||||
internal sealed record PolicyFindingExplainHint(string Message);
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record PolicySimulationInput(
|
||||
int? BaseVersion,
|
||||
int? CandidateVersion,
|
||||
IReadOnlyList<string> SbomSet,
|
||||
IReadOnlyDictionary<string, object?> Environment,
|
||||
bool Explain);
|
||||
|
||||
internal sealed record PolicySimulationResult(
|
||||
PolicySimulationDiff Diff,
|
||||
string? ExplainUri);
|
||||
|
||||
internal sealed record PolicySimulationDiff(
|
||||
string? SchemaVersion,
|
||||
int Added,
|
||||
int Removed,
|
||||
int Unchanged,
|
||||
IReadOnlyDictionary<string, PolicySimulationSeverityDelta> BySeverity,
|
||||
IReadOnlyList<PolicySimulationRuleDelta> RuleHits);
|
||||
|
||||
internal sealed record PolicySimulationSeverityDelta(int? Up, int? Down);
|
||||
|
||||
internal sealed record PolicySimulationRuleDelta(string RuleId, string RuleName, int? Up, int? Down);
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationRequest(
|
||||
string? Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<string> Images);
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationResult(
|
||||
int TtlSeconds,
|
||||
DateTimeOffset? ExpiresAtUtc,
|
||||
string? PolicyRevision,
|
||||
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Decisions);
|
||||
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
string PolicyVerdict,
|
||||
bool? Signed,
|
||||
bool? HasSbomReferrers,
|
||||
IReadOnlyList<string> Reasons,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
IReadOnlyDictionary<string, object?> AdditionalProperties);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ScannerArtifactResult(string Path, long SizeBytes, bool FromCache);
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class JobRunResponse
|
||||
{
|
||||
public Guid RunId { get; set; }
|
||||
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public string Trigger { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? StartedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
public string? Error { get; set; }
|
||||
|
||||
public TimeSpan? Duration { get; set; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Parameters { get; set; } = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class JobTriggerRequest
|
||||
{
|
||||
public string Trigger { get; set; } = "cli";
|
||||
|
||||
public Dictionary<string, object?> Parameters { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class OfflineKitBundleDescriptorTransport
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string? BundleName { get; set; }
|
||||
|
||||
public string? BundleSha256 { get; set; }
|
||||
|
||||
public long BundleSize { get; set; }
|
||||
|
||||
public string? BundleUrl { get; set; }
|
||||
|
||||
public string? BundlePath { get; set; }
|
||||
|
||||
public string? BundleSignatureName { get; set; }
|
||||
|
||||
public string? BundleSignatureUrl { get; set; }
|
||||
|
||||
public string? BundleSignaturePath { get; set; }
|
||||
|
||||
public string? ManifestName { get; set; }
|
||||
|
||||
public string? ManifestSha256 { get; set; }
|
||||
|
||||
public long? ManifestSize { get; set; }
|
||||
|
||||
public string? ManifestUrl { get; set; }
|
||||
|
||||
public string? ManifestPath { get; set; }
|
||||
|
||||
public string? ManifestSignatureName { get; set; }
|
||||
|
||||
public string? ManifestSignatureUrl { get; set; }
|
||||
|
||||
public string? ManifestSignaturePath { 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; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitStatusBundleTransport
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? Kind { get; set; }
|
||||
|
||||
public bool? IsDelta { get; set; }
|
||||
|
||||
public string? BaseBundleId { get; set; }
|
||||
|
||||
public string? BundleSha256 { get; set; }
|
||||
|
||||
public long? BundleSize { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? ImportedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitStatusTransport
|
||||
{
|
||||
public OfflineKitStatusBundleTransport? Current { get; set; }
|
||||
|
||||
public List<OfflineKitComponentStatusTransport>? Components { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitComponentStatusTransport
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public string? Digest { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public long? SizeBytes { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitImportResponseTransport
|
||||
{
|
||||
public string? ImportId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicyActivationRequestDocument
|
||||
{
|
||||
public string? Comment { get; set; }
|
||||
|
||||
public bool? RunNow { get; set; }
|
||||
|
||||
public DateTimeOffset? ScheduledAt { get; set; }
|
||||
|
||||
public string? Priority { get; set; }
|
||||
|
||||
public bool? Rollback { get; set; }
|
||||
|
||||
public string? IncidentId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationResponseDocument
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
|
||||
public PolicyActivationRevisionDocument? Revision { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationRevisionDocument
|
||||
{
|
||||
public string? PackId { get; set; }
|
||||
|
||||
public int? Version { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public bool? RequiresTwoPersonApproval { get; set; }
|
||||
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
public List<PolicyActivationApprovalDocument>? Approvals { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationApprovalDocument
|
||||
{
|
||||
public string? ActorId { get; set; }
|
||||
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicyFindingsResponseDocument
|
||||
{
|
||||
public List<PolicyFindingDocumentDocument>? Items { get; set; }
|
||||
|
||||
public string? NextCursor { get; set; }
|
||||
|
||||
public int? TotalCount { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingDocumentDocument
|
||||
{
|
||||
public string? FindingId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public PolicyFindingSeverityDocument? Severity { get; set; }
|
||||
|
||||
public string? SbomId { get; set; }
|
||||
|
||||
public List<string>? AdvisoryIds { get; set; }
|
||||
|
||||
public PolicyFindingVexDocument? Vex { get; set; }
|
||||
|
||||
public int? PolicyVersion { get; set; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public string? RunId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingSeverityDocument
|
||||
{
|
||||
public string? Normalized { get; set; }
|
||||
|
||||
public double? Score { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingVexDocument
|
||||
{
|
||||
public string? WinningStatementId { get; set; }
|
||||
|
||||
public string? Source { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainResponseDocument
|
||||
{
|
||||
public string? FindingId { get; set; }
|
||||
|
||||
public int? PolicyVersion { get; set; }
|
||||
|
||||
public List<PolicyFindingExplainStepDocument>? Steps { get; set; }
|
||||
|
||||
public List<PolicyFindingExplainHintDocument>? SealedHints { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainStepDocument
|
||||
{
|
||||
public string? Rule { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public string? Action { get; set; }
|
||||
|
||||
public double? Score { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Inputs { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Evidence { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainHintDocument
|
||||
{
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicySimulationRequestDocument
|
||||
{
|
||||
public int? BaseVersion { get; set; }
|
||||
|
||||
public int? CandidateVersion { get; set; }
|
||||
|
||||
public IReadOnlyList<string>? SbomSet { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Env { get; set; }
|
||||
|
||||
public bool? Explain { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationResponseDocument
|
||||
{
|
||||
public PolicySimulationDiffDocument? Diff { get; set; }
|
||||
|
||||
public string? ExplainUri { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationDiffDocument
|
||||
{
|
||||
public string? SchemaVersion { get; set; }
|
||||
|
||||
public int? Added { get; set; }
|
||||
|
||||
public int? Removed { get; set; }
|
||||
|
||||
public int? Unchanged { get; set; }
|
||||
|
||||
public Dictionary<string, PolicySimulationSeverityDeltaDocument>? BySeverity { get; set; }
|
||||
|
||||
public List<PolicySimulationRuleDeltaDocument>? RuleHits { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationSeverityDeltaDocument
|
||||
{
|
||||
public int? Up { get; set; }
|
||||
|
||||
public int? Down { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationRuleDeltaDocument
|
||||
{
|
||||
public string? RuleId { get; set; }
|
||||
|
||||
public string? RuleName { get; set; }
|
||||
|
||||
public int? Up { get; set; }
|
||||
|
||||
public int? Down { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class ProblemDocument
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
|
||||
public string? Title { get; set; }
|
||||
|
||||
public string? Detail { get; set; }
|
||||
|
||||
public int? Status { get; set; }
|
||||
|
||||
public string? Instance { get; set; }
|
||||
|
||||
public Dictionary<string, object?>? Extensions { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationRequestDocument
|
||||
{
|
||||
[JsonPropertyName("namespace")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Namespace { get; set; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public List<string> Images { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationResponseDocument
|
||||
{
|
||||
[JsonPropertyName("ttlSeconds")]
|
||||
public int? TtlSeconds { get; set; }
|
||||
|
||||
[JsonPropertyName("expiresAtUtc")]
|
||||
public DateTimeOffset? ExpiresAtUtc { get; set; }
|
||||
|
||||
[JsonPropertyName("policyRevision")]
|
||||
public string? PolicyRevision { get; set; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public Dictionary<string, RuntimePolicyEvaluationImageDocument>? Results { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationImageDocument
|
||||
{
|
||||
[JsonPropertyName("policyVerdict")]
|
||||
public string? PolicyVerdict { get; set; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool? Signed { get; set; }
|
||||
|
||||
[JsonPropertyName("hasSbomReferrers")]
|
||||
public bool? HasSbomReferrers { get; set; }
|
||||
|
||||
// Legacy field kept for pre-contract-sync services.
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool? HasSbomLegacy { get; set; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public List<string>? Reasons { get; set; }
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public RuntimePolicyRekorDocument? Rekor { get; set; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? ExtensionData { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyRekorDocument
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool? Verified { get; set; }
|
||||
}
|
||||
18
src/Cli/StellaOps.Cli/Services/PolicyApiException.cs
Normal file
18
src/Cli/StellaOps.Cli/Services/PolicyApiException.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class PolicyApiException : Exception
|
||||
{
|
||||
public PolicyApiException(string message, HttpStatusCode statusCode, string? errorCode, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
public string? ErrorCode { get; }
|
||||
}
|
||||
3
src/Cli/StellaOps.Cli/Services/ScannerExecutionResult.cs
Normal file
3
src/Cli/StellaOps.Cli/Services/ScannerExecutionResult.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed record ScannerExecutionResult(int ExitCode, string ResultsPath, string RunMetadataPath);
|
||||
329
src/Cli/StellaOps.Cli/Services/ScannerExecutor.cs
Normal file
329
src/Cli/StellaOps.Cli/Services/ScannerExecutor.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ScannerExecutor : IScannerExecutor
|
||||
{
|
||||
private readonly ILogger<ScannerExecutor> _logger;
|
||||
|
||||
public ScannerExecutor(ILogger<ScannerExecutor> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ScannerExecutionResult> RunAsync(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetDirectory))
|
||||
{
|
||||
throw new ArgumentException("Target directory must be provided.", nameof(targetDirectory));
|
||||
}
|
||||
|
||||
runner = string.IsNullOrWhiteSpace(runner) ? "docker" : runner.Trim().ToLowerInvariant();
|
||||
entry = entry?.Trim() ?? string.Empty;
|
||||
|
||||
var normalizedTarget = Path.GetFullPath(targetDirectory);
|
||||
if (!Directory.Exists(normalizedTarget))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Scan target directory '{normalizedTarget}' does not exist.");
|
||||
}
|
||||
|
||||
resultsDirectory = string.IsNullOrWhiteSpace(resultsDirectory)
|
||||
? Path.Combine(Directory.GetCurrentDirectory(), "scan-results")
|
||||
: Path.GetFullPath(resultsDirectory);
|
||||
|
||||
Directory.CreateDirectory(resultsDirectory);
|
||||
var executionTimestamp = DateTimeOffset.UtcNow;
|
||||
var baselineFiles = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
||||
var baseline = new HashSet<string>(baselineFiles, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var startInfo = BuildProcessStartInfo(runner, entry, normalizedTarget, resultsDirectory, arguments);
|
||||
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
||||
|
||||
var stdout = new List<string>();
|
||||
var stderr = new List<string>();
|
||||
|
||||
process.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
stdout.Add(args.Data);
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogInformation("[scan] {Line}", args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
stderr.Add(args.Data);
|
||||
_logger.LogError("[scan] {Line}", args.Data);
|
||||
};
|
||||
|
||||
_logger.LogInformation("Launching scanner via {Runner} (entry: {Entry})...", runner, entry);
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start scanner process.");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var completionTimestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("Scanner completed successfully.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner exited with code {Code}.", process.ExitCode);
|
||||
}
|
||||
|
||||
var resultsPath = ResolveResultsPath(resultsDirectory, executionTimestamp, baseline);
|
||||
if (string.IsNullOrWhiteSpace(resultsPath))
|
||||
{
|
||||
resultsPath = CreatePlaceholderResult(resultsDirectory);
|
||||
}
|
||||
|
||||
var metadataPath = WriteRunMetadata(
|
||||
resultsDirectory,
|
||||
executionTimestamp,
|
||||
completionTimestamp,
|
||||
runner,
|
||||
entry,
|
||||
normalizedTarget,
|
||||
resultsPath,
|
||||
arguments,
|
||||
process.ExitCode,
|
||||
stdout,
|
||||
stderr);
|
||||
|
||||
return new ScannerExecutionResult(process.ExitCode, resultsPath, metadataPath);
|
||||
}
|
||||
|
||||
private ProcessStartInfo BuildProcessStartInfo(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> args)
|
||||
{
|
||||
return runner switch
|
||||
{
|
||||
"self" or "native" => BuildNativeStartInfo(entry, args),
|
||||
"dotnet" => BuildDotNetStartInfo(entry, args),
|
||||
"docker" => BuildDockerStartInfo(entry, targetDirectory, resultsDirectory, args),
|
||||
_ => BuildCustomRunnerStartInfo(runner, entry, args)
|
||||
};
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildNativeStartInfo(string binaryPath, IReadOnlyList<string> args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(binaryPath) || !File.Exists(binaryPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner entrypoint not found.", binaryPath);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = binaryPath,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildDotNetStartInfo(string binaryPath, IReadOnlyList<string> args)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(binaryPath);
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildDockerStartInfo(string image, string targetDirectory, string resultsDirectory, IReadOnlyList<string> args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
throw new ArgumentException("Docker image must be provided when runner is 'docker'.", nameof(image));
|
||||
}
|
||||
|
||||
var cwd = Directory.GetCurrentDirectory();
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
WorkingDirectory = cwd
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("run");
|
||||
startInfo.ArgumentList.Add("--rm");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{cwd}:{cwd}");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{targetDirectory}:/scan-target:ro");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{resultsDirectory}:/scan-results");
|
||||
startInfo.ArgumentList.Add("-w");
|
||||
startInfo.ArgumentList.Add(cwd);
|
||||
startInfo.ArgumentList.Add(image);
|
||||
startInfo.ArgumentList.Add("--target");
|
||||
startInfo.ArgumentList.Add("/scan-target");
|
||||
startInfo.ArgumentList.Add("--output");
|
||||
startInfo.ArgumentList.Add("/scan-results/scan.json");
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildCustomRunnerStartInfo(string runner, string entry, IReadOnlyList<string> args)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = runner,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
startInfo.ArgumentList.Add(entry);
|
||||
}
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static string ResolveResultsPath(string resultsDirectory, DateTimeOffset startTimestamp, HashSet<string> baseline)
|
||||
{
|
||||
var candidates = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
||||
string? newest = null;
|
||||
DateTimeOffset newestTimestamp = startTimestamp;
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (baseline.Contains(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(candidate);
|
||||
if (info.LastWriteTimeUtc >= newestTimestamp)
|
||||
{
|
||||
newestTimestamp = info.LastWriteTimeUtc;
|
||||
newest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return newest ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string CreatePlaceholderResult(string resultsDirectory)
|
||||
{
|
||||
var fileName = $"scan-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
||||
var path = Path.Combine(resultsDirectory, fileName);
|
||||
File.WriteAllText(path, "{\"status\":\"placeholder\"}");
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string WriteRunMetadata(
|
||||
string resultsDirectory,
|
||||
DateTimeOffset startedAt,
|
||||
DateTimeOffset completedAt,
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsPath,
|
||||
IReadOnlyList<string> arguments,
|
||||
int exitCode,
|
||||
IReadOnlyList<string> stdout,
|
||||
IReadOnlyList<string> stderr)
|
||||
{
|
||||
var duration = completedAt - startedAt;
|
||||
var payload = new
|
||||
{
|
||||
runner,
|
||||
entry,
|
||||
targetDirectory,
|
||||
resultsPath,
|
||||
arguments,
|
||||
exitCode,
|
||||
startedAt = startedAt,
|
||||
completedAt = completedAt,
|
||||
durationSeconds = Math.Round(duration.TotalSeconds, 3, MidpointRounding.AwayFromZero),
|
||||
stdout,
|
||||
stderr
|
||||
};
|
||||
|
||||
var fileName = $"scan-run-{startedAt:yyyyMMddHHmmssfff}.json";
|
||||
var path = Path.Combine(resultsDirectory, fileName);
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload, options);
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
79
src/Cli/StellaOps.Cli/Services/ScannerInstaller.cs
Normal file
79
src/Cli/StellaOps.Cli/Services/ScannerInstaller.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ScannerInstaller : IScannerInstaller
|
||||
{
|
||||
private readonly ILogger<ScannerInstaller> _logger;
|
||||
|
||||
public ScannerInstaller(ILogger<ScannerInstaller> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactPath) || !File.Exists(artifactPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner artifact not found for installation.", artifactPath);
|
||||
}
|
||||
|
||||
// Current implementation assumes docker-based scanner bundle.
|
||||
var processInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
ArgumentList = { "load", "-i", artifactPath },
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = processInfo, EnableRaisingEvents = true };
|
||||
|
||||
process.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogInformation("[install] {Line}", args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogError("[install] {Line}", args.Data);
|
||||
};
|
||||
|
||||
_logger.LogInformation("Installing scanner container from {Path}...", artifactPath);
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start container installation process.");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Container installation failed with exit code {process.ExitCode}.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Scanner container installed successfully.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user