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

@@ -0,0 +1,70 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace StellaOps.Findings.Ledger.WebService.Contracts;
public sealed record LedgerEventRequest
{
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
[JsonPropertyName("chainId")]
public required Guid ChainId { get; init; }
[JsonPropertyName("sequence")]
public required long Sequence { get; init; }
[JsonPropertyName("eventId")]
public required Guid EventId { get; init; }
[JsonPropertyName("eventType")]
public required string EventType { get; init; }
[JsonPropertyName("policyVersion")]
public required string PolicyVersion { get; init; }
[JsonPropertyName("finding")]
public required LedgerFindingRequest Finding { get; init; }
[JsonPropertyName("artifactId")]
public required string ArtifactId { get; init; }
[JsonPropertyName("sourceRunId")]
public Guid? SourceRunId { get; init; }
[JsonPropertyName("actor")]
public required LedgerActorRequest Actor { get; init; }
[JsonPropertyName("occurredAt")]
public required DateTimeOffset OccurredAt { get; init; }
[JsonPropertyName("recordedAt")]
public DateTimeOffset? RecordedAt { get; init; }
[JsonPropertyName("payload")]
public JsonObject? Payload { get; init; }
[JsonPropertyName("previousHash")]
public string? PreviousHash { get; init; }
}
public sealed record LedgerFindingRequest
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("artifactId")]
public string? ArtifactId { get; init; }
[JsonPropertyName("vulnId")]
public required string VulnId { get; init; }
}
public sealed record LedgerActorRequest
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
}

View File

@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace StellaOps.Findings.Ledger.WebService.Contracts;
public sealed record LedgerEventResponse
{
[JsonPropertyName("eventId")]
public Guid EventId { get; init; }
[JsonPropertyName("chainId")]
public Guid ChainId { get; init; }
[JsonPropertyName("sequence")]
public long Sequence { get; init; }
[JsonPropertyName("status")]
public string Status { get; init; } = "created";
[JsonPropertyName("eventHash")]
public string EventHash { get; init; } = string.Empty;
[JsonPropertyName("previousHash")]
public string PreviousHash { get; init; } = string.Empty;
[JsonPropertyName("merkleLeafHash")]
public string MerkleLeafHash { get; init; } = string.Empty;
[JsonPropertyName("recordedAt")]
public DateTimeOffset RecordedAt { get; init; }
}

View File

@@ -0,0 +1,72 @@
using System.Text.Json.Nodes;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.WebService.Contracts;
namespace StellaOps.Findings.Ledger.WebService.Mappings;
public static class LedgerEventMapping
{
public static LedgerEventDraft ToDraft(this LedgerEventRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var recordedAt = (request.RecordedAt ?? DateTimeOffset.UtcNow).ToUniversalTime();
var payload = request.Payload is null ? new JsonObject() : (JsonObject)request.Payload.DeepClone();
var eventObject = new JsonObject
{
["id"] = request.EventId.ToString(),
["type"] = request.EventType,
["tenant"] = request.TenantId,
["chainId"] = request.ChainId.ToString(),
["sequence"] = request.Sequence,
["policyVersion"] = request.PolicyVersion,
["artifactId"] = request.ArtifactId,
["finding"] = new JsonObject
{
["id"] = request.Finding.Id,
["artifactId"] = request.Finding.ArtifactId ?? request.ArtifactId,
["vulnId"] = request.Finding.VulnId
},
["actor"] = new JsonObject
{
["id"] = request.Actor.Id,
["type"] = request.Actor.Type
},
["occurredAt"] = FormatTimestamp(request.OccurredAt),
["recordedAt"] = FormatTimestamp(recordedAt),
["payload"] = payload
};
if (request.SourceRunId is Guid sourceRunId && sourceRunId != Guid.Empty)
{
eventObject["sourceRunId"] = sourceRunId.ToString();
}
var envelope = new JsonObject
{
["event"] = eventObject
};
return new LedgerEventDraft(
request.TenantId,
request.ChainId,
request.Sequence,
request.EventId,
request.EventType,
request.PolicyVersion,
request.Finding.Id,
request.ArtifactId,
request.SourceRunId,
request.Actor.Id,
request.Actor.Type,
request.OccurredAt.ToUniversalTime(),
recordedAt,
payload,
envelope,
request.PreviousHash);
}
private static string FormatTimestamp(DateTimeOffset value)
=> value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
}

View File

@@ -0,0 +1,206 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Serilog;
using Serilog.Events;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.DependencyInjection;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Infrastructure.Merkle;
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
using StellaOps.Findings.Ledger.Infrastructure.Projection;
using StellaOps.Findings.Ledger.Infrastructure.Policy;
using StellaOps.Findings.Ledger.Options;
using StellaOps.Findings.Ledger.Services;
using StellaOps.Findings.Ledger.WebService.Contracts;
using StellaOps.Findings.Ledger.WebService.Mappings;
using StellaOps.Telemetry.Core;
const string LedgerWritePolicy = "ledger.events.write";
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "FINDINGS_LEDGER_";
options.ConfigureBuilder = configurationBuilder =>
{
configurationBuilder.AddYamlFile("../etc/findings-ledger.yaml", optional: true, reloadOnChange: true);
};
});
var bootstrapOptions = builder.Configuration.BindOptions<LedgerServiceOptions>(
LedgerServiceOptions.SectionName,
(opts, _) => opts.Validate());
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
loggerConfiguration
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console();
});
builder.Services.AddOptions<LedgerServiceOptions>()
.Bind(builder.Configuration.GetSection(LedgerServiceOptions.SectionName))
.PostConfigure(options => options.Validate())
.ValidateOnStart();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddHealthChecks();
builder.Services.AddStellaOpsTelemetry(
builder.Configuration,
configureMetering: meterBuilder =>
{
meterBuilder.AddAspNetCoreInstrumentation();
meterBuilder.AddHttpClientInstrumentation();
},
configureTracing: tracerBuilder =>
{
tracerBuilder.AddAspNetCoreInstrumentation();
tracerBuilder.AddHttpClientInstrumentation();
});
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = bootstrapOptions.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = bootstrapOptions.Authority.RequireHttpsMetadata;
resourceOptions.MetadataAddress = bootstrapOptions.Authority.MetadataAddress;
resourceOptions.BackchannelTimeout = bootstrapOptions.Authority.BackchannelTimeout;
resourceOptions.TokenClockSkew = bootstrapOptions.Authority.TokenClockSkew;
resourceOptions.Audiences.Clear();
foreach (var audience in bootstrapOptions.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
resourceOptions.RequiredScopes.Clear();
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
{
resourceOptions.BypassNetworks.Add(network);
}
});
builder.Services.AddAuthorization(options =>
{
var scopes = bootstrapOptions.Authority.RequiredScopes.Count > 0
? bootstrapOptions.Authority.RequiredScopes.ToArray()
: new[] { StellaOpsScopes.VulnOperate };
options.AddPolicy(LedgerWritePolicy, policy =>
{
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
});
});
builder.Services.AddSingleton<LedgerAnchorQueue>();
builder.Services.AddSingleton<LedgerDataSource>();
builder.Services.AddSingleton<IMerkleAnchorRepository, PostgresMerkleAnchorRepository>();
builder.Services.AddSingleton<ILedgerEventRepository, PostgresLedgerEventRepository>();
builder.Services.AddSingleton<IMerkleAnchorScheduler, PostgresMerkleAnchorScheduler>();
builder.Services.AddSingleton<ILedgerEventStream, PostgresLedgerEventStream>();
builder.Services.AddSingleton<IFindingProjectionRepository, PostgresFindingProjectionRepository>();
builder.Services.AddSingleton<IPolicyEvaluationService, InlinePolicyEvaluationService>();
builder.Services.AddSingleton<ILedgerEventWriteService, LedgerEventWriteService>();
builder.Services.AddHostedService<LedgerMerkleAnchorWorker>();
builder.Services.AddHostedService<LedgerProjectionWorker>();
var app = builder.Build();
app.UseSerilogRequestLogging();
app.UseExceptionHandler(exceptionApp =>
{
exceptionApp.Run(async context =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
if (feature?.Error is null)
{
return;
}
var problem = Results.Problem(
statusCode: StatusCodes.Status500InternalServerError,
title: "ledger_internal_error",
detail: feature.Error.Message);
await problem.ExecuteAsync(context);
});
});
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapPost("/vuln/ledger/events", async Task<Results<Created<LedgerEventResponse>, Ok<LedgerEventResponse>, ProblemHttpResult>> (
LedgerEventRequest request,
ILedgerEventWriteService writeService,
CancellationToken cancellationToken) =>
{
var draft = request.ToDraft();
var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
return result.Status switch
{
LedgerWriteStatus.Success => CreateCreatedResponse(result.Record!),
LedgerWriteStatus.Idempotent => TypedResults.Ok(CreateResponse(result.Record!, "idempotent")),
LedgerWriteStatus.ValidationFailed => TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "validation_failed",
detail: string.Join(";", result.Errors)),
LedgerWriteStatus.Conflict => TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict,
title: result.ConflictCode ?? "conflict",
detail: string.Join(";", result.Errors)),
_ => TypedResults.Problem(
statusCode: StatusCodes.Status500InternalServerError,
title: "ledger_internal_error",
detail: "Unexpected ledger status.")
};
})
.WithName("LedgerEventAppend")
.RequireAuthorization(LedgerWritePolicy)
.Produces(StatusCodes.Status201Created)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status500InternalServerError);
app.Run();
static Created<LedgerEventResponse> CreateCreatedResponse(LedgerEventRecord record)
{
var response = CreateResponse(record, "created");
return TypedResults.Created($"/vuln/ledger/events/{record.EventId}", response);
}
static LedgerEventResponse CreateResponse(LedgerEventRecord record, string status)
=> new()
{
EventId = record.EventId,
ChainId = record.ChainId,
Sequence = record.SequenceNumber,
Status = status,
EventHash = record.EventHash,
PreviousHash = record.PreviousHash,
MerkleLeafHash = record.MerkleLeafHash,
RecordedAt = record.RecordedAt
};

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Findings.Ledger\StellaOps.Findings.Ledger.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
</ItemGroup>
</Project>