up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
master
2025-11-28 18:21:46 +02:00
parent 05da719048
commit d1cbb905f8
103 changed files with 49604 additions and 105 deletions

View File

@@ -0,0 +1,385 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Output;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Reader for attestation files (DSSE envelopes with in-toto statements).
/// Per CLI-FORENSICS-54-002.
/// </summary>
internal sealed class AttestationReader : IAttestationReader
{
private const string PaePrefix = "DSSEv1";
private const string InTotoStatementType = "https://in-toto.io/Statement/v0.1";
private const string InTotoStatementV1Type = "https://in-toto.io/Statement/v1";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly ILogger<AttestationReader> _logger;
private readonly IForensicVerifier _verifier;
public AttestationReader(ILogger<AttestationReader> logger, IForensicVerifier verifier)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
}
public async Task<AttestationShowResult> ReadAttestationAsync(
string filePath,
AttestationShowOptions options,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
ArgumentNullException.ThrowIfNull(options);
_logger.LogDebug("Reading attestation from {FilePath}", filePath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Attestation file not found: {filePath}", filePath);
}
var json = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
AttestationEnvelope envelope;
try
{
envelope = JsonSerializer.Deserialize<AttestationEnvelope>(json, SerializerOptions)
?? throw new InvalidDataException("Invalid attestation JSON");
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse attestation envelope from {FilePath}", filePath);
throw new InvalidDataException($"Failed to parse attestation envelope: {ex.Message}", ex);
}
// Decode payload
byte[] payloadBytes;
try
{
payloadBytes = Convert.FromBase64String(envelope.Payload);
}
catch (FormatException ex)
{
throw new InvalidDataException($"Invalid base64 payload: {ex.Message}", ex);
}
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
InTotoStatement statement;
try
{
statement = JsonSerializer.Deserialize<InTotoStatement>(payloadJson, SerializerOptions)
?? throw new InvalidDataException("Invalid in-toto statement JSON");
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse in-toto statement from payload");
throw new InvalidDataException($"Failed to parse in-toto statement: {ex.Message}", ex);
}
// Extract subjects
var subjects = statement.Subject
.Select(s => new AttestationSubjectInfo
{
Name = s.Name,
DigestAlgorithm = s.Digest.Keys.FirstOrDefault() ?? "unknown",
DigestValue = s.Digest.Values.FirstOrDefault() ?? string.Empty
})
.ToList();
// Extract signatures
var signatures = new List<AttestationSignatureInfo>();
var trustRoots = options.TrustRoots.ToList();
if (!string.IsNullOrWhiteSpace(options.TrustRootPath))
{
var loadedRoots = await _verifier.LoadTrustRootsAsync(options.TrustRootPath, cancellationToken)
.ConfigureAwait(false);
trustRoots.AddRange(loadedRoots);
}
foreach (var sig in envelope.Signatures)
{
var sigInfo = new AttestationSignatureInfo
{
KeyId = sig.KeyId ?? "(no key id)",
Algorithm = "unknown" // Would need certificate parsing for actual algorithm
};
if (options.VerifySignatures && trustRoots.Count > 0)
{
var matchingRoot = trustRoots.FirstOrDefault(tr =>
string.Equals(tr.KeyId, sig.KeyId, StringComparison.OrdinalIgnoreCase));
if (matchingRoot is not null)
{
var isValid = VerifySignature(envelope, sig, matchingRoot);
var now = DateTimeOffset.UtcNow;
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);
sigInfo = sigInfo with
{
Algorithm = matchingRoot.Algorithm,
IsValid = isValid,
IsTrusted = isValid && timeValid,
SignerInfo = new AttestationSignerInfo
{
Fingerprint = matchingRoot.Fingerprint,
NotBefore = matchingRoot.NotBefore,
NotAfter = matchingRoot.NotAfter
},
Reason = !isValid ? "Signature verification failed" :
!timeValid ? "Key outside validity period" : null
};
}
else
{
sigInfo = sigInfo with
{
IsValid = null,
IsTrusted = false,
Reason = "No matching trust root found"
};
}
}
signatures.Add(sigInfo);
}
// Extract predicate summary
var predicateSummary = ExtractPredicateSummary(statement);
// Build verification result
AttestationVerificationResult? verificationResult = null;
if (options.VerifySignatures)
{
var validCount = signatures.Count(s => s.IsValid == true);
var trustedCount = signatures.Count(s => s.IsTrusted == true);
var errors = signatures
.Where(s => !string.IsNullOrWhiteSpace(s.Reason))
.Select(s => $"{s.KeyId}: {s.Reason}")
.ToList();
verificationResult = new AttestationVerificationResult
{
IsValid = validCount > 0,
SignatureCount = signatures.Count,
ValidSignatures = validCount,
TrustedSignatures = trustedCount,
Errors = errors
};
}
return new AttestationShowResult
{
FilePath = filePath,
PayloadType = envelope.PayloadType,
StatementType = statement.Type,
PredicateType = statement.PredicateType,
Subjects = subjects,
Signatures = signatures,
PredicateSummary = predicateSummary,
VerificationResult = verificationResult
};
}
private static bool VerifySignature(
AttestationEnvelope envelope,
AttestationSignature sig,
ForensicTrustRoot trustRoot)
{
try
{
var payloadBytes = Convert.FromBase64String(envelope.Payload);
var pae = BuildPreAuthEncoding(envelope.PayloadType, payloadBytes);
var signatureBytes = Convert.FromBase64String(sig.Signature);
var publicKeyBytes = Convert.FromBase64String(trustRoot.PublicKey);
using var rsa = RSA.Create();
rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
return rsa.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
}
catch (Exception)
{
return false;
}
}
private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payload)
{
// DSSE PAE format: "DSSEv1" + len(payloadType) + payloadType + len(payload) + payload
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms);
// Write "DSSEv1" prefix length and value
writer.Write((long)PaePrefix.Length);
writer.Write(Encoding.UTF8.GetBytes(PaePrefix));
// Write payload type length and value
writer.Write((long)payloadTypeBytes.Length);
writer.Write(payloadTypeBytes);
// Write payload length and value
writer.Write((long)payload.Length);
writer.Write(payload);
return ms.ToArray();
}
private static AttestationPredicateSummary? ExtractPredicateSummary(InTotoStatement statement)
{
if (statement.Predicate is null)
{
return null;
}
var summary = new AttestationPredicateSummary
{
Type = statement.PredicateType
};
// Try to extract common fields from predicate
if (statement.Predicate is JsonElement element)
{
var metadata = new Dictionary<string, string>();
var materials = new List<AttestationMaterial>();
// Extract buildType (SLSA)
if (element.TryGetProperty("buildType", out var buildTypeProp) &&
buildTypeProp.ValueKind == JsonValueKind.String)
{
summary = summary with { BuildType = buildTypeProp.GetString() };
}
// Extract builder (SLSA)
if (element.TryGetProperty("builder", out var builderProp))
{
if (builderProp.TryGetProperty("id", out var builderIdProp) &&
builderIdProp.ValueKind == JsonValueKind.String)
{
summary = summary with { Builder = builderIdProp.GetString() };
}
}
// Extract invocation ID (SLSA)
if (element.TryGetProperty("invocation", out var invocationProp))
{
if (invocationProp.TryGetProperty("configSource", out var configSourceProp) &&
configSourceProp.TryGetProperty("digest", out var digestProp))
{
foreach (var d in digestProp.EnumerateObject())
{
metadata[$"invocation.digest.{d.Name}"] = d.Value.GetString() ?? string.Empty;
}
}
}
// Extract materials
if (element.TryGetProperty("materials", out var materialsProp) &&
materialsProp.ValueKind == JsonValueKind.Array)
{
foreach (var material in materialsProp.EnumerateArray())
{
var uri = string.Empty;
var digest = new Dictionary<string, string>();
if (material.TryGetProperty("uri", out var uriProp) &&
uriProp.ValueKind == JsonValueKind.String)
{
uri = uriProp.GetString() ?? string.Empty;
}
if (material.TryGetProperty("digest", out var matDigestProp))
{
foreach (var d in matDigestProp.EnumerateObject())
{
digest[d.Name] = d.Value.GetString() ?? string.Empty;
}
}
materials.Add(new AttestationMaterial { Uri = uri, Digest = digest });
}
summary = summary with { Materials = materials };
}
// Extract timestamp
if (element.TryGetProperty("metadata", out var metaProp))
{
if (metaProp.TryGetProperty("buildStartedOn", out var startedProp) &&
startedProp.ValueKind == JsonValueKind.String &&
DateTimeOffset.TryParse(startedProp.GetString(), out var started))
{
summary = summary with { Timestamp = started };
}
else if (metaProp.TryGetProperty("buildFinishedOn", out var finishedProp) &&
finishedProp.ValueKind == JsonValueKind.String &&
DateTimeOffset.TryParse(finishedProp.GetString(), out var finished))
{
summary = summary with { Timestamp = finished };
}
if (metaProp.TryGetProperty("invocationId", out var invIdProp) &&
invIdProp.ValueKind == JsonValueKind.String)
{
summary = summary with { InvocationId = invIdProp.GetString() };
}
}
// Extract VEX-specific fields
if (statement.PredicateType.Contains("vex", StringComparison.OrdinalIgnoreCase))
{
if (element.TryGetProperty("author", out var authorProp) &&
authorProp.ValueKind == JsonValueKind.String)
{
metadata["author"] = authorProp.GetString() ?? string.Empty;
}
if (element.TryGetProperty("timestamp", out var tsProp) &&
tsProp.ValueKind == JsonValueKind.String)
{
if (DateTimeOffset.TryParse(tsProp.GetString(), out var ts))
{
summary = summary with { Timestamp = ts };
}
}
if (element.TryGetProperty("version", out var versionProp))
{
if (versionProp.ValueKind == JsonValueKind.String)
{
metadata["version"] = versionProp.GetString() ?? string.Empty;
}
else if (versionProp.ValueKind == JsonValueKind.Number)
{
metadata["version"] = versionProp.GetInt32().ToString();
}
}
}
if (metadata.Count > 0)
{
summary = summary with { Metadata = metadata };
}
}
return summary;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -82,6 +82,83 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
return result ?? new AdvisoryObservationsResponse();
}
/// <summary>
/// Gets advisory linkset with conflict information.
/// Per CLI-LNM-22-001.
/// </summary>
public async Task<AdvisoryLinksetResponse> GetLinksetAsync(
AdvisoryLinksetQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
EnsureConfigured();
var requestUri = BuildLinksetRequestUri(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 linkset (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<AdvisoryLinksetResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new AdvisoryLinksetResponse();
}
/// <summary>
/// Gets a single observation by ID.
/// Per CLI-LNM-22-001.
/// </summary>
public async Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
string tenant,
string observationId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
EnsureConfigured();
var requestUri = $"/concelier/observations/{Uri.EscapeDataString(observationId)}?tenant={Uri.EscapeDataString(tenant)}";
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.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get observation (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);
return await JsonSerializer
.DeserializeAsync<AdvisoryLinksetObservation>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
private static string BuildRequestUri(AdvisoryObservationsQuery query)
{
var builder = new StringBuilder("/concelier/observations?tenant=");
@@ -130,6 +207,71 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
}
}
private static string BuildLinksetRequestUri(AdvisoryLinksetQuery query)
{
var builder = new StringBuilder("/concelier/linkset?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);
AppendValues(builder, "source", query.Sources);
if (!string.IsNullOrWhiteSpace(query.Severity))
{
builder.Append("&severity=");
builder.Append(Uri.EscapeDataString(query.Severity));
}
if (query.KevOnly.HasValue)
{
builder.Append("&kevOnly=");
builder.Append(query.KevOnly.Value ? "true" : "false");
}
if (query.HasFix.HasValue)
{
builder.Append("&hasFix=");
builder.Append(query.HasFix.Value ? "true" : "false");
}
if (query.Limit.HasValue && query.Limit.Value > 0)
{
builder.Append("&limit=");
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(query.Cursor))
{
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))

View File

@@ -0,0 +1,850 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Determinism harness for running scanner with frozen conditions.
/// Per CLI-DETER-70-003.
/// </summary>
internal sealed class DeterminismHarness : IDeterminismHarness
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private static readonly string[] ArtifactPatterns = new[]
{
"sbom.json", "sbom.spdx.json", "sbom.cdx.json",
"vex.json", "findings.json", "scan.json",
"layers.json", "metadata.json"
};
private readonly ILogger<DeterminismHarness> _logger;
public DeterminismHarness(ILogger<DeterminismHarness> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DeterminismRunResult> RunAsync(
DeterminismRunRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var errors = new List<string>();
var warnings = new List<string>();
var imageResults = new List<DeterminismImageResult>();
var failedImages = new List<string>();
_logger.LogDebug("Starting determinism harness with {ImageCount} images, {Runs} runs each",
request.Images.Count, request.Runs);
// Validate prerequisites
if (!await IsDockerAvailableAsync(cancellationToken))
{
errors.Add("Docker is not available or not running");
return new DeterminismRunResult
{
Success = false,
Errors = errors,
DurationMs = sw.ElapsedMilliseconds
};
}
if (request.Images.Count == 0)
{
errors.Add("No images specified for determinism testing");
return new DeterminismRunResult
{
Success = false,
Errors = errors,
DurationMs = sw.ElapsedMilliseconds
};
}
if (string.IsNullOrWhiteSpace(request.Scanner))
{
errors.Add("Scanner image reference is required");
return new DeterminismRunResult
{
Success = false,
Errors = errors,
DurationMs = sw.ElapsedMilliseconds
};
}
// Create output directory
var outputDir = request.OutputDir ?? Path.Combine(
Path.GetTempPath(),
$"stella-detscore-{DateTime.UtcNow:yyyyMMdd-HHmmss}");
Directory.CreateDirectory(outputDir);
// Resolve SHAs
var scannerSha = await ResolveImageDigestAsync(request.Scanner, cancellationToken) ?? "unknown";
var policySha = await ComputeBundleShaAsync(request.PolicyBundle, cancellationToken) ?? "none";
var feedsSha = await ComputeBundleShaAsync(request.FeedsBundle, cancellationToken) ?? "none";
var fixedClock = request.FixedClock ?? DateTimeOffset.UtcNow;
// Run determinism tests for each image
foreach (var imageRef in request.Images)
{
_logger.LogInformation("Testing determinism for image: {Image}", imageRef);
try
{
var imageResult = await RunImageDeterminismAsync(
imageRef,
request,
fixedClock,
outputDir,
cancellationToken);
imageResults.Add(imageResult);
if (imageResult.Score < request.ImageThreshold)
{
failedImages.Add(imageRef);
warnings.Add($"Image {imageRef} score {imageResult.Score:P0} below threshold {request.ImageThreshold:P0}");
}
if (imageResult.NonDeterministic.Count > 0)
{
warnings.Add($"Image {imageRef} has non-deterministic artifacts: {string.Join(", ", imageResult.NonDeterministic)}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test determinism for image {Image}", imageRef);
errors.Add($"Failed to test image {imageRef}: {ex.Message}");
imageResults.Add(new DeterminismImageResult
{
Digest = imageRef,
Runs = 0,
Identical = 0,
Score = 0,
Notes = $"Error: {ex.Message}"
});
failedImages.Add(imageRef);
}
}
// Calculate overall score
var overallScore = imageResults.Count > 0
? imageResults.Average(r => r.Score)
: 0;
var passedThreshold = overallScore >= request.OverallThreshold &&
failedImages.Count == 0;
if (overallScore < request.OverallThreshold)
{
warnings.Add($"Overall score {overallScore:P0} below threshold {request.OverallThreshold:P0}");
}
// Build manifest
var manifest = new DeterminismManifest
{
Version = "1",
Release = request.Release ?? $"local-{DateTime.UtcNow:yyyyMMdd}",
Platform = request.Platform,
PolicySha = policySha,
FeedsSha = feedsSha,
ScannerSha = scannerSha,
Images = imageResults,
OverallScore = overallScore,
Thresholds = new DeterminismThresholds
{
ImageMin = request.ImageThreshold,
OverallMin = request.OverallThreshold
},
GeneratedAt = DateTimeOffset.UtcNow,
Execution = new DeterminismExecutionInfo
{
FixedClock = fixedClock,
RngSeed = request.RngSeed,
MaxConcurrency = request.MaxConcurrency,
MemoryLimit = request.MemoryLimit,
CpuSet = request.CpuSet,
NetworkMode = "none"
}
};
// Write manifest
var manifestPath = Path.Combine(outputDir, "determinism.json");
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken);
_logger.LogInformation("Wrote determinism manifest to {Path}", manifestPath);
sw.Stop();
return new DeterminismRunResult
{
Success = errors.Count == 0,
Manifest = manifest,
OutputPath = manifestPath,
PassedThreshold = passedThreshold,
FailedImages = failedImages,
Errors = errors,
Warnings = warnings,
DurationMs = sw.ElapsedMilliseconds
};
}
public DeterminismVerificationResult VerifyManifest(
DeterminismManifest manifest,
double imageThreshold,
double overallThreshold)
{
ArgumentNullException.ThrowIfNull(manifest);
var failedImages = new List<string>();
var warnings = new List<string>();
foreach (var image in manifest.Images)
{
if (image.Score < imageThreshold)
{
failedImages.Add(image.Digest);
}
if (image.NonDeterministic.Count > 0)
{
warnings.Add($"Image {image.Digest} has non-deterministic artifacts: {string.Join(", ", image.NonDeterministic)}");
}
}
var passed = manifest.OverallScore >= overallThreshold && failedImages.Count == 0;
if (manifest.OverallScore < overallThreshold)
{
warnings.Add($"Overall score {manifest.OverallScore:P0} below threshold {overallThreshold:P0}");
}
return new DeterminismVerificationResult
{
Passed = passed,
OverallScore = manifest.OverallScore,
FailedImages = failedImages.ToArray(),
Warnings = warnings.ToArray()
};
}
private async Task<DeterminismImageResult> RunImageDeterminismAsync(
string imageRef,
DeterminismRunRequest request,
DateTimeOffset fixedClock,
string outputDir,
CancellationToken cancellationToken)
{
var imageDigest = await ResolveImageDigestAsync(imageRef, cancellationToken) ?? imageRef;
var imageOutputDir = Path.Combine(outputDir, SanitizeForPath(imageDigest));
Directory.CreateDirectory(imageOutputDir);
var runDetails = new List<DeterminismRunDetail>();
Dictionary<string, string>? baselineHashes = null;
var identicalCount = 0;
var nonDeterministic = new HashSet<string>();
for (var runNum = 1; runNum <= request.Runs; runNum++)
{
_logger.LogDebug("Running determinism test {Run}/{Total} for {Image}",
runNum, request.Runs, imageRef);
var runDir = Path.Combine(imageOutputDir, $"run_{runNum}");
Directory.CreateDirectory(runDir);
var runSw = Stopwatch.StartNew();
var exitCode = await RunScannerAsync(
imageRef,
request.Scanner,
request.PolicyBundle,
request.FeedsBundle,
fixedClock,
request.RngSeed,
request.MaxConcurrency,
request.MemoryLimit,
request.CpuSet,
runDir,
cancellationToken);
runSw.Stop();
// Compute hashes for artifacts
var artifactHashes = await ComputeArtifactHashesAsync(runDir, cancellationToken);
// Compare with baseline
var isIdentical = true;
if (baselineHashes == null)
{
baselineHashes = artifactHashes;
isIdentical = true; // First run is always "identical" (it's the baseline)
}
else
{
foreach (var (artifact, hash) in artifactHashes)
{
if (baselineHashes.TryGetValue(artifact, out var baselineHash))
{
if (hash != baselineHash)
{
isIdentical = false;
nonDeterministic.Add(artifact);
_logger.LogWarning("Non-deterministic artifact {Artifact} in run {Run}: {Hash} != {Baseline}",
artifact, runNum, hash[..16], baselineHash[..16]);
}
}
else
{
// New artifact not in baseline
isIdentical = false;
nonDeterministic.Add(artifact);
}
}
// Check for missing artifacts
foreach (var artifact in baselineHashes.Keys)
{
if (!artifactHashes.ContainsKey(artifact))
{
isIdentical = false;
nonDeterministic.Add(artifact);
}
}
}
if (isIdentical)
{
identicalCount++;
}
runDetails.Add(new DeterminismRunDetail
{
RunNumber = runNum,
Identical = isIdentical,
ArtifactHashes = artifactHashes,
DurationMs = runSw.ElapsedMilliseconds,
ExitCode = exitCode
});
}
var score = request.Runs > 0 ? (double)identicalCount / request.Runs : 0;
return new DeterminismImageResult
{
Digest = imageDigest,
Runs = request.Runs,
Identical = identicalCount,
Score = score,
ArtifactHashes = baselineHashes ?? new Dictionary<string, string>(),
NonDeterministic = nonDeterministic.ToArray(),
RunDetails = runDetails
};
}
private async Task<int> RunScannerAsync(
string imageRef,
string scannerImage,
string? policyBundle,
string? feedsBundle,
DateTimeOffset fixedClock,
int rngSeed,
int maxConcurrency,
string memoryLimit,
string cpuSet,
string outputDir,
CancellationToken cancellationToken)
{
// Build docker run command with determinism constraints
var args = new StringBuilder();
args.Append("run --rm ");
args.Append($"--network=none ");
args.Append($"--cpuset-cpus={cpuSet} ");
args.Append($"--memory={memoryLimit} ");
args.Append($"-e RNG_SEED={rngSeed} ");
args.Append($"-e SCANNER_MAX_CONCURRENCY={maxConcurrency} ");
args.Append($"-v \"{outputDir}:/output\" ");
if (!string.IsNullOrWhiteSpace(policyBundle) && File.Exists(policyBundle))
{
args.Append($"-v \"{Path.GetFullPath(policyBundle)}:/policy:ro\" ");
}
if (!string.IsNullOrWhiteSpace(feedsBundle) && File.Exists(feedsBundle))
{
args.Append($"-v \"{Path.GetFullPath(feedsBundle)}:/feeds:ro\" ");
}
args.Append($"{scannerImage} ");
args.Append($"scan --image {imageRef} ");
args.Append($"--fixed-clock {fixedClock:yyyy-MM-ddTHH:mm:ssZ} ");
args.Append("--output /output ");
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "docker",
Arguments = args.ToString(),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
_logger.LogDebug("Executing: docker {Args}", args);
process.Start();
// Read output asynchronously
var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
var stdout = await stdoutTask;
var stderr = await stderrTask;
if (!string.IsNullOrWhiteSpace(stderr))
{
_logger.LogDebug("Scanner stderr: {Stderr}", stderr);
}
// Save stdout/stderr for diagnostics
await File.WriteAllTextAsync(
Path.Combine(outputDir, "stdout.log"),
stdout,
cancellationToken);
await File.WriteAllTextAsync(
Path.Combine(outputDir, "stderr.log"),
stderr,
cancellationToken);
return process.ExitCode;
}
private async Task<Dictionary<string, string>> ComputeArtifactHashesAsync(
string directory,
CancellationToken cancellationToken)
{
var hashes = new Dictionary<string, string>();
foreach (var pattern in ArtifactPatterns)
{
var filePath = Path.Combine(directory, pattern);
if (File.Exists(filePath))
{
var hash = await ComputeFileHashAsync(filePath, cancellationToken);
hashes[pattern] = hash;
}
}
// Also check for any .json files in output
foreach (var file in Directory.GetFiles(directory, "*.json"))
{
var fileName = Path.GetFileName(file);
if (!hashes.ContainsKey(fileName))
{
var hash = await ComputeFileHashAsync(file, cancellationToken);
hashes[fileName] = hash;
}
}
return hashes;
}
private static async Task<string> ComputeFileHashAsync(string filePath, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(filePath);
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static async Task<string?> ComputeBundleShaAsync(string? bundlePath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(bundlePath))
return null;
if (!File.Exists(bundlePath))
return null;
return await ComputeFileHashAsync(bundlePath, cancellationToken);
}
private static async Task<string?> ResolveImageDigestAsync(string imageRef, CancellationToken cancellationToken)
{
// If already contains digest, extract it
if (imageRef.Contains("@sha256:", StringComparison.OrdinalIgnoreCase))
{
var atIndex = imageRef.IndexOf('@');
if (atIndex >= 0)
{
return imageRef[(atIndex + 1)..];
}
}
// Try using crane/docker to resolve
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "docker",
Arguments = $"inspect --format='{{{{.RepoDigests}}}}' {imageRef}",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode == 0 && stdout.Contains("sha256:"))
{
var match = System.Text.RegularExpressions.Regex.Match(stdout, @"sha256:[a-f0-9]{64}");
if (match.Success)
{
return match.Value;
}
}
}
catch
{
// Ignore errors, return null
}
return null;
}
private static async Task<bool> IsDockerAvailableAsync(CancellationToken cancellationToken)
{
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "docker",
Arguments = "version --format '{{.Server.Version}}'",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
await process.WaitForExitAsync(cancellationToken);
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private static string SanitizeForPath(string input)
{
// Replace invalid path characters
var invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(input);
foreach (var c in invalid)
{
result.Replace(c, '_');
}
return result.ToString();
}
// CLI-DETER-70-004: Generate report implementation
public async Task<DeterminismReportResult> GenerateReportAsync(
DeterminismReportRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<string>();
var warnings = new List<string>();
var manifests = new List<(string path, DeterminismManifest manifest)>();
_logger.LogDebug("Generating determinism report from {Count} manifests", request.ManifestPaths.Count);
if (request.ManifestPaths.Count == 0)
{
errors.Add("No manifest paths provided");
return new DeterminismReportResult
{
Success = false,
Errors = errors
};
}
// Load all manifests
foreach (var path in request.ManifestPaths)
{
if (!File.Exists(path))
{
warnings.Add($"Manifest not found: {path}");
continue;
}
try
{
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
var manifest = JsonSerializer.Deserialize<DeterminismManifest>(json, SerializerOptions);
if (manifest != null)
{
manifests.Add((path, manifest));
}
else
{
warnings.Add($"Failed to parse manifest: {path}");
}
}
catch (Exception ex)
{
warnings.Add($"Error reading manifest {path}: {ex.Message}");
}
}
if (manifests.Count == 0)
{
errors.Add("No valid manifests found");
return new DeterminismReportResult
{
Success = false,
Errors = errors,
Warnings = warnings
};
}
// Build release entries
var releases = manifests.Select(m => new DeterminismReleaseEntry
{
Release = m.manifest.Release,
Platform = m.manifest.Platform,
OverallScore = m.manifest.OverallScore,
Passed = m.manifest.OverallScore >= m.manifest.Thresholds.OverallMin,
ImageCount = m.manifest.Images.Count,
GeneratedAt = m.manifest.GeneratedAt,
ScannerSha = m.manifest.ScannerSha,
ManifestPath = m.path
}).OrderByDescending(r => r.GeneratedAt).ToList();
// Build image matrix
var imageScores = new Dictionary<string, Dictionary<string, double>>();
var imageNonDet = new Dictionary<string, HashSet<string>>();
foreach (var (path, manifest) in manifests)
{
foreach (var img in manifest.Images)
{
if (!imageScores.ContainsKey(img.Digest))
{
imageScores[img.Digest] = new Dictionary<string, double>();
imageNonDet[img.Digest] = new HashSet<string>();
}
imageScores[img.Digest][manifest.Release] = img.Score;
foreach (var nd in img.NonDeterministic)
{
imageNonDet[img.Digest].Add(nd);
}
}
}
var imageMatrix = imageScores.Select(kvp => new DeterminismImageMatrixEntry
{
ImageDigest = kvp.Key,
Scores = kvp.Value,
AverageScore = kvp.Value.Values.Average(),
NonDeterministicArtifacts = imageNonDet[kvp.Key].ToArray()
}).OrderBy(e => e.AverageScore).ToList();
// Compute summary
var allScores = manifests.Select(m => m.manifest.OverallScore).ToList();
var allNonDet = manifests
.SelectMany(m => m.manifest.Images)
.SelectMany(i => i.NonDeterministic)
.Distinct()
.ToList();
var summary = new DeterminismReportSummary
{
TotalReleases = manifests.Count,
TotalImages = imageMatrix.Count,
AverageScore = allScores.Count > 0 ? allScores.Average() : 0,
MinScore = allScores.Count > 0 ? allScores.Min() : 0,
MaxScore = allScores.Count > 0 ? allScores.Max() : 0,
PassedCount = releases.Count(r => r.Passed),
FailedCount = releases.Count(r => !r.Passed),
NonDeterministicArtifacts = allNonDet
};
var report = new DeterminismReport
{
Title = request.Title ?? "Determinism Score Report",
GeneratedAt = DateTimeOffset.UtcNow,
Summary = summary,
Releases = releases,
ImageMatrix = imageMatrix
};
// Write output
string? outputPath = null;
if (!string.IsNullOrWhiteSpace(request.OutputPath))
{
outputPath = request.OutputPath;
var content = request.Format.ToLowerInvariant() switch
{
"json" => JsonSerializer.Serialize(report, SerializerOptions),
"csv" => GenerateCsvReport(report),
_ => GenerateMarkdownReport(report, request.IncludeDetails)
};
await File.WriteAllTextAsync(outputPath, content, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Wrote determinism report to {Path}", outputPath);
}
return new DeterminismReportResult
{
Success = true,
Report = report,
OutputPath = outputPath,
Format = request.Format,
Warnings = warnings
};
}
private static string GenerateMarkdownReport(DeterminismReport report, bool includeDetails)
{
var sb = new StringBuilder();
sb.AppendLine($"# {report.Title}");
sb.AppendLine();
sb.AppendLine($"Generated: {report.GeneratedAt:u}");
sb.AppendLine();
// Summary
sb.AppendLine("## Summary");
sb.AppendLine();
sb.AppendLine($"| Metric | Value |");
sb.AppendLine($"|--------|-------|");
sb.AppendLine($"| Total Releases | {report.Summary.TotalReleases} |");
sb.AppendLine($"| Total Images | {report.Summary.TotalImages} |");
sb.AppendLine($"| Average Score | {report.Summary.AverageScore:P1} |");
sb.AppendLine($"| Min Score | {report.Summary.MinScore:P1} |");
sb.AppendLine($"| Max Score | {report.Summary.MaxScore:P1} |");
sb.AppendLine($"| Passed | {report.Summary.PassedCount} |");
sb.AppendLine($"| Failed | {report.Summary.FailedCount} |");
sb.AppendLine();
if (report.Summary.NonDeterministicArtifacts.Count > 0)
{
sb.AppendLine("### Non-Deterministic Artifacts");
sb.AppendLine();
foreach (var artifact in report.Summary.NonDeterministicArtifacts)
{
sb.AppendLine($"- `{artifact}`");
}
sb.AppendLine();
}
// Releases table
sb.AppendLine("## Releases");
sb.AppendLine();
sb.AppendLine("| Release | Platform | Score | Status | Images | Generated |");
sb.AppendLine("|---------|----------|-------|--------|--------|-----------|");
foreach (var release in report.Releases)
{
var status = release.Passed ? "✅ PASS" : "❌ FAIL";
sb.AppendLine($"| {release.Release} | {release.Platform} | {release.OverallScore:P1} | {status} | {release.ImageCount} | {release.GeneratedAt:yyyy-MM-dd} |");
}
sb.AppendLine();
// Image matrix
if (includeDetails && report.ImageMatrix.Count > 0)
{
sb.AppendLine("## Per-Image Matrix");
sb.AppendLine();
var releaseNames = report.Releases.Select(r => r.Release).ToList();
// Header
sb.Append("| Image |");
foreach (var rel in releaseNames)
{
sb.Append($" {rel} |");
}
sb.AppendLine(" Avg |");
// Separator
sb.Append("|-------|");
foreach (var _ in releaseNames)
{
sb.Append("------|");
}
sb.AppendLine("-----|");
// Rows
foreach (var img in report.ImageMatrix)
{
var digest = img.ImageDigest.Length > 16 ? img.ImageDigest[..16] + "..." : img.ImageDigest;
sb.Append($"| `{digest}` |");
foreach (var rel in releaseNames)
{
if (img.Scores.TryGetValue(rel, out var score))
{
sb.Append($" {score:P0} |");
}
else
{
sb.Append(" - |");
}
}
sb.AppendLine($" {img.AverageScore:P0} |");
}
sb.AppendLine();
}
return sb.ToString();
}
private static string GenerateCsvReport(DeterminismReport report)
{
var sb = new StringBuilder();
// Header
sb.AppendLine("Release,Platform,Score,Passed,ImageCount,GeneratedAt,ScannerSha");
// Rows
foreach (var release in report.Releases)
{
sb.AppendLine($"{release.Release},{release.Platform},{release.OverallScore:F4},{release.Passed},{release.ImageCount},{release.GeneratedAt:o},{release.ScannerSha}");
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,596 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
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;
/// <summary>
/// Client for exception governance API operations.
/// Per CLI-EXC-25-001.
/// </summary>
internal sealed class ExceptionClient : IExceptionClient
{
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<ExceptionClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public ExceptionClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<ExceptionClient> 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.BackendUrl) && httpClient.BaseAddress is null)
{
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<ExceptionListResponse> ListAsync(
ExceptionListRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var uri = BuildListUri(request);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to list exceptions (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new ExceptionListResponse();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<ExceptionListResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ExceptionListResponse();
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while listing exceptions");
return new ExceptionListResponse();
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while listing exceptions");
return new ExceptionListResponse();
}
}
public async Task<ExceptionInstance?> GetAsync(
string exceptionId,
string? tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(exceptionId);
try
{
EnsureConfigured();
var uri = $"/api/v1/exceptions/{Uri.EscapeDataString(exceptionId)}";
if (!string.IsNullOrWhiteSpace(tenant))
{
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "exceptions.read", 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 payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get exception (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer
.DeserializeAsync<ExceptionInstance>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while getting exception");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while getting exception");
return null;
}
}
public async Task<ExceptionOperationResult> CreateAsync(
ExceptionCreateRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions")
{
Content = content
};
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to create exception (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new ExceptionOperationResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while creating exception");
return new ExceptionOperationResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while creating exception");
return new ExceptionOperationResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
public async Task<ExceptionOperationResult> PromoteAsync(
ExceptionPromoteRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/promote")
{
Content = content
};
await AuthorizeRequestAsync(httpRequest, "exceptions.approve", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to promote exception (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new ExceptionOperationResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while promoting exception");
return new ExceptionOperationResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while promoting exception");
return new ExceptionOperationResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
public async Task<ExceptionOperationResult> RevokeAsync(
ExceptionRevokeRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/revoke")
{
Content = content
};
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to revoke exception (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new ExceptionOperationResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while revoking exception");
return new ExceptionOperationResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while revoking exception");
return new ExceptionOperationResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
public async Task<ExceptionImportResult> ImportAsync(
ExceptionImportRequest request,
Stream ndjsonStream,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(ndjsonStream);
try
{
EnsureConfigured();
using var content = new MultipartFormDataContent();
var streamContent = new StreamContent(ndjsonStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson");
content.Add(streamContent, "file", "exceptions.ndjson");
content.Add(new StringContent(request.Tenant), "tenant");
content.Add(new StringContent(request.Stage.ToString().ToLowerInvariant()), "stage");
if (!string.IsNullOrWhiteSpace(request.Source))
{
content.Add(new StringContent(request.Source), "source");
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions/import")
{
Content = content
};
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to import exceptions (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new ExceptionImportResult
{
Success = false,
Errors = [new ExceptionImportError { Line = 0, Message = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}" }]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<ExceptionImportResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ExceptionImportResult { Success = false, Errors = [new ExceptionImportError { Line = 0, Message = "Empty response" }] };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while importing exceptions");
return new ExceptionImportResult
{
Success = false,
Errors = [new ExceptionImportError { Line = 0, Message = $"Connection error: {ex.Message}" }]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while importing exceptions");
return new ExceptionImportResult
{
Success = false,
Errors = [new ExceptionImportError { Line = 0, Message = "Request timed out" }]
};
}
}
public async Task<(Stream Content, ExceptionExportManifest? Manifest)> ExportAsync(
ExceptionExportRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (request.Statuses is { Count: > 0 })
{
foreach (var status in request.Statuses)
{
queryParams.Add($"status={Uri.EscapeDataString(status)}");
}
}
queryParams.Add($"format={Uri.EscapeDataString(request.Format)}");
queryParams.Add($"includeManifest={request.IncludeManifest.ToString().ToLowerInvariant()}");
queryParams.Add($"signed={request.Signed.ToString().ToLowerInvariant()}");
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
var uri = $"/api/v1/exceptions/export{query}";
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to export exceptions (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return (Stream.Null, null);
}
// Parse manifest from header if present
ExceptionExportManifest? manifest = null;
if (response.Headers.TryGetValues("X-Export-Manifest", out var manifestValues))
{
var manifestJson = string.Join("", manifestValues);
if (!string.IsNullOrWhiteSpace(manifestJson))
{
try
{
manifest = JsonSerializer.Deserialize<ExceptionExportManifest>(manifestJson, SerializerOptions);
}
catch (JsonException)
{
// Ignore parse errors for optional header
}
}
}
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return (contentStream, manifest);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while exporting exceptions");
return (Stream.Null, null);
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while exporting exceptions");
return (Stream.Null, null);
}
}
private static string BuildListUri(ExceptionListRequest request)
{
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (!string.IsNullOrWhiteSpace(request.Vuln))
{
queryParams.Add($"vuln={Uri.EscapeDataString(request.Vuln)}");
}
if (!string.IsNullOrWhiteSpace(request.ScopeType))
{
queryParams.Add($"scopeType={Uri.EscapeDataString(request.ScopeType)}");
}
if (!string.IsNullOrWhiteSpace(request.ScopeValue))
{
queryParams.Add($"scopeValue={Uri.EscapeDataString(request.ScopeValue)}");
}
if (request.Statuses is { Count: > 0 })
{
foreach (var status in request.Statuses)
{
queryParams.Add($"status={Uri.EscapeDataString(status)}");
}
}
if (!string.IsNullOrWhiteSpace(request.Owner))
{
queryParams.Add($"owner={Uri.EscapeDataString(request.Owner)}");
}
if (!string.IsNullOrWhiteSpace(request.EffectType))
{
queryParams.Add($"effectType={Uri.EscapeDataString(request.EffectType)}");
}
if (request.ExpiringBefore.HasValue)
{
queryParams.Add($"expiringBefore={Uri.EscapeDataString(request.ExpiringBefore.Value.ToString("O"))}");
}
if (request.IncludeExpired)
{
queryParams.Add("includeExpired=true");
}
queryParams.Add($"pageSize={request.PageSize}");
if (!string.IsNullOrWhiteSpace(request.PageToken))
{
queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}");
}
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
return $"/api/v1/exceptions{query}";
}
private void EnsureConfigured()
{
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
{
throw new InvalidOperationException(
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
}
}
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
{
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
{
if (tokenClient is null)
{
return null;
}
lock (tokenSync)
{
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
{
return cachedAccessToken;
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
cachedAccessTokenExpiresAt = result.ExpiresAt;
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
}
}

View File

@@ -0,0 +1,372 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
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;
/// <summary>
/// Client for forensic snapshot and evidence locker APIs.
/// Per CLI-FORENSICS-53-001.
/// </summary>
internal sealed class ForensicSnapshotClient : IForensicSnapshotClient
{
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<ForensicSnapshotClient> _logger;
private readonly IStellaOpsTokenClient? _tokenClient;
private readonly object _tokenSync = new();
private string? _cachedAccessToken;
private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public ForensicSnapshotClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<ForensicSnapshotClient> logger,
IStellaOpsTokenClient? tokenClient = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_tokenClient = tokenClient;
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
{
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<ForensicSnapshotDocument> CreateSnapshotAsync(
string tenant,
ForensicSnapshotCreateRequest request,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(request);
EnsureConfigured();
var requestUri = $"/forensic/snapshots?tenant={Uri.EscapeDataString(tenant)}";
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError(
"Failed to create forensic snapshot (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<ForensicSnapshotDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Invalid response from forensic API.");
}
public async Task<ForensicSnapshotListResponse> ListSnapshotsAsync(
ForensicSnapshotListQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
EnsureConfigured();
var requestUri = BuildListRequestUri(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 list forensic snapshots (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<ForensicSnapshotListResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ForensicSnapshotListResponse();
}
public async Task<ForensicSnapshotDocument?> GetSnapshotAsync(
string tenant,
string snapshotId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
EnsureConfigured();
var requestUri = $"/forensic/snapshots/{Uri.EscapeDataString(snapshotId)}?tenant={Uri.EscapeDataString(tenant)}";
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.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError(
"Failed to get forensic snapshot (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);
return await JsonSerializer
.DeserializeAsync<ForensicSnapshotDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
public async Task<ForensicSnapshotManifest?> GetSnapshotManifestAsync(
string tenant,
string snapshotId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
EnsureConfigured();
var requestUri = $"/forensic/snapshots/{Uri.EscapeDataString(snapshotId)}/manifest?tenant={Uri.EscapeDataString(tenant)}";
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.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError(
"Failed to get forensic snapshot manifest (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);
return await JsonSerializer
.DeserializeAsync<ForensicSnapshotManifest>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
private static string BuildListRequestUri(ForensicSnapshotListQuery query)
{
var builder = new StringBuilder("/forensic/snapshots?tenant=");
builder.Append(Uri.EscapeDataString(query.Tenant));
if (!string.IsNullOrWhiteSpace(query.CaseId))
{
builder.Append("&caseId=");
builder.Append(Uri.EscapeDataString(query.CaseId));
}
if (!string.IsNullOrWhiteSpace(query.Status))
{
builder.Append("&status=");
builder.Append(Uri.EscapeDataString(query.Status));
}
if (query.Tags is { Count: > 0 })
{
foreach (var tag in query.Tags)
{
if (!string.IsNullOrWhiteSpace(tag))
{
builder.Append("&tag=");
builder.Append(Uri.EscapeDataString(tag));
}
}
}
if (query.CreatedAfter.HasValue)
{
builder.Append("&createdAfter=");
builder.Append(Uri.EscapeDataString(query.CreatedAfter.Value.ToString("O", CultureInfo.InvariantCulture)));
}
if (query.CreatedBefore.HasValue)
{
builder.Append("&createdBefore=");
builder.Append(Uri.EscapeDataString(query.CreatedBefore.Value.ToString("O", CultureInfo.InvariantCulture)));
}
if (query.Limit.HasValue && query.Limit.Value > 0)
{
builder.Append("&limit=");
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
}
if (query.Offset.HasValue && query.Offset.Value > 0)
{
builder.Append("&offset=");
builder.Append(query.Offset.Value.ToString(CultureInfo.InvariantCulture));
}
return builder.ToString();
}
private void EnsureConfigured()
{
if (!string.IsNullOrWhiteSpace(_options.BackendUrl))
{
return;
}
throw new InvalidOperationException(
"BackendUrl is not configured. Set StellaOps:BackendUrl or STELLAOPS_BACKEND_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.EvidenceRead);
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);
}
}

View File

@@ -0,0 +1,592 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Output;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Verifier for forensic bundles including checksums, DSSE signatures, and chain-of-custody.
/// Per CLI-FORENSICS-54-001.
/// </summary>
internal sealed class ForensicVerifier : IForensicVerifier
{
private const string PaePrefix = "DSSEv1";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly ILogger<ForensicVerifier> _logger;
public ForensicVerifier(ILogger<ForensicVerifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ForensicVerificationResult> VerifyBundleAsync(
string bundlePath,
ForensicVerificationOptions options,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
ArgumentNullException.ThrowIfNull(options);
var errors = new List<ForensicVerificationError>();
var warnings = new List<string>();
var verifiedAt = DateTimeOffset.UtcNow;
_logger.LogDebug("Verifying forensic bundle at {BundlePath}", bundlePath);
// Check bundle exists
if (!File.Exists(bundlePath) && !Directory.Exists(bundlePath))
{
errors.Add(new ForensicVerificationError
{
Code = CliErrorCodes.ForensicBundleNotFound,
Message = "Bundle path not found",
Detail = bundlePath
});
return new ForensicVerificationResult
{
BundlePath = bundlePath,
IsValid = false,
VerifiedAt = verifiedAt,
Errors = errors
};
}
// Load manifest
var manifestPath = ResolveManifestPath(bundlePath);
if (manifestPath is null || !File.Exists(manifestPath))
{
errors.Add(new ForensicVerificationError
{
Code = CliErrorCodes.ForensicBundleInvalid,
Message = "Manifest not found in bundle",
Detail = "Expected manifest.json in bundle root"
});
return new ForensicVerificationResult
{
BundlePath = bundlePath,
IsValid = false,
VerifiedAt = verifiedAt,
Errors = errors
};
}
ForensicSnapshotManifest manifest;
try
{
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
manifest = JsonSerializer.Deserialize<ForensicSnapshotManifest>(manifestJson, SerializerOptions)
?? throw new InvalidDataException("Invalid manifest JSON");
}
catch (Exception ex) when (ex is JsonException or InvalidDataException)
{
_logger.LogError(ex, "Failed to parse manifest at {ManifestPath}", manifestPath);
errors.Add(new ForensicVerificationError
{
Code = CliErrorCodes.ForensicBundleInvalid,
Message = "Failed to parse manifest",
Detail = ex.Message
});
return new ForensicVerificationResult
{
BundlePath = bundlePath,
IsValid = false,
VerifiedAt = verifiedAt,
Errors = errors
};
}
var bundleDir = Path.GetDirectoryName(manifestPath) ?? bundlePath;
// Verify manifest
var manifestVerification = await VerifyManifestAsync(manifest, manifestPath, cancellationToken)
.ConfigureAwait(false);
if (!manifestVerification.IsValid)
{
errors.Add(new ForensicVerificationError
{
Code = CliErrorCodes.ForensicChecksumMismatch,
Message = "Manifest digest verification failed",
Detail = $"Expected: {manifestVerification.Digest}, Computed: {manifestVerification.ComputedDigest}"
});
}
// Verify checksums
ForensicChecksumVerification? checksumVerification = null;
if (options.VerifyChecksums)
{
checksumVerification = await VerifyChecksumsAsync(manifest, bundleDir, cancellationToken)
.ConfigureAwait(false);
foreach (var failure in checksumVerification.FailedArtifacts)
{
errors.Add(new ForensicVerificationError
{
Code = CliErrorCodes.ForensicChecksumMismatch,
Message = $"Checksum mismatch for artifact {failure.ArtifactId}",
Detail = failure.Reason,
ArtifactId = failure.ArtifactId
});
}
}
// Verify signatures
ForensicSignatureVerification? signatureVerification = null;
if (options.VerifySignatures && manifest.Signature is not null)
{
var trustRoots = options.TrustRoots.ToList();
if (!string.IsNullOrWhiteSpace(options.TrustRootPath))
{
var loadedRoots = await LoadTrustRootsAsync(options.TrustRootPath, cancellationToken)
.ConfigureAwait(false);
trustRoots.AddRange(loadedRoots);
}
if (trustRoots.Count == 0)
{
warnings.Add("No trust roots configured; signature verification skipped");
}
else
{
signatureVerification = VerifySignature(manifest, trustRoots);
if (!signatureVerification.IsValid)
{
var untrusted = signatureVerification.Signatures
.Where(s => !s.IsTrusted)
.Select(s => s.KeyId);
errors.Add(new ForensicVerificationError
{
Code = signatureVerification.VerifiedSignatures == 0
? CliErrorCodes.ForensicSignatureInvalid
: CliErrorCodes.ForensicSignatureUntrusted,
Message = "Signature verification failed",
Detail = string.Join(", ", signatureVerification.Signatures.Select(s => s.Reason).Where(r => r is not null))
});
}
}
}
// Verify chain of custody
ForensicChainOfCustodyVerification? chainVerification = null;
if (options.VerifyChainOfCustody && manifest.Metadata?.ChainOfCustody is { Count: > 0 })
{
chainVerification = VerifyChainOfCustody(manifest.Metadata.ChainOfCustody, options.StrictTimeline);
if (!chainVerification.IsValid)
{
var errorCode = !chainVerification.TimelineValid
? CliErrorCodes.ForensicTimelineInvalid
: CliErrorCodes.ForensicChainOfCustodyBroken;
errors.Add(new ForensicVerificationError
{
Code = errorCode,
Message = "Chain of custody verification failed",
Detail = chainVerification.Gaps.Count > 0
? $"Found {chainVerification.Gaps.Count} timeline gap(s)"
: "Invalid entry signatures"
});
}
}
var isValid = errors.Count == 0 &&
manifestVerification.IsValid &&
(checksumVerification?.IsValid ?? true) &&
(signatureVerification?.IsValid ?? true) &&
(chainVerification?.IsValid ?? true);
return new ForensicVerificationResult
{
BundlePath = bundlePath,
IsValid = isValid,
VerifiedAt = verifiedAt,
ManifestVerification = manifestVerification,
ChecksumVerification = checksumVerification,
SignatureVerification = signatureVerification,
ChainOfCustodyVerification = chainVerification,
Errors = errors,
Warnings = warnings
};
}
public async Task<IReadOnlyList<ForensicTrustRoot>> LoadTrustRootsAsync(
string trustRootPath,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(trustRootPath);
if (!File.Exists(trustRootPath))
{
_logger.LogWarning("Trust root file not found: {Path}", trustRootPath);
return Array.Empty<ForensicTrustRoot>();
}
try
{
var json = await File.ReadAllTextAsync(trustRootPath, cancellationToken).ConfigureAwait(false);
// Try array format first
var roots = JsonSerializer.Deserialize<List<ForensicTrustRoot>>(json, SerializerOptions);
if (roots is not null)
{
return roots;
}
// Try single object
var singleRoot = JsonSerializer.Deserialize<ForensicTrustRoot>(json, SerializerOptions);
if (singleRoot is not null)
{
return new[] { singleRoot };
}
return Array.Empty<ForensicTrustRoot>();
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse trust roots from {Path}", trustRootPath);
return Array.Empty<ForensicTrustRoot>();
}
}
private static string? ResolveManifestPath(string bundlePath)
{
if (File.Exists(bundlePath))
{
// If bundlePath is a file, check if it's the manifest
var fileName = Path.GetFileName(bundlePath);
if (fileName.Equals("manifest.json", StringComparison.OrdinalIgnoreCase))
{
return bundlePath;
}
// Otherwise look for manifest in same directory
var dir = Path.GetDirectoryName(bundlePath);
if (dir is not null)
{
var manifestInDir = Path.Combine(dir, "manifest.json");
if (File.Exists(manifestInDir))
{
return manifestInDir;
}
}
return null;
}
if (Directory.Exists(bundlePath))
{
var manifestPath = Path.Combine(bundlePath, "manifest.json");
return File.Exists(manifestPath) ? manifestPath : null;
}
return null;
}
private async Task<ForensicManifestVerification> VerifyManifestAsync(
ForensicSnapshotManifest manifest,
string manifestPath,
CancellationToken cancellationToken)
{
var manifestBytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken).ConfigureAwait(false);
var computedDigest = ComputeDigest(manifestBytes, manifest.DigestAlgorithm);
var isValid = string.Equals(manifest.Digest, computedDigest, StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrEmpty(manifest.Digest); // Allow empty digest for unsigned manifests
return new ForensicManifestVerification
{
IsValid = isValid,
ManifestId = manifest.ManifestId,
Version = manifest.Version,
Digest = manifest.Digest,
DigestAlgorithm = manifest.DigestAlgorithm,
ComputedDigest = computedDigest,
ArtifactCount = manifest.Artifacts.Count
};
}
private async Task<ForensicChecksumVerification> VerifyChecksumsAsync(
ForensicSnapshotManifest manifest,
string bundleDir,
CancellationToken cancellationToken)
{
var failures = new List<ForensicArtifactChecksumFailure>();
var verified = 0;
foreach (var artifact in manifest.Artifacts)
{
var artifactPath = Path.Combine(bundleDir, artifact.Path);
if (!File.Exists(artifactPath))
{
failures.Add(new ForensicArtifactChecksumFailure
{
ArtifactId = artifact.ArtifactId,
Path = artifact.Path,
ExpectedDigest = artifact.Digest,
ActualDigest = string.Empty,
Reason = "Artifact file not found"
});
continue;
}
try
{
var fileBytes = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false);
var actualDigest = ComputeDigest(fileBytes, artifact.DigestAlgorithm);
if (!string.Equals(artifact.Digest, actualDigest, StringComparison.OrdinalIgnoreCase))
{
failures.Add(new ForensicArtifactChecksumFailure
{
ArtifactId = artifact.ArtifactId,
Path = artifact.Path,
ExpectedDigest = artifact.Digest,
ActualDigest = actualDigest,
Reason = "Digest mismatch"
});
}
else
{
verified++;
}
}
catch (IOException ex)
{
failures.Add(new ForensicArtifactChecksumFailure
{
ArtifactId = artifact.ArtifactId,
Path = artifact.Path,
ExpectedDigest = artifact.Digest,
ActualDigest = string.Empty,
Reason = $"IO error: {ex.Message}"
});
}
}
return new ForensicChecksumVerification
{
IsValid = failures.Count == 0,
TotalArtifacts = manifest.Artifacts.Count,
VerifiedArtifacts = verified,
FailedArtifacts = failures
};
}
private ForensicSignatureVerification VerifySignature(
ForensicSnapshotManifest manifest,
IReadOnlyList<ForensicTrustRoot> trustRoots)
{
if (manifest.Signature is null)
{
return new ForensicSignatureVerification
{
IsValid = false,
SignatureCount = 0,
VerifiedSignatures = 0,
Signatures = Array.Empty<ForensicSignatureDetail>()
};
}
var signatures = new List<ForensicSignatureDetail>();
var verifiedCount = 0;
// Find matching trust root
var matchingRoot = trustRoots.FirstOrDefault(tr =>
string.Equals(tr.KeyId, manifest.Signature.KeyId, StringComparison.OrdinalIgnoreCase));
if (matchingRoot is null)
{
signatures.Add(new ForensicSignatureDetail
{
KeyId = manifest.Signature.KeyId ?? "unknown",
Algorithm = manifest.Signature.Algorithm,
IsValid = false,
IsTrusted = false,
SignedAt = manifest.Signature.SignedAt,
Reason = "No matching trust root found"
});
return new ForensicSignatureVerification
{
IsValid = false,
SignatureCount = 1,
VerifiedSignatures = 0,
Signatures = signatures
};
}
// Verify signature
var isValid = VerifyRsaPssSignature(
manifest.Digest,
manifest.Signature.Value,
matchingRoot.PublicKey);
// Check time validity
var now = DateTimeOffset.UtcNow;
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);
if (isValid && timeValid)
{
verifiedCount++;
}
signatures.Add(new ForensicSignatureDetail
{
KeyId = manifest.Signature.KeyId ?? "unknown",
Algorithm = manifest.Signature.Algorithm,
IsValid = isValid,
IsTrusted = isValid && timeValid,
SignedAt = manifest.Signature.SignedAt,
Fingerprint = matchingRoot.Fingerprint,
Reason = !isValid ? "Signature verification failed" :
!timeValid ? "Key outside validity period" : null
});
return new ForensicSignatureVerification
{
IsValid = verifiedCount > 0,
SignatureCount = 1,
VerifiedSignatures = verifiedCount,
Signatures = signatures
};
}
private ForensicChainOfCustodyVerification VerifyChainOfCustody(
IReadOnlyList<ForensicChainOfCustodyEntry> entries,
bool strictTimeline)
{
var entryVerifications = new List<ForensicChainOfCustodyEntryVerification>();
var gaps = new List<ForensicTimelineGap>();
var timelineValid = true;
var signaturesValid = true;
DateTimeOffset? lastTimestamp = null;
var index = 0;
foreach (var entry in entries.OrderBy(e => e.Timestamp))
{
// Check timeline progression
if (lastTimestamp.HasValue && entry.Timestamp < lastTimestamp.Value)
{
timelineValid = false;
gaps.Add(new ForensicTimelineGap
{
FromIndex = index - 1,
ToIndex = index,
FromTimestamp = lastTimestamp.Value,
ToTimestamp = entry.Timestamp,
GapDuration = lastTimestamp.Value - entry.Timestamp,
Description = "Timestamp out of order"
});
}
else if (strictTimeline && lastTimestamp.HasValue)
{
var gap = entry.Timestamp - lastTimestamp.Value;
if (gap > TimeSpan.FromDays(1))
{
gaps.Add(new ForensicTimelineGap
{
FromIndex = index - 1,
ToIndex = index,
FromTimestamp = lastTimestamp.Value,
ToTimestamp = entry.Timestamp,
GapDuration = gap,
Description = $"Large gap of {gap.TotalHours:F1} hours"
});
}
}
// Signature verification (if present)
bool? signatureValid = null;
if (!string.IsNullOrWhiteSpace(entry.Signature))
{
// For now, just check signature is present
// Full verification would require the signing key
signatureValid = true;
}
entryVerifications.Add(new ForensicChainOfCustodyEntryVerification
{
Index = index,
Action = entry.Action,
Actor = entry.Actor,
Timestamp = entry.Timestamp,
SignatureValid = signatureValid,
Notes = entry.Notes
});
lastTimestamp = entry.Timestamp;
index++;
}
return new ForensicChainOfCustodyVerification
{
IsValid = timelineValid && signaturesValid && (gaps.Count == 0 || !strictTimeline),
EntryCount = entries.Count,
TimelineValid = timelineValid,
SignaturesValid = signaturesValid,
Entries = entryVerifications,
Gaps = gaps
};
}
private static string ComputeDigest(byte[] data, string algorithm)
{
byte[] hash;
switch (algorithm.ToLowerInvariant())
{
case "sha256":
hash = SHA256.HashData(data);
break;
case "sha384":
hash = SHA384.HashData(data);
break;
case "sha512":
hash = SHA512.HashData(data);
break;
default:
hash = SHA256.HashData(data);
break;
}
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool VerifyRsaPssSignature(string digest, string signatureBase64, string publicKeyBase64)
{
try
{
var publicKeyBytes = Convert.FromBase64String(publicKeyBase64);
var signatureBytes = Convert.FromBase64String(signatureBase64);
var digestBytes = Convert.FromHexString(digest);
using var rsa = RSA.Create();
rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
return rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,20 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Reader for attestation files.
/// Per CLI-FORENSICS-54-002.
/// </summary>
internal interface IAttestationReader
{
/// <summary>
/// Reads and parses an attestation file.
/// </summary>
Task<AttestationShowResult> ReadAttestationAsync(
string filePath,
AttestationShowOptions options,
CancellationToken cancellationToken);
}

View File

@@ -83,4 +83,48 @@ internal interface IBackendOperationsClient
// CLI-VULN-29-005: Vulnerability export
Task<VulnExportResponse> ExportVulnerabilitiesAsync(VulnExportRequest request, string? tenant, CancellationToken cancellationToken);
Task<Stream> DownloadVulnExportAsync(string exportId, string? tenant, CancellationToken cancellationToken);
// CLI-POLICY-23-006: Policy history and explain
Task<PolicyHistoryResponse> GetPolicyHistoryAsync(PolicyHistoryRequest request, CancellationToken cancellationToken);
Task<PolicyExplainResult> GetPolicyExplainAsync(PolicyExplainRequest request, CancellationToken cancellationToken);
// CLI-POLICY-27-002: Policy submission/review workflow
Task<PolicyVersionBumpResult> BumpPolicyVersionAsync(PolicyVersionBumpRequest request, CancellationToken cancellationToken);
Task<PolicySubmitResult> SubmitPolicyForReviewAsync(PolicySubmitRequest request, CancellationToken cancellationToken);
Task<PolicyReviewCommentResult> AddPolicyReviewCommentAsync(PolicyReviewCommentRequest request, CancellationToken cancellationToken);
Task<PolicyApproveResult> ApprovePolicyReviewAsync(PolicyApproveRequest request, CancellationToken cancellationToken);
Task<PolicyRejectResult> RejectPolicyReviewAsync(PolicyRejectRequest request, CancellationToken cancellationToken);
Task<PolicyReviewSummary?> GetPolicyReviewStatusAsync(PolicyReviewStatusRequest request, CancellationToken cancellationToken);
// CLI-POLICY-27-004: Policy lifecycle (publish/promote/rollback/sign)
Task<PolicyPublishResult> PublishPolicyAsync(PolicyPublishRequest request, CancellationToken cancellationToken);
Task<PolicyPromoteResult> PromotePolicyAsync(PolicyPromoteRequest request, CancellationToken cancellationToken);
Task<PolicyRollbackResult> RollbackPolicyAsync(PolicyRollbackRequest request, CancellationToken cancellationToken);
Task<PolicySignResult> SignPolicyAsync(PolicySignRequest request, CancellationToken cancellationToken);
Task<PolicyVerifySignatureResult> VerifyPolicySignatureAsync(PolicyVerifySignatureRequest request, CancellationToken cancellationToken);
// CLI-RISK-66-001: Risk profile list
Task<RiskProfileListResponse> ListRiskProfilesAsync(RiskProfileListRequest request, CancellationToken cancellationToken);
// CLI-RISK-66-002: Risk simulate
Task<RiskSimulateResult> SimulateRiskAsync(RiskSimulateRequest request, CancellationToken cancellationToken);
// CLI-RISK-67-001: Risk results
Task<RiskResultsResponse> GetRiskResultsAsync(RiskResultsRequest request, CancellationToken cancellationToken);
// CLI-RISK-68-001: Risk bundle verify
Task<RiskBundleVerifyResult> VerifyRiskBundleAsync(RiskBundleVerifyRequest request, CancellationToken cancellationToken);
// CLI-SIG-26-001: Reachability operations
Task<ReachabilityUploadCallGraphResult> UploadCallGraphAsync(ReachabilityUploadCallGraphRequest request, Stream callGraphStream, CancellationToken cancellationToken);
Task<ReachabilityListResponse> ListReachabilityAnalysesAsync(ReachabilityListRequest request, CancellationToken cancellationToken);
Task<ReachabilityExplainResult> ExplainReachabilityAsync(ReachabilityExplainRequest request, CancellationToken cancellationToken);
// CLI-SDK-63-001: API spec download
Task<ApiSpecListResponse> ListApiSpecsAsync(string? tenant, CancellationToken cancellationToken);
Task<ApiSpecDownloadResult> DownloadApiSpecAsync(ApiSpecDownloadRequest request, CancellationToken cancellationToken);
// CLI-SDK-64-001: SDK update
Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken);
Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken);
}

View File

@@ -4,9 +4,32 @@ using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for Concelier advisory observations API.
/// Per CLI-LNM-22-001, supports obs get, linkset show, and export operations.
/// </summary>
internal interface IConcelierObservationsClient
{
/// <summary>
/// Gets advisory observations matching the query.
/// </summary>
Task<AdvisoryObservationsResponse> GetObservationsAsync(
AdvisoryObservationsQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Gets advisory linkset with conflict information.
/// Per CLI-LNM-22-001, includes conflict display.
/// </summary>
Task<AdvisoryLinksetResponse> GetLinksetAsync(
AdvisoryLinksetQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Gets a single observation by ID.
/// </summary>
Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
string tenant,
string observationId,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,46 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Determinism harness for running scanner with frozen conditions.
/// Per CLI-DETER-70-003/004.
/// </summary>
internal interface IDeterminismHarness
{
/// <summary>
/// Runs the determinism harness with the specified configuration.
/// </summary>
Task<DeterminismRunResult> RunAsync(
DeterminismRunRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Verifies a determinism manifest against thresholds.
/// </summary>
DeterminismVerificationResult VerifyManifest(
DeterminismManifest manifest,
double imageThreshold,
double overallThreshold);
/// <summary>
/// Generates a determinism report from multiple manifest files.
/// Per CLI-DETER-70-004.
/// </summary>
Task<DeterminismReportResult> GenerateReportAsync(
DeterminismReportRequest request,
CancellationToken cancellationToken);
}
/// <summary>
/// Result of verifying a determinism manifest.
/// </summary>
internal sealed class DeterminismVerificationResult
{
public bool Passed { get; init; }
public double OverallScore { get; init; }
public string[] FailedImages { get; init; } = System.Array.Empty<string>();
public string[] Warnings { get; init; } = System.Array.Empty<string>();
}

View File

@@ -0,0 +1,64 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for exception governance API operations.
/// Per CLI-EXC-25-001.
/// </summary>
internal interface IExceptionClient
{
/// <summary>
/// Lists exceptions matching the query.
/// </summary>
Task<ExceptionListResponse> ListAsync(
ExceptionListRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets an exception by ID.
/// </summary>
Task<ExceptionInstance?> GetAsync(
string exceptionId,
string? tenant,
CancellationToken cancellationToken);
/// <summary>
/// Creates a new exception.
/// </summary>
Task<ExceptionOperationResult> CreateAsync(
ExceptionCreateRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Promotes an exception to the next lifecycle stage.
/// </summary>
Task<ExceptionOperationResult> PromoteAsync(
ExceptionPromoteRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Revokes an exception.
/// </summary>
Task<ExceptionOperationResult> RevokeAsync(
ExceptionRevokeRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Imports exceptions from NDJSON stream.
/// </summary>
Task<ExceptionImportResult> ImportAsync(
ExceptionImportRequest request,
Stream ndjsonStream,
CancellationToken cancellationToken);
/// <summary>
/// Exports exceptions to a stream.
/// </summary>
Task<(Stream Content, ExceptionExportManifest? Manifest)> ExportAsync(
ExceptionExportRequest request,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,43 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for forensic snapshot and evidence locker APIs.
/// Per CLI-FORENSICS-53-001.
/// </summary>
internal interface IForensicSnapshotClient
{
/// <summary>
/// Creates a new forensic snapshot.
/// </summary>
Task<ForensicSnapshotDocument> CreateSnapshotAsync(
string tenant,
ForensicSnapshotCreateRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Lists forensic snapshots matching the query.
/// </summary>
Task<ForensicSnapshotListResponse> ListSnapshotsAsync(
ForensicSnapshotListQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Gets a forensic snapshot by ID.
/// </summary>
Task<ForensicSnapshotDocument?> GetSnapshotAsync(
string tenant,
string snapshotId,
CancellationToken cancellationToken);
/// <summary>
/// Gets the manifest for a forensic snapshot.
/// </summary>
Task<ForensicSnapshotManifest?> GetSnapshotManifestAsync(
string tenant,
string snapshotId,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,27 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Verifier for forensic bundles.
/// Per CLI-FORENSICS-54-001.
/// </summary>
internal interface IForensicVerifier
{
/// <summary>
/// Verifies a forensic bundle at the specified path.
/// </summary>
Task<ForensicVerificationResult> VerifyBundleAsync(
string bundlePath,
ForensicVerificationOptions options,
CancellationToken cancellationToken);
/// <summary>
/// Loads trust roots from a file path.
/// </summary>
Task<IReadOnlyList<ForensicTrustRoot>> LoadTrustRootsAsync(
string trustRootPath,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,70 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for Notify API operations.
/// Per CLI-PARITY-41-002.
/// </summary>
internal interface INotifyClient
{
/// <summary>
/// Lists notification channels.
/// </summary>
Task<NotifyChannelListResponse> ListChannelsAsync(
NotifyChannelListRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets a notification channel by ID.
/// </summary>
Task<NotifyChannelDetail?> GetChannelAsync(
string channelId,
string? tenant,
CancellationToken cancellationToken);
/// <summary>
/// Tests a notification channel.
/// </summary>
Task<NotifyChannelTestResult> TestChannelAsync(
NotifyChannelTestRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Lists notification rules.
/// </summary>
Task<NotifyRuleListResponse> ListRulesAsync(
NotifyRuleListRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Lists notification deliveries.
/// </summary>
Task<NotifyDeliveryListResponse> ListDeliveriesAsync(
NotifyDeliveryListRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets a delivery by ID.
/// </summary>
Task<NotifyDeliveryDetail?> GetDeliveryAsync(
string deliveryId,
string? tenant,
CancellationToken cancellationToken);
/// <summary>
/// Retries a failed delivery.
/// </summary>
Task<NotifyRetryResult> RetryDeliveryAsync(
NotifyRetryRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Sends a notification.
/// </summary>
Task<NotifySendResult> SendAsync(
NotifySendRequest request,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,59 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for observability API operations.
/// Per CLI-OBS-51-001/52-001.
/// </summary>
internal interface IObservabilityClient
{
/// <summary>
/// Gets platform health summary for obs top command.
/// </summary>
Task<ObsTopResult> GetHealthSummaryAsync(
ObsTopRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets a distributed trace by ID.
/// Per CLI-OBS-52-001.
/// </summary>
Task<ObsTraceResult> GetTraceAsync(
ObsTraceRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets logs within a time window.
/// Per CLI-OBS-52-001.
/// </summary>
Task<ObsLogsResult> GetLogsAsync(
ObsLogsRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets current incident mode status.
/// Per CLI-OBS-55-001.
/// </summary>
Task<IncidentModeResult> GetIncidentModeStatusAsync(
string? tenant,
CancellationToken cancellationToken);
/// <summary>
/// Enables incident mode.
/// Per CLI-OBS-55-001.
/// </summary>
Task<IncidentModeResult> EnableIncidentModeAsync(
IncidentModeEnableRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Disables incident mode.
/// Per CLI-OBS-55-001.
/// </summary>
Task<IncidentModeResult> DisableIncidentModeAsync(
IncidentModeDisableRequest request,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,102 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for orchestrator API operations.
/// Per CLI-ORCH-32-001.
/// </summary>
internal interface IOrchestratorClient
{
/// <summary>
/// Lists sources matching the query.
/// </summary>
Task<SourceListResponse> ListSourcesAsync(
SourceListRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets a source by ID.
/// </summary>
Task<OrchestratorSource?> GetSourceAsync(
string sourceId,
string? tenant,
CancellationToken cancellationToken);
/// <summary>
/// Pauses a source.
/// </summary>
Task<SourceOperationResult> PauseSourceAsync(
SourcePauseRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Resumes a paused source.
/// </summary>
Task<SourceOperationResult> ResumeSourceAsync(
SourceResumeRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Tests a source connection.
/// </summary>
Task<SourceTestResult> TestSourceAsync(
SourceTestRequest request,
CancellationToken cancellationToken);
// CLI-ORCH-34-001: Backfill operations
/// <summary>
/// Starts a backfill operation for a source.
/// </summary>
Task<BackfillResult> StartBackfillAsync(
BackfillRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets the status of a backfill operation.
/// </summary>
Task<BackfillResult?> GetBackfillAsync(
string backfillId,
string? tenant,
CancellationToken cancellationToken);
/// <summary>
/// Lists backfill operations.
/// </summary>
Task<BackfillListResponse> ListBackfillsAsync(
BackfillListRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Cancels a running backfill operation.
/// </summary>
Task<SourceOperationResult> CancelBackfillAsync(
BackfillCancelRequest request,
CancellationToken cancellationToken);
// CLI-ORCH-34-001: Quota management
/// <summary>
/// Gets quotas for a tenant/source.
/// </summary>
Task<QuotaGetResponse> GetQuotasAsync(
QuotaGetRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Sets a quota limit.
/// </summary>
Task<QuotaOperationResult> SetQuotaAsync(
QuotaSetRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Resets a quota's usage counter.
/// </summary>
Task<QuotaOperationResult> ResetQuotaAsync(
QuotaResetRequest request,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,124 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for Task Pack registry and runner API operations.
/// Per CLI-PACKS-42-001.
/// </summary>
internal interface IPackClient
{
/// <summary>
/// Plans a pack execution without running it.
/// Returns the execution graph, validation errors, and approval requirements.
/// </summary>
Task<PackPlanResult> PlanAsync(
PackPlanRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Runs a pack with the specified inputs.
/// Can optionally wait for completion.
/// </summary>
Task<PackRunResult> RunAsync(
PackRunRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets the status of a pack run.
/// </summary>
Task<PackRunStatus?> GetRunStatusAsync(
string runId,
string? tenant,
CancellationToken cancellationToken);
/// <summary>
/// Pushes a pack to the registry.
/// </summary>
Task<PackPushResult> PushAsync(
PackPushRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Pulls a pack from the registry.
/// </summary>
Task<PackPullResult> PullAsync(
PackPullRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Verifies a pack's signature, digest, and schema.
/// </summary>
Task<PackVerifyResult> VerifyAsync(
PackVerifyRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets pack info from the registry.
/// </summary>
Task<TaskPackInfo?> GetPackInfoAsync(
string packId,
string? version,
string? tenant,
CancellationToken cancellationToken);
// CLI-PACKS-43-001: Advanced pack features
/// <summary>
/// Lists pack runs with optional filters.
/// </summary>
Task<PackRunListResponse> ListRunsAsync(
PackRunListRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Cancels a running pack.
/// </summary>
Task<PackApprovalResult> CancelRunAsync(
PackCancelRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Pauses a pack run waiting for approval.
/// </summary>
Task<PackApprovalResult> PauseForApprovalAsync(
PackApprovalPauseRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Resumes a paused pack run with approval decision.
/// </summary>
Task<PackApprovalResult> ResumeWithApprovalAsync(
PackApprovalResumeRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Injects a secret into a pack run.
/// </summary>
Task<PackSecretInjectResult> InjectSecretAsync(
PackSecretInjectRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets logs for a pack run.
/// </summary>
Task<PackLogsResult> GetLogsAsync(
PackLogsRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Downloads an artifact from a pack run.
/// </summary>
Task<PackArtifactDownloadResult> DownloadArtifactAsync(
PackArtifactDownloadRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Manages the offline pack cache.
/// </summary>
Task<PackCacheResult> ManageCacheAsync(
PackCacheRequest request,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,42 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Assembler for promotion attestations.
/// Per CLI-PROMO-70-001/002.
/// </summary>
internal interface IPromotionAssembler
{
/// <summary>
/// Assembles a promotion attestation from the provided request.
/// </summary>
Task<PromotionAssembleResult> AssembleAsync(
PromotionAssembleRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Resolves image digest from registry.
/// </summary>
Task<string?> ResolveImageDigestAsync(
string imageRef,
CancellationToken cancellationToken);
/// <summary>
/// Signs a promotion predicate and produces a DSSE bundle.
/// Per CLI-PROMO-70-002.
/// </summary>
Task<PromotionAttestResult> AttestAsync(
PromotionAttestRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Verifies a promotion attestation bundle offline.
/// Per CLI-PROMO-70-002.
/// </summary>
Task<PromotionVerifyResult> VerifyAsync(
PromotionVerifyRequest request,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,53 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for SBOM API operations.
/// Per CLI-PARITY-41-001.
/// </summary>
internal interface ISbomClient
{
/// <summary>
/// Lists SBOMs matching the query.
/// </summary>
Task<SbomListResponse> ListAsync(
SbomListRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets an SBOM by ID with optional explain information.
/// </summary>
Task<SbomDetailResponse?> GetAsync(
string sbomId,
string? tenant,
bool includeComponents,
bool includeVulnerabilities,
bool includeLicenses,
bool explain,
CancellationToken cancellationToken);
/// <summary>
/// Compares two SBOMs.
/// </summary>
Task<SbomCompareResponse?> CompareAsync(
SbomCompareRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Exports an SBOM in the specified format.
/// </summary>
Task<(Stream Content, SbomExportResult? Result)> ExportAsync(
SbomExportRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets the parity matrix showing CLI command coverage.
/// </summary>
Task<ParityMatrixResponse> GetParityMatrixAsync(
string? tenant,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,78 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for Sbomer API operations (layer fragments and composition).
/// Per CLI-SBOM-60-001.
/// </summary>
internal interface ISbomerClient
{
/// <summary>
/// Lists layer fragments for a scan.
/// </summary>
Task<SbomerLayerListResponse> ListLayersAsync(
SbomerLayerListRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets layer fragment details.
/// </summary>
Task<SbomerLayerDetail?> GetLayerAsync(
SbomerLayerShowRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Verifies a layer fragment DSSE signature.
/// </summary>
Task<SbomerLayerVerifyResult> VerifyLayerAsync(
SbomerLayerVerifyRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets the composition manifest for a scan.
/// </summary>
Task<CompositionManifest?> GetCompositionManifestAsync(
SbomerCompositionShowRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Composes SBOM from layer fragments.
/// </summary>
Task<SbomerComposeResult> ComposeAsync(
SbomerComposeRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Verifies composition against manifest and fragments.
/// </summary>
Task<SbomerCompositionVerifyResult> VerifyCompositionAsync(
SbomerCompositionVerifyRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets Merkle diagnostics for a composition.
/// </summary>
Task<MerkleDiagnostics?> GetMerkleDiagnosticsAsync(
string scanId,
string? tenant,
CancellationToken cancellationToken);
// CLI-SBOM-60-002: Drift detection methods
/// <summary>
/// Analyzes drift between current SBOM and baseline.
/// </summary>
Task<SbomerDriftResult> AnalyzeDriftAsync(
SbomerDriftRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Verifies SBOM with local recomposition and drift detection.
/// </summary>
Task<SbomerDriftVerifyResult> VerifyDriftAsync(
SbomerDriftVerifyRequest request,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,34 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for VEX observation queries.
/// Per CLI-LNM-22-002.
/// </summary>
internal interface IVexObservationsClient
{
/// <summary>
/// Gets VEX observations matching the query.
/// </summary>
Task<VexObservationResponse> GetObservationsAsync(
VexObservationQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Gets a VEX linkset for a vulnerability ID.
/// </summary>
Task<VexLinksetResponse> GetLinksetAsync(
VexLinksetQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Gets a single VEX observation by ID.
/// </summary>
Task<VexObservation?> GetObservationByIdAsync(
string tenant,
string observationId,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,503 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-LNM-22-001: Advisory linkset models for obs get/linkset show/export commands
/// <summary>
/// Extended advisory linkset query with additional filters for CLI-LNM-22-001.
/// </summary>
internal sealed record AdvisoryLinksetQuery(
string Tenant,
IReadOnlyList<string> ObservationIds,
IReadOnlyList<string> Aliases,
IReadOnlyList<string> Purls,
IReadOnlyList<string> Cpes,
IReadOnlyList<string> Sources,
string? Severity,
bool? KevOnly,
bool? HasFix,
int? Limit,
string? Cursor)
{
/// <summary>
/// Creates from a basic AdvisoryObservationsQuery.
/// </summary>
public static AdvisoryLinksetQuery FromBasicQuery(AdvisoryObservationsQuery query)
{
return new AdvisoryLinksetQuery(
query.Tenant,
query.ObservationIds,
query.Aliases,
query.Purls,
query.Cpes,
Sources: Array.Empty<string>(),
Severity: null,
KevOnly: null,
HasFix: null,
query.Limit,
query.Cursor);
}
}
/// <summary>
/// Extended advisory linkset response with conflict information.
/// </summary>
internal sealed class AdvisoryLinksetResponse
{
[JsonPropertyName("observations")]
public IReadOnlyList<AdvisoryLinksetObservation> Observations { get; init; } =
Array.Empty<AdvisoryLinksetObservation>();
[JsonPropertyName("linkset")]
public AdvisoryLinksetAggregate Linkset { get; init; } = new();
[JsonPropertyName("conflicts")]
public IReadOnlyList<AdvisoryLinksetConflict> Conflicts { get; init; } =
Array.Empty<AdvisoryLinksetConflict>();
[JsonPropertyName("nextCursor")]
public string? NextCursor { get; init; }
[JsonPropertyName("hasMore")]
public bool HasMore { get; init; }
[JsonPropertyName("totalCount")]
public int? TotalCount { get; init; }
}
/// <summary>
/// Extended advisory observation with severity, KEV, and fix information.
/// </summary>
internal sealed class AdvisoryLinksetObservation
{
[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("severity")]
public AdvisoryLinksetSeverity? Severity { get; init; }
[JsonPropertyName("kev")]
public AdvisoryLinksetKev? Kev { get; init; }
[JsonPropertyName("fix")]
public AdvisoryLinksetFix? Fix { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset? UpdatedAt { get; init; }
}
/// <summary>
/// Advisory severity information.
/// </summary>
internal sealed class AdvisoryLinksetSeverity
{
[JsonPropertyName("level")]
public string Level { get; init; } = string.Empty;
[JsonPropertyName("cvssV3")]
public AdvisoryLinksetCvss? CvssV3 { get; init; }
[JsonPropertyName("cvssV2")]
public AdvisoryLinksetCvss? CvssV2 { get; init; }
}
/// <summary>
/// CVSS score details.
/// </summary>
internal sealed class AdvisoryLinksetCvss
{
[JsonPropertyName("score")]
public double Score { get; init; }
[JsonPropertyName("vector")]
public string? Vector { get; init; }
[JsonPropertyName("severity")]
public string? Severity { get; init; }
}
/// <summary>
/// KEV (Known Exploited Vulnerabilities) information.
/// </summary>
internal sealed class AdvisoryLinksetKev
{
[JsonPropertyName("listed")]
public bool Listed { get; init; }
[JsonPropertyName("addedDate")]
public DateTimeOffset? AddedDate { get; init; }
[JsonPropertyName("dueDate")]
public DateTimeOffset? DueDate { get; init; }
[JsonPropertyName("knownRansomwareCampaignUse")]
public bool? KnownRansomwareCampaignUse { get; init; }
}
/// <summary>
/// Fix availability information.
/// </summary>
internal sealed class AdvisoryLinksetFix
{
[JsonPropertyName("available")]
public bool Available { get; init; }
[JsonPropertyName("versions")]
public IReadOnlyList<string> Versions { get; init; } = Array.Empty<string>();
[JsonPropertyName("type")]
public string? Type { get; init; }
[JsonPropertyName("advisoryLinks")]
public IReadOnlyList<string> AdvisoryLinks { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Aggregated linkset with conflict summary.
/// </summary>
internal sealed class AdvisoryLinksetAggregate
{
[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>();
[JsonPropertyName("sourceCoverage")]
public AdvisorySourceCoverageSummary? SourceCoverage { get; init; }
[JsonPropertyName("conflictSummary")]
public AdvisoryConflictSummary? ConflictSummary { get; init; }
}
/// <summary>
/// Source coverage summary across observations.
/// </summary>
internal sealed class AdvisorySourceCoverageSummary
{
[JsonPropertyName("totalSources")]
public int TotalSources { get; init; }
[JsonPropertyName("sources")]
public IReadOnlyList<string> Sources { get; init; } = Array.Empty<string>();
[JsonPropertyName("coveragePercent")]
public double CoveragePercent { get; init; }
}
/// <summary>
/// Conflict summary for the linkset.
/// </summary>
internal sealed class AdvisoryConflictSummary
{
[JsonPropertyName("hasConflicts")]
public bool HasConflicts { get; init; }
[JsonPropertyName("totalConflicts")]
public int TotalConflicts { get; init; }
[JsonPropertyName("severityConflicts")]
public int SeverityConflicts { get; init; }
[JsonPropertyName("kevConflicts")]
public int KevConflicts { get; init; }
[JsonPropertyName("fixConflicts")]
public int FixConflicts { get; init; }
}
/// <summary>
/// Detailed conflict information between observations.
/// </summary>
internal sealed class AdvisoryLinksetConflict
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("field")]
public string Field { get; init; } = string.Empty;
[JsonPropertyName("sources")]
public IReadOnlyList<AdvisoryConflictSource> Sources { get; init; } =
Array.Empty<AdvisoryConflictSource>();
[JsonPropertyName("resolution")]
public string? Resolution { get; init; }
}
/// <summary>
/// Source contribution to a conflict.
/// </summary>
internal sealed class AdvisoryConflictSource
{
[JsonPropertyName("observationId")]
public string ObservationId { get; init; } = string.Empty;
[JsonPropertyName("source")]
public string Source { get; init; } = string.Empty;
[JsonPropertyName("value")]
public string Value { get; init; } = string.Empty;
}
/// <summary>
/// OSV (Open Source Vulnerability) format output.
/// Per CLI-LNM-22-001, supports JSON/OSV output format.
/// </summary>
internal sealed class OsvVulnerability
{
[JsonPropertyName("schema_version")]
public string SchemaVersion { get; init; } = "1.6.0";
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("modified")]
public string Modified { get; init; } = string.Empty;
[JsonPropertyName("published")]
public string? Published { get; init; }
[JsonPropertyName("withdrawn")]
public string? Withdrawn { get; init; }
[JsonPropertyName("aliases")]
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
[JsonPropertyName("related")]
public IReadOnlyList<string> Related { get; init; } = Array.Empty<string>();
[JsonPropertyName("summary")]
public string? Summary { get; init; }
[JsonPropertyName("details")]
public string? Details { get; init; }
[JsonPropertyName("severity")]
public IReadOnlyList<OsvSeverity> Severity { get; init; } = Array.Empty<OsvSeverity>();
[JsonPropertyName("affected")]
public IReadOnlyList<OsvAffected> Affected { get; init; } = Array.Empty<OsvAffected>();
[JsonPropertyName("references")]
public IReadOnlyList<OsvReference> References { get; init; } = Array.Empty<OsvReference>();
[JsonPropertyName("credits")]
public IReadOnlyList<OsvCredit> Credits { get; init; } = Array.Empty<OsvCredit>();
[JsonPropertyName("database_specific")]
public OsvDatabaseSpecific? DatabaseSpecific { get; init; }
}
/// <summary>
/// OSV severity entry.
/// </summary>
internal sealed class OsvSeverity
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("score")]
public string Score { get; init; } = string.Empty;
}
/// <summary>
/// OSV affected package entry.
/// </summary>
internal sealed class OsvAffected
{
[JsonPropertyName("package")]
public OsvPackage Package { get; init; } = new();
[JsonPropertyName("severity")]
public IReadOnlyList<OsvSeverity> Severity { get; init; } = Array.Empty<OsvSeverity>();
[JsonPropertyName("ranges")]
public IReadOnlyList<OsvRange> Ranges { get; init; } = Array.Empty<OsvRange>();
[JsonPropertyName("versions")]
public IReadOnlyList<string> Versions { get; init; } = Array.Empty<string>();
[JsonPropertyName("ecosystem_specific")]
public Dictionary<string, object>? EcosystemSpecific { get; init; }
[JsonPropertyName("database_specific")]
public Dictionary<string, object>? DatabaseSpecific { get; init; }
}
/// <summary>
/// OSV package identifier.
/// </summary>
internal sealed class OsvPackage
{
[JsonPropertyName("ecosystem")]
public string Ecosystem { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("purl")]
public string? Purl { get; init; }
}
/// <summary>
/// OSV version range.
/// </summary>
internal sealed class OsvRange
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("repo")]
public string? Repo { get; init; }
[JsonPropertyName("events")]
public IReadOnlyList<OsvEvent> Events { get; init; } = Array.Empty<OsvEvent>();
}
/// <summary>
/// OSV range event.
/// </summary>
internal sealed class OsvEvent
{
[JsonPropertyName("introduced")]
public string? Introduced { get; init; }
[JsonPropertyName("fixed")]
public string? Fixed { get; init; }
[JsonPropertyName("last_affected")]
public string? LastAffected { get; init; }
[JsonPropertyName("limit")]
public string? Limit { get; init; }
}
/// <summary>
/// OSV reference entry.
/// </summary>
internal sealed class OsvReference
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("url")]
public string Url { get; init; } = string.Empty;
}
/// <summary>
/// OSV credit entry.
/// </summary>
internal sealed class OsvCredit
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("contact")]
public IReadOnlyList<string> Contact { get; init; } = Array.Empty<string>();
[JsonPropertyName("type")]
public string? Type { get; init; }
}
/// <summary>
/// OSV database-specific metadata.
/// </summary>
internal sealed class OsvDatabaseSpecific
{
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("kev")]
public OsvKevInfo? Kev { get; init; }
[JsonPropertyName("stellaops")]
public OsvStellaOpsInfo? StellaOps { get; init; }
}
/// <summary>
/// OSV KEV information.
/// </summary>
internal sealed class OsvKevInfo
{
[JsonPropertyName("listed")]
public bool Listed { get; init; }
[JsonPropertyName("added_date")]
public string? AddedDate { get; init; }
[JsonPropertyName("due_date")]
public string? DueDate { get; init; }
[JsonPropertyName("ransomware")]
public bool? Ransomware { get; init; }
}
/// <summary>
/// StellaOps-specific OSV metadata.
/// </summary>
internal sealed class OsvStellaOpsInfo
{
[JsonPropertyName("observation_ids")]
public IReadOnlyList<string> ObservationIds { get; init; } = Array.Empty<string>();
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("sources")]
public IReadOnlyList<string> Sources { get; init; } = Array.Empty<string>();
[JsonPropertyName("has_conflicts")]
public bool HasConflicts { get; init; }
}
/// <summary>
/// Export format enumeration for advisory exports.
/// </summary>
internal enum AdvisoryExportFormat
{
/// <summary>
/// JSON format (native StellaOps).
/// </summary>
Json,
/// <summary>
/// OSV (Open Source Vulnerability) format.
/// </summary>
Osv,
/// <summary>
/// NDJSON (newline-delimited JSON) format.
/// </summary>
Ndjson,
/// <summary>
/// CSV format for spreadsheet imports.
/// </summary>
Csv
}

View File

@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
/// <summary>
/// Request for downloading API specification.
/// CLI-SDK-63-001: Exposes stella api spec download command.
/// </summary>
internal sealed class ApiSpecDownloadRequest
{
/// <summary>
/// Tenant context for the operation.
/// </summary>
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
/// <summary>
/// Output directory for the downloaded spec.
/// </summary>
[JsonIgnore]
public required string OutputPath { get; init; }
/// <summary>
/// Spec format to download (openapi-json, openapi-yaml).
/// </summary>
[JsonPropertyName("format")]
public string Format { get; init; } = "openapi-json";
/// <summary>
/// Whether to overwrite existing files.
/// </summary>
[JsonIgnore]
public bool Overwrite { get; init; }
/// <summary>
/// Optional service filter (e.g., "concelier", "scanner", "policy").
/// When null, downloads the aggregate/combined spec.
/// </summary>
[JsonPropertyName("service")]
public string? Service { get; init; }
/// <summary>
/// Expected ETag for conditional download (If-None-Match).
/// </summary>
[JsonIgnore]
public string? ExpectedETag { get; init; }
/// <summary>
/// Expected checksum for verification after download.
/// </summary>
[JsonIgnore]
public string? ExpectedChecksum { get; init; }
/// <summary>
/// Checksum algorithm (sha256, sha384, sha512).
/// </summary>
[JsonIgnore]
public string ChecksumAlgorithm { get; init; } = "sha256";
}
/// <summary>
/// Result of API spec download operation.
/// </summary>
internal sealed class ApiSpecDownloadResult
{
/// <summary>
/// Whether the operation was successful.
/// </summary>
[JsonPropertyName("success")]
public bool Success { get; init; }
/// <summary>
/// Path where the spec was downloaded.
/// </summary>
[JsonPropertyName("path")]
public string? Path { get; init; }
/// <summary>
/// Size of the downloaded spec in bytes.
/// </summary>
[JsonPropertyName("sizeBytes")]
public long SizeBytes { get; init; }
/// <summary>
/// Whether the result was served from cache (304 Not Modified).
/// </summary>
[JsonPropertyName("fromCache")]
public bool FromCache { get; init; }
/// <summary>
/// ETag of the downloaded spec.
/// </summary>
[JsonPropertyName("etag")]
public string? ETag { get; init; }
/// <summary>
/// Computed checksum of the downloaded spec.
/// </summary>
[JsonPropertyName("checksum")]
public string? Checksum { get; init; }
/// <summary>
/// Checksum algorithm used.
/// </summary>
[JsonPropertyName("checksumAlgorithm")]
public string? ChecksumAlgorithm { get; init; }
/// <summary>
/// Whether checksum verification passed.
/// </summary>
[JsonPropertyName("checksumVerified")]
public bool? ChecksumVerified { get; init; }
/// <summary>
/// API version extracted from the spec.
/// </summary>
[JsonPropertyName("apiVersion")]
public string? ApiVersion { get; init; }
/// <summary>
/// Timestamp of when the spec was generated.
/// </summary>
[JsonPropertyName("generatedAt")]
public DateTimeOffset? GeneratedAt { get; init; }
/// <summary>
/// Error message if the operation failed.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
/// <summary>
/// Error code if the operation failed.
/// </summary>
[JsonPropertyName("errorCode")]
public string? ErrorCode { get; init; }
}
/// <summary>
/// Information about available API specifications.
/// </summary>
internal sealed class ApiSpecInfo
{
/// <summary>
/// Service name.
/// </summary>
[JsonPropertyName("service")]
public required string Service { get; init; }
/// <summary>
/// API version.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// OpenAPI spec version (e.g., "3.1.0").
/// </summary>
[JsonPropertyName("openApiVersion")]
public string? OpenApiVersion { get; init; }
/// <summary>
/// Available formats.
/// </summary>
[JsonPropertyName("formats")]
public IReadOnlyList<string> Formats { get; init; } = [];
/// <summary>
/// ETag for the spec.
/// </summary>
[JsonPropertyName("etag")]
public string? ETag { get; init; }
/// <summary>
/// SHA-256 checksum of the JSON format.
/// </summary>
[JsonPropertyName("sha256")]
public string? Sha256 { get; init; }
/// <summary>
/// Last modified timestamp.
/// </summary>
[JsonPropertyName("lastModified")]
public DateTimeOffset? LastModified { get; init; }
/// <summary>
/// Download URL for the spec.
/// </summary>
[JsonPropertyName("downloadUrl")]
public string? DownloadUrl { get; init; }
}
/// <summary>
/// Response for listing available API specifications.
/// </summary>
internal sealed class ApiSpecListResponse
{
/// <summary>
/// Whether the operation was successful.
/// </summary>
[JsonPropertyName("success")]
public bool Success { get; init; }
/// <summary>
/// Available API specifications.
/// </summary>
[JsonPropertyName("specs")]
public IReadOnlyList<ApiSpecInfo> Specs { get; init; } = [];
/// <summary>
/// Aggregate spec info (combined all services).
/// </summary>
[JsonPropertyName("aggregate")]
public ApiSpecInfo? Aggregate { get; init; }
/// <summary>
/// Error message if the operation failed.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
}

View File

@@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-FORENSICS-54-002: Attestation models for forensic attest show command
/// <summary>
/// DSSE envelope from attestation file.
/// </summary>
internal sealed class AttestationEnvelope
{
[JsonPropertyName("payloadType")]
public string PayloadType { get; init; } = string.Empty;
[JsonPropertyName("payload")]
public string Payload { get; init; } = string.Empty;
[JsonPropertyName("signatures")]
public IReadOnlyList<AttestationSignature> Signatures { get; init; } = Array.Empty<AttestationSignature>();
}
/// <summary>
/// Signature in attestation envelope.
/// </summary>
internal sealed class AttestationSignature
{
[JsonPropertyName("keyid")]
public string? KeyId { get; init; }
[JsonPropertyName("sig")]
public string Signature { get; init; } = string.Empty;
}
/// <summary>
/// In-toto statement from attestation payload.
/// </summary>
internal sealed class InTotoStatement
{
[JsonPropertyName("_type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("subject")]
public IReadOnlyList<InTotoSubject> Subject { get; init; } = Array.Empty<InTotoSubject>();
[JsonPropertyName("predicateType")]
public string PredicateType { get; init; } = string.Empty;
[JsonPropertyName("predicate")]
public object? Predicate { get; init; }
}
/// <summary>
/// Subject in in-toto statement.
/// </summary>
internal sealed class InTotoSubject
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
}
/// <summary>
/// Result of attestation show operation.
/// </summary>
internal sealed class AttestationShowResult
{
[JsonPropertyName("filePath")]
public string FilePath { get; init; } = string.Empty;
[JsonPropertyName("payloadType")]
public string PayloadType { get; init; } = string.Empty;
[JsonPropertyName("statementType")]
public string StatementType { get; init; } = string.Empty;
[JsonPropertyName("predicateType")]
public string PredicateType { get; init; } = string.Empty;
[JsonPropertyName("subjects")]
public IReadOnlyList<AttestationSubjectInfo> Subjects { get; init; } = Array.Empty<AttestationSubjectInfo>();
[JsonPropertyName("signatures")]
public IReadOnlyList<AttestationSignatureInfo> Signatures { get; init; } = Array.Empty<AttestationSignatureInfo>();
[JsonPropertyName("predicateSummary")]
public AttestationPredicateSummary? PredicateSummary { get; init; }
[JsonPropertyName("verificationResult")]
public AttestationVerificationResult? VerificationResult { get; init; }
}
/// <summary>
/// Subject information for display.
/// </summary>
internal sealed class AttestationSubjectInfo
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("digestAlgorithm")]
public string DigestAlgorithm { get; init; } = string.Empty;
[JsonPropertyName("digestValue")]
public string DigestValue { get; init; } = string.Empty;
}
/// <summary>
/// Signature information for display.
/// </summary>
internal sealed class AttestationSignatureInfo
{
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("algorithm")]
public string Algorithm { get; init; } = string.Empty;
[JsonPropertyName("isValid")]
public bool? IsValid { get; init; }
[JsonPropertyName("isTrusted")]
public bool? IsTrusted { get; init; }
[JsonPropertyName("signerInfo")]
public AttestationSignerInfo? SignerInfo { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// Signer information extracted from signature or certificate.
/// </summary>
internal sealed class AttestationSignerInfo
{
[JsonPropertyName("commonName")]
public string? CommonName { get; init; }
[JsonPropertyName("organization")]
public string? Organization { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
[JsonPropertyName("issuer")]
public string? Issuer { get; init; }
[JsonPropertyName("notBefore")]
public DateTimeOffset? NotBefore { get; init; }
[JsonPropertyName("notAfter")]
public DateTimeOffset? NotAfter { get; init; }
[JsonPropertyName("fingerprint")]
public string? Fingerprint { get; init; }
}
/// <summary>
/// Summary of the predicate for display.
/// </summary>
internal sealed class AttestationPredicateSummary
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; init; }
[JsonPropertyName("buildType")]
public string? BuildType { get; init; }
[JsonPropertyName("builder")]
public string? Builder { get; init; }
[JsonPropertyName("invocationId")]
public string? InvocationId { get; init; }
[JsonPropertyName("materials")]
public IReadOnlyList<AttestationMaterial>? Materials { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Material in attestation predicate.
/// </summary>
internal sealed class AttestationMaterial
{
[JsonPropertyName("uri")]
public string Uri { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public IReadOnlyDictionary<string, string>? Digest { get; init; }
}
/// <summary>
/// Verification result for attestation.
/// </summary>
internal sealed class AttestationVerificationResult
{
[JsonPropertyName("isValid")]
public bool IsValid { get; init; }
[JsonPropertyName("signatureCount")]
public int SignatureCount { get; init; }
[JsonPropertyName("validSignatures")]
public int ValidSignatures { get; init; }
[JsonPropertyName("trustedSignatures")]
public int TrustedSignatures { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Options for attestation show operation.
/// </summary>
internal sealed class AttestationShowOptions
{
public bool VerifySignatures { get; init; } = true;
public string? TrustRootPath { get; init; }
public IReadOnlyList<ForensicTrustRoot> TrustRoots { get; init; } = Array.Empty<ForensicTrustRoot>();
}

View File

@@ -0,0 +1,420 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-DETER-70-003: Determinism score models (docs/modules/scanner/determinism-score.md)
/// <summary>
/// Request for running determinism harness.
/// </summary>
internal sealed class DeterminismRunRequest
{
/// <summary>
/// Image digests to test.
/// </summary>
[JsonPropertyName("images")]
public IReadOnlyList<string> Images { get; init; } = Array.Empty<string>();
/// <summary>
/// Scanner container image reference.
/// </summary>
[JsonPropertyName("scanner")]
public string Scanner { get; init; } = string.Empty;
/// <summary>
/// Policy bundle path or SHA.
/// </summary>
[JsonPropertyName("policyBundle")]
public string? PolicyBundle { get; init; }
/// <summary>
/// Feeds bundle path or SHA.
/// </summary>
[JsonPropertyName("feedsBundle")]
public string? FeedsBundle { get; init; }
/// <summary>
/// Number of runs per image (default 10).
/// </summary>
[JsonPropertyName("runs")]
public int Runs { get; init; } = 10;
/// <summary>
/// Fixed clock timestamp for deterministic execution.
/// </summary>
[JsonPropertyName("fixedClock")]
public DateTimeOffset? FixedClock { get; init; }
/// <summary>
/// RNG seed for deterministic execution.
/// </summary>
[JsonPropertyName("rngSeed")]
public int RngSeed { get; init; } = 1337;
/// <summary>
/// Maximum concurrency (default 1 for determinism).
/// </summary>
[JsonPropertyName("maxConcurrency")]
public int MaxConcurrency { get; init; } = 1;
/// <summary>
/// Memory limit for container (default 2G).
/// </summary>
[JsonPropertyName("memoryLimit")]
public string MemoryLimit { get; init; } = "2G";
/// <summary>
/// CPU set for container (default 0).
/// </summary>
[JsonPropertyName("cpuSet")]
public string CpuSet { get; init; } = "0";
/// <summary>
/// Platform (default linux/amd64).
/// </summary>
[JsonPropertyName("platform")]
public string Platform { get; init; } = "linux/amd64";
/// <summary>
/// Minimum threshold for individual image scores.
/// </summary>
[JsonPropertyName("imageThreshold")]
public double ImageThreshold { get; init; } = 0.90;
/// <summary>
/// Minimum threshold for overall score.
/// </summary>
[JsonPropertyName("overallThreshold")]
public double OverallThreshold { get; init; } = 0.95;
/// <summary>
/// Output directory for determinism.json and run artifacts.
/// </summary>
[JsonPropertyName("outputDir")]
public string? OutputDir { get; init; }
/// <summary>
/// Release version string for the manifest.
/// </summary>
[JsonPropertyName("release")]
public string? Release { get; init; }
}
/// <summary>
/// Determinism score manifest (determinism.json schema per SCAN-DETER-186-010).
/// </summary>
internal sealed class DeterminismManifest
{
[JsonPropertyName("version")]
public string Version { get; init; } = "1";
[JsonPropertyName("release")]
public string Release { get; init; } = string.Empty;
[JsonPropertyName("platform")]
public string Platform { get; init; } = "linux/amd64";
[JsonPropertyName("policy_sha")]
public string PolicySha { get; init; } = string.Empty;
[JsonPropertyName("feeds_sha")]
public string FeedsSha { get; init; } = string.Empty;
[JsonPropertyName("scanner_sha")]
public string ScannerSha { get; init; } = string.Empty;
[JsonPropertyName("images")]
public IReadOnlyList<DeterminismImageResult> Images { get; init; } = Array.Empty<DeterminismImageResult>();
[JsonPropertyName("overall_score")]
public double OverallScore { get; init; }
[JsonPropertyName("thresholds")]
public DeterminismThresholds Thresholds { get; init; } = new();
[JsonPropertyName("generated_at")]
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
[JsonPropertyName("execution")]
public DeterminismExecutionInfo? Execution { get; init; }
}
/// <summary>
/// Per-image determinism result.
/// </summary>
internal sealed class DeterminismImageResult
{
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("runs")]
public int Runs { get; init; }
[JsonPropertyName("identical")]
public int Identical { get; init; }
[JsonPropertyName("score")]
public double Score { get; init; }
[JsonPropertyName("artifact_hashes")]
public IReadOnlyDictionary<string, string> ArtifactHashes { get; init; } = new Dictionary<string, string>();
[JsonPropertyName("non_deterministic")]
public IReadOnlyList<string> NonDeterministic { get; init; } = Array.Empty<string>();
[JsonPropertyName("notes")]
public string? Notes { get; init; }
[JsonPropertyName("run_details")]
public IReadOnlyList<DeterminismRunDetail>? RunDetails { get; init; }
}
/// <summary>
/// Details of a single determinism run.
/// </summary>
internal sealed class DeterminismRunDetail
{
[JsonPropertyName("run_number")]
public int RunNumber { get; init; }
[JsonPropertyName("identical")]
public bool Identical { get; init; }
[JsonPropertyName("artifact_hashes")]
public IReadOnlyDictionary<string, string> ArtifactHashes { get; init; } = new Dictionary<string, string>();
[JsonPropertyName("duration_ms")]
public long DurationMs { get; init; }
[JsonPropertyName("exit_code")]
public int ExitCode { get; init; }
}
/// <summary>
/// Thresholds for determinism scoring.
/// </summary>
internal sealed class DeterminismThresholds
{
[JsonPropertyName("image_min")]
public double ImageMin { get; init; } = 0.90;
[JsonPropertyName("overall_min")]
public double OverallMin { get; init; } = 0.95;
}
/// <summary>
/// Execution information for determinism harness.
/// </summary>
internal sealed class DeterminismExecutionInfo
{
[JsonPropertyName("fixed_clock")]
public DateTimeOffset? FixedClock { get; init; }
[JsonPropertyName("rng_seed")]
public int RngSeed { get; init; }
[JsonPropertyName("max_concurrency")]
public int MaxConcurrency { get; init; }
[JsonPropertyName("memory_limit")]
public string MemoryLimit { get; init; } = "2G";
[JsonPropertyName("cpu_set")]
public string CpuSet { get; init; } = "0";
[JsonPropertyName("network_mode")]
public string NetworkMode { get; init; } = "none";
}
/// <summary>
/// Result of running the determinism harness.
/// </summary>
internal sealed class DeterminismRunResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("manifest")]
public DeterminismManifest? Manifest { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("passedThreshold")]
public bool PassedThreshold { get; init; }
[JsonPropertyName("failedImages")]
public IReadOnlyList<string> FailedImages { get; init; } = Array.Empty<string>();
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
[JsonPropertyName("durationMs")]
public long DurationMs { get; init; }
}
// CLI-DETER-70-004: Determinism report models
/// <summary>
/// Request for generating a determinism report.
/// </summary>
internal sealed class DeterminismReportRequest
{
/// <summary>
/// Paths to determinism.json files to include in report.
/// </summary>
[JsonPropertyName("manifestPaths")]
public IReadOnlyList<string> ManifestPaths { get; init; } = Array.Empty<string>();
/// <summary>
/// Output format (markdown, json, csv).
/// </summary>
[JsonPropertyName("format")]
public string Format { get; init; } = "markdown";
/// <summary>
/// Output path for the report.
/// </summary>
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
/// <summary>
/// Include detailed per-run information.
/// </summary>
[JsonPropertyName("includeDetails")]
public bool IncludeDetails { get; init; }
/// <summary>
/// Title for the report.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; init; }
}
/// <summary>
/// Aggregated determinism report across multiple manifests.
/// </summary>
internal sealed class DeterminismReport
{
[JsonPropertyName("title")]
public string Title { get; init; } = "Determinism Score Report";
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
[JsonPropertyName("summary")]
public DeterminismReportSummary Summary { get; init; } = new();
[JsonPropertyName("releases")]
public IReadOnlyList<DeterminismReleaseEntry> Releases { get; init; } = Array.Empty<DeterminismReleaseEntry>();
[JsonPropertyName("imageMatrix")]
public IReadOnlyList<DeterminismImageMatrixEntry> ImageMatrix { get; init; } = Array.Empty<DeterminismImageMatrixEntry>();
}
/// <summary>
/// Summary statistics for the report.
/// </summary>
internal sealed class DeterminismReportSummary
{
[JsonPropertyName("totalReleases")]
public int TotalReleases { get; init; }
[JsonPropertyName("totalImages")]
public int TotalImages { get; init; }
[JsonPropertyName("averageScore")]
public double AverageScore { get; init; }
[JsonPropertyName("minScore")]
public double MinScore { get; init; }
[JsonPropertyName("maxScore")]
public double MaxScore { get; init; }
[JsonPropertyName("passedCount")]
public int PassedCount { get; init; }
[JsonPropertyName("failedCount")]
public int FailedCount { get; init; }
[JsonPropertyName("nonDeterministicArtifacts")]
public IReadOnlyList<string> NonDeterministicArtifacts { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Entry for a single release in the report.
/// </summary>
internal sealed class DeterminismReleaseEntry
{
[JsonPropertyName("release")]
public string Release { get; init; } = string.Empty;
[JsonPropertyName("platform")]
public string Platform { get; init; } = string.Empty;
[JsonPropertyName("overallScore")]
public double OverallScore { get; init; }
[JsonPropertyName("passed")]
public bool Passed { get; init; }
[JsonPropertyName("imageCount")]
public int ImageCount { get; init; }
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("scannerSha")]
public string ScannerSha { get; init; } = string.Empty;
[JsonPropertyName("manifestPath")]
public string ManifestPath { get; init; } = string.Empty;
}
/// <summary>
/// Per-image matrix entry showing scores across releases.
/// </summary>
internal sealed class DeterminismImageMatrixEntry
{
[JsonPropertyName("imageDigest")]
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("scores")]
public IReadOnlyDictionary<string, double> Scores { get; init; } = new Dictionary<string, double>();
[JsonPropertyName("averageScore")]
public double AverageScore { get; init; }
[JsonPropertyName("nonDeterministicArtifacts")]
public IReadOnlyList<string> NonDeterministicArtifacts { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Result of generating a determinism report.
/// </summary>
internal sealed class DeterminismReportResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("report")]
public DeterminismReport? Report { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("format")]
public string Format { get; init; } = "markdown";
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,422 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-EXC-25-001: Exception governance models for stella exceptions commands
/// <summary>
/// Exception scope types.
/// </summary>
internal static class ExceptionScopeTypes
{
public const string Purl = "purl";
public const string Image = "image";
public const string Component = "component";
public const string TenantWide = "tenant";
}
/// <summary>
/// Exception status values following lifecycle: draft -> staged -> active -> expired.
/// </summary>
internal static class ExceptionStatuses
{
public const string Draft = "draft";
public const string Staged = "staged";
public const string Active = "active";
public const string Expired = "expired";
public const string Revoked = "revoked";
}
/// <summary>
/// Exception effect types.
/// </summary>
internal static class ExceptionEffectTypes
{
public const string Suppress = "suppress";
public const string Defer = "defer";
public const string Downgrade = "downgrade";
public const string RequireControl = "requireControl";
}
/// <summary>
/// Exception scope definition.
/// </summary>
internal sealed class ExceptionScope
{
[JsonPropertyName("type")]
public string Type { get; init; } = ExceptionScopeTypes.Purl;
[JsonPropertyName("value")]
public string Value { get; init; } = string.Empty;
[JsonPropertyName("ruleNames")]
public IReadOnlyList<string>? RuleNames { get; init; }
[JsonPropertyName("severities")]
public IReadOnlyList<string>? Severities { get; init; }
[JsonPropertyName("sources")]
public IReadOnlyList<string>? Sources { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
/// <summary>
/// Evidence reference for an exception.
/// </summary>
internal sealed class ExceptionEvidenceRef
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty; // ticket, vex_claim, scan_report, attestation
[JsonPropertyName("uri")]
public string Uri { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
/// <summary>
/// Exception effect definition.
/// </summary>
internal sealed class ExceptionEffect
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("effectType")]
public string EffectType { get; init; } = ExceptionEffectTypes.Suppress;
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("downgradeSeverity")]
public string? DowngradeSeverity { get; init; }
[JsonPropertyName("requiredControlId")]
public string? RequiredControlId { get; init; }
[JsonPropertyName("routingTemplate")]
public string? RoutingTemplate { get; init; }
[JsonPropertyName("maxDurationDays")]
public int? MaxDurationDays { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
/// <summary>
/// Exception instance representing a governed waiver.
/// </summary>
internal sealed class ExceptionInstance
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("vuln")]
public string Vuln { get; init; } = string.Empty; // CVE ID or alias
[JsonPropertyName("scope")]
public ExceptionScope Scope { get; init; } = new();
[JsonPropertyName("effectId")]
public string EffectId { get; init; } = string.Empty;
[JsonPropertyName("effect")]
public ExceptionEffect? Effect { get; init; }
[JsonPropertyName("justification")]
public string Justification { get; init; } = string.Empty;
[JsonPropertyName("owner")]
public string Owner { get; init; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; init; } = ExceptionStatuses.Draft;
[JsonPropertyName("expiration")]
public DateTimeOffset? Expiration { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset UpdatedAt { get; init; }
[JsonPropertyName("createdBy")]
public string? CreatedBy { get; init; }
[JsonPropertyName("approvedBy")]
public string? ApprovedBy { get; init; }
[JsonPropertyName("approvedAt")]
public DateTimeOffset? ApprovedAt { get; init; }
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<ExceptionEvidenceRef> EvidenceRefs { get; init; } = Array.Empty<ExceptionEvidenceRef>();
[JsonPropertyName("policyBinding")]
public string? PolicyBinding { get; init; }
[JsonPropertyName("supersedes")]
public string? Supersedes { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to list exceptions.
/// </summary>
internal sealed class ExceptionListRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("vuln")]
public string? Vuln { get; init; }
[JsonPropertyName("scopeType")]
public string? ScopeType { get; init; }
[JsonPropertyName("scopeValue")]
public string? ScopeValue { get; init; }
[JsonPropertyName("status")]
public IReadOnlyList<string>? Statuses { get; init; }
[JsonPropertyName("owner")]
public string? Owner { get; init; }
[JsonPropertyName("effectType")]
public string? EffectType { get; init; }
[JsonPropertyName("expiringBefore")]
public DateTimeOffset? ExpiringBefore { get; init; }
[JsonPropertyName("includeExpired")]
public bool IncludeExpired { get; init; }
[JsonPropertyName("pageSize")]
public int PageSize { get; init; } = 50;
[JsonPropertyName("pageToken")]
public string? PageToken { get; init; }
}
/// <summary>
/// Response from listing exceptions.
/// </summary>
internal sealed class ExceptionListResponse
{
[JsonPropertyName("exceptions")]
public IReadOnlyList<ExceptionInstance> Exceptions { get; init; } = Array.Empty<ExceptionInstance>();
[JsonPropertyName("nextPageToken")]
public string? NextPageToken { get; init; }
[JsonPropertyName("totalCount")]
public long? TotalCount { get; init; }
}
/// <summary>
/// Request to create an exception.
/// </summary>
internal sealed class ExceptionCreateRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("vuln")]
public string Vuln { get; init; } = string.Empty;
[JsonPropertyName("scope")]
public ExceptionScope Scope { get; init; } = new();
[JsonPropertyName("effectId")]
public string EffectId { get; init; } = string.Empty;
[JsonPropertyName("justification")]
public string Justification { get; init; } = string.Empty;
[JsonPropertyName("owner")]
public string Owner { get; init; } = string.Empty;
[JsonPropertyName("expiration")]
public DateTimeOffset? Expiration { get; init; }
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<ExceptionEvidenceRef>? EvidenceRefs { get; init; }
[JsonPropertyName("policyBinding")]
public string? PolicyBinding { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
[JsonPropertyName("stage")]
public bool Stage { get; init; }
}
/// <summary>
/// Request to promote an exception (draft -> staged -> active).
/// </summary>
internal sealed class ExceptionPromoteRequest
{
[JsonPropertyName("exceptionId")]
public string ExceptionId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("targetStatus")]
public string TargetStatus { get; init; } = ExceptionStatuses.Active;
[JsonPropertyName("comment")]
public string? Comment { get; init; }
}
/// <summary>
/// Request to revoke an exception.
/// </summary>
internal sealed class ExceptionRevokeRequest
{
[JsonPropertyName("exceptionId")]
public string ExceptionId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// Request to import exceptions from NDJSON.
/// </summary>
internal sealed class ExceptionImportRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("stage")]
public bool Stage { get; init; } = true;
[JsonPropertyName("source")]
public string? Source { get; init; }
}
/// <summary>
/// Result of exception import.
/// </summary>
internal sealed class ExceptionImportResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("imported")]
public int Imported { get; init; }
[JsonPropertyName("skipped")]
public int Skipped { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<ExceptionImportError> Errors { get; init; } = Array.Empty<ExceptionImportError>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Import error detail.
/// </summary>
internal sealed class ExceptionImportError
{
[JsonPropertyName("line")]
public int Line { get; init; }
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("field")]
public string? Field { get; init; }
}
/// <summary>
/// Request to export exceptions.
/// </summary>
internal sealed class ExceptionExportRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("statuses")]
public IReadOnlyList<string>? Statuses { get; init; }
[JsonPropertyName("format")]
public string Format { get; init; } = "ndjson"; // ndjson, json
[JsonPropertyName("includeManifest")]
public bool IncludeManifest { get; init; } = true;
[JsonPropertyName("signed")]
public bool Signed { get; init; }
}
/// <summary>
/// Export manifest for exception bundle.
/// </summary>
internal sealed class ExceptionExportManifest
{
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("count")]
public int Count { get; init; }
[JsonPropertyName("sha256")]
public string Sha256 { get; init; } = string.Empty;
[JsonPropertyName("aocEnforced")]
public bool AocEnforced { get; init; }
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("signatureUri")]
public string? SignatureUri { get; init; }
}
/// <summary>
/// Result of exception operation.
/// </summary>
internal sealed class ExceptionOperationResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("exception")]
public ExceptionInstance? Exception { get; init; }
[JsonPropertyName("auditEventId")]
public string? AuditEventId { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,279 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-FORENSICS-53-001: Forensic snapshot models for evidence locker integration
/// <summary>
/// Request to create a forensic snapshot.
/// </summary>
internal sealed record ForensicSnapshotCreateRequest(
[property: JsonPropertyName("caseId")] string CaseId,
[property: JsonPropertyName("description")] string? Description = null,
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags = null,
[property: JsonPropertyName("scope")] ForensicSnapshotScope? Scope = null,
[property: JsonPropertyName("retentionDays")] int? RetentionDays = null);
/// <summary>
/// Scope configuration for forensic snapshot.
/// </summary>
internal sealed record ForensicSnapshotScope(
[property: JsonPropertyName("sbomIds")] IReadOnlyList<string>? SbomIds = null,
[property: JsonPropertyName("scanIds")] IReadOnlyList<string>? ScanIds = null,
[property: JsonPropertyName("policyIds")] IReadOnlyList<string>? PolicyIds = null,
[property: JsonPropertyName("vulnerabilityIds")] IReadOnlyList<string>? VulnerabilityIds = null,
[property: JsonPropertyName("timeRange")] ForensicTimeRange? TimeRange = null);
/// <summary>
/// Time range for forensic snapshot scope.
/// </summary>
internal sealed record ForensicTimeRange(
[property: JsonPropertyName("from")] DateTimeOffset? From = null,
[property: JsonPropertyName("to")] DateTimeOffset? To = null);
/// <summary>
/// Forensic snapshot document from the evidence locker.
/// </summary>
internal sealed class ForensicSnapshotDocument
{
[JsonPropertyName("snapshotId")]
public string SnapshotId { get; init; } = string.Empty;
[JsonPropertyName("caseId")]
public string CaseId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("status")]
public string Status { get; init; } = string.Empty;
[JsonPropertyName("manifest")]
public ForensicSnapshotManifest? Manifest { get; init; }
[JsonPropertyName("scope")]
public ForensicSnapshotScope? Scope { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("completedAt")]
public DateTimeOffset? CompletedAt { get; init; }
[JsonPropertyName("expiresAt")]
public DateTimeOffset? ExpiresAt { get; init; }
[JsonPropertyName("createdBy")]
public string? CreatedBy { get; init; }
[JsonPropertyName("sizeBytes")]
public long? SizeBytes { get; init; }
[JsonPropertyName("artifactCount")]
public int? ArtifactCount { get; init; }
}
/// <summary>
/// Manifest for a forensic snapshot.
/// </summary>
internal sealed class ForensicSnapshotManifest
{
[JsonPropertyName("manifestId")]
public string ManifestId { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; init; } = "1.0";
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("digestAlgorithm")]
public string DigestAlgorithm { get; init; } = "sha256";
[JsonPropertyName("signature")]
public ForensicManifestSignature? Signature { get; init; }
[JsonPropertyName("artifacts")]
public IReadOnlyList<ForensicSnapshotArtifact> Artifacts { get; init; } =
Array.Empty<ForensicSnapshotArtifact>();
[JsonPropertyName("metadata")]
public ForensicManifestMetadata? Metadata { get; init; }
}
/// <summary>
/// Signature information for the manifest.
/// </summary>
internal sealed class ForensicManifestSignature
{
[JsonPropertyName("algorithm")]
public string Algorithm { get; init; } = string.Empty;
[JsonPropertyName("keyId")]
public string? KeyId { get; init; }
[JsonPropertyName("value")]
public string Value { get; init; } = string.Empty;
[JsonPropertyName("signedAt")]
public DateTimeOffset? SignedAt { get; init; }
[JsonPropertyName("certificate")]
public string? Certificate { get; init; }
}
/// <summary>
/// Individual artifact in a forensic snapshot.
/// </summary>
internal sealed class ForensicSnapshotArtifact
{
[JsonPropertyName("artifactId")]
public string ArtifactId { get; init; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("path")]
public string Path { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("digestAlgorithm")]
public string DigestAlgorithm { get; init; } = "sha256";
[JsonPropertyName("sizeBytes")]
public long SizeBytes { get; init; }
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("metadata")]
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Metadata for the forensic manifest.
/// </summary>
internal sealed class ForensicManifestMetadata
{
[JsonPropertyName("capturedAt")]
public DateTimeOffset CapturedAt { get; init; }
[JsonPropertyName("capturedBy")]
public string? CapturedBy { get; init; }
[JsonPropertyName("toolVersion")]
public string? ToolVersion { get; init; }
[JsonPropertyName("stellaOpsVersion")]
public string? StellaOpsVersion { get; init; }
[JsonPropertyName("chainOfCustody")]
public IReadOnlyList<ForensicChainOfCustodyEntry> ChainOfCustody { get; init; } =
Array.Empty<ForensicChainOfCustodyEntry>();
}
/// <summary>
/// Chain of custody entry.
/// </summary>
internal sealed class ForensicChainOfCustodyEntry
{
[JsonPropertyName("action")]
public string Action { get; init; } = string.Empty;
[JsonPropertyName("actor")]
public string Actor { get; init; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("notes")]
public string? Notes { get; init; }
[JsonPropertyName("signature")]
public string? Signature { get; init; }
}
/// <summary>
/// Response for listing forensic snapshots.
/// </summary>
internal sealed class ForensicSnapshotListResponse
{
[JsonPropertyName("snapshots")]
public IReadOnlyList<ForensicSnapshotDocument> Snapshots { get; init; } =
Array.Empty<ForensicSnapshotDocument>();
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("limit")]
public int Limit { get; init; }
[JsonPropertyName("offset")]
public int Offset { get; init; }
[JsonPropertyName("hasMore")]
public bool HasMore { get; init; }
}
/// <summary>
/// Query parameters for listing forensic snapshots.
/// </summary>
internal sealed record ForensicSnapshotListQuery(
string Tenant,
string? CaseId = null,
string? Status = null,
IReadOnlyList<string>? Tags = null,
DateTimeOffset? CreatedAfter = null,
DateTimeOffset? CreatedBefore = null,
int? Limit = null,
int? Offset = null);
/// <summary>
/// Local cache metadata for forensic snapshots.
/// </summary>
internal sealed class ForensicSnapshotCacheEntry
{
[JsonPropertyName("snapshotId")]
public string SnapshotId { get; init; } = string.Empty;
[JsonPropertyName("caseId")]
public string CaseId { get; init; } = string.Empty;
[JsonPropertyName("localPath")]
public string LocalPath { get; init; } = string.Empty;
[JsonPropertyName("manifestDigest")]
public string ManifestDigest { get; init; } = string.Empty;
[JsonPropertyName("downloadedAt")]
public DateTimeOffset DownloadedAt { get; init; }
[JsonPropertyName("verified")]
public bool Verified { get; init; }
[JsonPropertyName("sizeBytes")]
public long SizeBytes { get; init; }
}
/// <summary>
/// Snapshot status enumeration.
/// </summary>
internal static class ForensicSnapshotStatus
{
public const string Pending = "pending";
public const string Creating = "creating";
public const string Ready = "ready";
public const string Failed = "failed";
public const string Expired = "expired";
public const string Archived = "archived";
}

View File

@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-FORENSICS-54-001: Forensic bundle verification models
/// <summary>
/// Represents a forensic bundle for local verification.
/// </summary>
internal sealed class ForensicBundle
{
[JsonPropertyName("manifestPath")]
public string ManifestPath { get; init; } = string.Empty;
[JsonPropertyName("manifest")]
public ForensicSnapshotManifest? Manifest { get; init; }
[JsonPropertyName("artifacts")]
public IReadOnlyList<ForensicBundleArtifact> Artifacts { get; init; } = Array.Empty<ForensicBundleArtifact>();
[JsonPropertyName("dsseEnvelopes")]
public IReadOnlyList<ForensicDsseEnvelope> DsseEnvelopes { get; init; } = Array.Empty<ForensicDsseEnvelope>();
}
/// <summary>
/// Artifact in a forensic bundle with local file reference.
/// </summary>
internal sealed class ForensicBundleArtifact
{
[JsonPropertyName("artifactId")]
public string ArtifactId { get; init; } = string.Empty;
[JsonPropertyName("localPath")]
public string LocalPath { get; init; } = string.Empty;
[JsonPropertyName("expectedDigest")]
public string ExpectedDigest { get; init; } = string.Empty;
[JsonPropertyName("digestAlgorithm")]
public string DigestAlgorithm { get; init; } = "sha256";
[JsonPropertyName("sizeBytes")]
public long SizeBytes { get; init; }
}
/// <summary>
/// DSSE envelope for signature verification.
/// </summary>
internal sealed class ForensicDsseEnvelope
{
[JsonPropertyName("payloadType")]
public string PayloadType { get; init; } = string.Empty;
[JsonPropertyName("payload")]
public string Payload { get; init; } = string.Empty;
[JsonPropertyName("signatures")]
public IReadOnlyList<ForensicDsseSignature> Signatures { get; init; } = Array.Empty<ForensicDsseSignature>();
}
/// <summary>
/// DSSE signature entry.
/// </summary>
internal sealed class ForensicDsseSignature
{
[JsonPropertyName("keyid")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("sig")]
public string Signature { get; init; } = string.Empty;
}
/// <summary>
/// Result of forensic bundle verification.
/// </summary>
internal sealed class ForensicVerificationResult
{
[JsonPropertyName("bundlePath")]
public string BundlePath { get; init; } = string.Empty;
[JsonPropertyName("isValid")]
public bool IsValid { get; init; }
[JsonPropertyName("verifiedAt")]
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
[JsonPropertyName("manifestVerification")]
public ForensicManifestVerification? ManifestVerification { get; init; }
[JsonPropertyName("checksumVerification")]
public ForensicChecksumVerification? ChecksumVerification { get; init; }
[JsonPropertyName("signatureVerification")]
public ForensicSignatureVerification? SignatureVerification { get; init; }
[JsonPropertyName("chainOfCustodyVerification")]
public ForensicChainOfCustodyVerification? ChainOfCustodyVerification { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<ForensicVerificationError> Errors { get; init; } = Array.Empty<ForensicVerificationError>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Manifest verification result.
/// </summary>
internal sealed class ForensicManifestVerification
{
[JsonPropertyName("isValid")]
public bool IsValid { get; init; }
[JsonPropertyName("manifestId")]
public string ManifestId { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("digestAlgorithm")]
public string DigestAlgorithm { get; init; } = string.Empty;
[JsonPropertyName("computedDigest")]
public string ComputedDigest { get; init; } = string.Empty;
[JsonPropertyName("artifactCount")]
public int ArtifactCount { get; init; }
}
/// <summary>
/// Checksum verification result.
/// </summary>
internal sealed class ForensicChecksumVerification
{
[JsonPropertyName("isValid")]
public bool IsValid { get; init; }
[JsonPropertyName("totalArtifacts")]
public int TotalArtifacts { get; init; }
[JsonPropertyName("verifiedArtifacts")]
public int VerifiedArtifacts { get; init; }
[JsonPropertyName("failedArtifacts")]
public IReadOnlyList<ForensicArtifactChecksumFailure> FailedArtifacts { get; init; } =
Array.Empty<ForensicArtifactChecksumFailure>();
}
/// <summary>
/// Individual artifact checksum failure.
/// </summary>
internal sealed class ForensicArtifactChecksumFailure
{
[JsonPropertyName("artifactId")]
public string ArtifactId { get; init; } = string.Empty;
[JsonPropertyName("path")]
public string Path { get; init; } = string.Empty;
[JsonPropertyName("expectedDigest")]
public string ExpectedDigest { get; init; } = string.Empty;
[JsonPropertyName("actualDigest")]
public string ActualDigest { get; init; } = string.Empty;
[JsonPropertyName("reason")]
public string Reason { get; init; } = string.Empty;
}
/// <summary>
/// Signature verification result.
/// </summary>
internal sealed class ForensicSignatureVerification
{
[JsonPropertyName("isValid")]
public bool IsValid { get; init; }
[JsonPropertyName("signatureCount")]
public int SignatureCount { get; init; }
[JsonPropertyName("verifiedSignatures")]
public int VerifiedSignatures { get; init; }
[JsonPropertyName("signatures")]
public IReadOnlyList<ForensicSignatureDetail> Signatures { get; init; } =
Array.Empty<ForensicSignatureDetail>();
}
/// <summary>
/// Individual signature verification detail.
/// </summary>
internal sealed class ForensicSignatureDetail
{
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("algorithm")]
public string Algorithm { get; init; } = string.Empty;
[JsonPropertyName("isValid")]
public bool IsValid { get; init; }
[JsonPropertyName("isTrusted")]
public bool IsTrusted { get; init; }
[JsonPropertyName("signedAt")]
public DateTimeOffset? SignedAt { get; init; }
[JsonPropertyName("fingerprint")]
public string? Fingerprint { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// Chain of custody verification result.
/// </summary>
internal sealed class ForensicChainOfCustodyVerification
{
[JsonPropertyName("isValid")]
public bool IsValid { get; init; }
[JsonPropertyName("entryCount")]
public int EntryCount { get; init; }
[JsonPropertyName("timelineValid")]
public bool TimelineValid { get; init; }
[JsonPropertyName("signaturesValid")]
public bool SignaturesValid { get; init; }
[JsonPropertyName("entries")]
public IReadOnlyList<ForensicChainOfCustodyEntryVerification> Entries { get; init; } =
Array.Empty<ForensicChainOfCustodyEntryVerification>();
[JsonPropertyName("gaps")]
public IReadOnlyList<ForensicTimelineGap> Gaps { get; init; } = Array.Empty<ForensicTimelineGap>();
}
/// <summary>
/// Individual chain of custody entry verification.
/// </summary>
internal sealed class ForensicChainOfCustodyEntryVerification
{
[JsonPropertyName("index")]
public int Index { get; init; }
[JsonPropertyName("action")]
public string Action { get; init; } = string.Empty;
[JsonPropertyName("actor")]
public string Actor { get; init; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("signatureValid")]
public bool? SignatureValid { get; init; }
[JsonPropertyName("notes")]
public string? Notes { get; init; }
}
/// <summary>
/// Timeline gap in chain of custody.
/// </summary>
internal sealed class ForensicTimelineGap
{
[JsonPropertyName("fromIndex")]
public int FromIndex { get; init; }
[JsonPropertyName("toIndex")]
public int ToIndex { get; init; }
[JsonPropertyName("fromTimestamp")]
public DateTimeOffset FromTimestamp { get; init; }
[JsonPropertyName("toTimestamp")]
public DateTimeOffset ToTimestamp { get; init; }
[JsonPropertyName("gapDuration")]
public TimeSpan GapDuration { get; init; }
[JsonPropertyName("description")]
public string Description { get; init; } = string.Empty;
}
/// <summary>
/// Verification error detail.
/// </summary>
internal sealed class ForensicVerificationError
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("detail")]
public string? Detail { get; init; }
[JsonPropertyName("artifactId")]
public string? ArtifactId { get; init; }
}
/// <summary>
/// Trust root configuration for forensic verification.
/// </summary>
internal sealed class ForensicTrustRoot
{
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("fingerprint")]
public string Fingerprint { get; init; } = string.Empty;
[JsonPropertyName("publicKey")]
public string PublicKey { get; init; } = string.Empty;
[JsonPropertyName("algorithm")]
public string Algorithm { get; init; } = "rsa-pss-sha256";
[JsonPropertyName("notBefore")]
public DateTimeOffset? NotBefore { get; init; }
[JsonPropertyName("notAfter")]
public DateTimeOffset? NotAfter { get; init; }
}
/// <summary>
/// Verification options for forensic bundle.
/// </summary>
internal sealed class ForensicVerificationOptions
{
public bool VerifyChecksums { get; init; } = true;
public bool VerifySignatures { get; init; } = true;
public bool VerifyChainOfCustody { get; init; } = true;
public bool StrictTimeline { get; init; } = false;
public IReadOnlyList<ForensicTrustRoot> TrustRoots { get; init; } = Array.Empty<ForensicTrustRoot>();
public string? TrustRootPath { get; init; }
}

View File

@@ -0,0 +1,612 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-PARITY-41-002: Notify command models for CLI
/// <summary>
/// Notify channel types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
internal enum NotifyChannelType
{
Slack,
Teams,
Email,
Webhook,
Custom,
PagerDuty,
OpsGenie,
Cli,
InAppInbox,
InApp
}
/// <summary>
/// Notify delivery status.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
internal enum NotifyDeliveryStatus
{
Pending,
Sent,
Failed,
Throttled,
Digested,
Dropped
}
/// <summary>
/// Notify channel list request.
/// </summary>
internal sealed class NotifyChannelListRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("type")]
public string? Type { get; init; }
[JsonPropertyName("enabled")]
public bool? Enabled { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("offset")]
public int? Offset { get; init; }
[JsonPropertyName("cursor")]
public string? Cursor { get; init; }
}
/// <summary>
/// Notify channel list response.
/// </summary>
internal sealed class NotifyChannelListResponse
{
[JsonPropertyName("items")]
public IReadOnlyList<NotifyChannelSummary> Items { get; init; } = [];
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("hasMore")]
public bool HasMore { get; init; }
[JsonPropertyName("nextCursor")]
public string? NextCursor { get; init; }
}
/// <summary>
/// Notify channel summary for list view.
/// </summary>
internal sealed class NotifyChannelSummary
{
[JsonPropertyName("channelId")]
public string ChannelId { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("displayName")]
public string? DisplayName { get; init; }
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("enabled")]
public bool Enabled { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset UpdatedAt { get; init; }
[JsonPropertyName("deliveryCount")]
public int DeliveryCount { get; init; }
[JsonPropertyName("failureRate")]
public double? FailureRate { get; init; }
}
/// <summary>
/// Detailed notify channel response.
/// </summary>
internal sealed class NotifyChannelDetail
{
[JsonPropertyName("channelId")]
public string ChannelId { get; init; } = string.Empty;
[JsonPropertyName("tenantId")]
public string TenantId { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("displayName")]
public string? DisplayName { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("enabled")]
public bool Enabled { get; init; }
[JsonPropertyName("config")]
public NotifyChannelConfigInfo? Config { get; init; }
[JsonPropertyName("labels")]
public IReadOnlyDictionary<string, string>? Labels { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
[JsonPropertyName("createdBy")]
public string? CreatedBy { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updatedBy")]
public string? UpdatedBy { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset UpdatedAt { get; init; }
[JsonPropertyName("stats")]
public NotifyChannelStats? Stats { get; init; }
[JsonPropertyName("health")]
public NotifyChannelHealth? Health { get; init; }
}
/// <summary>
/// Notify channel configuration info (redacted secrets).
/// </summary>
internal sealed class NotifyChannelConfigInfo
{
[JsonPropertyName("secretRef")]
public string SecretRef { get; init; } = string.Empty;
[JsonPropertyName("target")]
public string? Target { get; init; }
[JsonPropertyName("endpoint")]
public string? Endpoint { get; init; }
[JsonPropertyName("properties")]
public IReadOnlyDictionary<string, string>? Properties { get; init; }
[JsonPropertyName("limits")]
public NotifyChannelLimitsInfo? Limits { get; init; }
}
/// <summary>
/// Notify channel limits.
/// </summary>
internal sealed class NotifyChannelLimitsInfo
{
[JsonPropertyName("concurrency")]
public int? Concurrency { get; init; }
[JsonPropertyName("requestsPerMinute")]
public int? RequestsPerMinute { get; init; }
[JsonPropertyName("timeoutSeconds")]
public int? TimeoutSeconds { get; init; }
[JsonPropertyName("maxBatchSize")]
public int? MaxBatchSize { get; init; }
}
/// <summary>
/// Notify channel statistics.
/// </summary>
internal sealed class NotifyChannelStats
{
[JsonPropertyName("totalDeliveries")]
public long TotalDeliveries { get; init; }
[JsonPropertyName("successfulDeliveries")]
public long SuccessfulDeliveries { get; init; }
[JsonPropertyName("failedDeliveries")]
public long FailedDeliveries { get; init; }
[JsonPropertyName("throttledDeliveries")]
public long ThrottledDeliveries { get; init; }
[JsonPropertyName("lastDeliveryAt")]
public DateTimeOffset? LastDeliveryAt { get; init; }
[JsonPropertyName("avgLatencyMs")]
public double? AvgLatencyMs { get; init; }
}
/// <summary>
/// Notify channel health status.
/// </summary>
internal sealed class NotifyChannelHealth
{
[JsonPropertyName("status")]
public string Status { get; init; } = string.Empty;
[JsonPropertyName("lastCheckAt")]
public DateTimeOffset? LastCheckAt { get; init; }
[JsonPropertyName("consecutiveFailures")]
public int ConsecutiveFailures { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
}
/// <summary>
/// Channel test request.
/// </summary>
internal sealed class NotifyChannelTestRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("channelId")]
public string ChannelId { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string? Message { get; init; }
}
/// <summary>
/// Channel test result.
/// </summary>
internal sealed class NotifyChannelTestResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("channelId")]
public string ChannelId { get; init; } = string.Empty;
[JsonPropertyName("latencyMs")]
public long? LatencyMs { get; init; }
[JsonPropertyName("responseCode")]
public int? ResponseCode { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
[JsonPropertyName("deliveryId")]
public string? DeliveryId { get; init; }
}
/// <summary>
/// Notify rule list request.
/// </summary>
internal sealed class NotifyRuleListRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("enabled")]
public bool? Enabled { get; init; }
[JsonPropertyName("eventType")]
public string? EventType { get; init; }
[JsonPropertyName("channelId")]
public string? ChannelId { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("offset")]
public int? Offset { get; init; }
}
/// <summary>
/// Notify rule list response.
/// </summary>
internal sealed class NotifyRuleListResponse
{
[JsonPropertyName("items")]
public IReadOnlyList<NotifyRuleSummary> Items { get; init; } = [];
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("hasMore")]
public bool HasMore { get; init; }
}
/// <summary>
/// Notify rule summary.
/// </summary>
internal sealed class NotifyRuleSummary
{
[JsonPropertyName("ruleId")]
public string RuleId { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("enabled")]
public bool Enabled { get; init; }
[JsonPropertyName("eventTypes")]
public IReadOnlyList<string> EventTypes { get; init; } = [];
[JsonPropertyName("channelIds")]
public IReadOnlyList<string> ChannelIds { get; init; } = [];
[JsonPropertyName("priority")]
public int Priority { get; init; }
[JsonPropertyName("matchCount")]
public long MatchCount { get; init; }
}
/// <summary>
/// Notify delivery list request.
/// </summary>
internal sealed class NotifyDeliveryListRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("channelId")]
public string? ChannelId { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("eventType")]
public string? EventType { get; init; }
[JsonPropertyName("since")]
public DateTimeOffset? Since { get; init; }
[JsonPropertyName("until")]
public DateTimeOffset? Until { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("cursor")]
public string? Cursor { get; init; }
}
/// <summary>
/// Notify delivery list response.
/// </summary>
internal sealed class NotifyDeliveryListResponse
{
[JsonPropertyName("items")]
public IReadOnlyList<NotifyDeliverySummary> Items { get; init; } = [];
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("hasMore")]
public bool HasMore { get; init; }
[JsonPropertyName("nextCursor")]
public string? NextCursor { get; init; }
}
/// <summary>
/// Notify delivery summary.
/// </summary>
internal sealed class NotifyDeliverySummary
{
[JsonPropertyName("deliveryId")]
public string DeliveryId { get; init; } = string.Empty;
[JsonPropertyName("channelId")]
public string ChannelId { get; init; } = string.Empty;
[JsonPropertyName("channelName")]
public string? ChannelName { get; init; }
[JsonPropertyName("channelType")]
public string ChannelType { get; init; } = string.Empty;
[JsonPropertyName("eventType")]
public string EventType { get; init; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; init; } = string.Empty;
[JsonPropertyName("attemptCount")]
public int AttemptCount { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("sentAt")]
public DateTimeOffset? SentAt { get; init; }
[JsonPropertyName("latencyMs")]
public long? LatencyMs { get; init; }
}
/// <summary>
/// Notify delivery detail.
/// </summary>
internal sealed class NotifyDeliveryDetail
{
[JsonPropertyName("deliveryId")]
public string DeliveryId { get; init; } = string.Empty;
[JsonPropertyName("tenantId")]
public string TenantId { get; init; } = string.Empty;
[JsonPropertyName("channelId")]
public string ChannelId { get; init; } = string.Empty;
[JsonPropertyName("channelName")]
public string? ChannelName { get; init; }
[JsonPropertyName("channelType")]
public string ChannelType { get; init; } = string.Empty;
[JsonPropertyName("ruleId")]
public string? RuleId { get; init; }
[JsonPropertyName("eventId")]
public string? EventId { get; init; }
[JsonPropertyName("eventType")]
public string EventType { get; init; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; init; } = string.Empty;
[JsonPropertyName("subject")]
public string? Subject { get; init; }
[JsonPropertyName("attemptCount")]
public int AttemptCount { get; init; }
[JsonPropertyName("attempts")]
public IReadOnlyList<NotifyDeliveryAttempt>? Attempts { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("sentAt")]
public DateTimeOffset? SentAt { get; init; }
[JsonPropertyName("failedAt")]
public DateTimeOffset? FailedAt { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
[JsonPropertyName("idempotencyKey")]
public string? IdempotencyKey { get; init; }
}
/// <summary>
/// Notify delivery attempt.
/// </summary>
internal sealed class NotifyDeliveryAttempt
{
[JsonPropertyName("attemptNumber")]
public int AttemptNumber { get; init; }
[JsonPropertyName("status")]
public string Status { get; init; } = string.Empty;
[JsonPropertyName("attemptedAt")]
public DateTimeOffset AttemptedAt { get; init; }
[JsonPropertyName("latencyMs")]
public long? LatencyMs { get; init; }
[JsonPropertyName("responseCode")]
public int? ResponseCode { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
}
/// <summary>
/// Retry delivery request.
/// </summary>
internal sealed class NotifyRetryRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("deliveryId")]
public string DeliveryId { get; init; } = string.Empty;
[JsonPropertyName("idempotencyKey")]
public string? IdempotencyKey { get; init; }
}
/// <summary>
/// Retry delivery result.
/// </summary>
internal sealed class NotifyRetryResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("deliveryId")]
public string DeliveryId { get; init; } = string.Empty;
[JsonPropertyName("newStatus")]
public string? NewStatus { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
[JsonPropertyName("auditEventId")]
public string? AuditEventId { get; init; }
}
/// <summary>
/// Send notification request.
/// </summary>
internal sealed class NotifySendRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("channelId")]
public string? ChannelId { get; init; }
[JsonPropertyName("eventType")]
public string EventType { get; init; } = string.Empty;
[JsonPropertyName("subject")]
public string? Subject { get; init; }
[JsonPropertyName("body")]
public string Body { get; init; } = string.Empty;
[JsonPropertyName("severity")]
public string? Severity { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
[JsonPropertyName("idempotencyKey")]
public string? IdempotencyKey { get; init; }
}
/// <summary>
/// Send notification result.
/// </summary>
internal sealed class NotifySendResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("eventId")]
public string? EventId { get; init; }
[JsonPropertyName("deliveryIds")]
public IReadOnlyList<string>? DeliveryIds { get; init; }
[JsonPropertyName("channelsMatched")]
public int ChannelsMatched { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
[JsonPropertyName("idempotencyKey")]
public string? IdempotencyKey { get; init; }
}

View File

@@ -0,0 +1,542 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-OBS-51-001: Observability models for stella obs commands
/// <summary>
/// Service health status from the platform.
/// </summary>
internal sealed class ServiceHealthStatus
{
[JsonPropertyName("service")]
public string Service { get; init; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; init; } = "unknown"; // healthy, degraded, unhealthy, unknown
[JsonPropertyName("availability")]
public double Availability { get; init; }
[JsonPropertyName("sloTarget")]
public double SloTarget { get; init; } = 0.999;
[JsonPropertyName("errorBudgetRemaining")]
public double ErrorBudgetRemaining { get; init; }
[JsonPropertyName("burnRate")]
public BurnRateInfo? BurnRate { get; init; }
[JsonPropertyName("latency")]
public LatencyInfo? Latency { get; init; }
[JsonPropertyName("traffic")]
public TrafficInfo? Traffic { get; init; }
[JsonPropertyName("queues")]
public IReadOnlyList<QueueHealth> Queues { get; init; } = Array.Empty<QueueHealth>();
[JsonPropertyName("lastUpdated")]
public DateTimeOffset LastUpdated { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Burn rate alert information.
/// </summary>
internal sealed class BurnRateInfo
{
[JsonPropertyName("current")]
public double Current { get; init; }
[JsonPropertyName("shortWindow")]
public double ShortWindow { get; init; } // 5m or 1h window
[JsonPropertyName("longWindow")]
public double LongWindow { get; init; } // 6h or 3d window
[JsonPropertyName("alertLevel")]
public string AlertLevel { get; init; } = "none"; // none, warning, critical
[JsonPropertyName("threshold2x")]
public double Threshold2x { get; init; } = 2.0;
[JsonPropertyName("threshold14x")]
public double Threshold14x { get; init; } = 14.0;
}
/// <summary>
/// Latency percentile information.
/// </summary>
internal sealed class LatencyInfo
{
[JsonPropertyName("p50")]
public double P50Ms { get; init; }
[JsonPropertyName("p95")]
public double P95Ms { get; init; }
[JsonPropertyName("p99")]
public double P99Ms { get; init; }
[JsonPropertyName("p95Target")]
public double P95TargetMs { get; init; } = 300;
[JsonPropertyName("breaching")]
public bool Breaching { get; init; }
}
/// <summary>
/// Traffic/throughput information.
/// </summary>
internal sealed class TrafficInfo
{
[JsonPropertyName("requestsPerSecond")]
public double RequestsPerSecond { get; init; }
[JsonPropertyName("successRate")]
public double SuccessRate { get; init; }
[JsonPropertyName("errorRate")]
public double ErrorRate { get; init; }
[JsonPropertyName("totalRequests")]
public long TotalRequests { get; init; }
[JsonPropertyName("totalErrors")]
public long TotalErrors { get; init; }
}
/// <summary>
/// Queue health information.
/// </summary>
internal sealed class QueueHealth
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("depth")]
public long Depth { get; init; }
[JsonPropertyName("depthThreshold")]
public long DepthThreshold { get; init; } = 1000;
[JsonPropertyName("oldestMessageAge")]
public TimeSpan OldestMessageAge { get; init; }
[JsonPropertyName("throughput")]
public double Throughput { get; init; }
[JsonPropertyName("successRate")]
public double SuccessRate { get; init; }
[JsonPropertyName("alerting")]
public bool Alerting { get; init; }
}
/// <summary>
/// Platform-wide health summary.
/// </summary>
internal sealed class PlatformHealthSummary
{
[JsonPropertyName("overallStatus")]
public string OverallStatus { get; init; } = "unknown";
[JsonPropertyName("services")]
public IReadOnlyList<ServiceHealthStatus> Services { get; init; } = Array.Empty<ServiceHealthStatus>();
[JsonPropertyName("activeAlerts")]
public IReadOnlyList<ActiveAlert> ActiveAlerts { get; init; } = Array.Empty<ActiveAlert>();
[JsonPropertyName("globalErrorBudget")]
public double GlobalErrorBudget { get; init; }
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Active alert information.
/// </summary>
internal sealed class ActiveAlert
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("service")]
public string Service { get; init; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty; // burn_rate, latency, error_rate, queue_depth
[JsonPropertyName("severity")]
public string Severity { get; init; } = "warning"; // warning, critical
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("startedAt")]
public DateTimeOffset StartedAt { get; init; }
[JsonPropertyName("value")]
public double Value { get; init; }
[JsonPropertyName("threshold")]
public double Threshold { get; init; }
}
/// <summary>
/// Request for obs top command.
/// </summary>
internal sealed class ObsTopRequest
{
/// <summary>
/// Filter by service names.
/// </summary>
[JsonPropertyName("services")]
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
/// <summary>
/// Filter by tenant.
/// </summary>
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
/// <summary>
/// Include queue details.
/// </summary>
[JsonPropertyName("includeQueues")]
public bool IncludeQueues { get; init; } = true;
/// <summary>
/// Refresh interval in seconds for streaming mode (0 = single fetch).
/// </summary>
[JsonPropertyName("refreshInterval")]
public int RefreshInterval { get; init; }
/// <summary>
/// Maximum alerts to return.
/// </summary>
[JsonPropertyName("maxAlerts")]
public int MaxAlerts { get; init; } = 20;
}
/// <summary>
/// Result of obs top command.
/// </summary>
internal sealed class ObsTopResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("summary")]
public PlatformHealthSummary? Summary { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
// CLI-OBS-52-001: Trace and logs models
/// <summary>
/// Request for fetching a trace by ID.
/// </summary>
internal sealed class ObsTraceRequest
{
[JsonPropertyName("traceId")]
public string TraceId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("includeEvidence")]
public bool IncludeEvidence { get; init; } = true;
}
/// <summary>
/// Distributed trace with spans.
/// </summary>
internal sealed class DistributedTrace
{
[JsonPropertyName("traceId")]
public string TraceId { get; init; } = string.Empty;
[JsonPropertyName("rootSpan")]
public TraceSpan? RootSpan { get; init; }
[JsonPropertyName("spans")]
public IReadOnlyList<TraceSpan> Spans { get; init; } = Array.Empty<TraceSpan>();
[JsonPropertyName("services")]
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
[JsonPropertyName("duration")]
public TimeSpan Duration { get; init; }
[JsonPropertyName("startTime")]
public DateTimeOffset StartTime { get; init; }
[JsonPropertyName("endTime")]
public DateTimeOffset EndTime { get; init; }
[JsonPropertyName("status")]
public string Status { get; init; } = "ok"; // ok, error
[JsonPropertyName("evidenceLinks")]
public IReadOnlyList<EvidenceLink> EvidenceLinks { get; init; } = Array.Empty<EvidenceLink>();
}
/// <summary>
/// Individual span within a trace.
/// </summary>
internal sealed class TraceSpan
{
[JsonPropertyName("spanId")]
public string SpanId { get; init; } = string.Empty;
[JsonPropertyName("parentSpanId")]
public string? ParentSpanId { get; init; }
[JsonPropertyName("operationName")]
public string OperationName { get; init; } = string.Empty;
[JsonPropertyName("serviceName")]
public string ServiceName { get; init; } = string.Empty;
[JsonPropertyName("startTime")]
public DateTimeOffset StartTime { get; init; }
[JsonPropertyName("duration")]
public TimeSpan Duration { get; init; }
[JsonPropertyName("status")]
public string Status { get; init; } = "ok"; // ok, error
[JsonPropertyName("tags")]
public IReadOnlyDictionary<string, string> Tags { get; init; } = new Dictionary<string, string>();
[JsonPropertyName("logs")]
public IReadOnlyList<SpanLog> Logs { get; init; } = Array.Empty<SpanLog>();
}
/// <summary>
/// Log entry within a span.
/// </summary>
internal sealed class SpanLog
{
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("level")]
public string Level { get; init; } = "info"; // debug, info, warn, error
[JsonPropertyName("fields")]
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
}
/// <summary>
/// Link to evidence artifact (SBOM, VEX, attestation, etc.).
/// </summary>
internal sealed class EvidenceLink
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty; // sbom, vex, attestation, scan_result
[JsonPropertyName("uri")]
public string Uri { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
}
/// <summary>
/// Result of fetching a trace.
/// </summary>
internal sealed class ObsTraceResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("trace")]
public DistributedTrace? Trace { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Request for fetching logs.
/// </summary>
internal sealed class ObsLogsRequest
{
[JsonPropertyName("from")]
public DateTimeOffset From { get; init; }
[JsonPropertyName("to")]
public DateTimeOffset To { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("services")]
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
[JsonPropertyName("levels")]
public IReadOnlyList<string> Levels { get; init; } = Array.Empty<string>();
[JsonPropertyName("query")]
public string? Query { get; init; }
[JsonPropertyName("pageSize")]
public int PageSize { get; init; } = 100;
[JsonPropertyName("pageToken")]
public string? PageToken { get; init; }
}
/// <summary>
/// Log entry from the platform.
/// </summary>
internal sealed class LogEntry
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("level")]
public string Level { get; init; } = "info";
[JsonPropertyName("service")]
public string Service { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("traceId")]
public string? TraceId { get; init; }
[JsonPropertyName("spanId")]
public string? SpanId { get; init; }
[JsonPropertyName("fields")]
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
[JsonPropertyName("evidenceLinks")]
public IReadOnlyList<EvidenceLink> EvidenceLinks { get; init; } = Array.Empty<EvidenceLink>();
}
/// <summary>
/// Result of fetching logs.
/// </summary>
internal sealed class ObsLogsResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("logs")]
public IReadOnlyList<LogEntry> Logs { get; init; } = Array.Empty<LogEntry>();
[JsonPropertyName("nextPageToken")]
public string? NextPageToken { get; init; }
[JsonPropertyName("totalCount")]
public long? TotalCount { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
// CLI-OBS-55-001: Incident mode models
/// <summary>
/// Incident mode state.
/// </summary>
internal sealed class IncidentModeState
{
[JsonPropertyName("enabled")]
public bool Enabled { get; init; }
[JsonPropertyName("setAt")]
public DateTimeOffset? SetAt { get; init; }
[JsonPropertyName("expiresAt")]
public DateTimeOffset? ExpiresAt { get; init; }
[JsonPropertyName("actor")]
public string? Actor { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("retentionExtensionDays")]
public int RetentionExtensionDays { get; init; } = 60;
[JsonPropertyName("source")]
public string Source { get; init; } = "cli"; // cli, config, api
}
/// <summary>
/// Request to enable incident mode.
/// </summary>
internal sealed class IncidentModeEnableRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("ttlMinutes")]
public int TtlMinutes { get; init; } = 30;
[JsonPropertyName("retentionExtensionDays")]
public int RetentionExtensionDays { get; init; } = 60;
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// Request to disable incident mode.
/// </summary>
internal sealed class IncidentModeDisableRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// Result of incident mode operation.
/// </summary>
internal sealed class IncidentModeResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("state")]
public IncidentModeState? State { get; init; }
[JsonPropertyName("previousState")]
public IncidentModeState? PreviousState { get; init; }
[JsonPropertyName("auditEventId")]
public string? AuditEventId { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,671 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-ORCH-32-001: Orchestrator source and job models for stella orch commands
/// <summary>
/// Source status values.
/// </summary>
internal static class SourceStatuses
{
public const string Active = "active";
public const string Paused = "paused";
public const string Disabled = "disabled";
public const string Throttled = "throttled";
public const string Error = "error";
}
/// <summary>
/// Source type values representing data feed categories.
/// </summary>
internal static class SourceTypes
{
public const string Advisory = "advisory";
public const string Vex = "vex";
public const string Sbom = "sbom";
public const string Package = "package";
public const string Registry = "registry";
public const string Custom = "custom";
}
/// <summary>
/// Orchestrator source definition representing a data feed.
/// </summary>
internal sealed class OrchestratorSource
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; init; } = SourceTypes.Advisory;
[JsonPropertyName("host")]
public string Host { get; init; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; init; } = SourceStatuses.Active;
[JsonPropertyName("enabled")]
public bool Enabled { get; init; } = true;
[JsonPropertyName("priority")]
public int Priority { get; init; }
[JsonPropertyName("schedule")]
public SourceSchedule? Schedule { get; init; }
[JsonPropertyName("rateLimit")]
public SourceRateLimit? RateLimit { get; init; }
[JsonPropertyName("lastRun")]
public SourceLastRun? LastRun { get; init; }
[JsonPropertyName("metrics")]
public SourceMetrics? Metrics { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset UpdatedAt { get; init; }
[JsonPropertyName("pausedAt")]
public DateTimeOffset? PausedAt { get; init; }
[JsonPropertyName("pausedBy")]
public string? PausedBy { get; init; }
[JsonPropertyName("pauseReason")]
public string? PauseReason { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Source schedule configuration.
/// </summary>
internal sealed class SourceSchedule
{
[JsonPropertyName("cron")]
public string? Cron { get; init; }
[JsonPropertyName("intervalMinutes")]
public int? IntervalMinutes { get; init; }
[JsonPropertyName("nextRunAt")]
public DateTimeOffset? NextRunAt { get; init; }
[JsonPropertyName("timezone")]
public string Timezone { get; init; } = "UTC";
}
/// <summary>
/// Source rate limit configuration.
/// </summary>
internal sealed class SourceRateLimit
{
[JsonPropertyName("maxRequestsPerMinute")]
public int MaxRequestsPerMinute { get; init; }
[JsonPropertyName("maxRequestsPerHour")]
public int? MaxRequestsPerHour { get; init; }
[JsonPropertyName("burstSize")]
public int BurstSize { get; init; } = 1;
[JsonPropertyName("currentTokens")]
public double? CurrentTokens { get; init; }
[JsonPropertyName("refillRatePerSecond")]
public double? RefillRatePerSecond { get; init; }
[JsonPropertyName("throttledUntil")]
public DateTimeOffset? ThrottledUntil { get; init; }
}
/// <summary>
/// Source last run information.
/// </summary>
internal sealed class SourceLastRun
{
[JsonPropertyName("runId")]
public string? RunId { get; init; }
[JsonPropertyName("startedAt")]
public DateTimeOffset? StartedAt { get; init; }
[JsonPropertyName("completedAt")]
public DateTimeOffset? CompletedAt { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("itemsProcessed")]
public long? ItemsProcessed { get; init; }
[JsonPropertyName("itemsFailed")]
public long? ItemsFailed { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
[JsonPropertyName("durationMs")]
public long? DurationMs { get; init; }
}
/// <summary>
/// Source metrics summary.
/// </summary>
internal sealed class SourceMetrics
{
[JsonPropertyName("totalRuns")]
public long TotalRuns { get; init; }
[JsonPropertyName("successfulRuns")]
public long SuccessfulRuns { get; init; }
[JsonPropertyName("failedRuns")]
public long FailedRuns { get; init; }
[JsonPropertyName("averageDurationMs")]
public double? AverageDurationMs { get; init; }
[JsonPropertyName("totalItemsProcessed")]
public long TotalItemsProcessed { get; init; }
[JsonPropertyName("totalItemsFailed")]
public long TotalItemsFailed { get; init; }
[JsonPropertyName("lastSuccessAt")]
public DateTimeOffset? LastSuccessAt { get; init; }
[JsonPropertyName("lastFailureAt")]
public DateTimeOffset? LastFailureAt { get; init; }
[JsonPropertyName("uptimePercent")]
public double? UptimePercent { get; init; }
}
/// <summary>
/// Request to list sources.
/// </summary>
internal sealed class SourceListRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("type")]
public string? Type { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("enabled")]
public bool? Enabled { get; init; }
[JsonPropertyName("host")]
public string? Host { get; init; }
[JsonPropertyName("tag")]
public string? Tag { get; init; }
[JsonPropertyName("pageSize")]
public int PageSize { get; init; } = 50;
[JsonPropertyName("pageToken")]
public string? PageToken { get; init; }
}
/// <summary>
/// Response from listing sources.
/// </summary>
internal sealed class SourceListResponse
{
[JsonPropertyName("sources")]
public IReadOnlyList<OrchestratorSource> Sources { get; init; } = Array.Empty<OrchestratorSource>();
[JsonPropertyName("nextPageToken")]
public string? NextPageToken { get; init; }
[JsonPropertyName("totalCount")]
public long? TotalCount { get; init; }
}
/// <summary>
/// Request to pause a source.
/// </summary>
internal sealed class SourcePauseRequest
{
[JsonPropertyName("sourceId")]
public string SourceId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
[JsonPropertyName("durationMinutes")]
public int? DurationMinutes { get; init; }
}
/// <summary>
/// Request to resume a source.
/// </summary>
internal sealed class SourceResumeRequest
{
[JsonPropertyName("sourceId")]
public string SourceId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// Request to test a source connection.
/// </summary>
internal sealed class SourceTestRequest
{
[JsonPropertyName("sourceId")]
public string SourceId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("timeout")]
public int TimeoutSeconds { get; init; } = 30;
}
/// <summary>
/// Result of source operation.
/// </summary>
internal sealed class SourceOperationResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("source")]
public OrchestratorSource? Source { get; init; }
[JsonPropertyName("auditEventId")]
public string? AuditEventId { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Result of source test operation.
/// </summary>
internal sealed class SourceTestResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("sourceId")]
public string SourceId { get; init; } = string.Empty;
[JsonPropertyName("reachable")]
public bool Reachable { get; init; }
[JsonPropertyName("latencyMs")]
public long? LatencyMs { get; init; }
[JsonPropertyName("statusCode")]
public int? StatusCode { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
[JsonPropertyName("tlsValid")]
public bool? TlsValid { get; init; }
[JsonPropertyName("tlsExpiry")]
public DateTimeOffset? TlsExpiry { get; init; }
[JsonPropertyName("testedAt")]
public DateTimeOffset TestedAt { get; init; }
}
// CLI-ORCH-34-001: Backfill wizard and quota management models
/// <summary>
/// Request to start a backfill operation for a source.
/// </summary>
internal sealed class BackfillRequest
{
[JsonPropertyName("sourceId")]
public string SourceId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("from")]
public DateTimeOffset From { get; init; }
[JsonPropertyName("to")]
public DateTimeOffset To { get; init; }
[JsonPropertyName("dryRun")]
public bool DryRun { get; init; }
[JsonPropertyName("priority")]
public int Priority { get; init; } = 5;
[JsonPropertyName("concurrency")]
public int Concurrency { get; init; } = 1;
[JsonPropertyName("batchSize")]
public int BatchSize { get; init; } = 100;
[JsonPropertyName("resume")]
public bool Resume { get; init; }
[JsonPropertyName("filter")]
public string? Filter { get; init; }
[JsonPropertyName("force")]
public bool Force { get; init; }
}
/// <summary>
/// Result of a backfill operation.
/// </summary>
internal sealed class BackfillResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("backfillId")]
public string? BackfillId { get; init; }
[JsonPropertyName("sourceId")]
public string SourceId { get; init; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; init; } = string.Empty;
[JsonPropertyName("from")]
public DateTimeOffset From { get; init; }
[JsonPropertyName("to")]
public DateTimeOffset To { get; init; }
[JsonPropertyName("dryRun")]
public bool DryRun { get; init; }
[JsonPropertyName("estimatedItems")]
public long? EstimatedItems { get; init; }
[JsonPropertyName("processedItems")]
public long ProcessedItems { get; init; }
[JsonPropertyName("failedItems")]
public long FailedItems { get; init; }
[JsonPropertyName("skippedItems")]
public long SkippedItems { get; init; }
[JsonPropertyName("startedAt")]
public DateTimeOffset? StartedAt { get; init; }
[JsonPropertyName("completedAt")]
public DateTimeOffset? CompletedAt { get; init; }
[JsonPropertyName("estimatedDurationMs")]
public long? EstimatedDurationMs { get; init; }
[JsonPropertyName("actualDurationMs")]
public long? ActualDurationMs { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
[JsonPropertyName("auditEventId")]
public string? AuditEventId { get; init; }
}
/// <summary>
/// Status values for backfill operations.
/// </summary>
internal static class BackfillStatuses
{
public const string Pending = "pending";
public const string Running = "running";
public const string Completed = "completed";
public const string Failed = "failed";
public const string Cancelled = "cancelled";
public const string DryRun = "dry_run";
}
/// <summary>
/// Request to list backfill operations.
/// </summary>
internal sealed class BackfillListRequest
{
[JsonPropertyName("sourceId")]
public string? SourceId { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("pageSize")]
public int PageSize { get; init; } = 20;
[JsonPropertyName("pageToken")]
public string? PageToken { get; init; }
}
/// <summary>
/// Response from listing backfill operations.
/// </summary>
internal sealed class BackfillListResponse
{
[JsonPropertyName("backfills")]
public IReadOnlyList<BackfillResult> Backfills { get; init; } = Array.Empty<BackfillResult>();
[JsonPropertyName("nextPageToken")]
public string? NextPageToken { get; init; }
[JsonPropertyName("totalCount")]
public long? TotalCount { get; init; }
}
/// <summary>
/// Request to cancel a backfill operation.
/// </summary>
internal sealed class BackfillCancelRequest
{
[JsonPropertyName("backfillId")]
public string BackfillId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// Quota resource representing usage limits for a tenant/source.
/// </summary>
internal sealed class OrchestratorQuota
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("sourceId")]
public string? SourceId { get; init; }
[JsonPropertyName("resourceType")]
public string ResourceType { get; init; } = string.Empty;
[JsonPropertyName("limit")]
public long Limit { get; init; }
[JsonPropertyName("used")]
public long Used { get; init; }
[JsonPropertyName("remaining")]
public long Remaining { get; init; }
[JsonPropertyName("period")]
public string Period { get; init; } = "monthly";
[JsonPropertyName("periodStart")]
public DateTimeOffset PeriodStart { get; init; }
[JsonPropertyName("periodEnd")]
public DateTimeOffset PeriodEnd { get; init; }
[JsonPropertyName("resetAt")]
public DateTimeOffset ResetAt { get; init; }
[JsonPropertyName("warningThreshold")]
public double WarningThreshold { get; init; } = 0.8;
[JsonPropertyName("isWarning")]
public bool IsWarning { get; init; }
[JsonPropertyName("isExceeded")]
public bool IsExceeded { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Quota resource types.
/// </summary>
internal static class QuotaResourceTypes
{
public const string ApiCalls = "api_calls";
public const string DataIngested = "data_ingested_bytes";
public const string ItemsProcessed = "items_processed";
public const string Backfills = "backfills";
public const string ConcurrentJobs = "concurrent_jobs";
public const string Storage = "storage_bytes";
}
/// <summary>
/// Quota period types.
/// </summary>
internal static class QuotaPeriods
{
public const string Hourly = "hourly";
public const string Daily = "daily";
public const string Weekly = "weekly";
public const string Monthly = "monthly";
}
/// <summary>
/// Request to get quotas.
/// </summary>
internal sealed class QuotaGetRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("sourceId")]
public string? SourceId { get; init; }
[JsonPropertyName("resourceType")]
public string? ResourceType { get; init; }
}
/// <summary>
/// Response from getting quotas.
/// </summary>
internal sealed class QuotaGetResponse
{
[JsonPropertyName("quotas")]
public IReadOnlyList<OrchestratorQuota> Quotas { get; init; } = Array.Empty<OrchestratorQuota>();
}
/// <summary>
/// Request to set a quota limit.
/// </summary>
internal sealed class QuotaSetRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("sourceId")]
public string? SourceId { get; init; }
[JsonPropertyName("resourceType")]
public string ResourceType { get; init; } = string.Empty;
[JsonPropertyName("limit")]
public long Limit { get; init; }
[JsonPropertyName("period")]
public string Period { get; init; } = QuotaPeriods.Monthly;
[JsonPropertyName("warningThreshold")]
public double WarningThreshold { get; init; } = 0.8;
}
/// <summary>
/// Result of a quota operation.
/// </summary>
internal sealed class QuotaOperationResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("quota")]
public OrchestratorQuota? Quota { get; init; }
[JsonPropertyName("auditEventId")]
public string? AuditEventId { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Request to reset a quota's usage counter.
/// </summary>
internal sealed class QuotaResetRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("sourceId")]
public string? SourceId { get; init; }
[JsonPropertyName("resourceType")]
public string ResourceType { get; init; } = string.Empty;
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}

View File

@@ -0,0 +1,915 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-PACKS-42-001: Task Pack models for stella pack commands
/// <summary>
/// Task pack metadata from the registry.
/// </summary>
internal sealed class TaskPackInfo
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("author")]
public string? Author { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("signature")]
public PackSignature? Signature { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset UpdatedAt { get; init; }
[JsonPropertyName("labels")]
public IReadOnlyDictionary<string, string> Labels { get; init; } = new Dictionary<string, string>();
[JsonPropertyName("inputs")]
public IReadOnlyList<PackInputSchema> Inputs { get; init; } = Array.Empty<PackInputSchema>();
[JsonPropertyName("outputs")]
public IReadOnlyList<PackOutputSchema> Outputs { get; init; } = Array.Empty<PackOutputSchema>();
}
/// <summary>
/// Pack signature information.
/// </summary>
internal sealed class PackSignature
{
[JsonPropertyName("algorithm")]
public string Algorithm { get; init; } = string.Empty; // ecdsa-p256, rsa-pkcs1-sha256, etc.
[JsonPropertyName("keyId")]
public string? KeyId { get; init; }
[JsonPropertyName("certificate")]
public string? Certificate { get; init; }
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; init; }
[JsonPropertyName("rekorLogId")]
public string? RekorLogId { get; init; }
[JsonPropertyName("verified")]
public bool Verified { get; init; }
}
/// <summary>
/// Pack input parameter schema.
/// </summary>
internal sealed class PackInputSchema
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; init; } = "string"; // string, number, boolean, array, object
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("required")]
public bool Required { get; init; }
[JsonPropertyName("default")]
public object? Default { get; init; }
[JsonPropertyName("enum")]
public IReadOnlyList<string>? Enum { get; init; }
}
/// <summary>
/// Pack output schema.
/// </summary>
internal sealed class PackOutputSchema
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; init; } = "string";
[JsonPropertyName("description")]
public string? Description { get; init; }
}
/// <summary>
/// Request to plan a pack execution.
/// </summary>
internal sealed class PackPlanRequest
{
[JsonPropertyName("packId")]
public string PackId { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("inputs")]
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("dryRun")]
public bool DryRun { get; init; }
[JsonPropertyName("validateOnly")]
public bool ValidateOnly { get; init; }
}
/// <summary>
/// Execution plan step.
/// </summary>
internal sealed class PackPlanStep
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("action")]
public string Action { get; init; } = string.Empty;
[JsonPropertyName("dependsOn")]
public IReadOnlyList<string> DependsOn { get; init; } = Array.Empty<string>();
[JsonPropertyName("condition")]
public string? Condition { get; init; }
[JsonPropertyName("timeout")]
public TimeSpan? Timeout { get; init; }
[JsonPropertyName("retryPolicy")]
public PackRetryPolicy? RetryPolicy { get; init; }
[JsonPropertyName("inputs")]
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
[JsonPropertyName("requiresApproval")]
public bool RequiresApproval { get; init; }
}
/// <summary>
/// Retry policy for pack steps.
/// </summary>
internal sealed class PackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; } = 1;
[JsonPropertyName("backoffMultiplier")]
public double BackoffMultiplier { get; init; } = 2.0;
[JsonPropertyName("initialDelayMs")]
public int InitialDelayMs { get; init; } = 1000;
}
/// <summary>
/// Result of pack plan operation.
/// </summary>
internal sealed class PackPlanResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("planId")]
public string? PlanId { get; init; }
[JsonPropertyName("planHash")]
public string? PlanHash { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<PackPlanStep> Steps { get; init; } = Array.Empty<PackPlanStep>();
[JsonPropertyName("requiresApproval")]
public bool RequiresApproval { get; init; }
[JsonPropertyName("approvalGates")]
public IReadOnlyList<string> ApprovalGates { get; init; } = Array.Empty<string>();
[JsonPropertyName("estimatedDuration")]
public TimeSpan? EstimatedDuration { get; init; }
[JsonPropertyName("validationErrors")]
public IReadOnlyList<PackValidationError> ValidationErrors { get; init; } = Array.Empty<PackValidationError>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Validation error from pack plan/verify.
/// </summary>
internal sealed class PackValidationError
{
[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; }
[JsonPropertyName("severity")]
public string Severity { get; init; } = "error"; // error, warning
}
/// <summary>
/// Request to run a pack.
/// </summary>
internal sealed class PackRunRequest
{
[JsonPropertyName("packId")]
public string PackId { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("planId")]
public string? PlanId { get; init; }
[JsonPropertyName("inputs")]
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("labels")]
public IReadOnlyDictionary<string, string>? Labels { get; init; }
[JsonPropertyName("waitForCompletion")]
public bool WaitForCompletion { get; init; }
[JsonPropertyName("timeoutMinutes")]
public int TimeoutMinutes { get; init; } = 60;
}
/// <summary>
/// Pack run status.
/// </summary>
internal sealed class PackRunStatus
{
[JsonPropertyName("runId")]
public string RunId { get; init; } = string.Empty;
[JsonPropertyName("packId")]
public string PackId { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; init; } = "pending"; // pending, running, succeeded, failed, cancelled, waiting_approval
[JsonPropertyName("startedAt")]
public DateTimeOffset? StartedAt { get; init; }
[JsonPropertyName("completedAt")]
public DateTimeOffset? CompletedAt { get; init; }
[JsonPropertyName("duration")]
public TimeSpan? Duration { get; init; }
[JsonPropertyName("actor")]
public string? Actor { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("currentStep")]
public string? CurrentStep { get; init; }
[JsonPropertyName("stepStatuses")]
public IReadOnlyList<PackStepStatus> StepStatuses { get; init; } = Array.Empty<PackStepStatus>();
[JsonPropertyName("outputs")]
public IReadOnlyDictionary<string, object>? Outputs { get; init; }
[JsonPropertyName("artifacts")]
public IReadOnlyList<PackArtifact> Artifacts { get; init; } = Array.Empty<PackArtifact>();
[JsonPropertyName("error")]
public string? Error { get; init; }
[JsonPropertyName("auditEventId")]
public string? AuditEventId { get; init; }
}
/// <summary>
/// Status of individual pack step.
/// </summary>
internal sealed class PackStepStatus
{
[JsonPropertyName("stepId")]
public string StepId { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; init; } = "pending"; // pending, running, succeeded, failed, skipped
[JsonPropertyName("startedAt")]
public DateTimeOffset? StartedAt { get; init; }
[JsonPropertyName("completedAt")]
public DateTimeOffset? CompletedAt { get; init; }
[JsonPropertyName("duration")]
public TimeSpan? Duration { get; init; }
[JsonPropertyName("attempt")]
public int Attempt { get; init; } = 1;
[JsonPropertyName("error")]
public string? Error { get; init; }
[JsonPropertyName("outputs")]
public IReadOnlyDictionary<string, object>? Outputs { get; init; }
}
/// <summary>
/// Artifact produced by pack run.
/// </summary>
internal sealed class PackArtifact
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty; // log, sbom, report, attestation
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("size")]
public long Size { get; init; }
[JsonPropertyName("uri")]
public string? Uri { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Result of pack run operation.
/// </summary>
internal sealed class PackRunResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("run")]
public PackRunStatus? Run { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Request to push a pack to the registry.
/// </summary>
internal sealed class PackPushRequest
{
[JsonPropertyName("packPath")]
public string PackPath { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("sign")]
public bool Sign { get; init; }
[JsonPropertyName("keyId")]
public string? KeyId { get; init; }
[JsonPropertyName("force")]
public bool Force { get; init; }
[JsonPropertyName("labels")]
public IReadOnlyDictionary<string, string>? Labels { get; init; }
}
/// <summary>
/// Result of pack push operation.
/// </summary>
internal sealed class PackPushResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("pack")]
public TaskPackInfo? Pack { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("rekorLogId")]
public string? RekorLogId { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Request to pull a pack from the registry.
/// </summary>
internal sealed class PackPullRequest
{
[JsonPropertyName("packId")]
public string PackId { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("verify")]
public bool Verify { get; init; } = true;
}
/// <summary>
/// Result of pack pull operation.
/// </summary>
internal sealed class PackPullResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("pack")]
public TaskPackInfo? Pack { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("verified")]
public bool Verified { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Request to verify a pack.
/// </summary>
internal sealed class PackVerifyRequest
{
[JsonPropertyName("packPath")]
public string? PackPath { get; init; }
[JsonPropertyName("packId")]
public string? PackId { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("checkRekor")]
public bool CheckRekor { get; init; } = true;
[JsonPropertyName("checkExpiry")]
public bool CheckExpiry { get; init; } = true;
}
/// <summary>
/// Result of pack verify operation.
/// </summary>
internal sealed class PackVerifyResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("pack")]
public TaskPackInfo? Pack { get; init; }
[JsonPropertyName("signatureValid")]
public bool SignatureValid { get; init; }
[JsonPropertyName("digestMatch")]
public bool DigestMatch { get; init; }
[JsonPropertyName("rekorVerified")]
public bool? RekorVerified { get; init; }
[JsonPropertyName("certificateValid")]
public bool? CertificateValid { get; init; }
[JsonPropertyName("certificateExpiry")]
public DateTimeOffset? CertificateExpiry { get; init; }
[JsonPropertyName("schemaValid")]
public bool SchemaValid { get; init; }
[JsonPropertyName("validationErrors")]
public IReadOnlyList<PackValidationError> ValidationErrors { get; init; } = Array.Empty<PackValidationError>();
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
// CLI-PACKS-43-001: Advanced pack features
/// <summary>
/// Request to pause a pack run waiting for approval.
/// </summary>
internal sealed class PackApprovalPauseRequest
{
[JsonPropertyName("runId")]
public string RunId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
[JsonPropertyName("stepId")]
public string? StepId { get; init; }
}
/// <summary>
/// Request to resume a paused pack run with approval.
/// </summary>
internal sealed class PackApprovalResumeRequest
{
[JsonPropertyName("runId")]
public string RunId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("approved")]
public bool Approved { get; init; } = true;
[JsonPropertyName("reason")]
public string? Reason { get; init; }
[JsonPropertyName("stepId")]
public string? StepId { get; init; }
}
/// <summary>
/// Result of an approval operation.
/// </summary>
internal sealed class PackApprovalResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("run")]
public PackRunStatus? Run { get; init; }
[JsonPropertyName("auditEventId")]
public string? AuditEventId { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Request to inject a secret into a pack run.
/// </summary>
internal sealed class PackSecretInjectRequest
{
[JsonPropertyName("runId")]
public string RunId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("secretRef")]
public string SecretRef { get; init; } = string.Empty;
[JsonPropertyName("secretProvider")]
public string SecretProvider { get; init; } = "vault"; // vault, aws-ssm, azure-keyvault, k8s-secret
[JsonPropertyName("targetEnvVar")]
public string? TargetEnvVar { get; init; }
[JsonPropertyName("targetPath")]
public string? TargetPath { get; init; }
[JsonPropertyName("stepId")]
public string? StepId { get; init; }
}
/// <summary>
/// Result of a secret injection operation.
/// </summary>
internal sealed class PackSecretInjectResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("secretRef")]
public string SecretRef { get; init; } = string.Empty;
[JsonPropertyName("injectedAt")]
public DateTimeOffset InjectedAt { get; init; }
[JsonPropertyName("auditEventId")]
public string? AuditEventId { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Request to list pack runs.
/// </summary>
internal sealed class PackRunListRequest
{
[JsonPropertyName("packId")]
public string? PackId { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("actor")]
public string? Actor { get; init; }
[JsonPropertyName("since")]
public DateTimeOffset? Since { get; init; }
[JsonPropertyName("until")]
public DateTimeOffset? Until { get; init; }
[JsonPropertyName("pageSize")]
public int PageSize { get; init; } = 20;
[JsonPropertyName("pageToken")]
public string? PageToken { get; init; }
}
/// <summary>
/// Response from listing pack runs.
/// </summary>
internal sealed class PackRunListResponse
{
[JsonPropertyName("runs")]
public IReadOnlyList<PackRunStatus> Runs { get; init; } = Array.Empty<PackRunStatus>();
[JsonPropertyName("nextPageToken")]
public string? NextPageToken { get; init; }
[JsonPropertyName("totalCount")]
public long? TotalCount { get; init; }
}
/// <summary>
/// Request to cancel a pack run.
/// </summary>
internal sealed class PackCancelRequest
{
[JsonPropertyName("runId")]
public string RunId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
[JsonPropertyName("force")]
public bool Force { get; init; }
}
/// <summary>
/// Request to get pack run logs.
/// </summary>
internal sealed class PackLogsRequest
{
[JsonPropertyName("runId")]
public string RunId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("stepId")]
public string? StepId { get; init; }
[JsonPropertyName("follow")]
public bool Follow { get; init; }
[JsonPropertyName("tail")]
public int? Tail { get; init; }
[JsonPropertyName("since")]
public DateTimeOffset? Since { get; init; }
}
/// <summary>
/// Pack log entry.
/// </summary>
internal sealed class PackLogEntry
{
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("stepId")]
public string? StepId { get; init; }
[JsonPropertyName("level")]
public string Level { get; init; } = "info"; // debug, info, warn, error
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("stream")]
public string Stream { get; init; } = "stdout"; // stdout, stderr
}
/// <summary>
/// Result of pack logs request.
/// </summary>
internal sealed class PackLogsResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("runId")]
public string RunId { get; init; } = string.Empty;
[JsonPropertyName("logs")]
public IReadOnlyList<PackLogEntry> Logs { get; init; } = Array.Empty<PackLogEntry>();
[JsonPropertyName("nextToken")]
public string? NextToken { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Request to download a pack artifact.
/// </summary>
internal sealed class PackArtifactDownloadRequest
{
[JsonPropertyName("runId")]
public string RunId { get; init; } = string.Empty;
[JsonPropertyName("artifactName")]
public string ArtifactName { get; init; } = string.Empty;
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
}
/// <summary>
/// Result of artifact download.
/// </summary>
internal sealed class PackArtifactDownloadResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("artifact")]
public PackArtifact? Artifact { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("verified")]
public bool Verified { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Offline cache entry for packs.
/// </summary>
internal sealed class PackCacheEntry
{
[JsonPropertyName("packId")]
public string PackId { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("cachedAt")]
public DateTimeOffset CachedAt { get; init; }
[JsonPropertyName("expiresAt")]
public DateTimeOffset? ExpiresAt { get; init; }
[JsonPropertyName("size")]
public long Size { get; init; }
[JsonPropertyName("path")]
public string Path { get; init; } = string.Empty;
[JsonPropertyName("verified")]
public bool Verified { get; init; }
}
/// <summary>
/// Request to manage offline cache.
/// </summary>
internal sealed class PackCacheRequest
{
[JsonPropertyName("action")]
public string Action { get; init; } = "list"; // list, add, remove, sync, prune
[JsonPropertyName("packId")]
public string? PackId { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("cacheDir")]
public string? CacheDir { get; init; }
[JsonPropertyName("maxAge")]
public TimeSpan? MaxAge { get; init; }
[JsonPropertyName("maxSize")]
public long? MaxSize { get; init; }
}
/// <summary>
/// Result of cache operation.
/// </summary>
internal sealed class PackCacheResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("entries")]
public IReadOnlyList<PackCacheEntry> Entries { get; init; } = Array.Empty<PackCacheEntry>();
[JsonPropertyName("totalSize")]
public long TotalSize { get; init; }
[JsonPropertyName("prunedCount")]
public int PrunedCount { get; init; }
[JsonPropertyName("prunedSize")]
public long PrunedSize { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}

View File

@@ -2,16 +2,39 @@ using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
// CLI-POLICY-27-003: Enhanced simulation modes
internal enum PolicySimulationMode
{
Quick,
Batch
}
/// <summary>
/// Input for policy simulation.
/// Per CLI-EXC-25-002, supports exception preview via WithExceptions/WithoutExceptions.
/// Per CLI-POLICY-27-003, supports mode (quick/batch), SBOM selectors, heatmap, and manifest download.
/// Per CLI-SIG-26-002, supports reachability overrides for vulnerability/package state and score.
/// </summary>
internal sealed record PolicySimulationInput(
int? BaseVersion,
int? CandidateVersion,
IReadOnlyList<string> SbomSet,
IReadOnlyDictionary<string, object?> Environment,
bool Explain);
bool Explain,
IReadOnlyList<string>? WithExceptions = null,
IReadOnlyList<string>? WithoutExceptions = null,
PolicySimulationMode? Mode = null,
IReadOnlyList<string>? SbomSelectors = null,
bool IncludeHeatmap = false,
bool IncludeManifest = false,
IReadOnlyList<ReachabilityOverride>? ReachabilityOverrides = null);
internal sealed record PolicySimulationResult(
PolicySimulationDiff Diff,
string? ExplainUri);
string? ExplainUri,
PolicySimulationHeatmap? Heatmap = null,
string? ManifestDownloadUri = null,
string? ManifestDigest = null);
internal sealed record PolicySimulationDiff(
string? SchemaVersion,
@@ -24,3 +47,17 @@ internal sealed record PolicySimulationDiff(
internal sealed record PolicySimulationSeverityDelta(int? Up, int? Down);
internal sealed record PolicySimulationRuleDelta(string RuleId, string RuleName, int? Up, int? Down);
// CLI-POLICY-27-003: Heatmap summary for quick severity visualization
internal sealed record PolicySimulationHeatmap(
int Critical,
int High,
int Medium,
int Low,
int Info,
IReadOnlyList<PolicySimulationHeatmapBucket> Buckets);
internal sealed record PolicySimulationHeatmapBucket(
string Label,
int Count,
string? Color);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,468 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-PROMO-70-001: Promotion attestation models
/// <summary>
/// Request for assembling a promotion attestation.
/// </summary>
internal sealed class PromotionAssembleRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("image")]
public string Image { get; init; } = string.Empty;
[JsonPropertyName("sbomPath")]
public string? SbomPath { get; init; }
[JsonPropertyName("vexPath")]
public string? VexPath { get; init; }
[JsonPropertyName("fromEnvironment")]
public string FromEnvironment { get; init; } = "staging";
[JsonPropertyName("toEnvironment")]
public string ToEnvironment { get; init; } = "prod";
[JsonPropertyName("actor")]
public string? Actor { get; init; }
[JsonPropertyName("pipeline")]
public string? Pipeline { get; init; }
[JsonPropertyName("ticket")]
public string? Ticket { get; init; }
[JsonPropertyName("notes")]
public string? Notes { get; init; }
[JsonPropertyName("skipRekor")]
public bool SkipRekor { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
}
/// <summary>
/// Promotion attestation predicate following stella.ops/promotion@v1 schema.
/// </summary>
internal sealed class PromotionPredicate
{
[JsonPropertyName("_type")]
public string Type { get; init; } = "stella.ops/promotion@v1";
[JsonPropertyName("subject")]
public IReadOnlyList<PromotionSubject> Subject { get; init; } = Array.Empty<PromotionSubject>();
[JsonPropertyName("materials")]
public IReadOnlyList<PromotionMaterial> Materials { get; init; } = Array.Empty<PromotionMaterial>();
[JsonPropertyName("promotion")]
public PromotionMetadata Promotion { get; init; } = new();
[JsonPropertyName("rekor")]
public PromotionRekorEntry? Rekor { get; init; }
[JsonPropertyName("attestation")]
public PromotionAttestationMetadata? Attestation { get; init; }
}
/// <summary>
/// Subject in promotion attestation (image reference).
/// </summary>
internal sealed class PromotionSubject
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
}
/// <summary>
/// Material in promotion attestation (SBOM, VEX, etc.).
/// </summary>
internal sealed class PromotionMaterial
{
[JsonPropertyName("role")]
public string Role { get; init; } = string.Empty;
[JsonPropertyName("algo")]
public string Algo { get; init; } = "sha256";
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("uri")]
public string? Uri { get; init; }
}
/// <summary>
/// Promotion metadata.
/// </summary>
internal sealed class PromotionMetadata
{
[JsonPropertyName("from")]
public string From { get; init; } = "staging";
[JsonPropertyName("to")]
public string To { get; init; } = "prod";
[JsonPropertyName("actor")]
public string? Actor { get; init; }
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
[JsonPropertyName("pipeline")]
public string? Pipeline { get; init; }
[JsonPropertyName("ticket")]
public string? Ticket { get; init; }
[JsonPropertyName("notes")]
public string? Notes { get; init; }
}
/// <summary>
/// Rekor entry in promotion attestation.
/// </summary>
internal sealed class PromotionRekorEntry
{
[JsonPropertyName("uuid")]
public string Uuid { get; init; } = string.Empty;
[JsonPropertyName("logIndex")]
public long LogIndex { get; init; }
[JsonPropertyName("inclusionProof")]
public PromotionInclusionProof? InclusionProof { get; init; }
}
/// <summary>
/// Merkle inclusion proof.
/// </summary>
internal sealed class PromotionInclusionProof
{
[JsonPropertyName("rootHash")]
public string RootHash { get; init; } = string.Empty;
[JsonPropertyName("hashes")]
public IReadOnlyList<string> Hashes { get; init; } = Array.Empty<string>();
[JsonPropertyName("treeSize")]
public long TreeSize { get; init; }
[JsonPropertyName("checkpoint")]
public PromotionCheckpoint? Checkpoint { get; init; }
}
/// <summary>
/// Rekor checkpoint.
/// </summary>
internal sealed class PromotionCheckpoint
{
[JsonPropertyName("origin")]
public string Origin { get; init; } = string.Empty;
[JsonPropertyName("size")]
public long Size { get; init; }
[JsonPropertyName("hash")]
public string Hash { get; init; } = string.Empty;
[JsonPropertyName("signedNote")]
public string? SignedNote { get; init; }
}
/// <summary>
/// Attestation metadata.
/// </summary>
internal sealed class PromotionAttestationMetadata
{
[JsonPropertyName("bundle_sha256")]
public string BundleSha256 { get; init; } = string.Empty;
[JsonPropertyName("witness")]
public string? Witness { get; init; }
}
/// <summary>
/// Result of promotion assemble operation.
/// </summary>
internal sealed class PromotionAssembleResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("predicate")]
public PromotionPredicate? Predicate { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("imageDigest")]
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("materials")]
public IReadOnlyList<PromotionMaterial> Materials { get; init; } = Array.Empty<PromotionMaterial>();
[JsonPropertyName("rekorEntry")]
public PromotionRekorEntry? RekorEntry { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
// CLI-PROMO-70-002: Promotion attest/verify models
/// <summary>
/// Request for attesting a promotion predicate.
/// </summary>
internal sealed class PromotionAttestRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("predicatePath")]
public string? PredicatePath { get; init; }
[JsonPropertyName("predicate")]
public PromotionPredicate? Predicate { get; init; }
[JsonPropertyName("keyId")]
public string? KeyId { get; init; }
[JsonPropertyName("useKeyless")]
public bool UseKeyless { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("uploadToRekor")]
public bool UploadToRekor { get; init; } = true;
}
/// <summary>
/// Result of promotion attest operation.
/// </summary>
internal sealed class PromotionAttestResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("bundlePath")]
public string? BundlePath { get; init; }
[JsonPropertyName("dsseEnvelope")]
public DsseEnvelope? DsseEnvelope { get; init; }
[JsonPropertyName("rekorEntry")]
public PromotionRekorEntry? RekorEntry { get; init; }
[JsonPropertyName("auditId")]
public string? AuditId { get; init; }
[JsonPropertyName("signerKeyId")]
public string? SignerKeyId { get; init; }
[JsonPropertyName("signedAt")]
public DateTimeOffset? SignedAt { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
/// <summary>
/// DSSE envelope for promotion attestation.
/// </summary>
internal sealed class DsseEnvelope
{
[JsonPropertyName("payloadType")]
public string PayloadType { get; init; } = "application/vnd.in-toto+json";
[JsonPropertyName("payload")]
public string Payload { get; init; } = string.Empty;
[JsonPropertyName("signatures")]
public IReadOnlyList<DsseSignature> Signatures { get; init; } = Array.Empty<DsseSignature>();
}
/// <summary>
/// DSSE signature.
/// </summary>
internal sealed class DsseSignature
{
[JsonPropertyName("keyid")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("sig")]
public string Sig { get; init; } = string.Empty;
[JsonPropertyName("cert")]
public string? Cert { get; init; }
}
/// <summary>
/// Request for verifying a promotion attestation.
/// </summary>
internal sealed class PromotionVerifyRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("bundlePath")]
public string? BundlePath { get; init; }
[JsonPropertyName("predicatePath")]
public string? PredicatePath { get; init; }
[JsonPropertyName("sbomPath")]
public string? SbomPath { get; init; }
[JsonPropertyName("vexPath")]
public string? VexPath { get; init; }
[JsonPropertyName("trustRootPath")]
public string? TrustRootPath { get; init; }
[JsonPropertyName("checkpointPath")]
public string? CheckpointPath { get; init; }
[JsonPropertyName("skipRekorVerification")]
public bool SkipRekorVerification { get; init; }
[JsonPropertyName("skipSignatureVerification")]
public bool SkipSignatureVerification { get; init; }
}
/// <summary>
/// Result of promotion verify operation.
/// </summary>
internal sealed class PromotionVerifyResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("verified")]
public bool Verified { get; init; }
[JsonPropertyName("signatureVerification")]
public PromotionSignatureVerification? SignatureVerification { get; init; }
[JsonPropertyName("materialVerification")]
public PromotionMaterialVerification? MaterialVerification { get; init; }
[JsonPropertyName("rekorVerification")]
public PromotionRekorVerification? RekorVerification { get; init; }
[JsonPropertyName("predicate")]
public PromotionPredicate? Predicate { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Signature verification result.
/// </summary>
internal sealed class PromotionSignatureVerification
{
[JsonPropertyName("verified")]
public bool Verified { get; init; }
[JsonPropertyName("keyId")]
public string? KeyId { get; init; }
[JsonPropertyName("algorithm")]
public string? Algorithm { get; init; }
[JsonPropertyName("certSubject")]
public string? CertSubject { get; init; }
[JsonPropertyName("certIssuer")]
public string? CertIssuer { get; init; }
[JsonPropertyName("validFrom")]
public DateTimeOffset? ValidFrom { get; init; }
[JsonPropertyName("validTo")]
public DateTimeOffset? ValidTo { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
}
/// <summary>
/// Material verification result.
/// </summary>
internal sealed class PromotionMaterialVerification
{
[JsonPropertyName("verified")]
public bool Verified { get; init; }
[JsonPropertyName("materials")]
public IReadOnlyList<PromotionMaterialVerificationEntry> Materials { get; init; } = Array.Empty<PromotionMaterialVerificationEntry>();
}
/// <summary>
/// Individual material verification entry.
/// </summary>
internal sealed class PromotionMaterialVerificationEntry
{
[JsonPropertyName("role")]
public string Role { get; init; } = string.Empty;
[JsonPropertyName("verified")]
public bool Verified { get; init; }
[JsonPropertyName("expectedDigest")]
public string ExpectedDigest { get; init; } = string.Empty;
[JsonPropertyName("actualDigest")]
public string? ActualDigest { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
}
/// <summary>
/// Rekor verification result.
/// </summary>
internal sealed class PromotionRekorVerification
{
[JsonPropertyName("verified")]
public bool Verified { get; init; }
[JsonPropertyName("uuid")]
public string? Uuid { get; init; }
[JsonPropertyName("logIndex")]
public long? LogIndex { get; init; }
[JsonPropertyName("inclusionProofVerified")]
public bool InclusionProofVerified { get; init; }
[JsonPropertyName("checkpointVerified")]
public bool CheckpointVerified { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
}

View File

@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-SIG-26-001: Reachability command models
/// <summary>
/// Request to upload a call graph for reachability analysis.
/// </summary>
internal sealed class ReachabilityUploadCallGraphRequest
{
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("assetId")]
public string? AssetId { get; init; }
[JsonPropertyName("callGraphPath")]
public string CallGraphPath { get; init; } = string.Empty;
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
}
/// <summary>
/// Result of uploading a call graph.
/// </summary>
internal sealed class ReachabilityUploadCallGraphResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("callGraphId")]
public string CallGraphId { get; init; } = string.Empty;
[JsonPropertyName("entriesProcessed")]
public int EntriesProcessed { get; init; }
[JsonPropertyName("errorsCount")]
public int ErrorsCount { get; init; }
[JsonPropertyName("uploadedAt")]
public DateTimeOffset UploadedAt { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Request to list reachability analyses.
/// </summary>
internal sealed class ReachabilityListRequest
{
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("assetId")]
public string? AssetId { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("offset")]
public int? Offset { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
}
/// <summary>
/// Response containing reachability analyses.
/// </summary>
internal sealed class ReachabilityListResponse
{
[JsonPropertyName("analyses")]
public IReadOnlyList<ReachabilityAnalysisSummary> Analyses { get; init; } = Array.Empty<ReachabilityAnalysisSummary>();
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("limit")]
public int Limit { get; init; }
[JsonPropertyName("offset")]
public int Offset { get; init; }
}
/// <summary>
/// Summary of a reachability analysis.
/// </summary>
internal sealed class ReachabilityAnalysisSummary
{
[JsonPropertyName("analysisId")]
public string AnalysisId { get; init; } = string.Empty;
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("assetId")]
public string? AssetId { get; init; }
[JsonPropertyName("assetName")]
public string? AssetName { get; init; }
[JsonPropertyName("callGraphId")]
public string CallGraphId { get; init; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; init; } = string.Empty;
[JsonPropertyName("reachableCount")]
public int ReachableCount { get; init; }
[JsonPropertyName("unreachableCount")]
public int UnreachableCount { get; init; }
[JsonPropertyName("unknownCount")]
public int UnknownCount { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("completedAt")]
public DateTimeOffset? CompletedAt { get; init; }
}
/// <summary>
/// Request to explain reachability for a specific vulnerability or package.
/// </summary>
internal sealed class ReachabilityExplainRequest
{
[JsonPropertyName("analysisId")]
public string AnalysisId { get; init; } = string.Empty;
[JsonPropertyName("vulnerabilityId")]
public string? VulnerabilityId { get; init; }
[JsonPropertyName("packagePurl")]
public string? PackagePurl { get; init; }
[JsonPropertyName("includeCallPaths")]
public bool IncludeCallPaths { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
}
/// <summary>
/// Result of reachability explanation.
/// </summary>
internal sealed class ReachabilityExplainResult
{
[JsonPropertyName("analysisId")]
public string AnalysisId { get; init; } = string.Empty;
[JsonPropertyName("vulnerabilityId")]
public string? VulnerabilityId { get; init; }
[JsonPropertyName("packagePurl")]
public string? PackagePurl { get; init; }
[JsonPropertyName("reachabilityState")]
public string ReachabilityState { get; init; } = string.Empty;
[JsonPropertyName("reachabilityScore")]
public double? ReachabilityScore { get; init; }
[JsonPropertyName("confidence")]
public string Confidence { get; init; } = string.Empty;
[JsonPropertyName("reasoning")]
public string? Reasoning { get; init; }
[JsonPropertyName("callPaths")]
public IReadOnlyList<ReachabilityCallPath> CallPaths { get; init; } = Array.Empty<ReachabilityCallPath>();
[JsonPropertyName("affectedFunctions")]
public IReadOnlyList<ReachabilityFunction> AffectedFunctions { get; init; } = Array.Empty<ReachabilityFunction>();
}
/// <summary>
/// Call path demonstrating reachability.
/// </summary>
internal sealed class ReachabilityCallPath
{
[JsonPropertyName("pathId")]
public string PathId { get; init; } = string.Empty;
[JsonPropertyName("depth")]
public int Depth { get; init; }
[JsonPropertyName("entryPoint")]
public ReachabilityFunction EntryPoint { get; init; } = new();
[JsonPropertyName("frames")]
public IReadOnlyList<ReachabilityFunction> Frames { get; init; } = Array.Empty<ReachabilityFunction>();
[JsonPropertyName("vulnerableFunction")]
public ReachabilityFunction VulnerableFunction { get; init; } = new();
}
/// <summary>
/// Function in the call graph.
/// </summary>
internal sealed class ReachabilityFunction
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("signature")]
public string? Signature { get; init; }
[JsonPropertyName("className")]
public string? ClassName { get; init; }
[JsonPropertyName("packageName")]
public string? PackageName { get; init; }
[JsonPropertyName("filePath")]
public string? FilePath { get; init; }
[JsonPropertyName("lineNumber")]
public int? LineNumber { get; init; }
}
// CLI-SIG-26-002: Policy simulate reachability override models (extends PolicySimulationInput)
/// <summary>
/// Reachability override for policy simulation.
/// </summary>
internal sealed class ReachabilityOverride
{
[JsonPropertyName("vulnerabilityId")]
public string? VulnerabilityId { get; init; }
[JsonPropertyName("packagePurl")]
public string? PackagePurl { get; init; }
[JsonPropertyName("state")]
public string State { get; init; } = string.Empty;
[JsonPropertyName("score")]
public double? Score { get; init; }
}

View File

@@ -0,0 +1,448 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-RISK-66-001: Risk profile list models
/// <summary>
/// Request to list risk profiles.
/// </summary>
internal sealed class RiskProfileListRequest
{
[JsonPropertyName("includeDisabled")]
public bool IncludeDisabled { get; init; }
[JsonPropertyName("category")]
public string? Category { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("offset")]
public int? Offset { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
}
/// <summary>
/// Response containing a list of risk profiles.
/// </summary>
internal sealed class RiskProfileListResponse
{
[JsonPropertyName("profiles")]
public IReadOnlyList<RiskProfileSummary> Profiles { get; init; } = Array.Empty<RiskProfileSummary>();
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("limit")]
public int Limit { get; init; }
[JsonPropertyName("offset")]
public int Offset { get; init; }
}
/// <summary>
/// Summary of a risk profile.
/// </summary>
internal sealed class RiskProfileSummary
{
[JsonPropertyName("profileId")]
public string ProfileId { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("category")]
public string Category { get; init; } = string.Empty;
[JsonPropertyName("version")]
public int Version { get; init; }
[JsonPropertyName("enabled")]
public bool Enabled { get; init; }
[JsonPropertyName("builtIn")]
public bool BuiltIn { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset? UpdatedAt { get; init; }
[JsonPropertyName("ruleCount")]
public int RuleCount { get; init; }
[JsonPropertyName("severityWeights")]
public RiskSeverityWeights? SeverityWeights { get; init; }
}
/// <summary>
/// Severity weights for risk scoring.
/// </summary>
internal sealed class RiskSeverityWeights
{
[JsonPropertyName("critical")]
public double Critical { get; init; }
[JsonPropertyName("high")]
public double High { get; init; }
[JsonPropertyName("medium")]
public double Medium { get; init; }
[JsonPropertyName("low")]
public double Low { get; init; }
[JsonPropertyName("info")]
public double Info { get; init; }
}
// CLI-RISK-66-002: Risk simulate models
/// <summary>
/// Request to simulate risk scoring.
/// </summary>
internal sealed class RiskSimulateRequest
{
[JsonPropertyName("profileId")]
public string? ProfileId { get; init; }
[JsonPropertyName("sbomId")]
public string? SbomId { get; init; }
[JsonPropertyName("sbomPath")]
public string? SbomPath { get; init; }
[JsonPropertyName("assetId")]
public string? AssetId { get; init; }
[JsonPropertyName("diffMode")]
public bool DiffMode { get; init; }
[JsonPropertyName("baselineProfileId")]
public string? BaselineProfileId { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
}
/// <summary>
/// Result of risk simulation.
/// </summary>
internal sealed class RiskSimulateResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("profileId")]
public string ProfileId { get; init; } = string.Empty;
[JsonPropertyName("profileName")]
public string ProfileName { get; init; } = string.Empty;
[JsonPropertyName("overallScore")]
public double OverallScore { get; init; }
[JsonPropertyName("grade")]
public string Grade { get; init; } = string.Empty;
[JsonPropertyName("findings")]
public RiskSimulateFindingsSummary Findings { get; init; } = new();
[JsonPropertyName("componentScores")]
public IReadOnlyList<RiskComponentScore> ComponentScores { get; init; } = Array.Empty<RiskComponentScore>();
[JsonPropertyName("diff")]
public RiskSimulateDiff? Diff { get; init; }
[JsonPropertyName("simulatedAt")]
public DateTimeOffset SimulatedAt { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Summary of findings from risk simulation.
/// </summary>
internal sealed class RiskSimulateFindingsSummary
{
[JsonPropertyName("critical")]
public int Critical { get; init; }
[JsonPropertyName("high")]
public int High { get; init; }
[JsonPropertyName("medium")]
public int Medium { get; init; }
[JsonPropertyName("low")]
public int Low { get; init; }
[JsonPropertyName("info")]
public int Info { get; init; }
[JsonPropertyName("total")]
public int Total { get; init; }
}
/// <summary>
/// Component-level risk score.
/// </summary>
internal sealed class RiskComponentScore
{
[JsonPropertyName("componentId")]
public string ComponentId { get; init; } = string.Empty;
[JsonPropertyName("componentName")]
public string ComponentName { get; init; } = string.Empty;
[JsonPropertyName("score")]
public double Score { get; init; }
[JsonPropertyName("grade")]
public string Grade { get; init; } = string.Empty;
[JsonPropertyName("findingCount")]
public int FindingCount { get; init; }
}
/// <summary>
/// Diff between baseline and candidate risk scores.
/// </summary>
internal sealed class RiskSimulateDiff
{
[JsonPropertyName("baselineScore")]
public double BaselineScore { get; init; }
[JsonPropertyName("candidateScore")]
public double CandidateScore { get; init; }
[JsonPropertyName("delta")]
public double Delta { get; init; }
[JsonPropertyName("improved")]
public bool Improved { get; init; }
[JsonPropertyName("findingsAdded")]
public int FindingsAdded { get; init; }
[JsonPropertyName("findingsRemoved")]
public int FindingsRemoved { get; init; }
}
// CLI-RISK-67-001: Risk results models
/// <summary>
/// Request to get risk results.
/// </summary>
internal sealed class RiskResultsRequest
{
[JsonPropertyName("assetId")]
public string? AssetId { get; init; }
[JsonPropertyName("sbomId")]
public string? SbomId { get; init; }
[JsonPropertyName("profileId")]
public string? ProfileId { get; init; }
[JsonPropertyName("minSeverity")]
public string? MinSeverity { get; init; }
[JsonPropertyName("maxScore")]
public double? MaxScore { get; init; }
[JsonPropertyName("includeExplain")]
public bool IncludeExplain { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("offset")]
public int? Offset { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
}
/// <summary>
/// Response containing risk results.
/// </summary>
internal sealed class RiskResultsResponse
{
[JsonPropertyName("results")]
public IReadOnlyList<RiskResult> Results { get; init; } = Array.Empty<RiskResult>();
[JsonPropertyName("summary")]
public RiskResultsSummary Summary { get; init; } = new();
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("limit")]
public int Limit { get; init; }
[JsonPropertyName("offset")]
public int Offset { get; init; }
}
/// <summary>
/// Individual risk result.
/// </summary>
internal sealed class RiskResult
{
[JsonPropertyName("resultId")]
public string ResultId { get; init; } = string.Empty;
[JsonPropertyName("assetId")]
public string AssetId { get; init; } = string.Empty;
[JsonPropertyName("assetName")]
public string? AssetName { get; init; }
[JsonPropertyName("profileId")]
public string ProfileId { get; init; } = string.Empty;
[JsonPropertyName("profileName")]
public string? ProfileName { get; init; }
[JsonPropertyName("score")]
public double Score { get; init; }
[JsonPropertyName("grade")]
public string Grade { get; init; } = string.Empty;
[JsonPropertyName("severity")]
public string Severity { get; init; } = string.Empty;
[JsonPropertyName("findingCount")]
public int FindingCount { get; init; }
[JsonPropertyName("evaluatedAt")]
public DateTimeOffset EvaluatedAt { get; init; }
[JsonPropertyName("explain")]
public RiskResultExplain? Explain { get; init; }
}
/// <summary>
/// Explanation for a risk result.
/// </summary>
internal sealed class RiskResultExplain
{
[JsonPropertyName("factors")]
public IReadOnlyList<RiskFactor> Factors { get; init; } = Array.Empty<RiskFactor>();
[JsonPropertyName("recommendations")]
public IReadOnlyList<string> Recommendations { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Factor contributing to risk score.
/// </summary>
internal sealed class RiskFactor
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("weight")]
public double Weight { get; init; }
[JsonPropertyName("contribution")]
public double Contribution { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
/// <summary>
/// Summary of risk results.
/// </summary>
internal sealed class RiskResultsSummary
{
[JsonPropertyName("averageScore")]
public double AverageScore { get; init; }
[JsonPropertyName("minScore")]
public double MinScore { get; init; }
[JsonPropertyName("maxScore")]
public double MaxScore { get; init; }
[JsonPropertyName("assetCount")]
public int AssetCount { get; init; }
[JsonPropertyName("bySeverity")]
public RiskSimulateFindingsSummary BySeverity { get; init; } = new();
}
// CLI-RISK-68-001: Risk bundle verify models
/// <summary>
/// Request to verify a risk bundle.
/// </summary>
internal sealed class RiskBundleVerifyRequest
{
[JsonPropertyName("bundlePath")]
public string BundlePath { get; init; } = string.Empty;
[JsonPropertyName("signaturePath")]
public string? SignaturePath { get; init; }
[JsonPropertyName("checkRekor")]
public bool CheckRekor { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
}
/// <summary>
/// Result of verifying a risk bundle.
/// </summary>
internal sealed class RiskBundleVerifyResult
{
[JsonPropertyName("valid")]
public bool Valid { get; init; }
[JsonPropertyName("bundleId")]
public string BundleId { get; init; } = string.Empty;
[JsonPropertyName("bundleVersion")]
public string BundleVersion { get; init; } = string.Empty;
[JsonPropertyName("profileCount")]
public int ProfileCount { get; init; }
[JsonPropertyName("ruleCount")]
public int RuleCount { get; init; }
[JsonPropertyName("signatureValid")]
public bool? SignatureValid { get; init; }
[JsonPropertyName("signedAt")]
public DateTimeOffset? SignedAt { get; init; }
[JsonPropertyName("signedBy")]
public string? SignedBy { get; init; }
[JsonPropertyName("rekorVerified")]
public bool? RekorVerified { get; init; }
[JsonPropertyName("rekorLogIndex")]
public string? RekorLogIndex { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,633 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-PARITY-41-001: SBOM Explorer models for CLI
/// <summary>
/// SBOM list request parameters.
/// </summary>
internal sealed class SbomListRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("imageRef")]
public string? ImageRef { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("createdAfter")]
public DateTimeOffset? CreatedAfter { get; init; }
[JsonPropertyName("createdBefore")]
public DateTimeOffset? CreatedBefore { get; init; }
[JsonPropertyName("hasVulnerabilities")]
public bool? HasVulnerabilities { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("offset")]
public int? Offset { get; init; }
[JsonPropertyName("cursor")]
public string? Cursor { get; init; }
}
/// <summary>
/// Paginated SBOM list response.
/// </summary>
internal sealed class SbomListResponse
{
[JsonPropertyName("items")]
public IReadOnlyList<SbomSummary> Items { get; init; } = [];
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("limit")]
public int Limit { get; init; }
[JsonPropertyName("offset")]
public int Offset { get; init; }
[JsonPropertyName("hasMore")]
public bool HasMore { get; init; }
[JsonPropertyName("nextCursor")]
public string? NextCursor { get; init; }
}
/// <summary>
/// Summary view of an SBOM.
/// </summary>
internal sealed class SbomSummary
{
[JsonPropertyName("sbomId")]
public string SbomId { get; init; } = string.Empty;
[JsonPropertyName("imageRef")]
public string? ImageRef { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("format")]
public string Format { get; init; } = string.Empty;
[JsonPropertyName("formatVersion")]
public string? FormatVersion { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("componentCount")]
public int ComponentCount { get; init; }
[JsonPropertyName("vulnerabilityCount")]
public int VulnerabilityCount { get; init; }
[JsonPropertyName("licensesDetected")]
public int LicensesDetected { get; init; }
[JsonPropertyName("determinismScore")]
public double? DeterminismScore { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
/// <summary>
/// Detailed SBOM response including components, vulnerabilities, and metadata.
/// </summary>
internal sealed class SbomDetailResponse
{
[JsonPropertyName("sbomId")]
public string SbomId { get; init; } = string.Empty;
[JsonPropertyName("imageRef")]
public string? ImageRef { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("format")]
public string Format { get; init; } = string.Empty;
[JsonPropertyName("formatVersion")]
public string? FormatVersion { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("componentCount")]
public int ComponentCount { get; init; }
[JsonPropertyName("vulnerabilityCount")]
public int VulnerabilityCount { get; init; }
[JsonPropertyName("licensesDetected")]
public int LicensesDetected { get; init; }
[JsonPropertyName("determinismScore")]
public double? DeterminismScore { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
[JsonPropertyName("components")]
public IReadOnlyList<SbomComponent>? Components { get; init; }
[JsonPropertyName("metadata")]
public SbomMetadata? Metadata { get; init; }
[JsonPropertyName("vulnerabilities")]
public IReadOnlyList<SbomVulnerability>? Vulnerabilities { get; init; }
[JsonPropertyName("licenses")]
public IReadOnlyList<SbomLicense>? Licenses { get; init; }
[JsonPropertyName("attestation")]
public SbomAttestation? Attestation { get; init; }
[JsonPropertyName("explain")]
public SbomExplainInfo? Explain { get; init; }
}
/// <summary>
/// SBOM component information.
/// </summary>
internal sealed class SbomComponent
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("cpe")]
public string? Cpe { get; init; }
[JsonPropertyName("type")]
public string? Type { get; init; }
[JsonPropertyName("supplier")]
public string? Supplier { get; init; }
[JsonPropertyName("licenses")]
public IReadOnlyList<string>? Licenses { get; init; }
[JsonPropertyName("hashes")]
public IReadOnlyDictionary<string, string>? Hashes { get; init; }
[JsonPropertyName("scope")]
public string? Scope { get; init; }
[JsonPropertyName("dependencies")]
public IReadOnlyList<string>? Dependencies { get; init; }
}
/// <summary>
/// SBOM creation metadata.
/// </summary>
internal sealed class SbomMetadata
{
[JsonPropertyName("toolName")]
public string? ToolName { get; init; }
[JsonPropertyName("toolVersion")]
public string? ToolVersion { get; init; }
[JsonPropertyName("scannerVersion")]
public string? ScannerVersion { get; init; }
[JsonPropertyName("serialNumber")]
public string? SerialNumber { get; init; }
[JsonPropertyName("documentNamespace")]
public string? DocumentNamespace { get; init; }
[JsonPropertyName("creators")]
public IReadOnlyList<string>? Creators { get; init; }
[JsonPropertyName("annotations")]
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
/// <summary>
/// Vulnerability found in SBOM.
/// </summary>
internal sealed class SbomVulnerability
{
[JsonPropertyName("vulnerabilityId")]
public string VulnerabilityId { get; init; } = string.Empty;
[JsonPropertyName("severity")]
public string? Severity { get; init; }
[JsonPropertyName("score")]
public double? Score { get; init; }
[JsonPropertyName("affectedComponent")]
public string? AffectedComponent { get; init; }
[JsonPropertyName("fixedIn")]
public string? FixedIn { get; init; }
[JsonPropertyName("vexStatus")]
public string? VexStatus { get; init; }
}
/// <summary>
/// License information in SBOM.
/// </summary>
internal sealed class SbomLicense
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("url")]
public string? Url { get; init; }
[JsonPropertyName("componentCount")]
public int ComponentCount { get; init; }
[JsonPropertyName("components")]
public IReadOnlyList<string>? Components { get; init; }
}
/// <summary>
/// Attestation information for SBOM.
/// </summary>
internal sealed class SbomAttestation
{
[JsonPropertyName("signed")]
public bool Signed { get; init; }
[JsonPropertyName("signatureAlgorithm")]
public string? SignatureAlgorithm { get; init; }
[JsonPropertyName("signatureKeyId")]
public string? SignatureKeyId { get; init; }
[JsonPropertyName("signedAt")]
public DateTimeOffset? SignedAt { get; init; }
[JsonPropertyName("rekorLogIndex")]
public long? RekorLogIndex { get; init; }
[JsonPropertyName("rekorLogId")]
public string? RekorLogId { get; init; }
[JsonPropertyName("certificateIssuer")]
public string? CertificateIssuer { get; init; }
[JsonPropertyName("certificateSubject")]
public string? CertificateSubject { get; init; }
}
/// <summary>
/// Explain information for SBOM generation (determinism debugging).
/// </summary>
internal sealed class SbomExplainInfo
{
[JsonPropertyName("determinismFactors")]
public IReadOnlyList<SbomDeterminismFactor>? DeterminismFactors { get; init; }
[JsonPropertyName("compositionPath")]
public IReadOnlyList<SbomCompositionStep>? CompositionPath { get; init; }
[JsonPropertyName("warnings")]
public IReadOnlyList<string>? Warnings { get; init; }
}
/// <summary>
/// Factor affecting SBOM determinism score.
/// </summary>
internal sealed class SbomDeterminismFactor
{
[JsonPropertyName("factor")]
public string Factor { get; init; } = string.Empty;
[JsonPropertyName("impact")]
public string Impact { get; init; } = string.Empty;
[JsonPropertyName("score")]
public double Score { get; init; }
[JsonPropertyName("details")]
public string? Details { get; init; }
}
/// <summary>
/// Step in SBOM composition chain.
/// </summary>
internal sealed class SbomCompositionStep
{
[JsonPropertyName("step")]
public int Step { get; init; }
[JsonPropertyName("operation")]
public string Operation { get; init; } = string.Empty;
[JsonPropertyName("input")]
public string? Input { get; init; }
[JsonPropertyName("output")]
public string? Output { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
}
/// <summary>
/// SBOM compare request parameters.
/// </summary>
internal sealed class SbomCompareRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("baseSbomId")]
public string BaseSbomId { get; init; } = string.Empty;
[JsonPropertyName("targetSbomId")]
public string TargetSbomId { get; init; } = string.Empty;
[JsonPropertyName("includeUnchanged")]
public bool IncludeUnchanged { get; init; }
}
/// <summary>
/// SBOM comparison result.
/// </summary>
internal sealed class SbomCompareResponse
{
[JsonPropertyName("baseSbomId")]
public string BaseSbomId { get; init; } = string.Empty;
[JsonPropertyName("targetSbomId")]
public string TargetSbomId { get; init; } = string.Empty;
[JsonPropertyName("summary")]
public SbomCompareSummary Summary { get; init; } = new();
[JsonPropertyName("componentChanges")]
public IReadOnlyList<SbomComponentChange>? ComponentChanges { get; init; }
[JsonPropertyName("vulnerabilityChanges")]
public IReadOnlyList<SbomVulnerabilityChange>? VulnerabilityChanges { get; init; }
[JsonPropertyName("licenseChanges")]
public IReadOnlyList<SbomLicenseChange>? LicenseChanges { get; init; }
}
/// <summary>
/// Summary of SBOM comparison.
/// </summary>
internal sealed class SbomCompareSummary
{
[JsonPropertyName("componentsAdded")]
public int ComponentsAdded { get; init; }
[JsonPropertyName("componentsRemoved")]
public int ComponentsRemoved { get; init; }
[JsonPropertyName("componentsModified")]
public int ComponentsModified { get; init; }
[JsonPropertyName("componentsUnchanged")]
public int ComponentsUnchanged { get; init; }
[JsonPropertyName("vulnerabilitiesAdded")]
public int VulnerabilitiesAdded { get; init; }
[JsonPropertyName("vulnerabilitiesRemoved")]
public int VulnerabilitiesRemoved { get; init; }
[JsonPropertyName("licensesAdded")]
public int LicensesAdded { get; init; }
[JsonPropertyName("licensesRemoved")]
public int LicensesRemoved { get; init; }
}
/// <summary>
/// Component change in comparison.
/// </summary>
internal sealed class SbomComponentChange
{
[JsonPropertyName("changeType")]
public string ChangeType { get; init; } = string.Empty;
[JsonPropertyName("componentName")]
public string ComponentName { get; init; } = string.Empty;
[JsonPropertyName("baseVersion")]
public string? BaseVersion { get; init; }
[JsonPropertyName("targetVersion")]
public string? TargetVersion { get; init; }
[JsonPropertyName("basePurl")]
public string? BasePurl { get; init; }
[JsonPropertyName("targetPurl")]
public string? TargetPurl { get; init; }
[JsonPropertyName("details")]
public IReadOnlyList<string>? Details { get; init; }
}
/// <summary>
/// Vulnerability change in comparison.
/// </summary>
internal sealed class SbomVulnerabilityChange
{
[JsonPropertyName("changeType")]
public string ChangeType { get; init; } = string.Empty;
[JsonPropertyName("vulnerabilityId")]
public string VulnerabilityId { get; init; } = string.Empty;
[JsonPropertyName("severity")]
public string? Severity { get; init; }
[JsonPropertyName("affectedComponent")]
public string? AffectedComponent { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}
/// <summary>
/// License change in comparison.
/// </summary>
internal sealed class SbomLicenseChange
{
[JsonPropertyName("changeType")]
public string ChangeType { get; init; } = string.Empty;
[JsonPropertyName("licenseId")]
public string? LicenseId { get; init; }
[JsonPropertyName("licenseName")]
public string LicenseName { get; init; } = string.Empty;
[JsonPropertyName("componentCount")]
public int ComponentCount { get; init; }
}
/// <summary>
/// SBOM export request parameters.
/// </summary>
internal sealed class SbomExportRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("sbomId")]
public string SbomId { get; init; } = string.Empty;
[JsonPropertyName("format")]
public string Format { get; init; } = "spdx";
[JsonPropertyName("formatVersion")]
public string? FormatVersion { get; init; }
[JsonPropertyName("signed")]
public bool Signed { get; init; }
[JsonPropertyName("includeVex")]
public bool IncludeVex { get; init; }
}
/// <summary>
/// SBOM export result.
/// </summary>
internal sealed class SbomExportResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("exportId")]
public string? ExportId { get; init; }
[JsonPropertyName("format")]
public string Format { get; init; } = string.Empty;
[JsonPropertyName("downloadUrl")]
public string? DownloadUrl { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("digestAlgorithm")]
public string? DigestAlgorithm { get; init; }
[JsonPropertyName("signed")]
public bool Signed { get; init; }
[JsonPropertyName("signatureKeyId")]
public string? SignatureKeyId { get; init; }
[JsonPropertyName("expiresAt")]
public DateTimeOffset? ExpiresAt { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
}
// CLI-PARITY-41-001: Parity matrix models
/// <summary>
/// Parity matrix entry showing CLI command coverage.
/// </summary>
internal sealed class ParityMatrixEntry
{
[JsonPropertyName("commandGroup")]
public string CommandGroup { get; init; } = string.Empty;
[JsonPropertyName("command")]
public string Command { get; init; } = string.Empty;
[JsonPropertyName("cliSupport")]
public string CliSupport { get; init; } = string.Empty;
[JsonPropertyName("apiEndpoint")]
public string? ApiEndpoint { get; init; }
[JsonPropertyName("uiEquivalent")]
public string? UiEquivalent { get; init; }
[JsonPropertyName("deterministic")]
public bool Deterministic { get; init; }
[JsonPropertyName("explainSupport")]
public bool ExplainSupport { get; init; }
[JsonPropertyName("offlineSupport")]
public bool OfflineSupport { get; init; }
[JsonPropertyName("notes")]
public string? Notes { get; init; }
}
/// <summary>
/// Parity matrix summary response.
/// </summary>
internal sealed class ParityMatrixResponse
{
[JsonPropertyName("entries")]
public IReadOnlyList<ParityMatrixEntry> Entries { get; init; } = [];
[JsonPropertyName("summary")]
public ParityMatrixSummary Summary { get; init; } = new();
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("cliVersion")]
public string? CliVersion { get; init; }
}
/// <summary>
/// Summary of parity matrix coverage.
/// </summary>
internal sealed class ParityMatrixSummary
{
[JsonPropertyName("totalCommands")]
public int TotalCommands { get; init; }
[JsonPropertyName("fullParity")]
public int FullParity { get; init; }
[JsonPropertyName("partialParity")]
public int PartialParity { get; init; }
[JsonPropertyName("noParity")]
public int NoParity { get; init; }
[JsonPropertyName("deterministicCommands")]
public int DeterministicCommands { get; init; }
[JsonPropertyName("explainEnabledCommands")]
public int ExplainEnabledCommands { get; init; }
[JsonPropertyName("offlineCapableCommands")]
public int OfflineCapableCommands { get; init; }
}

View File

@@ -0,0 +1,834 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-SBOM-60-001: Sbomer command models for layer/compose operations
/// <summary>
/// SBOM fragment from a container layer.
/// </summary>
internal sealed class SbomFragment
{
[JsonPropertyName("fragmentId")]
public string FragmentId { get; init; } = string.Empty;
[JsonPropertyName("layerDigest")]
public string LayerDigest { get; init; } = string.Empty;
[JsonPropertyName("fragmentSha256")]
public string FragmentSha256 { get; init; } = string.Empty;
[JsonPropertyName("dsseEnvelopeSha256")]
public string? DsseEnvelopeSha256 { get; init; }
[JsonPropertyName("dsseEnvelopeUri")]
public string? DsseEnvelopeUri { get; init; }
[JsonPropertyName("componentCount")]
public int ComponentCount { get; init; }
[JsonPropertyName("format")]
public string Format { get; init; } = string.Empty;
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("signatureAlgorithm")]
public string? SignatureAlgorithm { get; init; }
[JsonPropertyName("signatureValid")]
public bool? SignatureValid { get; init; }
[JsonPropertyName("components")]
public IReadOnlyList<SbomFragmentComponent>? Components { get; init; }
}
/// <summary>
/// Component within an SBOM fragment.
/// </summary>
internal sealed class SbomFragmentComponent
{
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("type")]
public string? Type { get; init; }
[JsonPropertyName("identityKey")]
public string? IdentityKey { get; init; }
}
/// <summary>
/// Layer list request for sbomer layer list.
/// </summary>
internal sealed class SbomerLayerListRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("imageRef")]
public string? ImageRef { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("cursor")]
public string? Cursor { get; init; }
}
/// <summary>
/// Layer list response.
/// </summary>
internal sealed class SbomerLayerListResponse
{
[JsonPropertyName("items")]
public IReadOnlyList<SbomFragment> Items { get; init; } = [];
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("hasMore")]
public bool HasMore { get; init; }
[JsonPropertyName("nextCursor")]
public string? NextCursor { get; init; }
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("imageRef")]
public string? ImageRef { get; init; }
}
/// <summary>
/// Layer show request.
/// </summary>
internal sealed class SbomerLayerShowRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("layerDigest")]
public string LayerDigest { get; init; } = string.Empty;
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("includeComponents")]
public bool IncludeComponents { get; init; }
[JsonPropertyName("includeDsse")]
public bool IncludeDsse { get; init; }
}
/// <summary>
/// Layer detail response.
/// </summary>
internal sealed class SbomerLayerDetail
{
[JsonPropertyName("fragment")]
public SbomFragment Fragment { get; init; } = new();
[JsonPropertyName("dsseEnvelope")]
public DsseEnvelopeInfo? DsseEnvelope { get; init; }
[JsonPropertyName("canonicalJson")]
public string? CanonicalJson { get; init; }
[JsonPropertyName("merkleProof")]
public MerkleProofInfo? MerkleProof { get; init; }
}
/// <summary>
/// DSSE envelope information.
/// </summary>
internal sealed class DsseEnvelopeInfo
{
[JsonPropertyName("payloadType")]
public string PayloadType { get; init; } = string.Empty;
[JsonPropertyName("payloadSha256")]
public string PayloadSha256 { get; init; } = string.Empty;
[JsonPropertyName("signatures")]
public IReadOnlyList<DsseSignatureInfo> Signatures { get; init; } = [];
[JsonPropertyName("envelopeSha256")]
public string EnvelopeSha256 { get; init; } = string.Empty;
}
/// <summary>
/// DSSE signature information.
/// </summary>
internal sealed class DsseSignatureInfo
{
[JsonPropertyName("keyId")]
public string? KeyId { get; init; }
[JsonPropertyName("algorithm")]
public string Algorithm { get; init; } = string.Empty;
[JsonPropertyName("signatureSha256")]
public string SignatureSha256 { get; init; } = string.Empty;
[JsonPropertyName("valid")]
public bool? Valid { get; init; }
[JsonPropertyName("certificateSubject")]
public string? CertificateSubject { get; init; }
[JsonPropertyName("certificateExpiry")]
public DateTimeOffset? CertificateExpiry { get; init; }
}
/// <summary>
/// Merkle proof information.
/// </summary>
internal sealed class MerkleProofInfo
{
[JsonPropertyName("leafHash")]
public string LeafHash { get; init; } = string.Empty;
[JsonPropertyName("rootHash")]
public string RootHash { get; init; } = string.Empty;
[JsonPropertyName("proofHashes")]
public IReadOnlyList<string> ProofHashes { get; init; } = [];
[JsonPropertyName("leafIndex")]
public int LeafIndex { get; init; }
[JsonPropertyName("treeSize")]
public int TreeSize { get; init; }
[JsonPropertyName("valid")]
public bool? Valid { get; init; }
}
/// <summary>
/// Layer verify request.
/// </summary>
internal sealed class SbomerLayerVerifyRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("layerDigest")]
public string LayerDigest { get; init; } = string.Empty;
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("verifiersPath")]
public string? VerifiersPath { get; init; }
[JsonPropertyName("offline")]
public bool Offline { get; init; }
}
/// <summary>
/// Layer verify result.
/// </summary>
internal sealed class SbomerLayerVerifyResult
{
[JsonPropertyName("layerDigest")]
public string LayerDigest { get; init; } = string.Empty;
[JsonPropertyName("valid")]
public bool Valid { get; init; }
[JsonPropertyName("dsseValid")]
public bool DsseValid { get; init; }
[JsonPropertyName("contentHashMatch")]
public bool ContentHashMatch { get; init; }
[JsonPropertyName("merkleProofValid")]
public bool? MerkleProofValid { get; init; }
[JsonPropertyName("signatureAlgorithm")]
public string? SignatureAlgorithm { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
[JsonPropertyName("warnings")]
public IReadOnlyList<string>? Warnings { get; init; }
}
/// <summary>
/// Composition manifest (_composition.json).
/// </summary>
internal sealed class CompositionManifest
{
[JsonPropertyName("version")]
public string Version { get; init; } = "1.0";
[JsonPropertyName("scanId")]
public string ScanId { get; init; } = string.Empty;
[JsonPropertyName("imageRef")]
public string? ImageRef { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("merkleRoot")]
public string MerkleRoot { get; init; } = string.Empty;
[JsonPropertyName("composedSha256")]
public string ComposedSha256 { get; init; } = string.Empty;
[JsonPropertyName("fragments")]
public IReadOnlyList<CompositionFragmentEntry> Fragments { get; init; } = [];
[JsonPropertyName("canonicalOrder")]
public IReadOnlyList<string> CanonicalOrder { get; init; } = [];
[JsonPropertyName("properties")]
public IReadOnlyDictionary<string, string>? Properties { get; init; }
}
/// <summary>
/// Fragment entry in composition manifest.
/// </summary>
internal sealed class CompositionFragmentEntry
{
[JsonPropertyName("layerDigest")]
public string LayerDigest { get; init; } = string.Empty;
[JsonPropertyName("fragmentSha256")]
public string FragmentSha256 { get; init; } = string.Empty;
[JsonPropertyName("dsseEnvelopeSha256")]
public string? DsseEnvelopeSha256 { get; init; }
[JsonPropertyName("componentCount")]
public int ComponentCount { get; init; }
[JsonPropertyName("order")]
public int Order { get; init; }
}
/// <summary>
/// Compose request for sbomer compose.
/// </summary>
internal sealed class SbomerComposeRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("imageRef")]
public string? ImageRef { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("verifyFragments")]
public bool VerifyFragments { get; init; }
[JsonPropertyName("verifiersPath")]
public string? VerifiersPath { get; init; }
[JsonPropertyName("offline")]
public bool Offline { get; init; }
[JsonPropertyName("emitCompositionManifest")]
public bool EmitCompositionManifest { get; init; } = true;
[JsonPropertyName("emitMerkleDiagnostics")]
public bool EmitMerkleDiagnostics { get; init; }
}
/// <summary>
/// Compose result.
/// </summary>
internal sealed class SbomerComposeResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("scanId")]
public string ScanId { get; init; } = string.Empty;
[JsonPropertyName("composedSha256")]
public string? ComposedSha256 { get; init; }
[JsonPropertyName("merkleRoot")]
public string? MerkleRoot { get; init; }
[JsonPropertyName("fragmentCount")]
public int FragmentCount { get; init; }
[JsonPropertyName("totalComponents")]
public int TotalComponents { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("compositionManifestPath")]
public string? CompositionManifestPath { get; init; }
[JsonPropertyName("merkleDiagnosticsPath")]
public string? MerkleDiagnosticsPath { get; init; }
[JsonPropertyName("fragmentVerifications")]
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
[JsonPropertyName("warnings")]
public IReadOnlyList<string>? Warnings { get; init; }
[JsonPropertyName("deterministic")]
public bool Deterministic { get; init; }
[JsonPropertyName("duration")]
public TimeSpan? Duration { get; init; }
}
/// <summary>
/// Composition show request.
/// </summary>
internal sealed class SbomerCompositionShowRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("compositionPath")]
public string? CompositionPath { get; init; }
}
/// <summary>
/// Merkle diagnostics for composition.
/// </summary>
internal sealed class MerkleDiagnostics
{
[JsonPropertyName("scanId")]
public string ScanId { get; init; } = string.Empty;
[JsonPropertyName("rootHash")]
public string RootHash { get; init; } = string.Empty;
[JsonPropertyName("treeSize")]
public int TreeSize { get; init; }
[JsonPropertyName("leaves")]
public IReadOnlyList<MerkleLeafInfo> Leaves { get; init; } = [];
[JsonPropertyName("intermediateNodes")]
public IReadOnlyList<MerkleNodeInfo>? IntermediateNodes { get; init; }
[JsonPropertyName("valid")]
public bool Valid { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Merkle leaf information.
/// </summary>
internal sealed class MerkleLeafInfo
{
[JsonPropertyName("index")]
public int Index { get; init; }
[JsonPropertyName("layerDigest")]
public string LayerDigest { get; init; } = string.Empty;
[JsonPropertyName("hash")]
public string Hash { get; init; } = string.Empty;
[JsonPropertyName("fragmentSha256")]
public string FragmentSha256 { get; init; } = string.Empty;
}
/// <summary>
/// Merkle intermediate node information.
/// </summary>
internal sealed class MerkleNodeInfo
{
[JsonPropertyName("level")]
public int Level { get; init; }
[JsonPropertyName("index")]
public int Index { get; init; }
[JsonPropertyName("hash")]
public string Hash { get; init; } = string.Empty;
[JsonPropertyName("leftChild")]
public string? LeftChild { get; init; }
[JsonPropertyName("rightChild")]
public string? RightChild { get; init; }
}
/// <summary>
/// Composition verify request.
/// </summary>
internal sealed class SbomerCompositionVerifyRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("compositionPath")]
public string? CompositionPath { get; init; }
[JsonPropertyName("sbomPath")]
public string? SbomPath { get; init; }
[JsonPropertyName("verifiersPath")]
public string? VerifiersPath { get; init; }
[JsonPropertyName("offline")]
public bool Offline { get; init; }
[JsonPropertyName("recompose")]
public bool Recompose { get; init; }
}
/// <summary>
/// Composition verify result.
/// </summary>
internal sealed class SbomerCompositionVerifyResult
{
[JsonPropertyName("valid")]
public bool Valid { get; init; }
[JsonPropertyName("scanId")]
public string ScanId { get; init; } = string.Empty;
[JsonPropertyName("merkleRootMatch")]
public bool MerkleRootMatch { get; init; }
[JsonPropertyName("composedHashMatch")]
public bool ComposedHashMatch { get; init; }
[JsonPropertyName("allFragmentsValid")]
public bool AllFragmentsValid { get; init; }
[JsonPropertyName("fragmentCount")]
public int FragmentCount { get; init; }
[JsonPropertyName("fragmentVerifications")]
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
[JsonPropertyName("recomposedHash")]
public string? RecomposedHash { get; init; }
[JsonPropertyName("expectedHash")]
public string? ExpectedHash { get; init; }
[JsonPropertyName("deterministic")]
public bool Deterministic { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
[JsonPropertyName("warnings")]
public IReadOnlyList<string>? Warnings { get; init; }
}
// CLI-SBOM-60-002: Drift detection and explain models
/// <summary>
/// Drift analysis request.
/// </summary>
internal sealed class SbomerDriftRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("baselineScanId")]
public string? BaselineScanId { get; init; }
[JsonPropertyName("sbomPath")]
public string? SbomPath { get; init; }
[JsonPropertyName("baselinePath")]
public string? BaselinePath { get; init; }
[JsonPropertyName("compositionPath")]
public string? CompositionPath { get; init; }
[JsonPropertyName("explain")]
public bool Explain { get; init; }
[JsonPropertyName("offline")]
public bool Offline { get; init; }
[JsonPropertyName("offlineKitPath")]
public string? OfflineKitPath { get; init; }
}
/// <summary>
/// Drift analysis result.
/// </summary>
internal sealed class SbomerDriftResult
{
[JsonPropertyName("hasDrift")]
public bool HasDrift { get; init; }
[JsonPropertyName("deterministic")]
public bool Deterministic { get; init; }
[JsonPropertyName("scanId")]
public string ScanId { get; init; } = string.Empty;
[JsonPropertyName("baselineScanId")]
public string? BaselineScanId { get; init; }
[JsonPropertyName("currentHash")]
public string? CurrentHash { get; init; }
[JsonPropertyName("baselineHash")]
public string? BaselineHash { get; init; }
[JsonPropertyName("driftSummary")]
public DriftSummary? Summary { get; init; }
[JsonPropertyName("driftDetails")]
public IReadOnlyList<DriftDetail>? Details { get; init; }
[JsonPropertyName("explanations")]
public IReadOnlyList<DriftExplanation>? Explanations { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
[JsonPropertyName("warnings")]
public IReadOnlyList<string>? Warnings { get; init; }
}
/// <summary>
/// Summary of drift between two SBOMs.
/// </summary>
internal sealed class DriftSummary
{
[JsonPropertyName("componentsAdded")]
public int ComponentsAdded { get; init; }
[JsonPropertyName("componentsRemoved")]
public int ComponentsRemoved { get; init; }
[JsonPropertyName("componentsModified")]
public int ComponentsModified { get; init; }
[JsonPropertyName("arrayOrderingDrifts")]
public int ArrayOrderingDrifts { get; init; }
[JsonPropertyName("timestampDrifts")]
public int TimestampDrifts { get; init; }
[JsonPropertyName("keyOrderingDrifts")]
public int KeyOrderingDrifts { get; init; }
[JsonPropertyName("whitespaceDrifts")]
public int WhitespaceDrifts { get; init; }
[JsonPropertyName("totalDrifts")]
public int TotalDrifts { get; init; }
}
/// <summary>
/// Detailed drift information.
/// </summary>
internal sealed class DriftDetail
{
[JsonPropertyName("path")]
public string Path { get; init; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("description")]
public string Description { get; init; } = string.Empty;
[JsonPropertyName("currentValue")]
public string? CurrentValue { get; init; }
[JsonPropertyName("baselineValue")]
public string? BaselineValue { get; init; }
[JsonPropertyName("layerDigest")]
public string? LayerDigest { get; init; }
[JsonPropertyName("severity")]
public string Severity { get; init; } = "info";
[JsonPropertyName("breaksDeterminism")]
public bool BreaksDeterminism { get; init; }
}
/// <summary>
/// Explanation for drift occurrence.
/// </summary>
internal sealed class DriftExplanation
{
[JsonPropertyName("path")]
public string Path { get; init; } = string.Empty;
[JsonPropertyName("reason")]
public string Reason { get; init; } = string.Empty;
[JsonPropertyName("expectedBehavior")]
public string? ExpectedBehavior { get; init; }
[JsonPropertyName("actualBehavior")]
public string? ActualBehavior { get; init; }
[JsonPropertyName("rootCause")]
public string? RootCause { get; init; }
[JsonPropertyName("remediation")]
public string? Remediation { get; init; }
[JsonPropertyName("documentationUrl")]
public string? DocumentationUrl { get; init; }
[JsonPropertyName("affectedLayers")]
public IReadOnlyList<string>? AffectedLayers { get; init; }
}
/// <summary>
/// Drift verify request for offline verification.
/// </summary>
internal sealed class SbomerDriftVerifyRequest
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
[JsonPropertyName("sbomPath")]
public string? SbomPath { get; init; }
[JsonPropertyName("compositionPath")]
public string? CompositionPath { get; init; }
[JsonPropertyName("verifiersPath")]
public string? VerifiersPath { get; init; }
[JsonPropertyName("offlineKitPath")]
public string? OfflineKitPath { get; init; }
[JsonPropertyName("recomposeLocally")]
public bool RecomposeLocally { get; init; }
[JsonPropertyName("validateFragments")]
public bool ValidateFragments { get; init; }
[JsonPropertyName("checkMerkleProofs")]
public bool CheckMerkleProofs { get; init; }
}
/// <summary>
/// Drift verify result.
/// </summary>
internal sealed class SbomerDriftVerifyResult
{
[JsonPropertyName("valid")]
public bool Valid { get; init; }
[JsonPropertyName("deterministic")]
public bool Deterministic { get; init; }
[JsonPropertyName("scanId")]
public string ScanId { get; init; } = string.Empty;
[JsonPropertyName("compositionValid")]
public bool CompositionValid { get; init; }
[JsonPropertyName("fragmentsValid")]
public bool FragmentsValid { get; init; }
[JsonPropertyName("merkleProofsValid")]
public bool MerkleProofsValid { get; init; }
[JsonPropertyName("recomposedHashMatch")]
public bool? RecomposedHashMatch { get; init; }
[JsonPropertyName("currentHash")]
public string? CurrentHash { get; init; }
[JsonPropertyName("recomposedHash")]
public string? RecomposedHash { get; init; }
[JsonPropertyName("fragmentVerifications")]
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
[JsonPropertyName("driftResult")]
public SbomerDriftResult? DriftResult { get; init; }
[JsonPropertyName("offlineKitInfo")]
public OfflineKitInfo? OfflineKitInfo { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
[JsonPropertyName("warnings")]
public IReadOnlyList<string>? Warnings { get; init; }
}
/// <summary>
/// Information about offline kit used for verification.
/// </summary>
internal sealed class OfflineKitInfo
{
[JsonPropertyName("path")]
public string Path { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset? CreatedAt { get; init; }
[JsonPropertyName("fragmentCount")]
public int FragmentCount { get; init; }
[JsonPropertyName("verifiersPresent")]
public bool VerifiersPresent { get; init; }
[JsonPropertyName("compositionManifestPresent")]
public bool CompositionManifestPresent { get; init; }
}

View File

@@ -0,0 +1,282 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
/// <summary>
/// Request for checking SDK updates.
/// CLI-SDK-64-001: Supports stella sdk update command.
/// </summary>
internal sealed class SdkUpdateRequest
{
/// <summary>
/// Tenant context for the operation.
/// </summary>
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
/// <summary>
/// Language filter (typescript, go, csharp, python, java).
/// </summary>
[JsonPropertyName("language")]
public string? Language { get; init; }
/// <summary>
/// Whether to only check for updates without downloading.
/// </summary>
[JsonIgnore]
public bool CheckOnly { get; init; }
/// <summary>
/// Whether to include changelog information.
/// </summary>
[JsonPropertyName("includeChangelog")]
public bool IncludeChangelog { get; init; }
/// <summary>
/// Whether to include deprecation notices.
/// </summary>
[JsonPropertyName("includeDeprecations")]
public bool IncludeDeprecations { get; init; }
}
/// <summary>
/// Response for SDK update check.
/// </summary>
internal sealed class SdkUpdateResponse
{
/// <summary>
/// Whether the operation was successful.
/// </summary>
[JsonPropertyName("success")]
public bool Success { get; init; }
/// <summary>
/// Available SDK updates.
/// </summary>
[JsonPropertyName("updates")]
public IReadOnlyList<SdkVersionInfo> Updates { get; init; } = [];
/// <summary>
/// Deprecation notices.
/// </summary>
[JsonPropertyName("deprecations")]
public IReadOnlyList<SdkDeprecation> Deprecations { get; init; } = [];
/// <summary>
/// Timestamp when updates were last checked.
/// </summary>
[JsonPropertyName("checkedAt")]
public DateTimeOffset CheckedAt { get; init; }
/// <summary>
/// Error message if the operation failed.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
}
/// <summary>
/// SDK version information.
/// </summary>
internal sealed class SdkVersionInfo
{
/// <summary>
/// SDK language (typescript, go, csharp, python, java).
/// </summary>
[JsonPropertyName("language")]
public required string Language { get; init; }
/// <summary>
/// Display name for the SDK.
/// </summary>
[JsonPropertyName("displayName")]
public string? DisplayName { get; init; }
/// <summary>
/// Package name (e.g., @stellaops/sdk, stellaops-sdk).
/// </summary>
[JsonPropertyName("packageName")]
public required string PackageName { get; init; }
/// <summary>
/// Current installed version.
/// </summary>
[JsonPropertyName("installedVersion")]
public string? InstalledVersion { get; init; }
/// <summary>
/// Latest available version.
/// </summary>
[JsonPropertyName("latestVersion")]
public required string LatestVersion { get; init; }
/// <summary>
/// Whether an update is available.
/// </summary>
[JsonPropertyName("updateAvailable")]
public bool UpdateAvailable { get; init; }
/// <summary>
/// Minimum supported API version.
/// </summary>
[JsonPropertyName("minApiVersion")]
public string? MinApiVersion { get; init; }
/// <summary>
/// Maximum supported API version.
/// </summary>
[JsonPropertyName("maxApiVersion")]
public string? MaxApiVersion { get; init; }
/// <summary>
/// Release date of the latest version.
/// </summary>
[JsonPropertyName("releaseDate")]
public DateTimeOffset? ReleaseDate { get; init; }
/// <summary>
/// Changelog for recent versions.
/// </summary>
[JsonPropertyName("changelog")]
public IReadOnlyList<SdkChangelogEntry>? Changelog { get; init; }
/// <summary>
/// Download URL for the package.
/// </summary>
[JsonPropertyName("downloadUrl")]
public string? DownloadUrl { get; init; }
/// <summary>
/// Package registry URL.
/// </summary>
[JsonPropertyName("registryUrl")]
public string? RegistryUrl { get; init; }
/// <summary>
/// Documentation URL.
/// </summary>
[JsonPropertyName("docsUrl")]
public string? DocsUrl { get; init; }
}
/// <summary>
/// SDK changelog entry.
/// </summary>
internal sealed class SdkChangelogEntry
{
/// <summary>
/// Version number.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// Release date.
/// </summary>
[JsonPropertyName("releaseDate")]
public DateTimeOffset? ReleaseDate { get; init; }
/// <summary>
/// Change type (feature, fix, breaking, deprecation).
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; init; }
/// <summary>
/// Change description.
/// </summary>
[JsonPropertyName("description")]
public required string Description { get; init; }
/// <summary>
/// Whether this is a breaking change.
/// </summary>
[JsonPropertyName("isBreaking")]
public bool IsBreaking { get; init; }
/// <summary>
/// Link to more details.
/// </summary>
[JsonPropertyName("link")]
public string? Link { get; init; }
}
/// <summary>
/// SDK deprecation notice.
/// </summary>
internal sealed class SdkDeprecation
{
/// <summary>
/// SDK language affected.
/// </summary>
[JsonPropertyName("language")]
public required string Language { get; init; }
/// <summary>
/// Deprecated feature or API.
/// </summary>
[JsonPropertyName("feature")]
public required string Feature { get; init; }
/// <summary>
/// Deprecation message.
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; init; }
/// <summary>
/// Version when deprecation was introduced.
/// </summary>
[JsonPropertyName("deprecatedInVersion")]
public string? DeprecatedInVersion { get; init; }
/// <summary>
/// Version when feature will be removed.
/// </summary>
[JsonPropertyName("removedInVersion")]
public string? RemovedInVersion { get; init; }
/// <summary>
/// Replacement or migration path.
/// </summary>
[JsonPropertyName("replacement")]
public string? Replacement { get; init; }
/// <summary>
/// Link to migration guide.
/// </summary>
[JsonPropertyName("migrationGuide")]
public string? MigrationGuide { get; init; }
/// <summary>
/// Severity of the deprecation (info, warning, critical).
/// </summary>
[JsonPropertyName("severity")]
public string Severity { get; init; } = "warning";
}
/// <summary>
/// Response for listing installed SDKs.
/// </summary>
internal sealed class SdkListResponse
{
/// <summary>
/// Whether the operation was successful.
/// </summary>
[JsonPropertyName("success")]
public bool Success { get; init; }
/// <summary>
/// Installed SDK versions.
/// </summary>
[JsonPropertyName("sdks")]
public IReadOnlyList<SdkVersionInfo> Sdks { get; init; } = [];
/// <summary>
/// Error message if the operation failed.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
}

View File

@@ -14,6 +14,15 @@ internal sealed class PolicySimulationRequestDocument
public Dictionary<string, JsonElement>? Env { get; set; }
public bool? Explain { get; set; }
// CLI-POLICY-27-003: Enhanced simulation options
public string? Mode { get; set; }
public IReadOnlyList<string>? SbomSelectors { get; set; }
public bool? IncludeHeatmap { get; set; }
public bool? IncludeManifest { get; set; }
}
internal sealed class PolicySimulationResponseDocument
@@ -21,6 +30,13 @@ internal sealed class PolicySimulationResponseDocument
public PolicySimulationDiffDocument? Diff { get; set; }
public string? ExplainUri { get; set; }
// CLI-POLICY-27-003: Enhanced response fields
public PolicySimulationHeatmapDocument? Heatmap { get; set; }
public string? ManifestDownloadUri { get; set; }
public string? ManifestDigest { get; set; }
}
internal sealed class PolicySimulationDiffDocument
@@ -55,3 +71,28 @@ internal sealed class PolicySimulationRuleDeltaDocument
public int? Down { get; set; }
}
// CLI-POLICY-27-003: Heatmap response documents
internal sealed class PolicySimulationHeatmapDocument
{
public int? Critical { get; set; }
public int? High { get; set; }
public int? Medium { get; set; }
public int? Low { get; set; }
public int? Info { get; set; }
public List<PolicySimulationHeatmapBucketDocument>? Buckets { get; set; }
}
internal sealed class PolicySimulationHeatmapBucketDocument
{
public string? Label { get; set; }
public int? Count { get; set; }
public string? Color { get; set; }
}

View File

@@ -1,18 +1,184 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models.Transport;
/// <summary>
/// RFC 7807 Problem Details response.
/// </summary>
internal sealed class ProblemDocument
{
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("detail")]
public string? Detail { get; set; }
[JsonPropertyName("status")]
public int? Status { get; set; }
[JsonPropertyName("instance")]
public string? Instance { get; set; }
[JsonPropertyName("extensions")]
public Dictionary<string, object?>? Extensions { get; set; }
}
/// <summary>
/// Standardized API error envelope with error.code and trace_id.
/// CLI-SDK-62-002: Supports surfacing structured error information.
/// </summary>
internal sealed class ApiErrorEnvelope
{
/// <summary>
/// Error details.
/// </summary>
[JsonPropertyName("error")]
public ApiErrorDetail? Error { get; set; }
/// <summary>
/// Distributed trace identifier.
/// </summary>
[JsonPropertyName("trace_id")]
public string? TraceId { get; set; }
/// <summary>
/// Request identifier.
/// </summary>
[JsonPropertyName("request_id")]
public string? RequestId { get; set; }
/// <summary>
/// Timestamp of the error.
/// </summary>
[JsonPropertyName("timestamp")]
public string? Timestamp { get; set; }
}
/// <summary>
/// Error detail within the standardized envelope.
/// </summary>
internal sealed class ApiErrorDetail
{
/// <summary>
/// Machine-readable error code (e.g., "ERR_AUTH_INVALID_SCOPE").
/// </summary>
[JsonPropertyName("code")]
public string? Code { get; set; }
/// <summary>
/// Human-readable error message.
/// </summary>
[JsonPropertyName("message")]
public string? Message { get; set; }
/// <summary>
/// Detailed description of the error.
/// </summary>
[JsonPropertyName("detail")]
public string? Detail { get; set; }
/// <summary>
/// Target of the error (field name, resource identifier).
/// </summary>
[JsonPropertyName("target")]
public string? Target { get; set; }
/// <summary>
/// Inner errors for nested error details.
/// </summary>
[JsonPropertyName("inner_errors")]
public IReadOnlyList<ApiErrorDetail>? InnerErrors { get; set; }
/// <summary>
/// Additional metadata about the error.
/// </summary>
[JsonPropertyName("metadata")]
public Dictionary<string, object?>? Metadata { get; set; }
/// <summary>
/// Help URL for more information.
/// </summary>
[JsonPropertyName("help_url")]
public string? HelpUrl { get; set; }
/// <summary>
/// Retry-after hint in seconds (for rate limiting).
/// </summary>
[JsonPropertyName("retry_after")]
public int? RetryAfter { get; set; }
}
/// <summary>
/// Parsed API error result combining multiple error formats.
/// </summary>
internal sealed class ParsedApiError
{
/// <summary>
/// Error code (from envelope, problem, or HTTP status).
/// </summary>
public required string Code { get; init; }
/// <summary>
/// Error message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Detailed error description.
/// </summary>
public string? Detail { get; init; }
/// <summary>
/// Trace ID for distributed tracing.
/// </summary>
public string? TraceId { get; init; }
/// <summary>
/// Request ID.
/// </summary>
public string? RequestId { get; init; }
/// <summary>
/// HTTP status code.
/// </summary>
public int HttpStatus { get; init; }
/// <summary>
/// Target of the error.
/// </summary>
public string? Target { get; init; }
/// <summary>
/// Help URL for more information.
/// </summary>
public string? HelpUrl { get; init; }
/// <summary>
/// Retry-after hint in seconds.
/// </summary>
public int? RetryAfter { get; init; }
/// <summary>
/// Inner errors.
/// </summary>
public IReadOnlyList<ApiErrorDetail>? InnerErrors { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
public Dictionary<string, object?>? Metadata { get; init; }
/// <summary>
/// Original problem document if parsed.
/// </summary>
public ProblemDocument? ProblemDocument { get; init; }
/// <summary>
/// Original error envelope if parsed.
/// </summary>
public ApiErrorEnvelope? ErrorEnvelope { get; init; }
}

View File

@@ -0,0 +1,292 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-LNM-22-002: VEX observation models for CLI commands
/// <summary>
/// Query options for VEX observations.
/// </summary>
internal sealed class VexObservationQuery
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("vulnerabilityIds")]
public IReadOnlyList<string> VulnerabilityIds { get; init; } = Array.Empty<string>();
[JsonPropertyName("productKeys")]
public IReadOnlyList<string> ProductKeys { 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("statuses")]
public IReadOnlyList<string> Statuses { get; init; } = Array.Empty<string>();
[JsonPropertyName("providerIds")]
public IReadOnlyList<string> ProviderIds { get; init; } = Array.Empty<string>();
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("cursor")]
public string? Cursor { get; init; }
}
/// <summary>
/// Response from VEX observation query.
/// </summary>
internal sealed class VexObservationResponse
{
[JsonPropertyName("observations")]
public IReadOnlyList<VexObservation> Observations { get; init; } = Array.Empty<VexObservation>();
[JsonPropertyName("aggregate")]
public VexObservationAggregate? Aggregate { get; init; }
[JsonPropertyName("nextCursor")]
public string? NextCursor { get; init; }
[JsonPropertyName("hasMore")]
public bool HasMore { get; init; }
}
/// <summary>
/// VEX observation document.
/// </summary>
internal sealed class VexObservation
{
[JsonPropertyName("observationId")]
public string ObservationId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("vulnerabilityId")]
public string VulnerabilityId { get; init; } = string.Empty;
[JsonPropertyName("providerId")]
public string ProviderId { get; init; } = string.Empty;
[JsonPropertyName("product")]
public VexObservationProduct? Product { get; init; }
[JsonPropertyName("status")]
public string Status { get; init; } = string.Empty;
[JsonPropertyName("justification")]
public string? Justification { get; init; }
[JsonPropertyName("detail")]
public string? Detail { get; init; }
[JsonPropertyName("document")]
public VexObservationDocument? Document { get; init; }
[JsonPropertyName("firstSeen")]
public DateTimeOffset FirstSeen { get; init; }
[JsonPropertyName("lastSeen")]
public DateTimeOffset LastSeen { get; init; }
[JsonPropertyName("confidence")]
public VexObservationConfidence? Confidence { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset? UpdatedAt { get; init; }
}
/// <summary>
/// Product information in VEX observation.
/// </summary>
internal sealed class VexObservationProduct
{
[JsonPropertyName("key")]
public string Key { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("cpe")]
public string? Cpe { get; init; }
[JsonPropertyName("componentIdentifiers")]
public IReadOnlyList<string> ComponentIdentifiers { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Document reference in VEX observation.
/// </summary>
internal sealed class VexObservationDocument
{
[JsonPropertyName("format")]
public string Format { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("sourceUri")]
public string SourceUri { get; init; } = string.Empty;
[JsonPropertyName("revision")]
public string? Revision { get; init; }
[JsonPropertyName("signature")]
public VexObservationSignature? Signature { get; init; }
}
/// <summary>
/// Signature metadata for VEX document.
/// </summary>
internal sealed class VexObservationSignature
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("subject")]
public string? Subject { get; init; }
[JsonPropertyName("issuer")]
public string? Issuer { get; init; }
[JsonPropertyName("keyId")]
public string? KeyId { get; init; }
[JsonPropertyName("verifiedAt")]
public DateTimeOffset? VerifiedAt { get; init; }
[JsonPropertyName("transparencyLogReference")]
public string? TransparencyLogReference { get; init; }
}
/// <summary>
/// Confidence level in VEX observation.
/// </summary>
internal sealed class VexObservationConfidence
{
[JsonPropertyName("level")]
public string Level { get; init; } = string.Empty;
[JsonPropertyName("score")]
public double? Score { get; init; }
[JsonPropertyName("method")]
public string? Method { get; init; }
}
/// <summary>
/// Aggregate data from VEX observation query.
/// </summary>
internal sealed class VexObservationAggregate
{
[JsonPropertyName("vulnerabilityIds")]
public IReadOnlyList<string> VulnerabilityIds { get; init; } = Array.Empty<string>();
[JsonPropertyName("productKeys")]
public IReadOnlyList<string> ProductKeys { 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("providerIds")]
public IReadOnlyList<string> ProviderIds { get; init; } = Array.Empty<string>();
[JsonPropertyName("statusCounts")]
public IReadOnlyDictionary<string, int> StatusCounts { get; init; } = new Dictionary<string, int>();
}
/// <summary>
/// VEX linkset query options.
/// </summary>
internal sealed class VexLinksetQuery
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("vulnerabilityId")]
public string VulnerabilityId { get; init; } = string.Empty;
[JsonPropertyName("productKeys")]
public IReadOnlyList<string> ProductKeys { get; init; } = Array.Empty<string>();
[JsonPropertyName("purls")]
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
[JsonPropertyName("statuses")]
public IReadOnlyList<string> Statuses { get; init; } = Array.Empty<string>();
}
/// <summary>
/// VEX linkset response showing linked observations.
/// </summary>
internal sealed class VexLinksetResponse
{
[JsonPropertyName("vulnerabilityId")]
public string VulnerabilityId { get; init; } = string.Empty;
[JsonPropertyName("observations")]
public IReadOnlyList<VexObservation> Observations { get; init; } = Array.Empty<VexObservation>();
[JsonPropertyName("summary")]
public VexLinksetSummary? Summary { get; init; }
[JsonPropertyName("conflicts")]
public IReadOnlyList<VexLinksetConflict> Conflicts { get; init; } = Array.Empty<VexLinksetConflict>();
}
/// <summary>
/// Summary of VEX linkset.
/// </summary>
internal sealed class VexLinksetSummary
{
[JsonPropertyName("totalObservations")]
public int TotalObservations { get; init; }
[JsonPropertyName("providers")]
public IReadOnlyList<string> Providers { get; init; } = Array.Empty<string>();
[JsonPropertyName("products")]
public IReadOnlyList<string> Products { get; init; } = Array.Empty<string>();
[JsonPropertyName("statusCounts")]
public IReadOnlyDictionary<string, int> StatusCounts { get; init; } = new Dictionary<string, int>();
[JsonPropertyName("hasConflicts")]
public bool HasConflicts { get; init; }
}
/// <summary>
/// Conflict between VEX observations.
/// </summary>
internal sealed class VexLinksetConflict
{
[JsonPropertyName("productKey")]
public string ProductKey { get; init; } = string.Empty;
[JsonPropertyName("conflictingStatuses")]
public IReadOnlyList<string> ConflictingStatuses { get; init; } = Array.Empty<string>();
[JsonPropertyName("observations")]
public IReadOnlyList<string> ObservationIds { get; init; } = Array.Empty<string>();
[JsonPropertyName("description")]
public string Description { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,654 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
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;
/// <summary>
/// Client for Notify API operations.
/// Per CLI-PARITY-41-002.
/// </summary>
internal sealed class NotifyClient : INotifyClient
{
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<NotifyClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public NotifyClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<NotifyClient> 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.BackendUrl) && httpClient.BaseAddress is null)
{
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<NotifyChannelListResponse> ListChannelsAsync(
NotifyChannelListRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var uri = BuildChannelListUri(request);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to list notify channels (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyChannelListResponse();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyChannelListResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyChannelListResponse();
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while listing notify channels");
return new NotifyChannelListResponse();
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while listing notify channels");
return new NotifyChannelListResponse();
}
}
public async Task<NotifyChannelDetail?> GetChannelAsync(
string channelId,
string? tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
try
{
EnsureConfigured();
var uri = $"/api/v1/notify/channels/{Uri.EscapeDataString(channelId)}";
if (!string.IsNullOrWhiteSpace(tenant))
{
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "notify.read", 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 payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get notify channel (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer
.DeserializeAsync<NotifyChannelDetail>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while getting notify channel");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while getting notify channel");
return null;
}
}
public async Task<NotifyChannelTestResult> TestChannelAsync(
NotifyChannelTestRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/channels/{Uri.EscapeDataString(request.ChannelId)}/test")
{
Content = content
};
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to test notify channel (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyChannelTestResult
{
Success = false,
ChannelId = request.ChannelId,
ErrorMessage = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyChannelTestResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyChannelTestResult { Success = false, ChannelId = request.ChannelId, ErrorMessage = "Empty response" };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while testing notify channel");
return new NotifyChannelTestResult
{
Success = false,
ChannelId = request.ChannelId,
ErrorMessage = $"Connection error: {ex.Message}"
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while testing notify channel");
return new NotifyChannelTestResult
{
Success = false,
ChannelId = request.ChannelId,
ErrorMessage = "Request timed out"
};
}
}
public async Task<NotifyRuleListResponse> ListRulesAsync(
NotifyRuleListRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (request.Enabled.HasValue)
{
queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}");
}
if (!string.IsNullOrWhiteSpace(request.EventType))
{
queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}");
}
if (!string.IsNullOrWhiteSpace(request.ChannelId))
{
queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}");
}
if (request.Limit.HasValue)
{
queryParams.Add($"limit={request.Limit.Value}");
}
if (request.Offset.HasValue)
{
queryParams.Add($"offset={request.Offset.Value}");
}
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
var uri = $"/api/v1/notify/rules{query}";
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to list notify rules (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyRuleListResponse();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyRuleListResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyRuleListResponse();
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while listing notify rules");
return new NotifyRuleListResponse();
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while listing notify rules");
return new NotifyRuleListResponse();
}
}
public async Task<NotifyDeliveryListResponse> ListDeliveriesAsync(
NotifyDeliveryListRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (!string.IsNullOrWhiteSpace(request.ChannelId))
{
queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}");
}
if (!string.IsNullOrWhiteSpace(request.Status))
{
queryParams.Add($"status={Uri.EscapeDataString(request.Status)}");
}
if (!string.IsNullOrWhiteSpace(request.EventType))
{
queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}");
}
if (request.Since.HasValue)
{
queryParams.Add($"since={Uri.EscapeDataString(request.Since.Value.ToString("O"))}");
}
if (request.Until.HasValue)
{
queryParams.Add($"until={Uri.EscapeDataString(request.Until.Value.ToString("O"))}");
}
if (request.Limit.HasValue)
{
queryParams.Add($"limit={request.Limit.Value}");
}
if (!string.IsNullOrWhiteSpace(request.Cursor))
{
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
}
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
var uri = $"/api/v1/notify/deliveries{query}";
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to list notify deliveries (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyDeliveryListResponse();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyDeliveryListResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyDeliveryListResponse();
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while listing notify deliveries");
return new NotifyDeliveryListResponse();
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while listing notify deliveries");
return new NotifyDeliveryListResponse();
}
}
public async Task<NotifyDeliveryDetail?> GetDeliveryAsync(
string deliveryId,
string? tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deliveryId);
try
{
EnsureConfigured();
var uri = $"/api/v1/notify/deliveries/{Uri.EscapeDataString(deliveryId)}";
if (!string.IsNullOrWhiteSpace(tenant))
{
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "notify.read", 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 payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get notify delivery (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer
.DeserializeAsync<NotifyDeliveryDetail>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while getting notify delivery");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while getting notify delivery");
return null;
}
}
public async Task<NotifyRetryResult> RetryDeliveryAsync(
NotifyRetryRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/deliveries/{Uri.EscapeDataString(request.DeliveryId)}/retry")
{
Content = content
};
// Add idempotency key header if present
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey);
}
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to retry notify delivery (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyRetryResult
{
Success = false,
DeliveryId = request.DeliveryId,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyRetryResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyRetryResult { Success = false, DeliveryId = request.DeliveryId, Errors = ["Empty response"] };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while retrying notify delivery");
return new NotifyRetryResult
{
Success = false,
DeliveryId = request.DeliveryId,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while retrying notify delivery");
return new NotifyRetryResult
{
Success = false,
DeliveryId = request.DeliveryId,
Errors = ["Request timed out"]
};
}
}
public async Task<NotifySendResult> SendAsync(
NotifySendRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/send")
{
Content = content
};
// Add idempotency key header if present
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey);
}
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to send notification (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifySendResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifySendResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifySendResult { Success = false, Errors = ["Empty response"] };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while sending notification");
return new NotifySendResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while sending notification");
return new NotifySendResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
private static string BuildChannelListUri(NotifyChannelListRequest request)
{
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (!string.IsNullOrWhiteSpace(request.Type))
{
queryParams.Add($"type={Uri.EscapeDataString(request.Type)}");
}
if (request.Enabled.HasValue)
{
queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}");
}
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 query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
return $"/api/v1/notify/channels{query}";
}
private void EnsureConfigured()
{
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
{
throw new InvalidOperationException(
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
}
}
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
{
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
{
if (tokenClient is null)
{
return null;
}
lock (tokenSync)
{
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
{
return cachedAccessToken;
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
cachedAccessTokenExpiresAt = result.ExpiresAt;
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
}
}

View File

@@ -0,0 +1,557 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
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;
/// <summary>
/// Client for observability API operations.
/// Per CLI-OBS-51-001.
/// </summary>
internal sealed class ObservabilityClient : IObservabilityClient
{
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<ObservabilityClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public ObservabilityClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<ObservabilityClient> 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.BackendUrl) && httpClient.BaseAddress is null)
{
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<ObsTopResult> GetHealthSummaryAsync(
ObsTopRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var requestUri = BuildHealthSummaryUri(request);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get health summary (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new ObsTopResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var summary = await JsonSerializer
.DeserializeAsync<PlatformHealthSummary>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return new ObsTopResult
{
Success = true,
Summary = summary ?? new PlatformHealthSummary()
};
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while fetching health summary");
return new ObsTopResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while fetching health summary");
return new ObsTopResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
private static string BuildHealthSummaryUri(ObsTopRequest request)
{
var queryParams = new List<string>();
if (request.Services.Count > 0)
{
foreach (var service in request.Services)
{
queryParams.Add($"service={Uri.EscapeDataString(service)}");
}
}
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
queryParams.Add($"includeQueues={request.IncludeQueues.ToString().ToLowerInvariant()}");
queryParams.Add($"maxAlerts={request.MaxAlerts}");
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
return $"/api/v1/observability/health{query}";
}
private void EnsureConfigured()
{
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
{
throw new InvalidOperationException(
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
}
}
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
private async Task<string?> GetAccessTokenAsync(CancellationToken cancellationToken)
{
if (tokenClient is null)
{
return null;
}
lock (tokenSync)
{
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
{
return cachedAccessToken;
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = ["obs:read"] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
cachedAccessTokenExpiresAt = result.ExpiresAt;
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
}
// CLI-OBS-52-001: Trace retrieval
public async Task<ObsTraceResult> GetTraceAsync(
ObsTraceRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var requestUri = BuildTraceUri(request);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return new ObsTraceResult
{
Success = false,
Errors = [$"Trace not found: {request.TraceId}"]
};
}
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get trace (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new ObsTraceResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var trace = await JsonSerializer
.DeserializeAsync<DistributedTrace>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return new ObsTraceResult
{
Success = true,
Trace = trace
};
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while fetching trace");
return new ObsTraceResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while fetching trace");
return new ObsTraceResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
private static string BuildTraceUri(ObsTraceRequest request)
{
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
queryParams.Add($"includeEvidence={request.IncludeEvidence.ToString().ToLowerInvariant()}");
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
return $"/api/v1/observability/traces/{Uri.EscapeDataString(request.TraceId)}{query}";
}
// CLI-OBS-52-001: Logs retrieval
public async Task<ObsLogsResult> GetLogsAsync(
ObsLogsRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var requestUri = BuildLogsUri(request);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get logs (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new ObsLogsResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<ObsLogsResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ObsLogsResult { Success = true };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while fetching logs");
return new ObsLogsResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while fetching logs");
return new ObsLogsResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
private static string BuildLogsUri(ObsLogsRequest request)
{
var queryParams = new List<string>
{
$"from={Uri.EscapeDataString(request.From.ToString("o"))}",
$"to={Uri.EscapeDataString(request.To.ToString("o"))}"
};
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
foreach (var service in request.Services)
{
queryParams.Add($"service={Uri.EscapeDataString(service)}");
}
foreach (var level in request.Levels)
{
queryParams.Add($"level={Uri.EscapeDataString(level)}");
}
if (!string.IsNullOrWhiteSpace(request.Query))
{
queryParams.Add($"q={Uri.EscapeDataString(request.Query)}");
}
queryParams.Add($"pageSize={request.PageSize}");
if (!string.IsNullOrWhiteSpace(request.PageToken))
{
queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}");
}
var query = "?" + string.Join("&", queryParams);
return $"/api/v1/observability/logs{query}";
}
// CLI-OBS-55-001: Incident mode operations
public async Task<IncidentModeResult> GetIncidentModeStatusAsync(
string? tenant,
CancellationToken cancellationToken)
{
try
{
EnsureConfigured();
var query = !string.IsNullOrWhiteSpace(tenant)
? $"?tenant={Uri.EscapeDataString(tenant)}"
: string.Empty;
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/observability/incident-mode{query}");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get incident mode status (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new IncidentModeResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var state = await JsonSerializer
.DeserializeAsync<IncidentModeState>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return new IncidentModeResult
{
Success = true,
State = state
};
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while fetching incident mode status");
return new IncidentModeResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while fetching incident mode status");
return new IncidentModeResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
public async Task<IncidentModeResult> EnableIncidentModeAsync(
IncidentModeEnableRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/observability/incident-mode/enable");
var json = JsonSerializer.Serialize(request, SerializerOptions);
httpRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to enable incident mode (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new IncidentModeResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<IncidentModeResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new IncidentModeResult { Success = true };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while enabling incident mode");
return new IncidentModeResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while enabling incident mode");
return new IncidentModeResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
public async Task<IncidentModeResult> DisableIncidentModeAsync(
IncidentModeDisableRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/observability/incident-mode/disable");
var json = JsonSerializer.Serialize(request, SerializerOptions);
httpRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to disable incident mode (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new IncidentModeResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<IncidentModeResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new IncidentModeResult { Success = true };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while disabling incident mode");
return new IncidentModeResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while disabling incident mode");
return new IncidentModeResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
}

View File

@@ -0,0 +1,463 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Client;
using StellaOps.Auth.Client.Scopes;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// HTTP client for orchestrator API operations.
/// Per CLI-ORCH-32-001.
/// </summary>
internal sealed class OrchestratorClient : IOrchestratorClient
{
private readonly HttpClient _httpClient;
private readonly IStellaOpsTokenClient _tokenClient;
private readonly StellaOpsCliOptions _options;
private readonly ILogger<OrchestratorClient> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public OrchestratorClient(
HttpClient httpClient,
IStellaOpsTokenClient tokenClient,
IOptions<StellaOpsCliOptions> options,
ILogger<OrchestratorClient> logger)
{
_httpClient = httpClient;
_tokenClient = tokenClient;
_options = options.Value;
_logger = logger;
}
public async Task<SourceListResponse> ListSourcesAsync(
SourceListRequest request,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = BuildSourcesListUrl(request);
_logger.LogDebug("Listing orchestrator sources: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to list sources: {StatusCode} {Content}", response.StatusCode, errorContent);
throw new HttpRequestException($"Failed to list sources: {response.StatusCode}");
}
var result = await response.Content.ReadFromJsonAsync<SourceListResponse>(JsonOptions, cancellationToken);
return result ?? new SourceListResponse();
}
public async Task<OrchestratorSource?> GetSourceAsync(
string sourceId,
string? tenant,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(sourceId)}";
if (!string.IsNullOrWhiteSpace(tenant))
{
url += $"?tenant={Uri.EscapeDataString(tenant)}";
}
_logger.LogDebug("Getting orchestrator source: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to get source: {StatusCode} {Content}", response.StatusCode, errorContent);
throw new HttpRequestException($"Failed to get source: {response.StatusCode}");
}
return await response.Content.ReadFromJsonAsync<OrchestratorSource>(JsonOptions, cancellationToken);
}
public async Task<SourceOperationResult> PauseSourceAsync(
SourcePauseRequest request,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:pause";
_logger.LogDebug("Pausing orchestrator source: {SourceId}", request.SourceId);
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to pause source: {StatusCode} {Content}", response.StatusCode, errorContent);
return new SourceOperationResult
{
Success = false,
Errors = new[] { $"Failed to pause source: {response.StatusCode} - {errorContent}" }
};
}
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
return result ?? new SourceOperationResult { Success = true };
}
public async Task<SourceOperationResult> ResumeSourceAsync(
SourceResumeRequest request,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:resume";
_logger.LogDebug("Resuming orchestrator source: {SourceId}", request.SourceId);
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to resume source: {StatusCode} {Content}", response.StatusCode, errorContent);
return new SourceOperationResult
{
Success = false,
Errors = new[] { $"Failed to resume source: {response.StatusCode} - {errorContent}" }
};
}
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
return result ?? new SourceOperationResult { Success = true };
}
public async Task<SourceTestResult> TestSourceAsync(
SourceTestRequest request,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:test";
_logger.LogDebug("Testing orchestrator source: {SourceId}", request.SourceId);
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to test source: {StatusCode} {Content}", response.StatusCode, errorContent);
return new SourceTestResult
{
Success = false,
SourceId = request.SourceId,
Reachable = false,
ErrorMessage = $"Failed to test source: {response.StatusCode} - {errorContent}",
TestedAt = DateTimeOffset.UtcNow
};
}
var result = await response.Content.ReadFromJsonAsync<SourceTestResult>(JsonOptions, cancellationToken);
return result ?? new SourceTestResult
{
Success = true,
SourceId = request.SourceId,
Reachable = true,
TestedAt = DateTimeOffset.UtcNow
};
}
// CLI-ORCH-34-001: Backfill operations
public async Task<BackfillResult> StartBackfillAsync(
BackfillRequest request,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = $"{GetBaseUrl()}/backfills";
_logger.LogDebug("Starting backfill for source: {SourceId} from {From} to {To}",
request.SourceId, request.From, request.To);
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to start backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
return new BackfillResult
{
Success = false,
SourceId = request.SourceId,
Status = BackfillStatuses.Failed,
From = request.From,
To = request.To,
DryRun = request.DryRun,
Errors = new[] { $"Failed to start backfill: {response.StatusCode} - {errorContent}" }
};
}
var result = await response.Content.ReadFromJsonAsync<BackfillResult>(JsonOptions, cancellationToken);
return result ?? new BackfillResult
{
Success = true,
SourceId = request.SourceId,
Status = request.DryRun ? BackfillStatuses.DryRun : BackfillStatuses.Pending,
From = request.From,
To = request.To,
DryRun = request.DryRun
};
}
public async Task<BackfillResult?> GetBackfillAsync(
string backfillId,
string? tenant,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = $"{GetBaseUrl()}/backfills/{Uri.EscapeDataString(backfillId)}";
if (!string.IsNullOrWhiteSpace(tenant))
{
url += $"?tenant={Uri.EscapeDataString(tenant)}";
}
_logger.LogDebug("Getting backfill status: {BackfillId}", backfillId);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to get backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
throw new HttpRequestException($"Failed to get backfill: {response.StatusCode}");
}
return await response.Content.ReadFromJsonAsync<BackfillResult>(JsonOptions, cancellationToken);
}
public async Task<BackfillListResponse> ListBackfillsAsync(
BackfillListRequest request,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = BuildBackfillsListUrl(request);
_logger.LogDebug("Listing backfills: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to list backfills: {StatusCode} {Content}", response.StatusCode, errorContent);
throw new HttpRequestException($"Failed to list backfills: {response.StatusCode}");
}
var result = await response.Content.ReadFromJsonAsync<BackfillListResponse>(JsonOptions, cancellationToken);
return result ?? new BackfillListResponse();
}
public async Task<SourceOperationResult> CancelBackfillAsync(
BackfillCancelRequest request,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = $"{GetBaseUrl()}/backfills/{Uri.EscapeDataString(request.BackfillId)}:cancel";
_logger.LogDebug("Cancelling backfill: {BackfillId}", request.BackfillId);
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to cancel backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
return new SourceOperationResult
{
Success = false,
Errors = new[] { $"Failed to cancel backfill: {response.StatusCode} - {errorContent}" }
};
}
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
return result ?? new SourceOperationResult { Success = true };
}
// CLI-ORCH-34-001: Quota management
public async Task<QuotaGetResponse> GetQuotasAsync(
QuotaGetRequest request,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = BuildQuotasGetUrl(request);
_logger.LogDebug("Getting quotas: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to get quotas: {StatusCode} {Content}", response.StatusCode, errorContent);
throw new HttpRequestException($"Failed to get quotas: {response.StatusCode}");
}
var result = await response.Content.ReadFromJsonAsync<QuotaGetResponse>(JsonOptions, cancellationToken);
return result ?? new QuotaGetResponse();
}
public async Task<QuotaOperationResult> SetQuotaAsync(
QuotaSetRequest request,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = $"{GetBaseUrl()}/quotas";
_logger.LogDebug("Setting quota for tenant: {Tenant}, resource: {ResourceType}",
request.Tenant, request.ResourceType);
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to set quota: {StatusCode} {Content}", response.StatusCode, errorContent);
return new QuotaOperationResult
{
Success = false,
Errors = new[] { $"Failed to set quota: {response.StatusCode} - {errorContent}" }
};
}
var result = await response.Content.ReadFromJsonAsync<QuotaOperationResult>(JsonOptions, cancellationToken);
return result ?? new QuotaOperationResult { Success = true };
}
public async Task<QuotaOperationResult> ResetQuotaAsync(
QuotaResetRequest request,
CancellationToken cancellationToken)
{
await ConfigureAuthAsync(cancellationToken);
var url = $"{GetBaseUrl()}/quotas:reset";
_logger.LogDebug("Resetting quota for tenant: {Tenant}, resource: {ResourceType}",
request.Tenant, request.ResourceType);
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Failed to reset quota: {StatusCode} {Content}", response.StatusCode, errorContent);
return new QuotaOperationResult
{
Success = false,
Errors = new[] { $"Failed to reset quota: {response.StatusCode} - {errorContent}" }
};
}
var result = await response.Content.ReadFromJsonAsync<QuotaOperationResult>(JsonOptions, cancellationToken);
return result ?? new QuotaOperationResult { Success = true };
}
private async Task ConfigureAuthAsync(CancellationToken cancellationToken)
{
var token = await _tokenClient.GetCachedAccessTokenAsync(
new[] { StellaOpsScope.OrchRead },
cancellationToken);
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.AccessToken);
}
private string GetBaseUrl()
{
var baseUrl = _options.BackendUrl?.TrimEnd('/') ?? "https://api.stellaops.local";
return $"{baseUrl}/api/v1/orchestrator";
}
private string BuildSourcesListUrl(SourceListRequest request)
{
var builder = new UriBuilder($"{GetBaseUrl()}/sources");
var query = HttpUtility.ParseQueryString(string.Empty);
if (!string.IsNullOrWhiteSpace(request.Tenant))
query["tenant"] = request.Tenant;
if (!string.IsNullOrWhiteSpace(request.Type))
query["type"] = request.Type;
if (!string.IsNullOrWhiteSpace(request.Status))
query["status"] = request.Status;
if (request.Enabled.HasValue)
query["enabled"] = request.Enabled.Value.ToString().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(request.Host))
query["host"] = request.Host;
if (!string.IsNullOrWhiteSpace(request.Tag))
query["tag"] = request.Tag;
if (request.PageSize != 50)
query["page_size"] = request.PageSize.ToString();
if (!string.IsNullOrWhiteSpace(request.PageToken))
query["page_token"] = request.PageToken;
builder.Query = query.ToString();
return builder.ToString();
}
private string BuildBackfillsListUrl(BackfillListRequest request)
{
var builder = new UriBuilder($"{GetBaseUrl()}/backfills");
var query = HttpUtility.ParseQueryString(string.Empty);
if (!string.IsNullOrWhiteSpace(request.SourceId))
query["source_id"] = request.SourceId;
if (!string.IsNullOrWhiteSpace(request.Tenant))
query["tenant"] = request.Tenant;
if (!string.IsNullOrWhiteSpace(request.Status))
query["status"] = request.Status;
if (request.PageSize != 20)
query["page_size"] = request.PageSize.ToString();
if (!string.IsNullOrWhiteSpace(request.PageToken))
query["page_token"] = request.PageToken;
builder.Query = query.ToString();
return builder.ToString();
}
private string BuildQuotasGetUrl(QuotaGetRequest request)
{
var builder = new UriBuilder($"{GetBaseUrl()}/quotas");
var query = HttpUtility.ParseQueryString(string.Empty);
if (!string.IsNullOrWhiteSpace(request.Tenant))
query["tenant"] = request.Tenant;
if (!string.IsNullOrWhiteSpace(request.SourceId))
query["source_id"] = request.SourceId;
if (!string.IsNullOrWhiteSpace(request.ResourceType))
query["resource_type"] = request.ResourceType;
builder.Query = query.ToString();
return builder.ToString();
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
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;
/// <summary>
/// Client for SBOM API operations.
/// Per CLI-PARITY-41-001.
/// </summary>
internal sealed class SbomClient : ISbomClient
{
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<SbomClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public SbomClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<SbomClient> 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.BackendUrl) && httpClient.BaseAddress is null)
{
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<SbomListResponse> ListAsync(
SbomListRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var uri = BuildListUri(request);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to list SBOMs (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new SbomListResponse();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<SbomListResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new SbomListResponse();
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while listing SBOMs");
return new SbomListResponse();
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while listing SBOMs");
return new SbomListResponse();
}
}
public async Task<SbomDetailResponse?> GetAsync(
string sbomId,
string? tenant,
bool includeComponents,
bool includeVulnerabilities,
bool includeLicenses,
bool explain,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sbomId);
try
{
EnsureConfigured();
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(tenant)}");
}
if (includeComponents)
{
queryParams.Add("includeComponents=true");
}
if (includeVulnerabilities)
{
queryParams.Add("includeVulnerabilities=true");
}
if (includeLicenses)
{
queryParams.Add("includeLicenses=true");
}
if (explain)
{
queryParams.Add("explain=true");
}
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
var uri = $"/api/v1/sboms/{Uri.EscapeDataString(sbomId)}{query}";
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "sbom.read", 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 payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get SBOM (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer
.DeserializeAsync<SbomDetailResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while getting SBOM");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while getting SBOM");
return null;
}
}
public async Task<SbomCompareResponse?> CompareAsync(
SbomCompareRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var queryParams = new List<string>
{
$"base={Uri.EscapeDataString(request.BaseSbomId)}",
$"target={Uri.EscapeDataString(request.TargetSbomId)}"
};
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (request.IncludeUnchanged)
{
queryParams.Add("includeUnchanged=true");
}
var query = string.Join("&", queryParams);
var uri = $"/api/v1/sboms/compare?{query}";
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to compare SBOMs (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer
.DeserializeAsync<SbomCompareResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while comparing SBOMs");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while comparing SBOMs");
return null;
}
}
public async Task<(Stream Content, SbomExportResult? Result)> ExportAsync(
SbomExportRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var queryParams = new List<string>
{
$"format={Uri.EscapeDataString(request.Format)}"
};
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (!string.IsNullOrWhiteSpace(request.FormatVersion))
{
queryParams.Add($"formatVersion={Uri.EscapeDataString(request.FormatVersion)}");
}
queryParams.Add($"signed={request.Signed.ToString().ToLowerInvariant()}");
queryParams.Add($"includeVex={request.IncludeVex.ToString().ToLowerInvariant()}");
var query = string.Join("&", queryParams);
var uri = $"/api/v1/sboms/{Uri.EscapeDataString(request.SbomId)}/export?{query}";
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to export SBOM (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return (Stream.Null, new SbomExportResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
});
}
// Parse export metadata from headers if present
SbomExportResult? result = null;
if (response.Headers.TryGetValues("X-Export-Metadata", out var metadataValues))
{
var metadataJson = string.Join("", metadataValues);
if (!string.IsNullOrWhiteSpace(metadataJson))
{
try
{
result = JsonSerializer.Deserialize<SbomExportResult>(metadataJson, SerializerOptions);
}
catch (JsonException)
{
// Ignore parse errors for optional header
}
}
}
result ??= new SbomExportResult
{
Success = true,
Format = request.Format,
Signed = request.Signed
};
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return (contentStream, result);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while exporting SBOM");
return (Stream.Null, new SbomExportResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
});
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while exporting SBOM");
return (Stream.Null, new SbomExportResult
{
Success = false,
Errors = ["Request timed out"]
});
}
}
public async Task<ParityMatrixResponse> GetParityMatrixAsync(
string? tenant,
CancellationToken cancellationToken)
{
try
{
EnsureConfigured();
var uri = "/api/v1/cli/parity-matrix";
if (!string.IsNullOrWhiteSpace(tenant))
{
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "cli.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get parity matrix (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new ParityMatrixResponse();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<ParityMatrixResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ParityMatrixResponse();
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while getting parity matrix");
return new ParityMatrixResponse();
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while getting parity matrix");
return new ParityMatrixResponse();
}
}
private static string BuildListUri(SbomListRequest request)
{
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (!string.IsNullOrWhiteSpace(request.ImageRef))
{
queryParams.Add($"imageRef={Uri.EscapeDataString(request.ImageRef)}");
}
if (!string.IsNullOrWhiteSpace(request.Digest))
{
queryParams.Add($"digest={Uri.EscapeDataString(request.Digest)}");
}
if (!string.IsNullOrWhiteSpace(request.Format))
{
queryParams.Add($"format={Uri.EscapeDataString(request.Format)}");
}
if (request.CreatedAfter.HasValue)
{
queryParams.Add($"createdAfter={Uri.EscapeDataString(request.CreatedAfter.Value.ToString("O"))}");
}
if (request.CreatedBefore.HasValue)
{
queryParams.Add($"createdBefore={Uri.EscapeDataString(request.CreatedBefore.Value.ToString("O"))}");
}
if (request.HasVulnerabilities.HasValue)
{
queryParams.Add($"hasVulnerabilities={request.HasVulnerabilities.Value.ToString().ToLowerInvariant()}");
}
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 query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
return $"/api/v1/sboms{query}";
}
private void EnsureConfigured()
{
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
{
throw new InvalidOperationException(
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
}
}
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
{
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
{
if (tokenClient is null)
{
return null;
}
lock (tokenSync)
{
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
{
return cachedAccessToken;
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
cachedAccessTokenExpiresAt = result.ExpiresAt;
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
}
}

View File

@@ -0,0 +1,254 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Client;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// HTTP client for Sbomer API operations.
/// Per CLI-SBOM-60-001.
/// </summary>
internal sealed class SbomerClient : ISbomerClient
{
private readonly HttpClient _httpClient;
private readonly IStellaOpsTokenClient? _tokenClient;
private readonly ILogger<SbomerClient> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public SbomerClient(
HttpClient httpClient,
IStellaOpsTokenClient? tokenClient,
ILogger<SbomerClient> logger)
{
_httpClient = httpClient;
_tokenClient = tokenClient;
_logger = logger;
}
public async Task<SbomerLayerListResponse> ListLayersAsync(
SbomerLayerListRequest request,
CancellationToken cancellationToken)
{
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
var query = BuildQueryString(request);
var response = await _httpClient.GetAsync($"/api/v1/sbomer/layers{query}", cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SbomerLayerListResponse>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new SbomerLayerListResponse();
}
public async Task<SbomerLayerDetail?> GetLayerAsync(
SbomerLayerShowRequest request,
CancellationToken cancellationToken)
{
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
var query = BuildLayerShowQuery(request);
var response = await _httpClient.GetAsync($"/api/v1/sbomer/layers/{Uri.EscapeDataString(request.LayerDigest)}{query}", cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SbomerLayerDetail>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
public async Task<SbomerLayerVerifyResult> VerifyLayerAsync(
SbomerLayerVerifyRequest request,
CancellationToken cancellationToken)
{
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
var response = await _httpClient.PostAsJsonAsync(
$"/api/v1/sbomer/layers/{Uri.EscapeDataString(request.LayerDigest)}/verify",
request,
JsonOptions,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SbomerLayerVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new SbomerLayerVerifyResult { LayerDigest = request.LayerDigest };
}
public async Task<CompositionManifest?> GetCompositionManifestAsync(
SbomerCompositionShowRequest request,
CancellationToken cancellationToken)
{
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
var query = BuildCompositionShowQuery(request);
var response = await _httpClient.GetAsync($"/api/v1/sbomer/composition{query}", cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<CompositionManifest>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
public async Task<SbomerComposeResult> ComposeAsync(
SbomerComposeRequest request,
CancellationToken cancellationToken)
{
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/sbomer/compose",
request,
JsonOptions,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SbomerComposeResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new SbomerComposeResult();
}
public async Task<SbomerCompositionVerifyResult> VerifyCompositionAsync(
SbomerCompositionVerifyRequest request,
CancellationToken cancellationToken)
{
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/sbomer/composition/verify",
request,
JsonOptions,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SbomerCompositionVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new SbomerCompositionVerifyResult();
}
public async Task<MerkleDiagnostics?> GetMerkleDiagnosticsAsync(
string scanId,
string? tenant,
CancellationToken cancellationToken)
{
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
var query = string.IsNullOrWhiteSpace(tenant) ? "" : $"?tenant={Uri.EscapeDataString(tenant)}";
var response = await _httpClient.GetAsync($"/api/v1/sbomer/composition/{Uri.EscapeDataString(scanId)}/merkle{query}", cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<MerkleDiagnostics>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
// CLI-SBOM-60-002: Drift detection methods
public async Task<SbomerDriftResult> AnalyzeDriftAsync(
SbomerDriftRequest request,
CancellationToken cancellationToken)
{
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/sbomer/drift/analyze",
request,
JsonOptions,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SbomerDriftResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new SbomerDriftResult();
}
public async Task<SbomerDriftVerifyResult> VerifyDriftAsync(
SbomerDriftVerifyRequest request,
CancellationToken cancellationToken)
{
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/sbomer/drift/verify",
request,
JsonOptions,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SbomerDriftVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
?? new SbomerDriftVerifyResult();
}
private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken)
{
if (_tokenClient == null)
return;
var token = await _tokenClient.GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
}
private static string BuildQueryString(SbomerLayerListRequest request)
{
var parts = new System.Collections.Generic.List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
if (!string.IsNullOrWhiteSpace(request.ImageRef))
parts.Add($"imageRef={Uri.EscapeDataString(request.ImageRef)}");
if (!string.IsNullOrWhiteSpace(request.Digest))
parts.Add($"digest={Uri.EscapeDataString(request.Digest)}");
if (!string.IsNullOrWhiteSpace(request.ScanId))
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
if (request.Limit.HasValue)
parts.Add($"limit={request.Limit.Value}");
if (!string.IsNullOrWhiteSpace(request.Cursor))
parts.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
}
private static string BuildLayerShowQuery(SbomerLayerShowRequest request)
{
var parts = new System.Collections.Generic.List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
if (!string.IsNullOrWhiteSpace(request.ScanId))
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
if (request.IncludeComponents)
parts.Add("includeComponents=true");
if (request.IncludeDsse)
parts.Add("includeDsse=true");
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
}
private static string BuildCompositionShowQuery(SbomerCompositionShowRequest request)
{
var parts = new System.Collections.Generic.List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
if (!string.IsNullOrWhiteSpace(request.ScanId))
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
if (!string.IsNullOrWhiteSpace(request.CompositionPath))
parts.Add($"compositionPath={Uri.EscapeDataString(request.CompositionPath)}");
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
}
}

View File

@@ -0,0 +1,164 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Services.Transport;
/// <summary>
/// HTTP transport implementation for online mode.
/// CLI-SDK-62-001: Provides HTTP transport for online API operations.
/// </summary>
public sealed class HttpTransport : IStellaOpsTransport
{
private readonly HttpClient _httpClient;
private readonly TransportOptions _options;
private readonly ILogger<HttpTransport> _logger;
private bool _disposed;
public HttpTransport(HttpClient httpClient, TransportOptions options, ILogger<HttpTransport> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && _httpClient.BaseAddress is null)
{
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
{
_httpClient.BaseAddress = baseUri;
}
}
_httpClient.Timeout = _options.Timeout;
}
/// <inheritdoc />
public bool IsOffline => false;
/// <inheritdoc />
public string TransportMode => "http";
/// <inheritdoc />
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return await SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var attempt = 0;
var maxAttempts = Math.Max(1, _options.MaxRetries);
while (true)
{
attempt++;
try
{
_logger.LogDebug("Sending {Method} request to {Uri} (attempt {Attempt}/{MaxAttempts})",
request.Method, request.RequestUri, attempt, maxAttempts);
var response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Received response {StatusCode} from {Uri}",
(int)response.StatusCode, request.RequestUri);
return response;
}
catch (HttpRequestException ex) when (attempt < maxAttempts && IsRetryableException(ex))
{
var delay = GetRetryDelay(attempt);
_logger.LogWarning(ex, "Request failed (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s...",
attempt, maxAttempts, delay.TotalSeconds);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
// Clone the request for retry
request = await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
}
}
}
/// <inheritdoc />
public async Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// For HTTP transport, we return a memory stream that will be uploaded
// The caller is responsible for writing to the stream and then calling upload
return await Task.FromResult<Stream>(new MemoryStream()).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
var response = await SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
}
private static bool IsRetryableException(HttpRequestException ex)
{
// Retry on connection errors, timeouts, and server errors
return ex.InnerException is IOException
|| ex.InnerException is OperationCanceledException
|| (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 500);
}
private static TimeSpan GetRetryDelay(int attempt)
{
// Exponential backoff with jitter
var baseDelay = Math.Pow(2, attempt);
var jitter = Random.Shared.NextDouble() * 0.5;
return TimeSpan.FromSeconds(baseDelay + jitter);
}
private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
// Copy headers
foreach (var header in request.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
// Copy content if present
if (request.Content is not null)
{
var content = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
clone.Content = new ByteArrayContent(content);
foreach (var header in request.Content.Headers)
{
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
// Copy options
foreach (var option in request.Options)
{
clone.Options.TryAdd(option.Key, option.Value);
}
return clone;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// Note: We don't dispose _httpClient as it's typically managed by DI
}
}

View File

@@ -0,0 +1,95 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Services.Transport;
/// <summary>
/// Transport abstraction for CLI operations.
/// CLI-SDK-62-001: Supports modular transport for online and air-gapped modes.
/// </summary>
public interface IStellaOpsTransport : IDisposable
{
/// <summary>
/// Gets whether this transport is operating in offline/air-gapped mode.
/// </summary>
bool IsOffline { get; }
/// <summary>
/// Gets the transport mode identifier.
/// </summary>
string TransportMode { get; }
/// <summary>
/// Sends an HTTP request and returns the response.
/// </summary>
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
/// <summary>
/// Sends an HTTP request and returns the response with streaming content.
/// </summary>
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken);
/// <summary>
/// Gets a stream for uploading content.
/// </summary>
Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken);
/// <summary>
/// Gets a stream for downloading content.
/// </summary>
Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken);
}
/// <summary>
/// Transport configuration options.
/// </summary>
public sealed class TransportOptions
{
/// <summary>
/// Base URL for the backend API.
/// </summary>
public string? BackendUrl { get; set; }
/// <summary>
/// Whether to operate in offline/air-gapped mode.
/// </summary>
public bool IsOffline { get; set; }
/// <summary>
/// Directory for offline kit data.
/// </summary>
public string? OfflineKitDirectory { get; set; }
/// <summary>
/// Request timeout.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum number of retry attempts.
/// </summary>
public int MaxRetries { get; set; } = 3;
/// <summary>
/// Whether to validate SSL certificates.
/// </summary>
public bool ValidateSsl { get; set; } = true;
/// <summary>
/// Custom CA certificate path for SSL validation.
/// </summary>
public string? CaCertificatePath { get; set; }
/// <summary>
/// Proxy URL if required.
/// </summary>
public string? ProxyUrl { get; set; }
/// <summary>
/// User agent string.
/// </summary>
public string UserAgent { get; set; } = "StellaOps-CLI/1.0";
}

View File

@@ -0,0 +1,186 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Services.Transport;
/// <summary>
/// Offline transport implementation for air-gapped mode.
/// CLI-SDK-62-001: Provides offline transport for air-gapped operations.
/// </summary>
public sealed class OfflineTransport : IStellaOpsTransport
{
private readonly TransportOptions _options;
private readonly ILogger<OfflineTransport> _logger;
private readonly string _offlineKitDirectory;
private bool _disposed;
public OfflineTransport(TransportOptions options, ILogger<OfflineTransport> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
{
throw new ArgumentException("OfflineKitDirectory must be specified for offline transport.", nameof(options));
}
_offlineKitDirectory = options.OfflineKitDirectory;
if (!Directory.Exists(_offlineKitDirectory))
{
throw new DirectoryNotFoundException($"Offline kit directory not found: {_offlineKitDirectory}");
}
}
/// <inheritdoc />
public bool IsOffline => true;
/// <inheritdoc />
public string TransportMode => "offline";
/// <inheritdoc />
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
}
/// <inheritdoc />
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_logger.LogDebug("Offline transport handling {Method} {Uri}", request.Method, request.RequestUri);
// Map the request to an offline resource
var (found, content) = await TryGetOfflineContentAsync(request, cancellationToken).ConfigureAwait(false);
if (found)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = content,
RequestMessage = request
};
}
// Return a 503 Service Unavailable for operations that require online access
_logger.LogWarning("Operation not available in offline mode: {Method} {Uri}", request.Method, request.RequestUri);
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent(JsonSerializer.Serialize(new
{
error = new
{
code = "ERR_AIRGAP_EGRESS_BLOCKED",
message = "This operation is not available in offline/air-gapped mode."
}
})),
RequestMessage = request
};
}
/// <inheritdoc />
public Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Create a staging area for uploads in offline mode
var stagingDir = Path.Combine(_offlineKitDirectory, "staging", "uploads");
Directory.CreateDirectory(stagingDir);
var stagingFile = Path.Combine(stagingDir, $"{Guid.NewGuid():N}.dat");
_logger.LogDebug("Creating offline upload staging file: {Path}", stagingFile);
return Task.FromResult<Stream>(File.Create(stagingFile));
}
/// <inheritdoc />
public Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Map endpoint to offline file
var localPath = MapEndpointToLocalPath(endpoint);
if (!File.Exists(localPath))
{
_logger.LogWarning("Offline resource not found: {Path}", localPath);
throw new FileNotFoundException($"Offline resource not found: {endpoint}", localPath);
}
_logger.LogDebug("Opening offline resource: {Path}", localPath);
return Task.FromResult<Stream>(File.OpenRead(localPath));
}
private async Task<(bool Found, HttpContent? Content)> TryGetOfflineContentAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var uri = request.RequestUri;
if (uri is null)
return (false, null);
var path = uri.PathAndQuery.TrimStart('/');
// Check for cached API responses
var cachePath = Path.Combine(_offlineKitDirectory, "cache", "api", path.Replace('/', Path.DirectorySeparatorChar));
// Try with .json extension
var jsonPath = cachePath + ".json";
if (File.Exists(jsonPath))
{
var content = await File.ReadAllTextAsync(jsonPath, cancellationToken).ConfigureAwait(false);
return (true, new StringContent(content, System.Text.Encoding.UTF8, "application/json"));
}
// Try exact path
if (File.Exists(cachePath))
{
var content = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
return (true, new ByteArrayContent(content));
}
// Check for bundled data
var bundlePath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(bundlePath))
{
var content = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
return (true, new ByteArrayContent(content));
}
return (false, null);
}
private string MapEndpointToLocalPath(string endpoint)
{
var path = endpoint.TrimStart('/');
// Check data directory first
var dataPath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(dataPath))
return dataPath;
// Check cache directory
var cachePath = Path.Combine(_offlineKitDirectory, "cache", path.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(cachePath))
return cachePath;
// Return data path as default (will throw FileNotFoundException if not found)
return dataPath;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
}
}

View File

@@ -0,0 +1,264 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Output;
using StellaOps.Cli.Services.Models.Transport;
namespace StellaOps.Cli.Services.Transport;
/// <summary>
/// Base class for SDK-generated clients.
/// CLI-SDK-62-001: Provides common functionality for SDK clients with modular transport.
/// </summary>
public abstract class StellaOpsClientBase : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly IStellaOpsTransport _transport;
private readonly ILogger _logger;
private bool _disposed;
/// <summary>
/// Authorization token for API requests.
/// </summary>
protected string? AccessToken { get; set; }
/// <summary>
/// Current tenant context.
/// </summary>
protected string? TenantId { get; set; }
protected StellaOpsClientBase(IStellaOpsTransport transport, ILogger logger)
{
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Gets whether the client is operating in offline mode.
/// </summary>
public bool IsOffline => _transport.IsOffline;
/// <summary>
/// Sets the access token for authenticated requests.
/// </summary>
public void SetAccessToken(string? token)
{
AccessToken = token;
}
/// <summary>
/// Sets the tenant context.
/// </summary>
public void SetTenant(string? tenantId)
{
TenantId = tenantId;
}
/// <summary>
/// Throws if the operation requires online connectivity and transport is offline.
/// </summary>
protected void ThrowIfOffline(string operation)
{
if (_transport.IsOffline)
{
throw new InvalidOperationException(
$"Operation '{operation}' is not available in offline/air-gapped mode. " +
"Please use online mode or import the required data to the offline kit.");
}
}
/// <summary>
/// Sends a GET request and deserializes the response.
/// </summary>
protected async Task<TResponse?> GetAsync<TResponse>(
string relativeUrl,
CancellationToken cancellationToken) where TResponse : class
{
using var request = CreateRequest(HttpMethod.Get, relativeUrl);
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a POST request with JSON body and deserializes the response.
/// </summary>
protected async Task<TResponse?> PostAsync<TRequest, TResponse>(
string relativeUrl,
TRequest body,
CancellationToken cancellationToken)
where TRequest : class
where TResponse : class
{
using var request = CreateRequest(HttpMethod.Post, relativeUrl);
request.Content = JsonContent.Create(body, options: JsonOptions);
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a PUT request with JSON body and deserializes the response.
/// </summary>
protected async Task<TResponse?> PutAsync<TRequest, TResponse>(
string relativeUrl,
TRequest body,
CancellationToken cancellationToken)
where TRequest : class
where TResponse : class
{
using var request = CreateRequest(HttpMethod.Put, relativeUrl);
request.Content = JsonContent.Create(body, options: JsonOptions);
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a DELETE request.
/// </summary>
protected async Task DeleteAsync(string relativeUrl, CancellationToken cancellationToken)
{
using var request = CreateRequest(HttpMethod.Delete, relativeUrl);
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Sends a request and parses any error response.
/// </summary>
protected async Task<(TResponse? Result, CliError? Error)> TrySendAsync<TResponse>(
HttpRequestMessage request,
CancellationToken cancellationToken) where TResponse : class
{
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return (result, null);
}
var error = await ParseErrorAsync(response, cancellationToken).ConfigureAwait(false);
return (null, error);
}
/// <summary>
/// Creates an HTTP request with standard headers.
/// </summary>
protected HttpRequestMessage CreateRequest(HttpMethod method, string relativeUrl)
{
var request = new HttpRequestMessage(method, relativeUrl);
// Add authorization header
if (!string.IsNullOrWhiteSpace(AccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
}
// Add tenant header
if (!string.IsNullOrWhiteSpace(TenantId))
{
request.Headers.TryAddWithoutValidation("X-Tenant-Id", TenantId);
}
// Add standard headers
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return request;
}
/// <summary>
/// Sends a request through the transport.
/// </summary>
protected async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_logger.LogDebug("Sending {Method} request to {Uri}", request.Method, request.RequestUri);
return await _transport.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Parses an error response into a CliError.
/// </summary>
protected async Task<CliError> ParseErrorAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
var statusCode = (int)response.StatusCode;
string? content = null;
try
{
content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
// Ignore content read errors
}
// Try to parse as error envelope
if (!string.IsNullOrWhiteSpace(content))
{
try
{
var envelope = JsonSerializer.Deserialize<ApiErrorEnvelope>(content, JsonOptions);
if (envelope?.Error is not null)
{
return CliError.FromApiErrorEnvelope(envelope, statusCode);
}
}
catch (JsonException)
{
// Not an error envelope
}
// Try to parse as problem details
try
{
var problem = JsonSerializer.Deserialize<ProblemDocument>(content, JsonOptions);
if (problem is not null)
{
return new CliError(
Code: problem.Type ?? $"ERR_HTTP_{statusCode}",
Message: problem.Title ?? $"HTTP error {statusCode}",
Detail: problem.Detail);
}
}
catch (JsonException)
{
// Not a problem document
}
}
// Fall back to HTTP status-based error
return CliError.FromHttpStatus(statusCode, content);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_transport.Dispose();
}
}

View File

@@ -0,0 +1,126 @@
using System;
using System.Net.Http;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
namespace StellaOps.Cli.Services.Transport;
/// <summary>
/// Factory for creating transport instances based on configuration.
/// CLI-SDK-62-001: Provides modular transport selection for online/offline modes.
/// </summary>
public sealed class TransportFactory : ITransportFactory
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly StellaOpsCliOptions _options;
private readonly CliProfileManager _profileManager;
public TransportFactory(
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory,
StellaOpsCliOptions options,
CliProfileManager profileManager)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
_options = options ?? throw new ArgumentNullException(nameof(options));
_profileManager = profileManager ?? throw new ArgumentNullException(nameof(profileManager));
}
/// <summary>
/// Creates a transport instance based on current configuration.
/// </summary>
public IStellaOpsTransport CreateTransport()
{
var transportOptions = CreateTransportOptions();
if (transportOptions.IsOffline)
{
return CreateOfflineTransport(transportOptions);
}
return CreateHttpTransport(transportOptions);
}
/// <summary>
/// Creates an HTTP transport for online operations.
/// </summary>
public IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null)
{
options ??= CreateTransportOptions();
var httpClient = _httpClientFactory.CreateClient("StellaOps");
var logger = _loggerFactory.CreateLogger<HttpTransport>();
return new HttpTransport(httpClient, options, logger);
}
/// <summary>
/// Creates an offline transport for air-gapped operations.
/// </summary>
public IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null)
{
options ??= CreateTransportOptions();
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
{
throw new InvalidOperationException("Offline kit directory must be specified for offline transport.");
}
var logger = _loggerFactory.CreateLogger<OfflineTransport>();
return new OfflineTransport(options, logger);
}
/// <summary>
/// Creates transport options from current configuration.
/// </summary>
public TransportOptions CreateTransportOptions()
{
var profile = _profileManager.GetCurrentProfileAsync().GetAwaiter().GetResult();
return new TransportOptions
{
BackendUrl = profile?.BackendUrl ?? _options.BackendUrl,
IsOffline = profile?.IsOffline ?? _options.IsOffline,
OfflineKitDirectory = profile?.OfflineKitDirectory ?? _options.OfflineKitDirectory,
Timeout = TimeSpan.FromMinutes(5),
MaxRetries = 3,
ValidateSsl = true,
UserAgent = $"StellaOps-CLI/{GetVersion()}"
};
}
private static string GetVersion()
{
var assembly = typeof(TransportFactory).Assembly;
var version = assembly.GetName().Version;
return version?.ToString() ?? "1.0.0";
}
}
/// <summary>
/// Factory interface for creating transport instances.
/// </summary>
public interface ITransportFactory
{
/// <summary>
/// Creates a transport instance based on current configuration.
/// </summary>
IStellaOpsTransport CreateTransport();
/// <summary>
/// Creates an HTTP transport for online operations.
/// </summary>
IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null);
/// <summary>
/// Creates an offline transport for air-gapped operations.
/// </summary>
IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null);
/// <summary>
/// Creates transport options from current configuration.
/// </summary>
TransportOptions CreateTransportOptions();
}

View File

@@ -0,0 +1,228 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// HTTP client for VEX observation queries.
/// Per CLI-LNM-22-002.
/// </summary>
internal sealed class VexObservationsClient : IVexObservationsClient
{
private readonly HttpClient _httpClient;
private readonly ITokenClient? _tokenClient;
private readonly ILogger<VexObservationsClient> _logger;
private string? _cachedToken;
private DateTimeOffset _tokenExpiry;
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public VexObservationsClient(
HttpClient httpClient,
ILogger<VexObservationsClient> logger,
ITokenClient? tokenClient = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_tokenClient = tokenClient;
}
public async Task<VexObservationResponse> GetObservationsAsync(
VexObservationQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
var requestUri = BuildObservationRequestUri(query);
_logger.LogDebug("Fetching VEX observations from {Uri}", requestUri);
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("VEX observations request failed: {StatusCode} - {Body}",
response.StatusCode, errorBody);
throw new HttpRequestException($"Failed to fetch VEX observations: {response.StatusCode}");
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<VexObservationResponse>(content, SerializerOptions)
?? new VexObservationResponse();
}
public async Task<VexLinksetResponse> GetLinksetAsync(
VexLinksetQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
var requestUri = BuildLinksetRequestUri(query);
_logger.LogDebug("Fetching VEX linkset from {Uri}", requestUri);
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("VEX linkset request failed: {StatusCode} - {Body}",
response.StatusCode, errorBody);
throw new HttpRequestException($"Failed to fetch VEX linkset: {response.StatusCode}");
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<VexLinksetResponse>(content, SerializerOptions)
?? new VexLinksetResponse();
}
public async Task<VexObservation?> GetObservationByIdAsync(
string tenant,
string observationId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
var requestUri = $"api/v1/tenants/{Uri.EscapeDataString(tenant)}/vex/observations/{Uri.EscapeDataString(observationId)}";
_logger.LogDebug("Fetching VEX observation {ObservationId} from {Uri}", observationId, requestUri);
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("VEX observation request failed: {StatusCode} - {Body}",
response.StatusCode, errorBody);
throw new HttpRequestException($"Failed to fetch VEX observation: {response.StatusCode}");
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<VexObservation>(content, SerializerOptions);
}
private async Task EnsureAuthorizationAsync(CancellationToken cancellationToken)
{
if (_tokenClient is null)
{
return;
}
if (!string.IsNullOrWhiteSpace(_cachedToken) && DateTimeOffset.UtcNow < _tokenExpiry)
{
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _cachedToken);
return;
}
var tokenResult = await _tokenClient.GetAccessTokenAsync(
new[] { StellaOpsScopes.VexRead },
cancellationToken).ConfigureAwait(false);
if (tokenResult.IsSuccess && !string.IsNullOrWhiteSpace(tokenResult.AccessToken))
{
_cachedToken = tokenResult.AccessToken;
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _cachedToken);
}
else
{
_logger.LogWarning("Failed to acquire token for VEX API access.");
}
}
private static string BuildObservationRequestUri(VexObservationQuery query)
{
var sb = new StringBuilder();
sb.Append($"api/v1/tenants/{Uri.EscapeDataString(query.Tenant)}/vex/observations?");
foreach (var vulnId in query.VulnerabilityIds)
{
sb.Append($"vulnerabilityId={Uri.EscapeDataString(vulnId)}&");
}
foreach (var productKey in query.ProductKeys)
{
sb.Append($"productKey={Uri.EscapeDataString(productKey)}&");
}
foreach (var purl in query.Purls)
{
sb.Append($"purl={Uri.EscapeDataString(purl)}&");
}
foreach (var cpe in query.Cpes)
{
sb.Append($"cpe={Uri.EscapeDataString(cpe)}&");
}
foreach (var status in query.Statuses)
{
sb.Append($"status={Uri.EscapeDataString(status)}&");
}
foreach (var providerId in query.ProviderIds)
{
sb.Append($"providerId={Uri.EscapeDataString(providerId)}&");
}
if (query.Limit.HasValue)
{
sb.Append($"limit={query.Limit.Value}&");
}
if (!string.IsNullOrWhiteSpace(query.Cursor))
{
sb.Append($"cursor={Uri.EscapeDataString(query.Cursor)}&");
}
return sb.ToString().TrimEnd('&', '?');
}
private static string BuildLinksetRequestUri(VexLinksetQuery query)
{
var sb = new StringBuilder();
sb.Append($"api/v1/tenants/{Uri.EscapeDataString(query.Tenant)}/vex/linkset/{Uri.EscapeDataString(query.VulnerabilityId)}?");
foreach (var productKey in query.ProductKeys)
{
sb.Append($"productKey={Uri.EscapeDataString(productKey)}&");
}
foreach (var purl in query.Purls)
{
sb.Append($"purl={Uri.EscapeDataString(purl)}&");
}
foreach (var status in query.Statuses)
{
sb.Append($"status={Uri.EscapeDataString(status)}&");
}
return sb.ToString().TrimEnd('&', '?');
}
}