Add tests and implement timeline ingestion options with NATS and Redis subscribers

- Introduced `BinaryReachabilityLifterTests` to validate binary lifting functionality.
- Created `PackRunWorkerOptions` for configuring worker paths and execution persistence.
- Added `TimelineIngestionOptions` for configuring NATS and Redis ingestion transports.
- Implemented `NatsTimelineEventSubscriber` for subscribing to NATS events.
- Developed `RedisTimelineEventSubscriber` for reading from Redis Streams.
- Added `TimelineEnvelopeParser` to normalize incoming event envelopes.
- Created unit tests for `TimelineEnvelopeParser` to ensure correct field mapping.
- Implemented `TimelineAuthorizationAuditSink` for logging authorization outcomes.
This commit is contained in:
StellaOps Bot
2025-12-03 09:46:48 +02:00
parent e923880694
commit 35c8f9216f
520 changed files with 4416 additions and 31492 deletions

View File

@@ -17,8 +17,38 @@ builder.Configuration
builder.Services.AddOptions();
builder.Services.AddLogging();
// Register SBOM query services (InMemory seed; replace with Mongo-backed repository later).
builder.Services.AddSingleton<IComponentLookupRepository>(_ => new InMemoryComponentLookupRepository());
// Register SBOM query services (file-backed fixtures when present; fallback to in-memory seeds).
builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var env = sp.GetRequiredService<IHostEnvironment>();
var configured = config.GetValue<string>("SbomService:ComponentLookupPath");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return new FileComponentLookupRepository(configured!);
}
var candidate = FindFixture(env, "component_lookup.json");
return candidate is not null
? new FileComponentLookupRepository(candidate)
: new InMemoryComponentLookupRepository();
});
builder.Services.AddSingleton<ICatalogRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var env = sp.GetRequiredService<IHostEnvironment>();
var configured = config.GetValue<string>("SbomService:CatalogPath");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return new FileCatalogRepository(configured!);
}
var candidate = FindFixture(env, "catalog.json");
return candidate is not null
? new FileCatalogRepository(candidate)
: new InMemoryCatalogRepository();
});
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<ISbomEventStore, InMemorySbomEventStore>();
builder.Services.AddSingleton<ISbomEventPublisher>(sp => sp.GetRequiredService<ISbomEventStore>());
@@ -63,6 +93,28 @@ builder.Services.AddSingleton<IProjectionRepository>(sp =>
return new FileProjectionRepository(string.Empty);
});
static string? FindFixture(IHostEnvironment env, string fileName)
{
var candidateRoots = new[]
{
env.ContentRootPath,
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..")),
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..")),
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..", ".."))
};
foreach (var root in candidateRoots)
{
var candidate = Path.Combine(root, "docs", "modules", "sbomservice", "fixtures", "lnm-v1", fileName);
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
var app = builder.Build();
if (app.Environment.IsDevelopment())

View File

@@ -0,0 +1,29 @@
using System.Text.Json;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
public sealed class FileCatalogRepository : ICatalogRepository
{
private readonly IReadOnlyList<CatalogRecord> _items;
public FileCatalogRepository(string path)
{
if (!File.Exists(path))
{
_items = Array.Empty<CatalogRecord>();
return;
}
using var stream = File.OpenRead(path);
var items = JsonSerializer.Deserialize<List<CatalogRecord>>(stream, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
_items = items ?? Array.Empty<CatalogRecord>();
}
public Task<IReadOnlyList<CatalogRecord>> ListAsync(CancellationToken cancellationToken)
=> Task.FromResult(_items);
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
public sealed class FileComponentLookupRepository : IComponentLookupRepository
{
private readonly IReadOnlyList<ComponentLookupRecord> _items;
public FileComponentLookupRepository(string path)
{
if (!File.Exists(path))
{
_items = Array.Empty<ComponentLookupRecord>();
return;
}
using var stream = File.OpenRead(path);
var items = JsonSerializer.Deserialize<List<ComponentLookupRecord>>(stream, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
_items = items ?? Array.Empty<ComponentLookupRecord>();
}
public Task<(IReadOnlyList<ComponentLookupRecord> Items, int Total)> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
{
var filtered = _items
.Where(c => c.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase))
.Where(c => query.Artifact is null || c.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
.OrderBy(c => c.Artifact)
.ThenBy(c => c.Purl)
.ToList();
var page = filtered
.Skip(query.Offset)
.Take(query.Limit)
.ToList();
return Task.FromResult<(IReadOnlyList<ComponentLookupRecord> Items, int Total)>((page, filtered.Count));
}
}

View File

@@ -0,0 +1,8 @@
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
public interface ICatalogRepository
{
Task<IReadOnlyList<CatalogRecord>> ListAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,58 @@
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
public sealed class InMemoryCatalogRepository : ICatalogRepository
{
private static readonly IReadOnlyList<CatalogRecord> Seed = new List<CatalogRecord>
{
new(
Artifact: "ghcr.io/stellaops/sample-api",
SbomVersion: "2025.11.16.1",
Digest: "sha256:112",
License: "MIT",
Scope: "runtime",
AssetTags: new Dictionary<string, string>
{
["owner"] = "payments",
["criticality"] = "high",
["env"] = "prod"
},
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
ProjectionHash: "sha256:proj112",
EvaluationMetadata: "eval:passed:v1"),
new(
Artifact: "ghcr.io/stellaops/sample-api",
SbomVersion: "2025.11.15.1",
Digest: "sha256:111",
License: "MIT",
Scope: "runtime",
AssetTags: new Dictionary<string, string>
{
["owner"] = "payments",
["criticality"] = "high",
["env"] = "prod"
},
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
ProjectionHash: "sha256:proj111",
EvaluationMetadata: "eval:passed:v1"),
new(
Artifact: "ghcr.io/stellaops/sample-worker",
SbomVersion: "2025.11.12.0",
Digest: "sha256:222",
License: "Apache-2.0",
Scope: "runtime",
AssetTags: new Dictionary<string, string>
{
["owner"] = "platform",
["criticality"] = "medium",
["env"] = "staging"
},
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
ProjectionHash: "sha256:proj222",
EvaluationMetadata: "eval:pending:v1"),
};
public Task<IReadOnlyList<CatalogRecord>> ListAsync(CancellationToken cancellationToken)
=> Task.FromResult(Seed);
}

View File

@@ -21,6 +21,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
public InMemorySbomQueryService(
IComponentLookupRepository componentLookupRepository,
IProjectionRepository projectionRepository,
ICatalogRepository catalogRepository,
ISbomEventPublisher eventPublisher,
IClock clock)
{
@@ -31,7 +32,11 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
// Deterministic seed data for early contract testing; replace with Mongo-backed implementation later.
_paths = SeedPaths();
_timelines = SeedTimelines();
_catalog = SeedCatalog();
_catalog = catalogRepository.ListAsync(CancellationToken.None).GetAwaiter().GetResult();
if (_catalog.Count == 0)
{
_catalog = SeedCatalog();
}
}
public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken)