feat: Implement CVSS receipt management client and models
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -87,6 +87,31 @@ public sealed class AuthorityJwksServiceTests
|
||||
Assert.Contains(second.Response.Keys, key => key.Kid == "key-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jwks_IncludesSm2_WhenProviderSupportsIt()
|
||||
{
|
||||
var options = CreateAuthorityOptions();
|
||||
var provider = new TestCryptoProvider();
|
||||
provider.AddSm2Key("sm2-key");
|
||||
var registry = new TestRegistry(provider);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-30T12:00:00Z"));
|
||||
var hash = CryptoHashFactory.CreateDefault();
|
||||
var service = new AuthorityJwksService(
|
||||
registry,
|
||||
hash,
|
||||
NullLogger<AuthorityJwksService>.Instance,
|
||||
cache,
|
||||
clock,
|
||||
Options.Create(options));
|
||||
|
||||
var response = service.Get();
|
||||
var sm2 = response.Response.Keys.Single(key => key.Kid == "sm2-key");
|
||||
Assert.Equal(SignatureAlgorithms.Sm2, sm2.Alg);
|
||||
Assert.Equal("SM2", sm2.Crv);
|
||||
Assert.Equal("EC", sm2.Kty);
|
||||
}
|
||||
|
||||
private static StellaOpsAuthorityOptions CreateAuthorityOptions()
|
||||
{
|
||||
return new StellaOpsAuthorityOptions
|
||||
@@ -189,6 +214,19 @@ public sealed class AuthorityJwksServiceTests
|
||||
keys[keyId] = new TestKey(keyId, parameters);
|
||||
}
|
||||
|
||||
public void AddSm2Key(string keyId)
|
||||
{
|
||||
var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1");
|
||||
var domain = new Org.BouncyCastle.Crypto.Parameters.ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
|
||||
var generator = new Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator("EC");
|
||||
generator.Init(new Org.BouncyCastle.Crypto.Generators.ECKeyGenerationParameters(domain, new Org.BouncyCastle.Security.SecureRandom()));
|
||||
var pair = generator.GenerateKeyPair();
|
||||
var privateDer = Org.BouncyCastle.Asn1.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(pair.Private).GetDerEncoded();
|
||||
var keyRef = new CryptoKeyReference(keyId);
|
||||
var signingKey = new CryptoSigningKey(keyRef, SignatureAlgorithms.Sm2, privateDer, DateTimeOffset.UtcNow);
|
||||
keys[keyId] = new TestKey(keyId, signingKey.PublicParameters);
|
||||
}
|
||||
|
||||
private sealed class TestKey
|
||||
{
|
||||
public TestKey(string keyId, ECParameters parameters)
|
||||
|
||||
@@ -64,17 +64,18 @@ internal static class CommandFactory
|
||||
root.Add(BuildPromotionCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildDetscoreCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildObsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildPackCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildExceptionsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildOrchCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSbomCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildNotifyCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSbomerCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildRiskCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildReachabilityCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildApiCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildPackCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildExceptionsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildOrchCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSbomCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildNotifyCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSbomerCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildCvssCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildRiskCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildReachabilityCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildApiCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAirgapCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(SystemCommandBuilder.BuildSystemCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
@@ -126,9 +127,79 @@ internal static class CommandFactory
|
||||
return CommandHandlers.HandleScannerDownloadAsync(services, channel, output, overwrite, install, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
scanner.Add(download);
|
||||
return scanner;
|
||||
}
|
||||
scanner.Add(download);
|
||||
return scanner;
|
||||
}
|
||||
|
||||
private static Command BuildCvssCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var cvss = new Command("cvss", "CVSS v4.0 receipt operations (score, show, history, export)." );
|
||||
|
||||
var score = new Command("score", "Create a CVSS v4 receipt for a vulnerability.");
|
||||
var vulnOption = new Option<string>("--vuln") { Description = "Vulnerability identifier (e.g., CVE).", IsRequired = true };
|
||||
var policyFileOption = new Option<string>("--policy-file") { Description = "Path to CvssPolicy JSON file.", IsRequired = true };
|
||||
var vectorOption = new Option<string>("--vector") { Description = "CVSS:4.0 vector string.", IsRequired = true };
|
||||
var jsonOption = new Option<bool>("--json") { Description = "Emit JSON output." };
|
||||
score.Add(vulnOption);
|
||||
score.Add(policyFileOption);
|
||||
score.Add(vectorOption);
|
||||
score.Add(jsonOption);
|
||||
score.SetAction((parseResult, _) =>
|
||||
{
|
||||
var vuln = parseResult.GetValue(vulnOption) ?? string.Empty;
|
||||
var policyPath = parseResult.GetValue(policyFileOption) ?? string.Empty;
|
||||
var vector = parseResult.GetValue(vectorOption) ?? string.Empty;
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleCvssScoreAsync(services, vuln, policyPath, vector, json, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var show = new Command("show", "Fetch a CVSS receipt by ID.");
|
||||
var receiptArg = new Argument<string>("receipt-id") { Description = "Receipt identifier." };
|
||||
show.Add(receiptArg);
|
||||
var showJsonOption = new Option<bool>("--json") { Description = "Emit JSON output." };
|
||||
show.Add(showJsonOption);
|
||||
show.SetAction((parseResult, _) =>
|
||||
{
|
||||
var receiptId = parseResult.GetValue(receiptArg) ?? string.Empty;
|
||||
var json = parseResult.GetValue(showJsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleCvssShowAsync(services, receiptId, json, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var history = new Command("history", "Show receipt amendment history.");
|
||||
history.Add(receiptArg);
|
||||
var historyJsonOption = new Option<bool>("--json") { Description = "Emit JSON output." };
|
||||
history.Add(historyJsonOption);
|
||||
history.SetAction((parseResult, _) =>
|
||||
{
|
||||
var receiptId = parseResult.GetValue(receiptArg) ?? string.Empty;
|
||||
var json = parseResult.GetValue(historyJsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleCvssHistoryAsync(services, receiptId, json, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var export = new Command("export", "Export a CVSS receipt to JSON (pdf not yet supported).");
|
||||
export.Add(receiptArg);
|
||||
var formatOption = new Option<string>("--format") { Description = "json|pdf (json default)." };
|
||||
var outOption = new Option<string>("--out") { Description = "Output file path." };
|
||||
export.Add(formatOption);
|
||||
export.Add(outOption);
|
||||
export.SetAction((parseResult, _) =>
|
||||
{
|
||||
var receiptId = parseResult.GetValue(receiptArg) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var output = parseResult.GetValue(outOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleCvssExportAsync(services, receiptId, format, output, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
cvss.Add(score);
|
||||
cvss.Add(show);
|
||||
cvss.Add(history);
|
||||
cvss.Add(export);
|
||||
return cvss;
|
||||
}
|
||||
|
||||
private static Command BuildScanCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -27,14 +27,17 @@ using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Prompts;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Policies;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Node;
|
||||
@@ -67,12 +70,17 @@ internal static class CommandHandlers
|
||||
/// <summary>
|
||||
/// JSON serializer options for output (alias for JsonOptions).
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOutputOptions = JsonOptions;
|
||||
private static readonly JsonSerializerOptions JsonOutputOptions = JsonOptions;
|
||||
|
||||
private static readonly JsonSerializerOptions CompactJson = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Sets the verbosity level for logging.
|
||||
/// </summary>
|
||||
private static void SetVerbosity(IServiceProvider services, bool verbose)
|
||||
private static void SetVerbosity(IServiceProvider services, bool verbose)
|
||||
{
|
||||
// Configure logging level based on verbose flag
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
@@ -82,7 +90,215 @@ internal static class CommandHandlers
|
||||
var logger = loggerFactory.CreateLogger("StellaOps.Cli.Commands.CommandHandlers");
|
||||
logger.LogDebug("Verbose logging enabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleCvssScoreAsync(
|
||||
IServiceProvider services,
|
||||
string vulnerabilityId,
|
||||
string policyPath,
|
||||
string vector,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-score");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
|
||||
try
|
||||
{
|
||||
var policyJson = await File.ReadAllTextAsync(policyPath, cancellationToken).ConfigureAwait(false);
|
||||
var loader = new CvssPolicyLoader();
|
||||
var policyResult = loader.Load(policyJson, cancellationToken);
|
||||
if (!policyResult.IsValid || policyResult.Policy is null || string.IsNullOrWhiteSpace(policyResult.Hash))
|
||||
{
|
||||
var errors = string.Join("; ", policyResult.Errors.Select(e => $"{e.Path}: {e.Message}"));
|
||||
throw new InvalidOperationException($"Policy invalid: {errors}");
|
||||
}
|
||||
|
||||
var policy = policyResult.Policy with { Hash = policyResult.Hash };
|
||||
|
||||
var engine = scope.ServiceProvider.GetRequiredService<ICvssV4Engine>();
|
||||
var parsed = engine.ParseVector(vector);
|
||||
|
||||
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
|
||||
|
||||
var request = new CreateCvssReceipt(
|
||||
vulnerabilityId,
|
||||
policy,
|
||||
parsed.BaseMetrics,
|
||||
parsed.ThreatMetrics,
|
||||
parsed.EnvironmentalMetrics,
|
||||
parsed.SupplementalMetrics,
|
||||
Array.Empty<CvssEvidenceItem>(),
|
||||
SigningKey: null,
|
||||
CreatedBy: "cli",
|
||||
CreatedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
var receipt = await client.CreateReceiptAsync(request, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("CVSS receipt creation failed.");
|
||||
|
||||
if (json)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(receipt, CompactJson));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"✔ CVSS receipt {receipt.ReceiptId} created | Severity {receipt.Severity} | Effective {receipt.Scores.EffectiveScore:0.0}");
|
||||
Console.WriteLine($"Vector: {receipt.VectorString}");
|
||||
Console.WriteLine($"Policy: {receipt.PolicyRef.PolicyId} v{receipt.PolicyRef.Version} ({receipt.PolicyRef.Hash})");
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create CVSS receipt");
|
||||
Environment.ExitCode = 1;
|
||||
if (json)
|
||||
{
|
||||
var problem = new { error = "cvss_score_failed", message = ex.Message };
|
||||
Console.WriteLine(JsonSerializer.Serialize(problem, CompactJson));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleCvssShowAsync(
|
||||
IServiceProvider services,
|
||||
string receiptId,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-show");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
|
||||
try
|
||||
{
|
||||
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
|
||||
var receipt = await client.GetReceiptAsync(receiptId, cancellationToken).ConfigureAwait(false);
|
||||
if (receipt is null)
|
||||
{
|
||||
Environment.ExitCode = 5;
|
||||
Console.WriteLine(json
|
||||
? JsonSerializer.Serialize(new { error = "not_found", receiptId }, CompactJson)
|
||||
: $"✖ Receipt {receiptId} not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (json)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(receipt, CompactJson));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Receipt {receipt.ReceiptId} | Severity {receipt.Severity} | Effective {receipt.Scores.EffectiveScore:0.0}");
|
||||
Console.WriteLine($"Created {receipt.CreatedAt:u} by {receipt.CreatedBy}");
|
||||
Console.WriteLine($"Vector: {receipt.VectorString}");
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to fetch CVSS receipt {ReceiptId}", receiptId);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleCvssHistoryAsync(
|
||||
IServiceProvider services,
|
||||
string receiptId,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-history");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
|
||||
try
|
||||
{
|
||||
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
|
||||
var history = await client.GetHistoryAsync(receiptId, cancellationToken).ConfigureAwait(false);
|
||||
if (json)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(history, CompactJson));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (history.Count == 0)
|
||||
{
|
||||
Console.WriteLine("(no history)");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var entry in history.OrderBy(h => h.Timestamp))
|
||||
{
|
||||
Console.WriteLine($"{entry.Timestamp:u} | {entry.Actor} | {entry.ChangeType} {entry.Field} => {entry.NewValue ?? ""} ({entry.Reason})");
|
||||
}
|
||||
}
|
||||
}
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to fetch CVSS receipt history {ReceiptId}", receiptId);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleCvssExportAsync(
|
||||
IServiceProvider services,
|
||||
string receiptId,
|
||||
string format,
|
||||
string? output,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-export");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
|
||||
try
|
||||
{
|
||||
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
|
||||
var receipt = await client.GetReceiptAsync(receiptId, cancellationToken).ConfigureAwait(false);
|
||||
if (receipt is null)
|
||||
{
|
||||
Environment.ExitCode = 5;
|
||||
Console.WriteLine($"✖ Receipt {receiptId} not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Environment.ExitCode = 9;
|
||||
Console.WriteLine("Only json export is supported at this time.");
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPath = string.IsNullOrWhiteSpace(output)
|
||||
? $"cvss-receipt-{receipt.ReceiptId}.json"
|
||||
: output!;
|
||||
|
||||
var jsonPayload = JsonSerializer.Serialize(receipt, CompactJson);
|
||||
await File.WriteAllTextAsync(targetPath, jsonPayload, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"✔ Exported receipt to {targetPath}");
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to export CVSS receipt {ReceiptId}", receiptId);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
|
||||
namespace StellaOps.Cli;
|
||||
|
||||
@@ -213,8 +214,8 @@ internal static class Program
|
||||
// CLI-PARITY-41-002: Notify client for notification management
|
||||
services.AddHttpClient<INotifyClient, NotifyClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "notify-api");
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "notify-api");
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer client for layer/compose operations
|
||||
services.AddHttpClient<ISbomerClient, SbomerClient>(client =>
|
||||
@@ -222,6 +223,14 @@ internal static class Program
|
||||
client.Timeout = TimeSpan.FromMinutes(5); // Composition may take longer
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbomer-api");
|
||||
|
||||
// CLI-CVSS-190-010: CVSS receipt client (talks to Policy Gateway /api/cvss)
|
||||
services.AddHttpClient<ICvssClient, CvssClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "cvss-api");
|
||||
|
||||
services.AddSingleton<ICvssV4Engine, CvssV4Engine>();
|
||||
|
||||
// CLI-AIRGAP-56-001: Mirror bundle import service for air-gap operations
|
||||
services.AddSingleton<StellaOps.AirGap.Importer.Repositories.IBundleCatalogRepository,
|
||||
StellaOps.AirGap.Importer.Repositories.InMemoryBundleCatalogRepository>();
|
||||
|
||||
162
src/Cli/StellaOps.Cli/Services/CvssClient.cs
Normal file
162
src/Cli/StellaOps.Cli/Services/CvssClient.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Policy.Scoring;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class CvssClient : ICvssClient
|
||||
{
|
||||
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<CvssClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public CvssClient(HttpClient httpClient, StellaOpsCliOptions options, ILogger<CvssClient> 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<CvssScoreReceipt?> CreateReceiptAsync(CreateCvssReceipt request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/cvss/receipts")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: SerializerOptions)
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, StellaOps.Auth.Abstractions.StellaOpsScopes.PolicyRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
return await ReadResponseAsync<CvssScoreReceipt>(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<CvssScoreReceipt?> GetReceiptAsync(string receiptId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(receiptId);
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/cvss/receipts/{Uri.EscapeDataString(receiptId)}");
|
||||
await AuthorizeRequestAsync(httpRequest, StellaOps.Auth.Abstractions.StellaOpsScopes.FindingsRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
return await ReadResponseAsync<CvssScoreReceipt>(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReceiptHistoryEntry>> GetHistoryAsync(string receiptId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(receiptId);
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/cvss/receipts/{Uri.EscapeDataString(receiptId)}/history");
|
||||
await AuthorizeRequestAsync(httpRequest, StellaOps.Auth.Abstractions.StellaOpsScopes.FindingsRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
return await ReadResponseAsync<IReadOnlyList<ReceiptHistoryEntry>>(response, cancellationToken).ConfigureAwait(false)
|
||||
?? Array.Empty<ReceiptHistoryEntry>();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CvssPolicy>> ListPoliciesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, "/api/cvss/policies");
|
||||
await AuthorizeRequestAsync(httpRequest, StellaOps.Auth.Abstractions.StellaOpsScopes.PolicyRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
return await ReadResponseAsync<IReadOnlyList<CvssPolicy>>(response, cancellationToken).ConfigureAwait(false)
|
||||
?? Array.Empty<CvssPolicy>();
|
||||
}
|
||||
|
||||
private async Task<T?> ReadResponseAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogWarning("CVSS request failed with {Status}: {Body}", (int)response.StatusCode, string.IsNullOrWhiteSpace(body) ? "<empty>" : body);
|
||||
return default;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<T>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Backend URL not configured. Set STELLAOPS_BACKEND_URL or --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;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Token acquisition failed for scope {Scope}", scope);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/Cli/StellaOps.Cli/Services/ICvssClient.cs
Normal file
17
src/Cli/StellaOps.Cli/Services/ICvssClient.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy.Scoring;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface ICvssClient
|
||||
{
|
||||
Task<CvssScoreReceipt?> CreateReceiptAsync(CreateCvssReceipt request, CancellationToken cancellationToken);
|
||||
|
||||
Task<CvssScoreReceipt?> GetReceiptAsync(string receiptId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ReceiptHistoryEntry>> GetHistoryAsync(string receiptId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<CvssPolicy>> ListPoliciesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
19
src/Cli/StellaOps.Cli/Services/Models/CvssModels.cs
Normal file
19
src/Cli/StellaOps.Cli/Services/Models/CvssModels.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Policy.Scoring;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record CreateCvssReceipt(
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("policy")] CvssPolicy Policy,
|
||||
[property: JsonPropertyName("baseMetrics")] CvssBaseMetrics BaseMetrics,
|
||||
[property: JsonPropertyName("threatMetrics")] CvssThreatMetrics? ThreatMetrics,
|
||||
[property: JsonPropertyName("environmentalMetrics")] CvssEnvironmentalMetrics? EnvironmentalMetrics,
|
||||
[property: JsonPropertyName("supplementalMetrics")] CvssSupplementalMetrics? SupplementalMetrics,
|
||||
[property: JsonPropertyName("evidence")] IReadOnlyList<CvssEvidenceItem> Evidence,
|
||||
[property: JsonPropertyName("signingKey")] EnvelopeKey? SigningKey,
|
||||
[property: JsonPropertyName("createdBy")] string? CreatedBy,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset? CreatedAt);
|
||||
@@ -69,6 +69,7 @@
|
||||
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Policies;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Services;
|
||||
|
||||
public sealed class CvssClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetReceiptAsync_ParsesReceipt()
|
||||
{
|
||||
var sampleReceipt = CreateSampleReceipt();
|
||||
var handler = new StubHttpMessageHandler((request, _) =>
|
||||
{
|
||||
Assert.Equal("/api/cvss/receipts/r1", request.RequestUri!.AbsolutePath);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(sampleReceipt, new JsonSerializerOptions(JsonSerializerDefaults.Web))),
|
||||
RequestMessage = request
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://policy.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions { BackendUrl = "https://policy.example" };
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new CvssClient(httpClient, options, loggerFactory.CreateLogger<CvssClient>());
|
||||
|
||||
var result = await client.GetReceiptAsync("r1", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("r1", result!.ReceiptId);
|
||||
Assert.Equal(sampleReceipt.VectorString, result.VectorString);
|
||||
}
|
||||
|
||||
private static CvssScoreReceipt CreateSampleReceipt()
|
||||
{
|
||||
var engine = new CvssV4Engine();
|
||||
var parsed = engine.ParseVector("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H");
|
||||
var scores = engine.ComputeScores(parsed.BaseMetrics, parsed.ThreatMetrics, parsed.EnvironmentalMetrics);
|
||||
|
||||
return new CvssScoreReceipt
|
||||
{
|
||||
ReceiptId = "r1",
|
||||
TenantId = "tenant-1",
|
||||
VulnerabilityId = "CVE-2025-0001",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = "cli",
|
||||
ModifiedAt = null,
|
||||
ModifiedBy = null,
|
||||
BaseMetrics = parsed.BaseMetrics,
|
||||
ThreatMetrics = parsed.ThreatMetrics,
|
||||
EnvironmentalMetrics = parsed.EnvironmentalMetrics,
|
||||
SupplementalMetrics = parsed.SupplementalMetrics,
|
||||
Scores = scores,
|
||||
VectorString = engine.BuildVectorString(parsed.BaseMetrics, parsed.ThreatMetrics, parsed.EnvironmentalMetrics, parsed.SupplementalMetrics),
|
||||
Severity = engine.GetSeverity(scores.EffectiveScore),
|
||||
PolicyRef = new CvssPolicyReference { PolicyId = "cvss-policy", Version = "1.0.0", Hash = "abc", ActivatedAt = DateTimeOffset.UtcNow },
|
||||
InputHash = "deadbeef",
|
||||
History = System.Collections.Immutable.ImmutableList<ReceiptHistoryEntry>.Empty,
|
||||
Evidence = System.Collections.Immutable.ImmutableList<CvssEvidenceItem>.Empty,
|
||||
AttestationRefs = System.Collections.Immutable.ImmutableList<string>.Empty,
|
||||
ExportHash = null,
|
||||
AmendsReceiptId = null,
|
||||
SupersedesReceiptId = null,
|
||||
SupersededReason = null,
|
||||
IsActive = true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MongoDB.Bson;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Fetch;
|
||||
|
||||
@@ -8,9 +8,9 @@ namespace StellaOps.Concelier.Connector.Common.Fetch;
|
||||
/// </summary>
|
||||
public sealed class RawDocumentStorage
|
||||
{
|
||||
private readonly ConcurrentDictionary<ObjectId, byte[]> _blobs = new();
|
||||
private readonly ConcurrentDictionary<Guid, byte[]> _blobs = new();
|
||||
|
||||
public Task<ObjectId> UploadAsync(
|
||||
public Task<Guid> UploadAsync(
|
||||
string sourceName,
|
||||
string uri,
|
||||
byte[] content,
|
||||
@@ -18,19 +18,20 @@ public sealed class RawDocumentStorage
|
||||
CancellationToken cancellationToken)
|
||||
=> UploadAsync(sourceName, uri, content, contentType, expiresAt: null, cancellationToken);
|
||||
|
||||
public async Task<ObjectId> UploadAsync(
|
||||
public async Task<Guid> UploadAsync(
|
||||
string sourceName,
|
||||
string uri,
|
||||
byte[] content,
|
||||
string? contentType,
|
||||
DateTimeOffset? expiresAt,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
Guid? documentId = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(sourceName);
|
||||
ArgumentException.ThrowIfNullOrEmpty(uri);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var id = ObjectId.GenerateNewId();
|
||||
var id = documentId ?? Guid.NewGuid();
|
||||
var copy = new byte[content.Length];
|
||||
Buffer.BlockCopy(content, 0, copy, 0, content.Length);
|
||||
_blobs[id] = copy;
|
||||
@@ -38,17 +39,17 @@ public sealed class RawDocumentStorage
|
||||
return id;
|
||||
}
|
||||
|
||||
public Task<byte[]> DownloadAsync(ObjectId id, CancellationToken cancellationToken)
|
||||
public Task<byte[]> DownloadAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_blobs.TryGetValue(id, out var bytes))
|
||||
{
|
||||
return Task.FromResult(bytes);
|
||||
}
|
||||
|
||||
throw new MongoDB.Driver.GridFSFileNotFoundException($"Blob {id} not found.");
|
||||
throw new FileNotFoundException($"Blob {id} not found.");
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(ObjectId id, CancellationToken cancellationToken)
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
_blobs.TryRemove(id, out _);
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
|
||||
@@ -147,31 +147,33 @@ public sealed class SourceFetchService
|
||||
}
|
||||
}
|
||||
|
||||
var gridFsId = await _rawDocumentStorage.UploadAsync(
|
||||
var existing = await _documentStore.FindBySourceAndUriAsync(request.SourceName, request.RequestUri.ToString(), cancellationToken).ConfigureAwait(false);
|
||||
var recordId = existing?.Id ?? Guid.NewGuid();
|
||||
|
||||
var payloadId = await _rawDocumentStorage.UploadAsync(
|
||||
request.SourceName,
|
||||
request.RequestUri.ToString(),
|
||||
contentBytes,
|
||||
contentType,
|
||||
expiresAt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var existing = await _documentStore.FindBySourceAndUriAsync(request.SourceName, request.RequestUri.ToString(), cancellationToken).ConfigureAwait(false);
|
||||
var recordId = existing?.Id ?? Guid.NewGuid();
|
||||
|
||||
var record = new DocumentRecord(
|
||||
recordId,
|
||||
request.SourceName,
|
||||
cancellationToken,
|
||||
recordId).ConfigureAwait(false);
|
||||
|
||||
var record = new DocumentRecord(
|
||||
recordId,
|
||||
request.SourceName,
|
||||
request.RequestUri.ToString(),
|
||||
fetchedAt,
|
||||
contentHash,
|
||||
DocumentStatuses.PendingParse,
|
||||
contentType,
|
||||
headers,
|
||||
metadata,
|
||||
response.Headers.ETag?.Tag,
|
||||
response.Content.Headers.LastModified,
|
||||
gridFsId,
|
||||
expiresAt);
|
||||
headers,
|
||||
metadata,
|
||||
response.Headers.ETag?.Tag,
|
||||
response.Content.Headers.LastModified,
|
||||
payloadId,
|
||||
expiresAt,
|
||||
Payload: contentBytes);
|
||||
|
||||
var upserted = await _documentStore.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
SourceDiagnostics.RecordHttpRequest(request.SourceName, request.ClientName, response.StatusCode, sendResult.Attempts, duration, contentBytes.LongLength, rateLimitRemaining);
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Security.Cryptography;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
|
||||
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
|
||||
|
||||
/// <summary>
|
||||
/// Persists merge events with canonical before/after hashes for auditability.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo
|
||||
@@ -12,6 +13,17 @@ namespace StellaOps.Concelier.Storage.Mongo
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
|
||||
public static class MongoStorageDefaults
|
||||
{
|
||||
public static class Collections
|
||||
{
|
||||
public const string AdvisoryStatements = "advisory_statements";
|
||||
public const string AdvisoryRaw = "advisory_raw";
|
||||
public const string Alias = "aliases";
|
||||
public const string MergeEvent = "merge_events";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MongoStorageOptions
|
||||
{
|
||||
public string DefaultTenant { get; init; } = "default";
|
||||
@@ -87,7 +99,7 @@ namespace StellaOps.Concelier.Storage.Mongo
|
||||
Guid DocumentId,
|
||||
string SourceName,
|
||||
string Format,
|
||||
string Payload,
|
||||
MongoDB.Bson.BsonDocument Payload,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public interface IDtoStore
|
||||
@@ -117,9 +129,9 @@ namespace StellaOps.Concelier.Storage.Mongo
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, byte[]> _blobs = new();
|
||||
|
||||
public Task<Guid> UploadAsync(string sourceName, string uri, byte[] content, string? contentType, DateTimeOffset? expiresAt, CancellationToken cancellationToken)
|
||||
public Task<Guid> UploadAsync(string sourceName, string uri, byte[] content, string? contentType, DateTimeOffset? expiresAt, CancellationToken cancellationToken, Guid? documentId = null)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var id = documentId ?? Guid.NewGuid();
|
||||
_blobs[id] = content.ToArray();
|
||||
return Task.FromResult(id);
|
||||
}
|
||||
@@ -143,12 +155,12 @@ namespace StellaOps.Concelier.Storage.Mongo
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SourceStateRecord(string SourceName, string? CursorJson, DateTimeOffset UpdatedAt);
|
||||
public sealed record SourceStateRecord(string SourceName, MongoDB.Bson.BsonDocument? Cursor, DateTimeOffset UpdatedAt);
|
||||
|
||||
public interface ISourceStateRepository
|
||||
{
|
||||
Task<SourceStateRecord?> TryGetAsync(string sourceName, CancellationToken cancellationToken);
|
||||
Task UpdateCursorAsync(string sourceName, string cursorJson, DateTimeOffset completedAt, CancellationToken cancellationToken);
|
||||
Task UpdateCursorAsync(string sourceName, MongoDB.Bson.BsonDocument cursor, DateTimeOffset completedAt, CancellationToken cancellationToken);
|
||||
Task MarkFailureAsync(string sourceName, DateTimeOffset now, TimeSpan backoff, string reason, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -162,9 +174,9 @@ namespace StellaOps.Concelier.Storage.Mongo
|
||||
return Task.FromResult<SourceStateRecord?>(record);
|
||||
}
|
||||
|
||||
public Task UpdateCursorAsync(string sourceName, string cursorJson, DateTimeOffset completedAt, CancellationToken cancellationToken)
|
||||
public Task UpdateCursorAsync(string sourceName, MongoDB.Bson.BsonDocument cursor, DateTimeOffset completedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
_states[sourceName] = new SourceStateRecord(sourceName, cursorJson, completedAt);
|
||||
_states[sourceName] = new SourceStateRecord(sourceName, cursor.DeepClone(), completedAt);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -225,7 +237,15 @@ namespace StellaOps.Concelier.Storage.Mongo.Advisories
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Aliases
|
||||
{
|
||||
public static class AliasStoreConstants
|
||||
{
|
||||
public const string PrimaryScheme = "PRIMARY";
|
||||
public const string UnscopedScheme = "UNSCOPED";
|
||||
}
|
||||
|
||||
public sealed record AliasEntry(string Scheme, string Value);
|
||||
public sealed record AliasRecord(string AdvisoryKey, string Scheme, string Value);
|
||||
public sealed record AliasCollision(string Scheme, string Value, IReadOnlyList<string> AdvisoryKeys);
|
||||
|
||||
public interface IAliasStore
|
||||
{
|
||||
@@ -387,17 +407,52 @@ namespace StellaOps.Concelier.Storage.Mongo.Exporting
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.MergeEvents
|
||||
{
|
||||
public sealed record MergeEventRecord(string AdvisoryKey, string EventType, DateTimeOffset CreatedAt);
|
||||
public sealed record MergeEventRecord(
|
||||
Guid Id,
|
||||
string AdvisoryKey,
|
||||
byte[] BeforeHash,
|
||||
byte[] AfterHash,
|
||||
DateTimeOffset MergedAt,
|
||||
IReadOnlyList<Guid> InputDocumentIds,
|
||||
IReadOnlyList<MergeFieldDecision> FieldDecisions);
|
||||
|
||||
public sealed record MergeFieldDecision(
|
||||
string Field,
|
||||
string? SelectedSource,
|
||||
string DecisionReason,
|
||||
DateTimeOffset? SelectedModified,
|
||||
IReadOnlyList<string> ConsideredSources);
|
||||
|
||||
public interface IMergeEventStore
|
||||
{
|
||||
Task AppendAsync(MergeEventRecord record, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MergeEventRecord>> GetRecentAsync(string advisoryKey, int limit, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class InMemoryMergeEventStore : IMergeEventStore
|
||||
{
|
||||
private readonly ConcurrentBag<MergeEventRecord> _records = new();
|
||||
|
||||
public Task AppendAsync(MergeEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_records.Add(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MergeEventRecord>> GetRecentAsync(string advisoryKey, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var records = _records
|
||||
.Where(r => string.Equals(r.AdvisoryKey, advisoryKey, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(r => r.MergedAt)
|
||||
.Take(limit)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<MergeEventRecord>>(records);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo
|
||||
{
|
||||
public static class MongoStorageDefaults
|
||||
{
|
||||
public static class Collections
|
||||
{
|
||||
public const string AdvisoryStatements = "advisory_statements";
|
||||
public const string AdvisoryRaw = "advisory_raw";
|
||||
}
|
||||
}
|
||||
// Already defined above; kept for backward compatibility with legacy using directives.
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class PostgresDocumentStore : IDocumentStore
|
||||
MetadataJson: record.Metadata is null ? null : JsonSerializer.Serialize(record.Metadata, _json),
|
||||
Etag: record.Etag,
|
||||
LastModified: record.LastModified,
|
||||
Payload: Array.Empty<byte>(), // payload handled via RawDocumentStorage; keep pointer zero-length here
|
||||
Payload: record.Payload ?? Array.Empty<byte>(),
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: DateTimeOffset.UtcNow,
|
||||
ExpiresAt: record.ExpiresAt);
|
||||
@@ -82,7 +82,8 @@ public sealed class PostgresDocumentStore : IDocumentStore
|
||||
: JsonSerializer.Deserialize<Dictionary<string, string>>(row.MetadataJson, _json),
|
||||
row.Etag,
|
||||
row.LastModified,
|
||||
PayloadId: null,
|
||||
ExpiresAt: row.ExpiresAt);
|
||||
PayloadId: row.Id,
|
||||
ExpiresAt: row.ExpiresAt,
|
||||
Payload: row.Payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Lang.DotNet.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
|
||||
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />
|
||||
|
||||
@@ -101,7 +101,7 @@ public sealed class GlobalJsonParserTests
|
||||
|
||||
var result = GlobalJsonParser.Parse(content);
|
||||
|
||||
Assert.Equal(GlobalJsonParser.Empty, result);
|
||||
Assert.Equal(GlobalJsonResult.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -111,7 +111,7 @@ public sealed class GlobalJsonParserTests
|
||||
|
||||
var result = GlobalJsonParser.Parse(content);
|
||||
|
||||
Assert.Equal(GlobalJsonParser.Empty, result);
|
||||
Assert.Equal(GlobalJsonResult.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -120,7 +120,7 @@ public sealed class GlobalJsonParserTests
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var result = await GlobalJsonParser.ParseAsync("/nonexistent/global.json", cancellationToken);
|
||||
|
||||
Assert.Equal(GlobalJsonParser.Empty, result);
|
||||
Assert.Equal(GlobalJsonResult.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -346,7 +346,7 @@ public sealed class NuGetConfigParserTests
|
||||
|
||||
var result = NuGetConfigParser.Parse(content);
|
||||
|
||||
Assert.Equal(NuGetConfigParser.Empty, result);
|
||||
Assert.Equal(NuGetConfigResult.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -42,19 +42,19 @@ public sealed class PackagesConfigParserTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesAllowedVersions()
|
||||
public void ParsesCondition()
|
||||
{
|
||||
var content = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net472" allowedVersions="[13.0,14.0)" />
|
||||
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net472" />
|
||||
</packages>
|
||||
""";
|
||||
|
||||
var result = PackagesConfigParser.Parse(content);
|
||||
|
||||
Assert.Single(result.Packages);
|
||||
Assert.Equal("[13.0,14.0)", result.Packages[0].AllowedVersions);
|
||||
Assert.Equal("Newtonsoft.Json", result.Packages[0].PackageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -100,7 +100,7 @@ public sealed class PackagesConfigParserTests
|
||||
|
||||
var result = PackagesConfigParser.Parse(content);
|
||||
|
||||
Assert.Equal(PackagesConfigParser.Empty, result);
|
||||
Assert.Equal(PackagesConfigResult.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -110,7 +110,7 @@ public sealed class PackagesConfigParserTests
|
||||
|
||||
var result = PackagesConfigParser.Parse(content);
|
||||
|
||||
Assert.Equal(PackagesConfigParser.Empty, result);
|
||||
Assert.Equal(PackagesConfigResult.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -119,7 +119,7 @@ public sealed class PackagesConfigParserTests
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var result = await PackagesConfigParser.ParseAsync("/nonexistent/packages.config", cancellationToken);
|
||||
|
||||
Assert.Equal(PackagesConfigParser.Empty, result);
|
||||
Assert.Equal(PackagesConfigResult.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Global using directives for test framework -->
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Analyzers.Lang.DotNet\StellaOps.Scanner.Analyzers.Lang.DotNet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -155,7 +155,7 @@ internal static class DotNetFixtureBuilder
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(""" "version": 1,""");
|
||||
sb.AppendLine(""" "dependencies": {""");
|
||||
sb.AppendLine($""" "{targetFramework}": {{""");
|
||||
sb.AppendLine($" \"{targetFramework}\": {{");
|
||||
|
||||
for (var i = 0; i < packages.Length; i++)
|
||||
{
|
||||
@@ -163,10 +163,10 @@ internal static class DotNetFixtureBuilder
|
||||
var type = isDirect ? "Direct" : "Transitive";
|
||||
var comma = i < packages.Length - 1 ? "," : "";
|
||||
|
||||
sb.AppendLine($""" "{packageId}": {{""");
|
||||
sb.AppendLine($""" "type": "{type}",""");
|
||||
sb.AppendLine($""" "resolved": "{version}",""");
|
||||
sb.AppendLine($""" "contentHash": "sha512-test{i}==""");
|
||||
sb.AppendLine($" \"{packageId}\": {{");
|
||||
sb.AppendLine($" \"type\": \"{type}\",");
|
||||
sb.AppendLine($" \"resolved\": \"{version}\",");
|
||||
sb.AppendLine($" \"contentHash\": \"sha512-test{i}==\"");
|
||||
sb.AppendLine($" }}{comma}");
|
||||
}
|
||||
|
||||
@@ -216,18 +216,18 @@ internal static class DotNetFixtureBuilder
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(""" "sdk": {""");
|
||||
sb.Append($""" "version": "{sdkVersion}"""");
|
||||
sb.Append($" \"version\": \"{sdkVersion}\"");
|
||||
|
||||
if (!string.IsNullOrEmpty(rollForward))
|
||||
{
|
||||
sb.AppendLine(",");
|
||||
sb.Append($""" "rollForward": "{rollForward}"""");
|
||||
sb.Append($" \"rollForward\": \"{rollForward}\"");
|
||||
}
|
||||
|
||||
if (allowPrerelease.HasValue)
|
||||
{
|
||||
sb.AppendLine(",");
|
||||
sb.Append($""" "allowPrerelease": {allowPrerelease.Value.ToString().ToLowerInvariant()}""");
|
||||
sb.Append($" \"allowPrerelease\": {allowPrerelease.Value.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
@@ -319,22 +319,25 @@ internal static class DotNetFixtureBuilder
|
||||
var bundleSignature = ".net core bundle"u8.ToArray();
|
||||
|
||||
// Create a file with MZ header and bundle markers
|
||||
// Must be > 100KB (detector minimum) and put signature in last 64KB
|
||||
var content = new byte[1024 * 200]; // 200KB
|
||||
content[0] = 0x4D; // 'M'
|
||||
content[1] = 0x5A; // 'Z'
|
||||
|
||||
// Add bundle signature
|
||||
Array.Copy(bundleSignature, 0, content, 500, bundleSignature.Length);
|
||||
// Add bundle signature in the LAST 64KB (detector searches there)
|
||||
// Position it near the end of the file
|
||||
var signaturePosition = content.Length - (32 * 1024); // 32KB from end
|
||||
Array.Copy(bundleSignature, 0, content, signaturePosition, bundleSignature.Length);
|
||||
|
||||
// Add some System. namespace patterns
|
||||
// Add some System. namespace patterns in the last 64KB
|
||||
var systemPattern = "System.Runtime"u8.ToArray();
|
||||
Array.Copy(systemPattern, 0, content, 1000, systemPattern.Length);
|
||||
Array.Copy(systemPattern, 0, content, signaturePosition + 100, systemPattern.Length);
|
||||
|
||||
// Add .dll patterns
|
||||
// Add .dll patterns in the last 64KB for embedded pattern detection
|
||||
var dllPattern = ".dll"u8.ToArray();
|
||||
for (var i = 0; i < 15; i++)
|
||||
{
|
||||
Array.Copy(dllPattern, 0, content, 2000 + i * 100, dllPattern.Length);
|
||||
Array.Copy(dllPattern, 0, content, signaturePosition + 200 + i * 100, dllPattern.Length);
|
||||
}
|
||||
|
||||
var filePath = Path.Combine(directory, bundleName);
|
||||
|
||||
Reference in New Issue
Block a user