Add sample proof bundle configurations and verification script
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
Console CI / console-ci (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
VEX Proof Bundles / verify-bundles (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
Console CI / console-ci (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
VEX Proof Bundles / verify-bundles (push) Has been cancelled
- Introduced sample proof bundle configuration files for testing, including `sample-proof-bundle-config.dsse.json`, `sample-proof-bundle.dsse.json`, and `sample-proof-bundle.json`. - Implemented a verification script `test_verify_sample.sh` to validate proof bundles against specified schemas and catalogs. - Updated existing proof bundle configurations with new metadata, including versioning, created timestamps, and justification details. - Enhanced evidence entries with expiration dates and hashes for better integrity checks. - Ensured all new configurations adhere to the defined schema for consistency and reliability in testing.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.SbomService.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public class SbomMongoStorageTests : IAsyncLifetime
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private MongoDbRunner? _runner;
|
||||
|
||||
public SbomMongoStorageTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Console_catalog_reads_from_mongo_storage()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/console/sboms?artifact=mongo-api&limit=1");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomCatalogResult>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.Items.Should().ContainSingle();
|
||||
payload.Items[0].Artifact.Should().Be("ghcr.io/stellaops/mongo-api");
|
||||
payload.Items[0].ProjectionHash.Should().Be("sha256:proj-mongo-2");
|
||||
payload.NextCursor.Should().Be("1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Component_lookup_returns_storage_results_and_cursor()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/components/lookup?purl=pkg:npm/mongo-lib@1.0.0&limit=1");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ComponentLookupResult>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.CacheHint.Should().Be("storage");
|
||||
payload.Neighbors.Should().ContainSingle();
|
||||
payload.Neighbors[0].Purl.Should().Be("pkg:npm/express@4.18.2");
|
||||
payload.NextCursor.Should().Be("1");
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(singleNodeReplSet: false, additionalMongodArguments: "--quiet");
|
||||
return SeedMongoAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner?.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private HttpClient CreateClient()
|
||||
{
|
||||
if (_runner is null)
|
||||
{
|
||||
throw new InvalidOperationException("Mongo runner not started");
|
||||
}
|
||||
|
||||
var factory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["SbomService:Mongo:ConnectionString"] = _runner.ConnectionString,
|
||||
["SbomService:Mongo:Database"] = "sbom_console_tests"
|
||||
};
|
||||
|
||||
config.AddInMemoryCollection(settings);
|
||||
});
|
||||
});
|
||||
|
||||
return factory.CreateClient();
|
||||
}
|
||||
|
||||
private async Task SeedMongoAsync()
|
||||
{
|
||||
if (_runner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var client = new MongoClient(_runner.ConnectionString);
|
||||
var database = client.GetDatabase("sbom_console_tests");
|
||||
|
||||
var catalog = database.GetCollection<BsonDocument>("sbom_catalog");
|
||||
await catalog.DeleteManyAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
await catalog.InsertManyAsync(new[]
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "artifact", "ghcr.io/stellaops/mongo-api" },
|
||||
{ "sbomVersion", "2025.12.04.2" },
|
||||
{ "digest", "sha256:bbb" },
|
||||
{ "license", "Apache-2.0" },
|
||||
{ "scope", "runtime" },
|
||||
{ "assetTags", new BsonDocument { { "owner", "storage" }, { "env", "prod" } } },
|
||||
{ "createdAt", new BsonDateTime(DateTime.SpecifyKind(new DateTime(2025, 12, 4, 12, 0, 0), DateTimeKind.Utc)) },
|
||||
{ "projectionHash", "sha256:proj-mongo-2" },
|
||||
{ "evaluationMetadata", "eval:storage" }
|
||||
},
|
||||
new BsonDocument
|
||||
{
|
||||
{ "artifact", "ghcr.io/stellaops/mongo-api" },
|
||||
{ "sbomVersion", "2025.12.04.1" },
|
||||
{ "digest", "sha256:aaa" },
|
||||
{ "license", "Apache-2.0" },
|
||||
{ "scope", "runtime" },
|
||||
{ "assetTags", new BsonDocument { { "owner", "storage" }, { "env", "prod" } } },
|
||||
{ "createdAt", new BsonDateTime(DateTime.SpecifyKind(new DateTime(2025, 12, 4, 11, 0, 0), DateTimeKind.Utc)) },
|
||||
{ "projectionHash", "sha256:proj-mongo-1" },
|
||||
{ "evaluationMetadata", "eval:storage" }
|
||||
}
|
||||
});
|
||||
|
||||
var components = database.GetCollection<BsonDocument>("sbom_component_neighbors");
|
||||
await components.DeleteManyAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
await components.InsertManyAsync(new[]
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "artifact", "ghcr.io/stellaops/mongo-api" },
|
||||
{ "purl", "pkg:npm/mongo-lib@1.0.0" },
|
||||
{ "neighborPurl", "pkg:npm/express@4.18.2" },
|
||||
{ "relationship", "DEPENDS_ON" },
|
||||
{ "license", "MIT" },
|
||||
{ "scope", "runtime" },
|
||||
{ "runtimeFlag", true }
|
||||
},
|
||||
new BsonDocument
|
||||
{
|
||||
{ "artifact", "ghcr.io/stellaops/mongo-api" },
|
||||
{ "purl", "pkg:npm/mongo-lib@1.0.0" },
|
||||
{ "neighborPurl", "pkg:npm/body-parser@1.20.2" },
|
||||
{ "relationship", "DEPENDS_ON" },
|
||||
{ "license", "MIT" },
|
||||
{ "scope", "runtime" },
|
||||
{ "runtimeFlag", true }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.SbomService.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Raw catalog row persisted for the console catalog endpoint.
|
||||
/// </summary>
|
||||
public sealed record CatalogRecord(
|
||||
string Artifact,
|
||||
string SbomVersion,
|
||||
string Digest,
|
||||
string? License,
|
||||
string Scope,
|
||||
IReadOnlyDictionary<string, string> AssetTags,
|
||||
DateTimeOffset CreatedAt,
|
||||
string ProjectionHash,
|
||||
string EvaluationMetadata);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.SbomService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB configuration for SBOM Service storage-backed endpoints.
|
||||
/// </summary>
|
||||
public sealed class SbomMongoOptions
|
||||
{
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
public string Database { get; set; } = "sbom_service";
|
||||
|
||||
public string CatalogCollection { get; set; } = "sbom_catalog";
|
||||
|
||||
public string ComponentLookupCollection { get; set; } = "sbom_component_neighbors";
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Globalization;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Options;
|
||||
using StellaOps.SbomService.Services;
|
||||
using StellaOps.SbomService.Observability;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
@@ -17,38 +20,72 @@ builder.Configuration
|
||||
builder.Services.AddOptions();
|
||||
builder.Services.AddLogging();
|
||||
|
||||
// Register SBOM query services (file-backed fixtures when present; fallback to in-memory seeds).
|
||||
builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
|
||||
var mongoSection = builder.Configuration.GetSection("SbomService:Mongo");
|
||||
builder.Services.Configure<SbomMongoOptions>(mongoSection);
|
||||
var mongoConnectionString = mongoSection.GetValue<string>("ConnectionString");
|
||||
var mongoConfigured = !string.IsNullOrWhiteSpace(mongoConnectionString);
|
||||
|
||||
// Register SBOM query services (Mongo when configured; otherwise file-backed fixtures when present; fallback to in-memory seeds).
|
||||
if (mongoConfigured)
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var env = sp.GetRequiredService<IHostEnvironment>();
|
||||
var configured = config.GetValue<string>("SbomService:ComponentLookupPath");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
builder.Services.AddSingleton<IMongoClient>(sp =>
|
||||
{
|
||||
return new FileComponentLookupRepository(configured!);
|
||||
}
|
||||
var options = sp.GetRequiredService<IOptions<SbomMongoOptions>>().Value;
|
||||
var url = new MongoUrl(options.ConnectionString!);
|
||||
var settings = MongoClientSettings.FromUrl(url);
|
||||
settings.ServerSelectionTimeout = TimeSpan.FromSeconds(5);
|
||||
settings.RetryWrites = false;
|
||||
return new MongoClient(settings);
|
||||
});
|
||||
|
||||
var candidate = FindFixture(env, "component_lookup.json");
|
||||
return candidate is not null
|
||||
? new FileComponentLookupRepository(candidate)
|
||||
: new InMemoryComponentLookupRepository();
|
||||
});
|
||||
builder.Services.AddSingleton<IMongoDatabase>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SbomMongoOptions>>().Value;
|
||||
var client = sp.GetRequiredService<IMongoClient>();
|
||||
var url = new MongoUrl(options.ConnectionString!);
|
||||
var databaseName = string.IsNullOrWhiteSpace(options.Database)
|
||||
? url.DatabaseName ?? "sbom_service"
|
||||
: options.Database;
|
||||
return client.GetDatabase(databaseName);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<ICatalogRepository>(sp =>
|
||||
builder.Services.AddSingleton<IComponentLookupRepository, MongoComponentLookupRepository>();
|
||||
builder.Services.AddSingleton<ICatalogRepository, MongoCatalogRepository>();
|
||||
}
|
||||
else
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var env = sp.GetRequiredService<IHostEnvironment>();
|
||||
var configured = config.GetValue<string>("SbomService:CatalogPath");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
|
||||
{
|
||||
return new FileCatalogRepository(configured!);
|
||||
}
|
||||
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, "catalog.json");
|
||||
return candidate is not null
|
||||
? new FileCatalogRepository(candidate)
|
||||
: new InMemoryCatalogRepository();
|
||||
});
|
||||
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>());
|
||||
|
||||
@@ -21,9 +21,28 @@ public sealed class FileCatalogRepository : ICatalogRepository
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
_items = items ?? Array.Empty<CatalogRecord>();
|
||||
_items = items ?? new List<CatalogRecord>();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CatalogRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_items);
|
||||
|
||||
public Task<(IReadOnlyList<CatalogRecord> Items, int Total)> QueryAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var filtered = _items
|
||||
.Where(c => query.Artifact is null || c.Artifact.Contains(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.License is null || string.Equals(c.License, query.License, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.Scope is null || string.Equals(c.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.AssetTag is null || c.AssetTags.ContainsKey(query.AssetTag))
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ThenBy(c => c.Artifact)
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<(IReadOnlyList<CatalogRecord> Items, int Total)>((page, filtered.Count));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class FileComponentLookupRepository : IComponentLookupRepository
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
_items = items ?? Array.Empty<ComponentLookupRecord>();
|
||||
_items = items ?? new List<ComponentLookupRecord>();
|
||||
}
|
||||
|
||||
public Task<(IReadOnlyList<ComponentLookupRecord> Items, int Total)> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
|
||||
|
||||
@@ -5,4 +5,9 @@ namespace StellaOps.SbomService.Repositories;
|
||||
public interface ICatalogRepository
|
||||
{
|
||||
Task<IReadOnlyList<CatalogRecord>> ListAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a page of catalog records along with the total count for deterministic pagination.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<CatalogRecord> Items, int Total)> QueryAsync(SbomCatalogQuery query, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -55,4 +55,23 @@ public sealed class InMemoryCatalogRepository : ICatalogRepository
|
||||
|
||||
public Task<IReadOnlyList<CatalogRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(Seed);
|
||||
|
||||
public Task<(IReadOnlyList<CatalogRecord> Items, int Total)> QueryAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var filtered = Seed
|
||||
.Where(c => query.Artifact is null || c.Artifact.Contains(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.License is null || string.Equals(c.License, query.License, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.Scope is null || string.Equals(c.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.AssetTag is null || c.AssetTags.ContainsKey(query.AssetTag))
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ThenBy(c => c.Artifact)
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<(IReadOnlyList<CatalogRecord> Items, int Total)>((page, filtered.Count));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Options;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
public sealed class MongoCatalogRepository : ICatalogRepository
|
||||
{
|
||||
private readonly IMongoCollection<CatalogDocument> _collection;
|
||||
private readonly Collation _caseInsensitive = new("en", strength: CollationStrength.Secondary);
|
||||
|
||||
public MongoCatalogRepository(IMongoDatabase database, IOptions<SbomMongoOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var opts = options.Value;
|
||||
var collectionName = string.IsNullOrWhiteSpace(opts.CatalogCollection)
|
||||
? "sbom_catalog"
|
||||
: opts.CatalogCollection;
|
||||
|
||||
_collection = database.GetCollection<CatalogDocument>(collectionName);
|
||||
EnsureIndexes();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CatalogRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await _collection
|
||||
.Find(FilterDefinition<CatalogDocument>.Empty)
|
||||
.SortByDescending(c => c.CreatedAt)
|
||||
.ThenBy(c => c.Artifact)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return items.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<CatalogRecord> Items, int Total)> QueryAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = BuildFilter(query);
|
||||
|
||||
var total = (int)await _collection.CountDocumentsAsync(filter, cancellationToken: cancellationToken);
|
||||
|
||||
var items = await _collection
|
||||
.Find(filter, new FindOptions { Collation = _caseInsensitive })
|
||||
.SortByDescending(c => c.CreatedAt)
|
||||
.ThenBy(c => c.Artifact)
|
||||
.Skip(query.Offset)
|
||||
.Limit(query.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items.Select(Map).ToList(), total);
|
||||
}
|
||||
|
||||
private FilterDefinition<CatalogDocument> BuildFilter(SbomCatalogQuery query)
|
||||
{
|
||||
var filter = Builders<CatalogDocument>.Filter.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Artifact))
|
||||
{
|
||||
filter &= Builders<CatalogDocument>.Filter.Regex(c => c.Artifact, new BsonRegularExpression(query.Artifact, "i"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.License))
|
||||
{
|
||||
var escaped = Regex.Escape(query.License);
|
||||
filter &= Builders<CatalogDocument>.Filter.Regex(c => c.License, new BsonRegularExpression($"^{escaped}$", "i"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Scope))
|
||||
{
|
||||
filter &= Builders<CatalogDocument>.Filter.Eq(c => c.Scope, query.Scope);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.AssetTag))
|
||||
{
|
||||
filter &= Builders<CatalogDocument>.Filter.Exists($"AssetTags.{query.AssetTag}");
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
private void EnsureIndexes()
|
||||
{
|
||||
var timestampIdx = Builders<CatalogDocument>.IndexKeys
|
||||
.Descending(c => c.CreatedAt)
|
||||
.Ascending(c => c.Artifact);
|
||||
|
||||
_collection.Indexes.CreateOne(new CreateIndexModel<CatalogDocument>(timestampIdx, new CreateIndexOptions
|
||||
{
|
||||
Name = "catalog_created_artifact"
|
||||
}));
|
||||
|
||||
var filterIdx = Builders<CatalogDocument>.IndexKeys
|
||||
.Ascending(c => c.Artifact)
|
||||
.Ascending(c => c.Scope)
|
||||
.Ascending(c => c.License);
|
||||
|
||||
_collection.Indexes.CreateOne(new CreateIndexModel<CatalogDocument>(filterIdx, new CreateIndexOptions
|
||||
{
|
||||
Name = "catalog_filters"
|
||||
}));
|
||||
|
||||
var assetTagIdx = Builders<CatalogDocument>.IndexKeys
|
||||
.Ascending("AssetTags");
|
||||
|
||||
_collection.Indexes.CreateOne(new CreateIndexModel<CatalogDocument>(assetTagIdx, new CreateIndexOptions
|
||||
{
|
||||
Name = "catalog_asset_tags"
|
||||
}));
|
||||
}
|
||||
|
||||
private static CatalogRecord Map(CatalogDocument doc) => new(
|
||||
doc.Artifact,
|
||||
doc.SbomVersion,
|
||||
doc.Digest,
|
||||
doc.License,
|
||||
doc.Scope,
|
||||
doc.AssetTags ?? new Dictionary<string, string>(StringComparer.Ordinal),
|
||||
doc.CreatedAt,
|
||||
doc.ProjectionHash,
|
||||
doc.EvaluationMetadata ?? string.Empty);
|
||||
|
||||
private sealed class CatalogDocument
|
||||
{
|
||||
public string Artifact { get; set; } = string.Empty;
|
||||
public string SbomVersion { get; set; } = string.Empty;
|
||||
public string Digest { get; set; } = string.Empty;
|
||||
public string? License { get; set; }
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public Dictionary<string, string>? AssetTags { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string ProjectionHash { get; set; } = string.Empty;
|
||||
public string? EvaluationMetadata { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Options;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
public sealed class MongoComponentLookupRepository : IComponentLookupRepository
|
||||
{
|
||||
private readonly IMongoCollection<ComponentDocument> _collection;
|
||||
private readonly Collation _caseInsensitive = new("en", strength: CollationStrength.Secondary);
|
||||
|
||||
public MongoComponentLookupRepository(IMongoDatabase database, IOptions<SbomMongoOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var opts = options.Value;
|
||||
var collectionName = string.IsNullOrWhiteSpace(opts.ComponentLookupCollection)
|
||||
? "sbom_component_neighbors"
|
||||
: opts.ComponentLookupCollection;
|
||||
|
||||
_collection = database.GetCollection<ComponentDocument>(collectionName);
|
||||
EnsureIndexes();
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<ComponentLookupRecord> Items, int Total)> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ComponentDocument>.Filter.Eq(c => c.Purl, query.Purl);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Artifact))
|
||||
{
|
||||
filter &= Builders<ComponentDocument>.Filter.Eq(c => c.Artifact, query.Artifact);
|
||||
}
|
||||
|
||||
var total = (int)await _collection.CountDocumentsAsync(filter, cancellationToken: cancellationToken);
|
||||
|
||||
var items = await _collection
|
||||
.Find(filter, new FindOptions { Collation = _caseInsensitive })
|
||||
.SortBy(c => c.Artifact)
|
||||
.ThenBy(c => c.NeighborPurl)
|
||||
.Skip(query.Offset)
|
||||
.Limit(query.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items.Select(Map).ToList(), total);
|
||||
}
|
||||
|
||||
private void EnsureIndexes()
|
||||
{
|
||||
var purlArtifact = Builders<ComponentDocument>.IndexKeys
|
||||
.Ascending(c => c.Purl)
|
||||
.Ascending(c => c.Artifact);
|
||||
|
||||
_collection.Indexes.CreateOne(new CreateIndexModel<ComponentDocument>(purlArtifact, new CreateIndexOptions
|
||||
{
|
||||
Name = "component_lookup_purl_artifact"
|
||||
}));
|
||||
}
|
||||
|
||||
private static ComponentLookupRecord Map(ComponentDocument doc) => new(
|
||||
doc.Artifact,
|
||||
doc.Purl,
|
||||
doc.NeighborPurl,
|
||||
doc.Relationship,
|
||||
doc.License,
|
||||
doc.Scope,
|
||||
doc.RuntimeFlag);
|
||||
|
||||
private sealed class ComponentDocument
|
||||
{
|
||||
public string Artifact { get; set; } = string.Empty;
|
||||
public string Purl { get; set; } = string.Empty;
|
||||
public string NeighborPurl { get; set; } = string.Empty;
|
||||
public string Relationship { get; set; } = string.Empty;
|
||||
public string? License { get; set; }
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public bool RuntimeFlag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text.Json;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Observability;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
using StellaOps.SbomService.Services;
|
||||
|
||||
@@ -11,7 +13,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
{
|
||||
private readonly IReadOnlyList<PathRecord> _paths;
|
||||
private readonly IReadOnlyList<TimelineRecord> _timelines;
|
||||
private readonly IReadOnlyList<CatalogRecord> _catalog;
|
||||
private readonly ICatalogRepository _catalogRepository;
|
||||
private readonly IComponentLookupRepository _componentLookupRepository;
|
||||
private readonly IProjectionRepository _projectionRepository;
|
||||
private readonly ISbomEventPublisher _eventPublisher;
|
||||
@@ -25,6 +27,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
ISbomEventPublisher eventPublisher,
|
||||
IClock clock)
|
||||
{
|
||||
_catalogRepository = catalogRepository;
|
||||
_componentLookupRepository = componentLookupRepository;
|
||||
_projectionRepository = projectionRepository;
|
||||
_eventPublisher = eventPublisher;
|
||||
@@ -32,11 +35,6 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
// Deterministic seed data for early contract testing; replace with Mongo-backed implementation later.
|
||||
_paths = SeedPaths();
|
||||
_timelines = SeedTimelines();
|
||||
_catalog = catalogRepository.ListAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (_catalog.Count == 0)
|
||||
{
|
||||
_catalog = SeedCatalog();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken)
|
||||
@@ -109,46 +107,37 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
return Task.FromResult(new QueryResult<SbomTimelineResult>(result, false));
|
||||
}
|
||||
|
||||
public Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"catalog|{query.Artifact}|{query.License}|{query.Scope}|{query.AssetTag}|{query.Offset}|{query.Limit}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomCatalogResult cachedCatalog)
|
||||
{
|
||||
return Task.FromResult(new QueryResult<SbomCatalogResult>(cachedCatalog, true));
|
||||
}
|
||||
|
||||
var filtered = _catalog
|
||||
.Where(c => query.Artifact is null || c.Artifact.Contains(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.License is null || string.Equals(c.License, query.License, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.Scope is null || string.Equals(c.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.AssetTag is null || c.AssetTags.ContainsKey(query.AssetTag))
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ThenBy(c => c.Artifact)
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.Select(c => new SbomCatalogItem(
|
||||
c.Artifact,
|
||||
c.SbomVersion,
|
||||
c.Digest,
|
||||
c.License,
|
||||
c.Scope,
|
||||
c.AssetTags,
|
||||
c.CreatedAt,
|
||||
c.ProjectionHash,
|
||||
c.EvaluationMetadata))
|
||||
.ToList();
|
||||
|
||||
string? nextCursor = query.Offset + query.Limit < filtered.Count
|
||||
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var result = new SbomCatalogResult(page, nextCursor);
|
||||
_cache[cacheKey] = result;
|
||||
return Task.FromResult(new QueryResult<SbomCatalogResult>(result, false));
|
||||
}
|
||||
public async Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"catalog|{query.Artifact}|{query.License}|{query.Scope}|{query.AssetTag}|{query.Offset}|{query.Limit}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomCatalogResult cachedCatalog)
|
||||
{
|
||||
return new QueryResult<SbomCatalogResult>(cachedCatalog, true);
|
||||
}
|
||||
|
||||
var (items, total) = await _catalogRepository.QueryAsync(query, cancellationToken);
|
||||
|
||||
var page = items
|
||||
.Select(c => new SbomCatalogItem(
|
||||
c.Artifact,
|
||||
c.SbomVersion,
|
||||
c.Digest,
|
||||
c.License,
|
||||
c.Scope,
|
||||
c.AssetTags,
|
||||
c.CreatedAt,
|
||||
c.ProjectionHash,
|
||||
c.EvaluationMetadata))
|
||||
.ToList();
|
||||
|
||||
string? nextCursor = query.Offset + query.Limit < total
|
||||
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var result = new SbomCatalogResult(page, nextCursor);
|
||||
_cache[cacheKey] = result;
|
||||
return new QueryResult<SbomCatalogResult>(result, false);
|
||||
}
|
||||
|
||||
public async Task<QueryResult<ComponentLookupResult>> GetComponentLookupAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -168,7 +157,11 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
.Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag))
|
||||
.ToList();
|
||||
|
||||
var result = new ComponentLookupResult(query.Purl, query.Artifact, neighbors, nextCursor, CacheHint: "seeded");
|
||||
var cacheHint = _componentLookupRepository.GetType().Name.Contains("Mongo", StringComparison.OrdinalIgnoreCase)
|
||||
? "storage"
|
||||
: "seeded";
|
||||
|
||||
var result = new ComponentLookupResult(query.Purl, query.Artifact, neighbors, nextCursor, CacheHint: cacheHint);
|
||||
_cache[cacheKey] = result;
|
||||
return new QueryResult<ComponentLookupResult>(result, false);
|
||||
}
|
||||
@@ -291,7 +284,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
{
|
||||
foreach (var path in _paths)
|
||||
{
|
||||
var pathNodes = path.Nodes.Select(n => $"{n.Name}:{n.Type}").ToList();
|
||||
var pathNodes = path.Nodes.Select(n => $"{n.Name}:{n.Kind}").ToList();
|
||||
yield return new SbomInventoryEvidence(
|
||||
SnapshotId: snapshotId,
|
||||
TenantId: tenantId,
|
||||
@@ -381,89 +374,6 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CatalogRecord> SeedCatalog()
|
||||
{
|
||||
return 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"),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ComponentLookupRecord> SeedComponents()
|
||||
{
|
||||
return new List<ComponentLookupRecord>
|
||||
{
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
NeighborPurl: "pkg:npm/express@4.18.2",
|
||||
Relationship: "DEPENDS_ON",
|
||||
License: "MIT",
|
||||
Scope: "runtime",
|
||||
RuntimeFlag: true),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
NeighborPurl: "pkg:npm/rollup@3.0.0",
|
||||
Relationship: "DEPENDS_ON",
|
||||
License: "MIT",
|
||||
Scope: "build",
|
||||
RuntimeFlag: false),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-worker",
|
||||
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
|
||||
NeighborPurl: "pkg:nuget/StellaOps.Core@1.0.0",
|
||||
Relationship: "DEPENDS_ON",
|
||||
License: "Apache-2.0",
|
||||
Scope: "runtime",
|
||||
RuntimeFlag: true)
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record PathRecord(
|
||||
string Artifact,
|
||||
string Purl,
|
||||
@@ -482,23 +392,4 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
DateTimeOffset CreatedAt,
|
||||
string? Provenance);
|
||||
|
||||
private sealed record CatalogRecord(
|
||||
string Artifact,
|
||||
string SbomVersion,
|
||||
string Digest,
|
||||
string? License,
|
||||
string Scope,
|
||||
IReadOnlyDictionary<string, string> AssetTags,
|
||||
DateTimeOffset CreatedAt,
|
||||
string ProjectionHash,
|
||||
string EvaluationMetadata);
|
||||
|
||||
private sealed record ComponentLookupRecord(
|
||||
string Artifact,
|
||||
string Purl,
|
||||
string NeighborPurl,
|
||||
string Relationship,
|
||||
string? License,
|
||||
string Scope,
|
||||
bool RuntimeFlag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ public interface ISbomEventPublisher
|
||||
/// Publishes inventory evidence for resolver jobs (idempotent on snapshot+tenant+purl+scope+runtimeFlag).
|
||||
/// </summary>
|
||||
Task<bool> PublishInventoryAsync(SbomInventoryEvidence evt, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a resolver candidate envelope for downstream graph/index consumers.
|
||||
/// </summary>
|
||||
Task<bool> PublishResolverAsync(ResolverCandidate candidate, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface ISbomEventStore : ISbomEventPublisher
|
||||
@@ -29,7 +34,6 @@ public interface ISbomEventStore : ISbomEventPublisher
|
||||
Task<bool> ClearInventoryAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ResolverCandidate>> ListResolverAsync(CancellationToken cancellationToken);
|
||||
Task<bool> ClearResolverAsync(CancellationToken cancellationToken);
|
||||
Task<bool> PublishResolverAsync(ResolverCandidate candidate, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class InMemorySbomEventStore : ISbomEventStore
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user