Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
This commit is contained in:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -354,7 +354,7 @@ internal static class CommandHandlers
throw new InvalidOperationException("Tenant must be provided via --tenant or STELLA_TENANT.");
}
var payload = await LoadIngestInputAsync(input, cancellationToken).ConfigureAwait(false);
var payload = await LoadIngestInputAsync(services, input, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Executing ingestion dry-run for source {Source} using input {Input}.", source, payload.Name);
@@ -5009,22 +5009,22 @@ internal static class CommandHandlers
return string.IsNullOrWhiteSpace(fromEnvironment) ? string.Empty : fromEnvironment.Trim();
}
private static async Task<IngestInputPayload> LoadIngestInputAsync(string input, CancellationToken cancellationToken)
private static async Task<IngestInputPayload> LoadIngestInputAsync(IServiceProvider services, string input, CancellationToken cancellationToken)
{
if (Uri.TryCreate(input, UriKind.Absolute, out var uri) &&
(uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ||
uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
{
return await LoadIngestInputFromHttpAsync(uri, cancellationToken).ConfigureAwait(false);
return await LoadIngestInputFromHttpAsync(services, uri, cancellationToken).ConfigureAwait(false);
}
return await LoadIngestInputFromFileAsync(input, cancellationToken).ConfigureAwait(false);
}
private static async Task<IngestInputPayload> LoadIngestInputFromHttpAsync(Uri uri, CancellationToken cancellationToken)
private static async Task<IngestInputPayload> LoadIngestInputFromHttpAsync(IServiceProvider services, Uri uri, CancellationToken cancellationToken)
{
using var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All };
using var httpClient = new HttpClient(handler);
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("stellaops-cli.ingest-download");
using var response = await httpClient.GetAsync(uri, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)

View File

@@ -0,0 +1,51 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Policy;
namespace StellaOps.Cli.Configuration;
internal sealed class EgressPolicyHttpMessageHandler : DelegatingHandler
{
private readonly IEgressPolicy? _policy;
private readonly ILogger _logger;
private readonly string _component;
private readonly string _intent;
public EgressPolicyHttpMessageHandler(IEgressPolicy? policy, ILogger logger, string component, string intent)
{
_policy = policy;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_component = string.IsNullOrWhiteSpace(component) ? "stellaops-cli" : component;
_intent = string.IsNullOrWhiteSpace(intent) ? "cli-http" : intent;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_policy is null || request.RequestUri is not { IsAbsoluteUri: true } uri)
{
return base.SendAsync(request, cancellationToken);
}
try
{
var egressRequest = new EgressRequest(
_component,
uri,
_intent,
operation: request.Method.Method);
_policy.EnsureAllowed(egressRequest);
}
catch (AirGapEgressBlockedException ex)
{
_logger.LogWarning(ex, "Egress blocked for {Component} when contacting {Destination}", _component, request.RequestUri);
throw;
}
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,28 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Policy;
namespace StellaOps.Cli.Configuration;
internal static class HttpClientBuilderExtensions
{
public static IHttpClientBuilder AddEgressPolicyGuard(this IHttpClientBuilder builder, string component, string intent)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
return builder.AddHttpMessageHandler(sp =>
{
var policy = sp.GetService<IEgressPolicy>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
return new EgressPolicyHttpMessageHandler(
policy,
loggerFactory.CreateLogger<EgressPolicyHttpMessageHandler>(),
component,
intent);
});
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.CommandLine;
using System;
using System.CommandLine;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
@@ -9,7 +10,8 @@ using StellaOps.Auth.Client;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services;
using StellaOps.Cli.Telemetry;
using StellaOps.Cli.Telemetry;
using StellaOps.AirGap.Policy;
namespace StellaOps.Cli;
@@ -24,7 +26,8 @@ internal static class Program
services.AddSingleton(options);
var verbosityState = new VerbosityState();
services.AddSingleton(verbosityState);
services.AddSingleton(verbosityState);
services.AddAirGapEgressPolicy(configuration);
services.AddLogging(builder =>
{
@@ -89,7 +92,7 @@ internal static class Program
{
client.BaseAddress = authorityUri;
}
});
}).AddEgressPolicyGuard("stellaops-cli", "authority-revocation");
}
services.AddHttpClient<IBackendOperationsClient, BackendOperationsClient>(client =>
@@ -100,7 +103,7 @@ internal static class Program
{
client.BaseAddress = backendUri;
}
});
}).AddEgressPolicyGuard("stellaops-cli", "backend-api");
services.AddHttpClient<IConcelierObservationsClient, ConcelierObservationsClient>(client =>
{
@@ -110,7 +113,14 @@ internal static class Program
{
client.BaseAddress = concelierUri;
}
});
}).AddEgressPolicyGuard("stellaops-cli", "concelier-api");
services.AddHttpClient("stellaops-cli.ingest-download")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All
})
.AddEgressPolicyGuard("stellaops-cli", "sources-ingest");
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
@@ -127,8 +137,30 @@ internal static class Program
};
var rootCommand = CommandFactory.Create(serviceProvider, options, cts.Token, loggerFactory);
var commandConfiguration = new CommandLineConfiguration(rootCommand);
var commandExit = await commandConfiguration.InvokeAsync(args, cts.Token).ConfigureAwait(false);
var commandConfiguration = new CommandLineConfiguration(rootCommand);
int commandExit;
try
{
commandExit = await commandConfiguration.InvokeAsync(args, cts.Token).ConfigureAwait(false);
}
catch (AirGapEgressBlockedException ex)
{
var guardLogger = loggerFactory.CreateLogger("StellaOps.Cli.AirGap");
guardLogger.LogError("{ErrorCode}: {Reason} Remediation: {Remediation}", AirGapEgressBlockedException.ErrorCode, ex.Reason, ex.Remediation);
if (!string.IsNullOrWhiteSpace(ex.DocumentationUrl))
{
guardLogger.LogInformation("Documentation: {DocumentationUrl}", ex.DocumentationUrl);
}
if (!string.IsNullOrWhiteSpace(ex.SupportContact))
{
guardLogger.LogInformation("Support contact: {SupportContact}", ex.SupportContact);
}
Console.Error.WriteLine(ex.Message);
return 1;
}
var finalExit = Environment.ExitCode != 0 ? Environment.ExitCode : commandExit;
if (cts.IsCancellationRequested && finalExit == 0)

View File

@@ -41,6 +41,7 @@
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />

View File

@@ -12,6 +12,11 @@
> 2025-10-27: CLI reference now reflects final summary fields/JSON schema, quickstart includes verification/dry-run workflows, and API reference tables list both `sources ingest --dry-run` and `aoc verify`.
> 2025-11-01: Update CLI auth defaults to request `attestor.verify` (and `attestor.read` for list/detail) after Attestor scope split; tokens without new scopes will fail verification calls.
## Replay Enablement
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-REPLAY-187-002 | TODO | DevEx/CLI Guild | REPLAY-CORE-185-001, SCAN-REPLAY-186-001 | Implement `scan --record`, `verify`, `replay`, and `diff` commands with offline bundle resolution; update `docs/modules/cli/architecture.md` appendix referencing `docs/replay/DEVS_GUIDE_REPLAY.md`. | Commands tested (unit/integration); docs merged; offline workflows validated with sample bundles. |
## Policy Engine v2
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |

View File

@@ -2327,14 +2327,15 @@ public sealed class CommandHandlersTests
IStellaOpsTokenClient? tokenClient = null,
IConcelierObservationsClient? concelierClient = null)
{
var services = new ServiceCollection();
services.AddSingleton(backend);
services.AddSingleton<ILoggerFactory>(_ => LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)));
services.AddSingleton(new VerbosityState());
var resolvedOptions = options ?? new StellaOpsCliOptions
{
ResultsDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-results-{Guid.NewGuid():N}")
};
var services = new ServiceCollection();
services.AddSingleton(backend);
services.AddSingleton<ILoggerFactory>(_ => LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)));
services.AddSingleton(new VerbosityState());
services.AddHttpClient();
var resolvedOptions = options ?? new StellaOpsCliOptions
{
ResultsDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-results-{Guid.NewGuid():N}")
};
services.AddSingleton(resolvedOptions);
var resolvedExecutor = executor ?? CreateDefaultExecutor();

View File

@@ -0,0 +1,63 @@
using Xunit;
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AirGap.Policy;
using StellaOps.Cli.Configuration;
namespace StellaOps.Cli.Tests.Configuration;
public sealed class EgressPolicyHttpMessageHandlerTests
{
[Fact]
public async Task SendAsync_AllowsRequestWhenPolicyPermits()
{
var options = new EgressPolicyOptions
{
Mode = EgressPolicyMode.Sealed
};
options.AddAllowRule(example.com);
var policy = new EgressPolicy(options);
var handler = new EgressPolicyHttpMessageHandler(policy, NullLogger<EgressPolicyHttpMessageHandler>.Instance, cli, test)
{
InnerHandler = new StubHandler()
};
var client = new HttpClient(handler, disposeHandler: true);
var response = await client.GetAsync(https://example.com/resource, CancellationToken.None).ConfigureAwait(false);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task SendAsync_ThrowsWhenPolicyBlocksRequest()
{
var options = new EgressPolicyOptions
{
Mode = EgressPolicyMode.Sealed
};
var policy = new EgressPolicy(options);
var handler = new EgressPolicyHttpMessageHandler(policy, NullLogger<EgressPolicyHttpMessageHandler>.Instance, cli, test)
{
InnerHandler = new StubHandler()
};
var client = new HttpClient(handler, disposeHandler: true);
var exception = await Assert.ThrowsAsync<AirGapEgressBlockedException>(
() => client.GetAsync(https://blocked.example, CancellationToken.None)).ConfigureAwait(false);
Assert.Contains(AirGapEgressBlockedException.ErrorCode, exception.Message, StringComparison.OrdinalIgnoreCase);
}
private sealed class StubHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}