Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.SbomService.Storage.Postgres\StellaOps.SbomService.Storage.Postgres.csproj" />
<ProjectReference Include="..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.SbomService.Storage.Postgres</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.SbomService/StellaOps.SbomService.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -35,7 +35,7 @@ public class OrchestratorEndpointsTests : IClassFixture<WebApplicationFactory<Pr
var seeded = await client.GetFromJsonAsync<JsonElement>("/internal/orchestrator/sources?tenant=tenant-a");
seeded.TryGetProperty("items", out var items).Should().BeTrue();
items.GetArrayLength().Should().BeGreaterOrEqualTo(1);
items.GetArrayLength().Should().BeGreaterThanOrEqualTo(1);
var request = new RegisterOrchestratorSourceRequest(
TenantId: "tenant-a",

View File

@@ -37,7 +37,7 @@ public class ResolverFeedExportTests : IClassFixture<WebApplicationFactory<Progr
// verify deterministic ordering by first and last line comparison
var first = lines.First();
var last = lines.Last();
first.Should().BeLessOrEqualTo(last, Comparer<string>.Create(StringComparer.Ordinal.Compare));
string.Compare(first, last, StringComparison.Ordinal).Should().BeLessThanOrEqualTo(0);
// spot-check a known candidate
var candidates = await client.GetFromJsonAsync<List<ResolverCandidate>>("/internal/sbom/resolver-feed");

View File

@@ -164,7 +164,7 @@ public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
payload.ArtifactId.Should().Be("ghcr.io/stellaops/sample-api");
payload.Versions.Should().NotBeEmpty();
payload.DependencyPaths.Should().NotBeEmpty();
payload.Hash.Should().StartWith("sha256:", StringComparison.Ordinal);
payload.Hash.Should().StartWith("sha256:");
}
[Trait("Category", TestCategories.Unit)]

View File

@@ -29,7 +29,7 @@ public class SbomEventEndpointsTests : IClassFixture<WebApplicationFactory<Progr
var backfillPayload = await backfillResponse.Content.ReadFromJsonAsync<JsonElement>();
backfillPayload.TryGetProperty("published", out var publishedProp).Should().BeTrue();
publishedProp.GetInt32().Should().BeGreaterOrEqualTo(1);
publishedProp.GetInt32().Should().BeGreaterThanOrEqualTo(1);
var events = await client.GetFromJsonAsync<List<SbomVersionCreatedEvent>>("/internal/sbom/events");
events.Should().NotBeNull();

View File

@@ -47,7 +47,7 @@ public class SbomInventoryEventsTests : IClassFixture<WebApplicationFactory<Prog
var post = await client.GetFromJsonAsync<List<SbomInventoryEvidence>>("/internal/sbom/inventory");
post.Should().NotBeNull();
post!.Count.Should().BeGreaterOrEqualTo(pre!.Count);
post!.Count.Should().BeGreaterThanOrEqualTo(pre!.Count);
}
[Trait("Category", TestCategories.Unit)]

View File

@@ -1,4 +1,4 @@
using System.Net;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
@@ -93,7 +93,6 @@ public sealed class SbomLedgerEndpointsTests : IClassFixture<WebApplicationFacto
private static SbomUploadRequest CreateUploadRequest(string artifactRef, string sbomJson)
{
using var document = JsonDocument.Parse(sbomJson);
using StellaOps.TestKit;
return new SbomUploadRequest
{
ArtifactRef = artifactRef,

View File

@@ -3,15 +3,18 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.SbomService/StellaOps.SbomService.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,244 +1,442 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.SbomService", "StellaOps.SbomService\StellaOps.SbomService.csproj", "{0D9049C8-1667-4F98-9295-579AD9F3631C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{AF00CFB3-C548-4272-AE91-21720CCA0F51}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{0D5F8F7D-D66D-4415-956F-F4822AB72D31}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{CF8D1B05-BB50-45B9-B956-56380D5B4616}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{A7F565B4-F79B-471A-BD17-AE6314591345}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.SbomService.Tests", "StellaOps.SbomService.Tests\StellaOps.SbomService.Tests.csproj", "{5F0FA73A-B13B-4B53-B154-5396F077A3E1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Testing", "..\Concelier\__Libraries\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj", "{9FD5687F-1627-4051-87C7-C6F5FA3C1341}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "..\Concelier\__Libraries\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{9969E571-2F75-428F-822D-154A816C8D0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normalization", "..\Concelier\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj", "{14762A37-48BA-42E8-B6CF-FA1967C58F13}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "..\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{F921862B-2057-4E57-9765-2C34764BC226}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "..\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Debug|x64.ActiveCfg = Debug|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Debug|x64.Build.0 = Debug|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Debug|x86.ActiveCfg = Debug|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Debug|x86.Build.0 = Debug|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Release|Any CPU.Build.0 = Release|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Release|x64.ActiveCfg = Release|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Release|x64.Build.0 = Release|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Release|x86.ActiveCfg = Release|Any CPU
{0D9049C8-1667-4F98-9295-579AD9F3631C}.Release|x86.Build.0 = Release|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Debug|x64.ActiveCfg = Debug|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Debug|x64.Build.0 = Debug|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Debug|x86.ActiveCfg = Debug|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Debug|x86.Build.0 = Debug|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Release|Any CPU.Build.0 = Release|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Release|x64.ActiveCfg = Release|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Release|x64.Build.0 = Release|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Release|x86.ActiveCfg = Release|Any CPU
{AF00CFB3-C548-4272-AE91-21720CCA0F51}.Release|x86.Build.0 = Release|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Debug|x64.ActiveCfg = Debug|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Debug|x64.Build.0 = Debug|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Debug|x86.ActiveCfg = Debug|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Debug|x86.Build.0 = Debug|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Release|Any CPU.Build.0 = Release|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Release|x64.ActiveCfg = Release|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Release|x64.Build.0 = Release|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Release|x86.ActiveCfg = Release|Any CPU
{1D1D07F0-86EE-45FB-B9FA-6D9F7E49770C}.Release|x86.Build.0 = Release|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Debug|x64.ActiveCfg = Debug|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Debug|x64.Build.0 = Debug|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Debug|x86.ActiveCfg = Debug|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Debug|x86.Build.0 = Debug|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Release|Any CPU.Build.0 = Release|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Release|x64.ActiveCfg = Release|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Release|x64.Build.0 = Release|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Release|x86.ActiveCfg = Release|Any CPU
{0D5F8F7D-D66D-4415-956F-F4822AB72D31}.Release|x86.Build.0 = Release|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Debug|x64.ActiveCfg = Debug|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Debug|x64.Build.0 = Debug|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Debug|x86.ActiveCfg = Debug|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Debug|x86.Build.0 = Debug|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Release|Any CPU.Build.0 = Release|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Release|x64.ActiveCfg = Release|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Release|x64.Build.0 = Release|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Release|x86.ActiveCfg = Release|Any CPU
{CF8D1B05-BB50-45B9-B956-56380D5B4616}.Release|x86.Build.0 = Release|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Debug|x64.ActiveCfg = Debug|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Debug|x64.Build.0 = Debug|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Debug|x86.ActiveCfg = Debug|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Debug|x86.Build.0 = Debug|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Release|Any CPU.Build.0 = Release|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Release|x64.ActiveCfg = Release|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Release|x64.Build.0 = Release|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Release|x86.ActiveCfg = Release|Any CPU
{A7F565B4-F79B-471A-BD17-AE6314591345}.Release|x86.Build.0 = Release|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Debug|x64.ActiveCfg = Debug|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Debug|x64.Build.0 = Debug|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Debug|x86.ActiveCfg = Debug|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Debug|x86.Build.0 = Debug|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Release|Any CPU.Build.0 = Release|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Release|x64.ActiveCfg = Release|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Release|x64.Build.0 = Release|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Release|x86.ActiveCfg = Release|Any CPU
{5F0FA73A-B13B-4B53-B154-5396F077A3E1}.Release|x86.Build.0 = Release|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Debug|x64.ActiveCfg = Debug|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Debug|x64.Build.0 = Debug|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Debug|x86.ActiveCfg = Debug|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Debug|x86.Build.0 = Debug|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Release|Any CPU.Build.0 = Release|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Release|x64.ActiveCfg = Release|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Release|x64.Build.0 = Release|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Release|x86.ActiveCfg = Release|Any CPU
{9FD5687F-1627-4051-87C7-C6F5FA3C1341}.Release|x86.Build.0 = Release|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Debug|x64.ActiveCfg = Debug|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Debug|x64.Build.0 = Debug|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Debug|x86.ActiveCfg = Debug|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Debug|x86.Build.0 = Debug|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Release|Any CPU.Build.0 = Release|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Release|x64.ActiveCfg = Release|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Release|x64.Build.0 = Release|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Release|x86.ActiveCfg = Release|Any CPU
{1383D9F7-10A6-47E3-84CE-8AC9E5E59E25}.Release|x86.Build.0 = Release|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Debug|x64.ActiveCfg = Debug|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Debug|x64.Build.0 = Debug|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Debug|x86.ActiveCfg = Debug|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Debug|x86.Build.0 = Debug|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Release|Any CPU.Build.0 = Release|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Release|x64.ActiveCfg = Release|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Release|x64.Build.0 = Release|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Release|x86.ActiveCfg = Release|Any CPU
{6684AA9D-3FDA-42ED-A60F-8B10DAD3394B}.Release|x86.Build.0 = Release|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Debug|x64.ActiveCfg = Debug|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Debug|x64.Build.0 = Debug|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Debug|x86.ActiveCfg = Debug|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Debug|x86.Build.0 = Debug|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Release|Any CPU.Build.0 = Release|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Release|x64.ActiveCfg = Release|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Release|x64.Build.0 = Release|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Release|x86.ActiveCfg = Release|Any CPU
{DA225445-FC3D-429C-A1EE-7B14EB16AE0F}.Release|x86.Build.0 = Release|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Debug|x64.ActiveCfg = Debug|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Debug|x64.Build.0 = Debug|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Debug|x86.ActiveCfg = Debug|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Debug|x86.Build.0 = Debug|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Release|Any CPU.Build.0 = Release|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Release|x64.ActiveCfg = Release|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Release|x64.Build.0 = Release|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Release|x86.ActiveCfg = Release|Any CPU
{9969E571-2F75-428F-822D-154A816C8D0F}.Release|x86.Build.0 = Release|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Debug|Any CPU.Build.0 = Debug|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Debug|x64.ActiveCfg = Debug|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Debug|x64.Build.0 = Debug|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Debug|x86.ActiveCfg = Debug|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Debug|x86.Build.0 = Debug|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Release|Any CPU.ActiveCfg = Release|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Release|Any CPU.Build.0 = Release|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Release|x64.ActiveCfg = Release|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Release|x64.Build.0 = Release|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Release|x86.ActiveCfg = Release|Any CPU
{14762A37-48BA-42E8-B6CF-FA1967C58F13}.Release|x86.Build.0 = Release|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Debug|x64.ActiveCfg = Debug|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Debug|x64.Build.0 = Debug|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Debug|x86.ActiveCfg = Debug|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Debug|x86.Build.0 = Debug|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Release|Any CPU.Build.0 = Release|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Release|x64.ActiveCfg = Release|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Release|x64.Build.0 = Release|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Release|x86.ActiveCfg = Release|Any CPU
{F921862B-2057-4E57-9765-2C34764BC226}.Release|x86.Build.0 = Release|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Debug|Any CPU.Build.0 = Debug|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Debug|x64.ActiveCfg = Debug|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Debug|x64.Build.0 = Debug|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Debug|x86.ActiveCfg = Debug|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Debug|x86.Build.0 = Debug|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Release|Any CPU.ActiveCfg = Release|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Release|Any CPU.Build.0 = Release|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Release|x64.ActiveCfg = Release|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Release|x64.Build.0 = Release|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Release|x86.ActiveCfg = Release|Any CPU
{872BE10D-03C8-4F6A-9D4C-F56FFDCC6B16}.Release|x86.Build.0 = Release|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Debug|x64.ActiveCfg = Debug|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Debug|x64.Build.0 = Debug|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Debug|x86.ActiveCfg = Debug|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Debug|x86.Build.0 = Debug|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Release|Any CPU.Build.0 = Release|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Release|x64.ActiveCfg = Release|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Release|x64.Build.0 = Release|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Release|x86.ActiveCfg = Release|Any CPU
{DA1297B3-5B0A-4B4F-A213-9D0E633233EE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.SbomService", "StellaOps.SbomService", "{EA166A10-46CF-EE46-BF65-EF4EA8491F77}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.SbomService.Tests", "StellaOps.SbomService.Tests", "{2ADA3E28-5560-157A-7ED5-F031C23449B0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aoc", "Aoc", "{03DFF14F-7321-1784-D4C7-4E99D4120F48}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{BDD326D6-7616-84F0-B914-74743BFBA520}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Aoc", "StellaOps.Aoc", "{EC506DBE-AB6D-492E-786E-8B176021BF2E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Attestor", "Attestor", "{5AC09D9A-F2A5-9CFA-B3C5-8D25F257651C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Envelope", "StellaOps.Attestor.Envelope", "{018E0E11-1CCE-A2BE-641D-21EE14D2E90D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB67BDB9-D701-3AC9-9CDF-ECCDCCD8DB6D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.ProofChain", "StellaOps.Attestor.ProofChain", "{45F7FA87-7451-6970-7F6E-F8BAE45E081B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Authority", "Authority", "{C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority", "StellaOps.Authority", "{A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Abstractions", "StellaOps.Auth.Abstractions", "{F2E6CB0E-DF77-1FAA-582B-62B040DF3848}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority.Plugins.Abstractions", "StellaOps.Authority.Plugins.Abstractions", "{64689413-46D7-8499-68A6-B6367ACBC597}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Concelier", "Concelier", "{157C3671-CA0B-69FA-A7C9-74A1FDA97B99}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{F39E09D6-BF93-B64A-CFE7-2BA92815C0FE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.RawModels", "StellaOps.Concelier.RawModels", "{1DCF4EBB-DBC4-752C-13D4-D1EECE4E8907}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.SourceIntel", "StellaOps.Concelier.SourceIntel", "{F2B58F4E-6F28-A25F-5BFB-CDEBAD6B9A3E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Excititor", "Excititor", "{7D49FA52-6EA1-EAC8-4C5A-AC07188D6C57}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{C9CF27FC-12DB-954F-863C-576BA8E309A5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Core", "StellaOps.Excititor.Core", "{6DCAF6F3-717F-27A9-D96C-F2BFA5550347}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Persistence", "StellaOps.Excititor.Persistence", "{83791804-2407-CC2B-34AD-ED8FFAAF3257}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Feedser", "Feedser", "{C4A90603-BE42-0044-CAB4-3EB910AD51A5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.BinaryAnalysis", "StellaOps.Feedser.BinaryAnalysis", "{054761F9-16D3-B2F8-6F4D-EFC2248805CD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.Core", "StellaOps.Feedser.Core", "{B54CE64C-4167-1DD1-B7D6-2FD7A5AEF715}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Policy", "Policy", "{8E6B774C-CC4E-CE7C-AD4B-8AF7C92889A6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Policy.RiskProfile", "StellaOps.Policy.RiskProfile", "{BC12ED55-6015-7C8B-8384-B39CE93C76D6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{FF70543D-AFF9-1D38-4950-4F8EE18D60BB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Policy", "StellaOps.Policy", "{831265B0-8896-9C95-3488-E12FD9F6DC53}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Configuration", "StellaOps.Configuration", "{538E2D98-5325-3F54-BE74-EFE5FC1ECBD8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.DependencyInjection", "StellaOps.Cryptography.DependencyInjection", "{7203223D-FF02-7BEB-2798-D1639ACC01C4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.CryptoPro", "StellaOps.Cryptography.Plugin.CryptoPro", "{3C69853C-90E3-D889-1960-3B9229882590}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "StellaOps.Cryptography.Plugin.OpenSslGost", "{643E4D4C-BC96-A37F-E0EC-488127F0B127}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "StellaOps.Cryptography.Plugin.Pkcs11Gost", "{6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.PqSoft", "StellaOps.Cryptography.Plugin.PqSoft", "{F04B7DBB-77A5-C978-B2DE-8C189A32AA72}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SimRemote", "StellaOps.Cryptography.Plugin.SimRemote", "{7C72F22A-20FF-DF5B-9191-6DFD0D497DB2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmRemote", "StellaOps.Cryptography.Plugin.SmRemote", "{C896CC0A-F5E6-9AA4-C582-E691441F8D32}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmSoft", "StellaOps.Cryptography.Plugin.SmSoft", "{0AA3A418-AB45-CCA4-46D4-EEBFE011FECA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.WineCsp", "StellaOps.Cryptography.Plugin.WineCsp", "{225D9926-4AE8-E539-70AD-8698E688F271}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.PluginLoader", "StellaOps.Cryptography.PluginLoader", "{D6E8E69C-F721-BBCB-8C39-9716D53D72AD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection", "{589A43FD-8213-E9E3-6CFF-9CBA72D53E98}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.EfCore", "StellaOps.Infrastructure.EfCore", "{FCD529E0-DD17-6587-B29C-12D425C0AD0C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres", "StellaOps.Infrastructure.Postgres", "{61B23570-4F2D-B060-BE1F-37995682E494}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Ingestion.Telemetry", "StellaOps.Ingestion.Telemetry", "{1182764D-2143-EEF0-9270-3DCE392F5D06}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Plugin", "StellaOps.Plugin", "{772B02B5-6280-E1D4-3E2E-248D0455C2FB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{90659617-4DF7-809A-4E5B-29BB5A98E8E1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres.Testing", "StellaOps.Infrastructure.Postgres.Testing", "{CEDC2447-F717-3C95-7E08-F214D575A7B7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.SbomService.Persistence", "StellaOps.SbomService.Persistence", "{DA065C06-DC96-542B-B4F1-44C49C8AA459}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.SbomService.Persistence.Tests", "StellaOps.SbomService.Persistence.Tests", "{B7E7261A-FDBA-FBB3-2618-3B2C22723B22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "E:\dev\git.stella-ops.org\src\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{776E2142-804F-03B9-C804-D061D64C6092}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope", "E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj", "{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain", "E:\dev\git.stella-ops.org\src\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj", "{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{97F94029-5419-6187-5A63-5C8FD9232FAE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{34EFF636-81A7-8DF6-7CC9-4DA784BAC7F3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SourceIntel", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj", "{EB093C48-CDAC-106B-1196-AE34809B34C0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{92C62F7B-8028-6EE1-B71B-F45F459B8E97}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{FA83F778-5252-0B80-5555-E69F790322EA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.CryptoPro", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.CryptoPro\StellaOps.Cryptography.Plugin.CryptoPro.csproj", "{C53E0895-879A-D9E6-0A43-24AD17A2F270}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj", "{0AED303F-69E6-238F-EF80-81985080EDB7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj", "{2904D288-CE64-A565-2C46-C2E85A96A1EE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.PqSoft", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj", "{A6667CC3-B77F-023E-3A67-05F99E9FF46A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SimRemote", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj", "{A26E2816-F787-F76B-1D6C-E086DD3E19CE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmRemote", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SmRemote\StellaOps.Cryptography.Plugin.SmRemote.csproj", "{B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmSoft", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj", "{90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.WineCsp", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj", "{059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.PluginLoader", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.PluginLoader\StellaOps.Cryptography.PluginLoader.csproj", "{8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{632A1F0D-1BA5-C84B-B716-2BE638A92780}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Core", "E:\dev\git.stella-ops.org\src\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj", "{9151601C-8784-01A6-C2E7-A5C0FAAB0AEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Persistence", "E:\dev\git.stella-ops.org\src\Excititor\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj", "{4F1EE2D9-9392-6A1C-7224-6B01FAB934E3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.BinaryAnalysis", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj", "{CB296A20-2732-77C1-7F23-27D5BAEDD0C7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{0DBEC9BA-FE1D-3898-B2C6-E4357DC23E0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.EfCore", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj", "{A63897D9-9531-989B-7309-E384BCFC2BB9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{8C594D82-3463-3367-4F06-900AC707753D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "E:\dev\git.stella-ops.org\src\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{52F400CD-D473-7A1F-7986-89011CD2A887}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{9588FBF9-C37E-D16E-2E8F-CFA226EAC01D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{38A9EE9B-6FC8-93BC-0D43-2A906E678D66}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "E:\dev\git.stella-ops.org\src\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{19868E2D-7163-2108-1094-F13887C4F070}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.RiskProfile", "E:\dev\git.stella-ops.org\src\Policy\StellaOps.Policy.RiskProfile\StellaOps.Policy.RiskProfile.csproj", "{CC319FC5-F4B1-C3DD-7310-4DAD343E0125}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.SbomService", "StellaOps.SbomService\StellaOps.SbomService.csproj", "{821AEC28-CEC6-352A-3393-5616907D5E62}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.SbomService.Persistence", "__Libraries\StellaOps.SbomService.Persistence\StellaOps.SbomService.Persistence.csproj", "{CA0D42AA-8234-7EF5-A69F-F317858B4247}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.SbomService.Persistence.Tests", "__Tests\StellaOps.SbomService.Persistence.Tests\StellaOps.SbomService.Persistence.Tests.csproj", "{0DE669DE-706F-BA8E-9329-9ED55BE5D20D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.SbomService.Tests", "StellaOps.SbomService.Tests\StellaOps.SbomService.Tests.csproj", "{88BBD601-11CD-B828-A08E-6601C99682E4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{776E2142-804F-03B9-C804-D061D64C6092}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{776E2142-804F-03B9-C804-D061D64C6092}.Debug|Any CPU.Build.0 = Debug|Any CPU
{776E2142-804F-03B9-C804-D061D64C6092}.Release|Any CPU.ActiveCfg = Release|Any CPU
{776E2142-804F-03B9-C804-D061D64C6092}.Release|Any CPU.Build.0 = Release|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Release|Any CPU.Build.0 = Release|Any CPU
{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}.Release|Any CPU.Build.0 = Release|Any CPU
{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Debug|Any CPU.Build.0 = Debug|Any CPU
{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Release|Any CPU.ActiveCfg = Release|Any CPU
{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Release|Any CPU.Build.0 = Release|Any CPU
{97F94029-5419-6187-5A63-5C8FD9232FAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{97F94029-5419-6187-5A63-5C8FD9232FAE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{97F94029-5419-6187-5A63-5C8FD9232FAE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{97F94029-5419-6187-5A63-5C8FD9232FAE}.Release|Any CPU.Build.0 = Release|Any CPU
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.Build.0 = Release|Any CPU
{34EFF636-81A7-8DF6-7CC9-4DA784BAC7F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{34EFF636-81A7-8DF6-7CC9-4DA784BAC7F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{34EFF636-81A7-8DF6-7CC9-4DA784BAC7F3}.Release|Any CPU.ActiveCfg = Release|Any CPU

View File

@@ -0,0 +1,82 @@
// -----------------------------------------------------------------------------
// ReplayVerificationModels.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-033)
// Task: Create replay verification API models
// Description: Request/response models for the replay verification endpoints.
// -----------------------------------------------------------------------------
namespace StellaOps.SbomService.Models;
/// <summary>
/// Request model for replay hash verification.
/// </summary>
public sealed record ReplayVerifyRequest
{
/// <summary>
/// The replay hash to verify.
/// </summary>
public required string ReplayHash { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Optional: SBOM digest to use for verification.
/// </summary>
public string? SbomDigest { get; init; }
/// <summary>
/// Optional: Feeds snapshot digest to use.
/// </summary>
public string? FeedsSnapshotDigest { get; init; }
/// <summary>
/// Optional: Policy version to use.
/// </summary>
public string? PolicyVersion { get; init; }
/// <summary>
/// Optional: VEX verdicts digest to use.
/// </summary>
public string? VexVerdictsDigest { get; init; }
/// <summary>
/// Optional: Timestamp to use for verification.
/// </summary>
public DateTimeOffset? Timestamp { get; init; }
/// <summary>
/// Whether to freeze time to the original evaluation timestamp.
/// Default: true.
/// </summary>
public bool? FreezeTime { get; init; }
/// <summary>
/// Whether to re-evaluate policy with frozen feeds.
/// Default: false.
/// </summary>
public bool? ReEvaluatePolicy { get; init; }
}
/// <summary>
/// Request model for comparing drift between two replay hashes.
/// </summary>
public sealed record CompareDriftRequest
{
/// <summary>
/// First replay hash.
/// </summary>
public required string HashA { get; init; }
/// <summary>
/// Second replay hash.
/// </summary>
public required string HashB { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
}

View File

@@ -21,6 +21,16 @@ public sealed record SbomUploadRequest
[JsonPropertyName("source")]
public SbomUploadSource? Source { get; init; }
// LIN-BE-003: Lineage ancestry fields
[JsonPropertyName("parentArtifactDigest")]
public string? ParentArtifactDigest { get; init; }
[JsonPropertyName("baseImageRef")]
public string? BaseImageRef { get; init; }
[JsonPropertyName("baseImageDigest")]
public string? BaseImageDigest { get; init; }
}
public sealed record SbomUploadSource
@@ -101,7 +111,12 @@ public sealed record SbomLedgerSubmission(
string Source,
SbomUploadSource? Provenance,
IReadOnlyList<SbomNormalizedComponent> Components,
Guid? ParentVersionId);
Guid? ParentVersionId,
// LIN-BE-003: Lineage ancestry fields
string? ParentArtifactDigest = null,
string? BaseImageRef = null,
string? BaseImageDigest = null,
string? BuildId = null);
public sealed record SbomLedgerVersion
{
@@ -117,7 +132,29 @@ public sealed record SbomLedgerVersion
public SbomUploadSource? Provenance { get; init; }
public Guid? ParentVersionId { get; init; }
public string? ParentDigest { get; init; }
// LIN-BE-003: Lineage ancestry fields
public string? ParentArtifactDigest { get; init; }
public string? BaseImageRef { get; init; }
public string? BaseImageDigest { get; init; }
public string? BuildId { get; init; }
public IReadOnlyList<SbomNormalizedComponent> Components { get; init; } = Array.Empty<SbomNormalizedComponent>();
// LIN-BE-023: Replay hash for reproducibility verification
public string? ReplayHash { get; init; }
public ReplayHashInputSnapshot? ReplayHashInputs { get; init; }
}
/// <summary>
/// Snapshot of inputs used to compute a replay hash.
/// Stored alongside the version for audit and reproducibility.
/// Sprint: LIN-BE-023
/// </summary>
public sealed record ReplayHashInputSnapshot
{
public required string SbomDigest { get; init; }
public required string FeedsSnapshotDigest { get; init; }
public required string PolicyVersion { get; init; }
public required string VexVerdictsDigest { get; init; }
public required DateTimeOffset ComputedAt { get; init; }
}
public sealed record SbomVersionHistoryItem(
@@ -196,6 +233,7 @@ public static class SbomLineageRelationships
{
public const string Parent = "parent";
public const string Build = "build";
public const string Base = "base";
}
public sealed record SbomLineageResult(
@@ -204,6 +242,140 @@ public sealed record SbomLineageResult(
IReadOnlyList<SbomLineageNode> Nodes,
IReadOnlyList<SbomLineageEdge> Edges);
// -----------------------------------------------------------------------------
// Extended Lineage Models for SBOM Lineage Graph
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-004/005)
// -----------------------------------------------------------------------------
/// <summary>
/// Persistent lineage edge stored in database.
/// Uses artifact digests as stable identifiers across systems.
/// </summary>
public sealed record SbomLineageEdgeEntity
{
public required Guid Id { get; init; }
public required string ParentDigest { get; init; }
public required string ChildDigest { get; init; }
public required LineageRelationship Relationship { get; init; }
public required Guid TenantId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Lineage relationship type.
/// </summary>
public enum LineageRelationship
{
/// <summary>
/// Direct version succession (v1.0 → v1.1).
/// </summary>
Parent,
/// <summary>
/// Same CI build produced multiple artifacts (multi-arch).
/// </summary>
Build,
/// <summary>
/// Derived from base image (FROM instruction).
/// </summary>
Base
}
/// <summary>
/// Extended lineage node with badge information for UI.
/// </summary>
public sealed record SbomLineageNodeExtended
{
public required Guid Id { get; init; }
public required string Digest { get; init; }
public required string ArtifactRef { get; init; }
public required int SequenceNumber { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string Source { get; init; }
public SbomLineageBadges? Badges { get; init; }
public string? ReplayHash { get; init; }
}
/// <summary>
/// Badge information for lineage node.
/// </summary>
public sealed record SbomLineageBadges
{
public int NewVulns { get; init; }
public int ResolvedVulns { get; init; }
public string SignatureStatus { get; init; } = "unknown";
public int ComponentCount { get; init; }
}
/// <summary>
/// Extended lineage edge with digest-based endpoints.
/// </summary>
public sealed record SbomLineageEdgeExtended
{
public required string From { get; init; }
public required string To { get; init; }
public required string Relationship { get; init; }
}
/// <summary>
/// Complete lineage graph response.
/// </summary>
public sealed record SbomLineageGraphResponse
{
public required string Artifact { get; init; }
public required IReadOnlyList<SbomLineageNodeExtended> Nodes { get; init; }
public required IReadOnlyList<SbomLineageEdgeExtended> Edges { get; init; }
}
/// <summary>
/// Lineage diff response with component and VEX deltas.
/// </summary>
public sealed record SbomLineageDiffResponse
{
public required SbomDiffResult SbomDiff { get; init; }
public required IReadOnlyList<VexDeltaSummary> VexDiff { get; init; }
public IReadOnlyList<ReachabilityDeltaSummary>? ReachabilityDiff { get; init; }
public required string ReplayHash { get; init; }
}
/// <summary>
/// VEX status change between versions.
/// </summary>
public sealed record VexDeltaSummary
{
public required string Cve { get; init; }
public required string FromStatus { get; init; }
public required string ToStatus { get; init; }
public string? Reason { get; init; }
public string? EvidenceLink { get; init; }
}
/// <summary>
/// Reachability change between versions.
/// </summary>
public sealed record ReachabilityDeltaSummary
{
public required string Cve { get; init; }
public required string FromStatus { get; init; }
public required string ToStatus { get; init; }
public int PathsAdded { get; init; }
public int PathsRemoved { get; init; }
public IReadOnlyList<string>? GatesAdded { get; init; }
public IReadOnlyList<string>? GatesRemoved { get; init; }
}
/// <summary>
/// Query options for lineage graph.
/// </summary>
public sealed record SbomLineageQueryOptions
{
public int MaxDepth { get; init; } = 10;
public bool IncludeVerdicts { get; init; } = true;
public bool IncludeBadges { get; init; } = true;
public bool IncludeReplayHash { get; init; }
}
public sealed record SbomRetentionResult(
int VersionsPruned,
int ChainsTouched,

View File

@@ -71,6 +71,36 @@ builder.Services.AddSingleton<ISbomLedgerService, SbomLedgerService>();
builder.Services.AddSingleton<ISbomAnalysisTrigger, InMemorySbomAnalysisTrigger>();
builder.Services.AddSingleton<ISbomUploadService, SbomUploadService>();
// Lineage graph services (LIN-BE-013)
builder.Services.AddSingleton<ISbomLineageEdgeRepository, InMemorySbomLineageEdgeRepository>();
// LIN-BE-015: Hover card cache for <150ms response times
// Use distributed cache if configured, otherwise in-memory
var hoverCacheConfig = builder.Configuration.GetSection("SbomService:HoverCache");
if (hoverCacheConfig.GetValue<bool>("UseDistributed"))
{
// Expects IDistributedCache to be registered (e.g., Valkey/Redis)
builder.Services.AddSingleton<ILineageHoverCache, DistributedLineageHoverCache>();
}
else
{
builder.Services.AddSingleton<ILineageHoverCache, InMemoryLineageHoverCache>();
}
builder.Services.AddSingleton<ISbomLineageGraphService, SbomLineageGraphService>();
// LIN-BE-028: Lineage compare service
builder.Services.AddSingleton<ILineageCompareService, LineageCompareService>();
// LIN-BE-023: Replay hash service
builder.Services.AddSingleton<IReplayHashService, ReplayHashService>();
// LIN-BE-033: Replay verification service
builder.Services.AddSingleton<IReplayVerificationService, ReplayVerificationService>();
// LIN-BE-034: Compare cache with TTL and VEX invalidation
builder.Services.Configure<CompareCacheOptions>(builder.Configuration.GetSection("SbomService:CompareCache"));
builder.Services.AddSingleton<ILineageCompareCache, InMemoryLineageCompareCache>();
builder.Services.AddSingleton<IProjectionRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
@@ -618,6 +648,307 @@ app.MapGet("/sbom/ledger/lineage", async Task<IResult> (
return Results.Ok(lineage);
});
// -----------------------------------------------------------------------------
// Lineage Graph API Endpoints (LIN-BE-013/014)
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i
// -----------------------------------------------------------------------------
app.MapGet("/api/v1/lineage/{artifactDigest}", async Task<IResult> (
[FromServices] ISbomLineageGraphService lineageService,
[FromRoute] string artifactDigest,
[FromQuery] string? tenant,
[FromQuery] int? maxDepth,
[FromQuery] bool? includeBadges,
[FromQuery] bool? includeReplayHash,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifactDigest is required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
var options = new SbomLineageQueryOptions
{
MaxDepth = maxDepth ?? 10,
IncludeBadges = includeBadges ?? true,
IncludeReplayHash = includeReplayHash ?? false
};
using var activity = SbomTracing.Source.StartActivity("lineage.graph", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
activity?.SetTag("artifact_digest", artifactDigest);
var graph = await lineageService.GetLineageGraphAsync(
artifactDigest.Trim(),
tenant.Trim(),
options,
cancellationToken);
if (graph is null)
{
return Results.NotFound(new { error = "lineage graph not found" });
}
return Results.Ok(graph);
});
app.MapGet("/api/v1/lineage/diff", async Task<IResult> (
[FromServices] ISbomLineageGraphService lineageService,
[FromQuery] string? from,
[FromQuery] string? to,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
return Results.BadRequest(new { error = "from and to digests are required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.diff", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
activity?.SetTag("from_digest", from);
activity?.SetTag("to_digest", to);
var diff = await lineageService.GetLineageDiffAsync(
from.Trim(),
to.Trim(),
tenant.Trim(),
cancellationToken);
if (diff is null)
{
return Results.NotFound(new { error = "lineage diff not found" });
}
SbomMetrics.LedgerDiffsTotal.Add(1);
return Results.Ok(diff);
});
app.MapGet("/api/v1/lineage/hover", async Task<IResult> (
[FromServices] ISbomLineageGraphService lineageService,
[FromQuery] string? from,
[FromQuery] string? to,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
return Results.BadRequest(new { error = "from and to digests are required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.hover", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
var hoverCard = await lineageService.GetHoverCardAsync(
from.Trim(),
to.Trim(),
tenant.Trim(),
cancellationToken);
if (hoverCard is null)
{
return Results.NotFound(new { error = "hover card data not found" });
}
return Results.Ok(hoverCard);
});
app.MapGet("/api/v1/lineage/{artifactDigest}/children", async Task<IResult> (
[FromServices] ISbomLineageGraphService lineageService,
[FromRoute] string artifactDigest,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifactDigest is required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.children", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
activity?.SetTag("artifact_digest", artifactDigest);
var children = await lineageService.GetChildrenAsync(
artifactDigest.Trim(),
tenant.Trim(),
cancellationToken);
return Results.Ok(new { parentDigest = artifactDigest.Trim(), children });
});
app.MapGet("/api/v1/lineage/{artifactDigest}/parents", async Task<IResult> (
[FromServices] ISbomLineageGraphService lineageService,
[FromRoute] string artifactDigest,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifactDigest is required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.parents", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
activity?.SetTag("artifact_digest", artifactDigest);
var parents = await lineageService.GetParentsAsync(
artifactDigest.Trim(),
tenant.Trim(),
cancellationToken);
return Results.Ok(new { childDigest = artifactDigest.Trim(), parents });
});
// -----------------------------------------------------------------------------
// Lineage Compare API (LIN-BE-028)
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
// -----------------------------------------------------------------------------
app.MapGet("/api/v1/lineage/compare", async Task<IResult> (
[FromServices] ILineageCompareService compareService,
[FromQuery(Name = "a")] string? fromDigest,
[FromQuery(Name = "b")] string? toDigest,
[FromQuery] string? tenant,
[FromQuery] bool? includeSbomDiff,
[FromQuery] bool? includeVexDeltas,
[FromQuery] bool? includeReachabilityDeltas,
[FromQuery] bool? includeAttestations,
[FromQuery] bool? includeReplayHashes,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(fromDigest) || string.IsNullOrWhiteSpace(toDigest))
{
return Results.BadRequest(new { error = "a (from digest) and b (to digest) query parameters are required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
var options = new LineageCompareOptions
{
IncludeSbomDiff = includeSbomDiff ?? true,
IncludeVexDeltas = includeVexDeltas ?? true,
IncludeReachabilityDeltas = includeReachabilityDeltas ?? true,
IncludeAttestations = includeAttestations ?? true,
IncludeReplayHashes = includeReplayHashes ?? true
};
using var activity = SbomTracing.Source.StartActivity("lineage.compare", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
activity?.SetTag("from_digest", fromDigest);
activity?.SetTag("to_digest", toDigest);
var result = await compareService.CompareAsync(
fromDigest.Trim(),
toDigest.Trim(),
tenant.Trim(),
options,
cancellationToken);
if (result is null)
{
return Results.NotFound(new { error = "comparison data not found for the specified artifacts" });
}
return Results.Ok(result);
});
// -----------------------------------------------------------------------------
// Replay Verification API (LIN-BE-033)
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
// -----------------------------------------------------------------------------
app.MapPost("/api/v1/lineage/verify", async Task<IResult> (
[FromServices] IReplayVerificationService verificationService,
[FromBody] ReplayVerifyRequest request,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.ReplayHash))
{
return Results.BadRequest(new { error = "replayHash is required" });
}
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenantId is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.verify", ActivityKind.Server);
activity?.SetTag("tenant", request.TenantId);
activity?.SetTag("replay_hash", request.ReplayHash.Length > 16 ? request.ReplayHash[..16] + "..." : request.ReplayHash);
var verifyRequest = new ReplayVerificationRequest
{
ReplayHash = request.ReplayHash,
TenantId = request.TenantId,
SbomDigest = request.SbomDigest,
FeedsSnapshotDigest = request.FeedsSnapshotDigest,
PolicyVersion = request.PolicyVersion,
VexVerdictsDigest = request.VexVerdictsDigest,
Timestamp = request.Timestamp,
FreezeTime = request.FreezeTime ?? true,
ReEvaluatePolicy = request.ReEvaluatePolicy ?? false
};
var result = await verificationService.VerifyAsync(verifyRequest, cancellationToken);
return Results.Ok(result);
});
app.MapPost("/api/v1/lineage/compare-drift", async Task<IResult> (
[FromServices] IReplayVerificationService verificationService,
[FromBody] CompareDriftRequest request,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.HashA) || string.IsNullOrWhiteSpace(request.HashB))
{
return Results.BadRequest(new { error = "hashA and hashB are required" });
}
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenantId is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.compare-drift", ActivityKind.Server);
activity?.SetTag("tenant", request.TenantId);
var result = await verificationService.CompareDriftAsync(
request.HashA,
request.HashB,
request.TenantId,
cancellationToken);
return Results.Ok(result);
});
app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromRoute] string? snapshotId,
@@ -932,4 +1263,5 @@ app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
app.Run();
// Program class public for WebApplicationFactory<Program>
public partial class Program;

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.SbomService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62535;http://localhost:62537"
}
}
}

View File

@@ -10,6 +10,8 @@ internal interface ISbomLedgerRepository
{
Task<SbomLedgerVersion> AddVersionAsync(SbomLedgerVersion version, CancellationToken cancellationToken);
Task<SbomLedgerVersion?> GetVersionAsync(Guid versionId, CancellationToken cancellationToken);
// LIN-BE-003: Lookup version by digest for ancestry resolution
Task<SbomLedgerVersion?> GetVersionByDigestAsync(string digest, CancellationToken cancellationToken);
Task<IReadOnlyList<SbomLedgerVersion>> GetVersionsAsync(string artifactRef, CancellationToken cancellationToken);
Task<Guid?> GetChainIdAsync(string artifactRef, CancellationToken cancellationToken);
Task<IReadOnlyList<SbomLedgerAuditEntry>> GetAuditAsync(string artifactRef, CancellationToken cancellationToken);

View File

@@ -0,0 +1,114 @@
// -----------------------------------------------------------------------------
// ISbomLineageEdgeRepository.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-005)
// Task: Repository interface for SBOM lineage edge persistence
// -----------------------------------------------------------------------------
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
/// <summary>
/// Repository for persisting and querying SBOM lineage edges.
/// Edges represent relationships between artifact versions using digests.
/// </summary>
public interface ISbomLineageEdgeRepository
{
/// <summary>
/// Adds a lineage edge between two artifacts.
/// </summary>
/// <param name="edge">The edge to add.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created edge with assigned ID.</returns>
ValueTask<SbomLineageEdgeEntity> AddAsync(
SbomLineageEdgeEntity edge,
CancellationToken ct = default);
/// <summary>
/// Adds multiple lineage edges in a single operation.
/// </summary>
/// <param name="edges">Edges to add.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of edges added.</returns>
ValueTask<int> AddRangeAsync(
IEnumerable<SbomLineageEdgeEntity> edges,
CancellationToken ct = default);
/// <summary>
/// Gets all edges where the specified digest is the parent.
/// </summary>
/// <param name="parentDigest">Parent artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of edges to child artifacts.</returns>
ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetChildrenAsync(
string parentDigest,
Guid tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets all edges where the specified digest is the child.
/// </summary>
/// <param name="childDigest">Child artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of edges from parent artifacts.</returns>
ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetParentsAsync(
string childDigest,
Guid tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets the full lineage graph for an artifact up to a specified depth.
/// </summary>
/// <param name="artifactDigest">Starting artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="maxDepth">Maximum traversal depth.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>All edges in the lineage graph.</returns>
ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetGraphAsync(
string artifactDigest,
Guid tenantId,
int maxDepth = 10,
CancellationToken ct = default);
/// <summary>
/// Checks if an edge exists between two artifacts.
/// </summary>
/// <param name="parentDigest">Parent artifact digest.</param>
/// <param name="childDigest">Child artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if edge exists.</returns>
ValueTask<bool> ExistsAsync(
string parentDigest,
string childDigest,
Guid tenantId,
CancellationToken ct = default);
/// <summary>
/// Deletes edges for an artifact (when pruning old versions).
/// </summary>
/// <param name="artifactDigest">Artifact digest to remove edges for.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of edges deleted.</returns>
ValueTask<int> DeleteByArtifactAsync(
string artifactDigest,
Guid tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets edges by relationship type.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="relationship">Relationship type filter.</param>
/// <param name="limit">Maximum edges to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Edges matching the relationship type.</returns>
ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetByRelationshipAsync(
Guid tenantId,
LineageRelationship relationship,
int limit = 100,
CancellationToken ct = default);
}

View File

@@ -44,6 +44,24 @@ internal sealed class InMemorySbomLedgerRepository : ISbomLedgerRepository
return Task.FromResult(version);
}
// LIN-BE-003: Lookup version by digest for ancestry resolution
public Task<SbomLedgerVersion?> GetVersionByDigestAsync(string digest, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(digest))
{
return Task.FromResult<SbomLedgerVersion?>(null);
}
// Find the most recent version with matching digest
var version = _versions.Values
.Where(v => string.Equals(v.Digest, digest, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(v => v.CreatedAtUtc)
.FirstOrDefault();
return Task.FromResult(version);
}
public Task<IReadOnlyList<SbomLedgerVersion>> GetVersionsAsync(string artifactRef, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

View File

@@ -0,0 +1,244 @@
// -----------------------------------------------------------------------------
// InMemorySbomLineageEdgeRepository.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-005)
// Task: In-memory implementation of SBOM lineage edge repository
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
/// <summary>
/// In-memory implementation of lineage edge repository.
/// Suitable for testing and development.
/// </summary>
public sealed class InMemorySbomLineageEdgeRepository : ISbomLineageEdgeRepository
{
private readonly ConcurrentDictionary<Guid, SbomLineageEdgeEntity> _edges = new();
private readonly object _lock = new();
/// <inheritdoc />
public ValueTask<SbomLineageEdgeEntity> AddAsync(
SbomLineageEdgeEntity edge,
CancellationToken ct = default)
{
lock (_lock)
{
// Check for duplicate edge
var existing = _edges.Values.FirstOrDefault(e =>
e.ParentDigest == edge.ParentDigest &&
e.ChildDigest == edge.ChildDigest &&
e.TenantId == edge.TenantId);
if (existing is not null)
{
return ValueTask.FromResult(existing);
}
var newEdge = edge with
{
Id = edge.Id == Guid.Empty ? Guid.NewGuid() : edge.Id,
CreatedAt = edge.CreatedAt == default ? DateTimeOffset.UtcNow : edge.CreatedAt
};
_edges[newEdge.Id] = newEdge;
return ValueTask.FromResult(newEdge);
}
}
/// <inheritdoc />
public ValueTask<int> AddRangeAsync(
IEnumerable<SbomLineageEdgeEntity> edges,
CancellationToken ct = default)
{
var count = 0;
lock (_lock)
{
foreach (var edge in edges)
{
var existing = _edges.Values.FirstOrDefault(e =>
e.ParentDigest == edge.ParentDigest &&
e.ChildDigest == edge.ChildDigest &&
e.TenantId == edge.TenantId);
if (existing is null)
{
var newEdge = edge with
{
Id = edge.Id == Guid.Empty ? Guid.NewGuid() : edge.Id,
CreatedAt = edge.CreatedAt == default ? DateTimeOffset.UtcNow : edge.CreatedAt
};
_edges[newEdge.Id] = newEdge;
count++;
}
}
}
return ValueTask.FromResult(count);
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetChildrenAsync(
string parentDigest,
Guid tenantId,
CancellationToken ct = default)
{
var children = _edges.Values
.Where(e => e.ParentDigest == parentDigest && e.TenantId == tenantId)
.OrderBy(e => e.ChildDigest, StringComparer.Ordinal)
.ToList();
return ValueTask.FromResult<IReadOnlyList<SbomLineageEdgeEntity>>(children);
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetParentsAsync(
string childDigest,
Guid tenantId,
CancellationToken ct = default)
{
var parents = _edges.Values
.Where(e => e.ChildDigest == childDigest && e.TenantId == tenantId)
.OrderBy(e => e.ParentDigest, StringComparer.Ordinal)
.ToList();
return ValueTask.FromResult<IReadOnlyList<SbomLineageEdgeEntity>>(parents);
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetGraphAsync(
string artifactDigest,
Guid tenantId,
int maxDepth = 10,
CancellationToken ct = default)
{
var visited = new HashSet<string>();
var result = new List<SbomLineageEdgeEntity>();
var queue = new Queue<(string Digest, int Depth)>();
queue.Enqueue((artifactDigest, 0));
visited.Add(artifactDigest);
while (queue.Count > 0)
{
var (currentDigest, depth) = queue.Dequeue();
if (depth >= maxDepth)
{
continue;
}
// Get edges where current is parent (descendants)
var childEdges = _edges.Values
.Where(e => e.ParentDigest == currentDigest && e.TenantId == tenantId)
.ToList();
foreach (var edge in childEdges)
{
result.Add(edge);
if (!visited.Contains(edge.ChildDigest))
{
visited.Add(edge.ChildDigest);
queue.Enqueue((edge.ChildDigest, depth + 1));
}
}
// Get edges where current is child (ancestors)
var parentEdges = _edges.Values
.Where(e => e.ChildDigest == currentDigest && e.TenantId == tenantId)
.ToList();
foreach (var edge in parentEdges)
{
if (!result.Any(r => r.Id == edge.Id))
{
result.Add(edge);
}
if (!visited.Contains(edge.ParentDigest))
{
visited.Add(edge.ParentDigest);
queue.Enqueue((edge.ParentDigest, depth + 1));
}
}
}
// Order deterministically
var orderedResult = result
.OrderBy(e => e.ParentDigest, StringComparer.Ordinal)
.ThenBy(e => e.ChildDigest, StringComparer.Ordinal)
.ThenBy(e => e.Relationship)
.ToList();
return ValueTask.FromResult<IReadOnlyList<SbomLineageEdgeEntity>>(orderedResult);
}
/// <inheritdoc />
public ValueTask<bool> ExistsAsync(
string parentDigest,
string childDigest,
Guid tenantId,
CancellationToken ct = default)
{
var exists = _edges.Values.Any(e =>
e.ParentDigest == parentDigest &&
e.ChildDigest == childDigest &&
e.TenantId == tenantId);
return ValueTask.FromResult(exists);
}
/// <inheritdoc />
public ValueTask<int> DeleteByArtifactAsync(
string artifactDigest,
Guid tenantId,
CancellationToken ct = default)
{
var count = 0;
lock (_lock)
{
var toRemove = _edges.Values
.Where(e => e.TenantId == tenantId &&
(e.ParentDigest == artifactDigest || e.ChildDigest == artifactDigest))
.Select(e => e.Id)
.ToList();
foreach (var id in toRemove)
{
if (_edges.TryRemove(id, out _))
{
count++;
}
}
}
return ValueTask.FromResult(count);
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetByRelationshipAsync(
Guid tenantId,
LineageRelationship relationship,
int limit = 100,
CancellationToken ct = default)
{
var edges = _edges.Values
.Where(e => e.TenantId == tenantId && e.Relationship == relationship)
.OrderByDescending(e => e.CreatedAt)
.Take(limit)
.ToList();
return ValueTask.FromResult<IReadOnlyList<SbomLineageEdgeEntity>>(edges);
}
/// <summary>
/// Clears all edges (for testing).
/// </summary>
public void Clear()
{
_edges.Clear();
}
/// <summary>
/// Gets the count of edges (for testing).
/// </summary>
public int Count => _edges.Count;
}

View File

@@ -0,0 +1,112 @@
// -----------------------------------------------------------------------------
// ILineageCompareCache.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-034)
// Task: Add caching for compare results
// Description: Interface for caching lineage compare results with TTL.
// -----------------------------------------------------------------------------
namespace StellaOps.SbomService.Services;
/// <summary>
/// Cache for lineage compare results.
/// Supports TTL-based expiration and VEX-triggered invalidation.
/// </summary>
public interface ILineageCompareCache
{
/// <summary>
/// Gets a cached compare result.
/// </summary>
/// <param name="fromDigest">The source artifact digest.</param>
/// <param name="toDigest">The target artifact digest.</param>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The cached result or null if not cached or expired.</returns>
Task<LineageCompareResponse?> GetAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Stores a compare result in cache.
/// </summary>
/// <param name="fromDigest">The source artifact digest.</param>
/// <param name="toDigest">The target artifact digest.</param>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="result">The compare result to cache.</param>
/// <param name="ttl">Optional TTL override.</param>
/// <param name="ct">Cancellation token.</param>
Task SetAsync(
string fromDigest,
string toDigest,
string tenantId,
LineageCompareResponse result,
TimeSpan? ttl = null,
CancellationToken ct = default);
/// <summary>
/// Invalidates cached results for a specific artifact.
/// Called when VEX data changes for the artifact.
/// </summary>
/// <param name="artifactDigest">The artifact digest to invalidate.</param>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of cache entries invalidated.</returns>
Task<int> InvalidateForArtifactAsync(
string artifactDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Invalidates all cached results for a tenant.
/// </summary>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of cache entries invalidated.</returns>
Task<int> InvalidateForTenantAsync(
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets cache statistics.
/// </summary>
CompareCacheStats GetStats();
}
/// <summary>
/// Cache statistics for monitoring.
/// </summary>
public sealed record CompareCacheStats
{
/// <summary>
/// Total number of cache entries.
/// </summary>
public int TotalEntries { get; init; }
/// <summary>
/// Number of cache hits.
/// </summary>
public long CacheHits { get; init; }
/// <summary>
/// Number of cache misses.
/// </summary>
public long CacheMisses { get; init; }
/// <summary>
/// Number of invalidations performed.
/// </summary>
public long Invalidations { get; init; }
/// <summary>
/// Hit rate percentage.
/// </summary>
public double HitRate => CacheHits + CacheMisses > 0
? (double)CacheHits / (CacheHits + CacheMisses) * 100
: 0;
/// <summary>
/// Estimated memory usage in bytes.
/// </summary>
public long EstimatedMemoryBytes { get; init; }
}

View File

@@ -0,0 +1,532 @@
// -----------------------------------------------------------------------------
// ILineageCompareService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-028)
// Task: Create GET /api/v1/lineage/compare endpoint
// Description: Service for full artifact comparison with SBOM, VEX, reachability deltas.
// -----------------------------------------------------------------------------
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for comprehensive artifact comparison.
/// Returns full comparison with SBOM diff, VEX deltas, reachability deltas,
/// attestation links, and replay hashes.
/// </summary>
internal interface ILineageCompareService
{
/// <summary>
/// Compares two artifacts and returns full comparison data.
/// </summary>
/// <param name="fromDigest">Source artifact digest.</param>
/// <param name="toDigest">Target artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="options">Comparison options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Full comparison response.</returns>
Task<LineageCompareResponse?> CompareAsync(
string fromDigest,
string toDigest,
string tenantId,
LineageCompareOptions? options = null,
CancellationToken ct = default);
}
/// <summary>
/// Options for lineage comparison.
/// </summary>
internal sealed record LineageCompareOptions
{
/// <summary>
/// Whether to include SBOM component diff.
/// </summary>
public bool IncludeSbomDiff { get; init; } = true;
/// <summary>
/// Whether to include VEX status deltas.
/// </summary>
public bool IncludeVexDeltas { get; init; } = true;
/// <summary>
/// Whether to include reachability deltas.
/// </summary>
public bool IncludeReachabilityDeltas { get; init; } = true;
/// <summary>
/// Whether to include attestation links.
/// </summary>
public bool IncludeAttestations { get; init; } = true;
/// <summary>
/// Whether to include replay hashes.
/// </summary>
public bool IncludeReplayHashes { get; init; } = true;
/// <summary>
/// Maximum number of component changes to include.
/// </summary>
public int MaxComponentChanges { get; init; } = 100;
/// <summary>
/// Maximum number of VEX deltas to include.
/// </summary>
public int MaxVexDeltas { get; init; } = 100;
/// <summary>
/// Maximum number of reachability deltas to include.
/// </summary>
public int MaxReachabilityDeltas { get; init; } = 50;
}
/// <summary>
/// Full comparison response between two artifacts.
/// </summary>
public sealed record LineageCompareResponse
{
/// <summary>
/// Source artifact digest.
/// </summary>
public required string FromDigest { get; init; }
/// <summary>
/// Target artifact digest.
/// </summary>
public required string ToDigest { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// When the comparison was computed.
/// </summary>
public DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Source artifact metadata.
/// </summary>
public LineageCompareArtifactInfo? FromArtifact { get; init; }
/// <summary>
/// Target artifact metadata.
/// </summary>
public LineageCompareArtifactInfo? ToArtifact { get; init; }
/// <summary>
/// Summary statistics for the comparison.
/// </summary>
public required LineageCompareSummary Summary { get; init; }
/// <summary>
/// SBOM component diff.
/// </summary>
public LineageSbomDiff? SbomDiff { get; init; }
/// <summary>
/// VEX status deltas.
/// </summary>
public LineageVexDeltaSummary? VexDeltas { get; init; }
/// <summary>
/// Reachability deltas.
/// </summary>
public LineageReachabilityDeltaSummary? ReachabilityDeltas { get; init; }
/// <summary>
/// Linked attestations.
/// </summary>
public IReadOnlyList<LineageAttestationLink>? Attestations { get; init; }
/// <summary>
/// Replay hash information.
/// </summary>
public LineageReplayHashInfo? ReplayHashes { get; init; }
}
/// <summary>
/// Artifact information in comparison.
/// </summary>
public sealed record LineageCompareArtifactInfo
{
/// <summary>
/// Artifact digest.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// SBOM digest if available.
/// </summary>
public string? SbomDigest { get; init; }
/// <summary>
/// Artifact name/repository.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Artifact version/tag.
/// </summary>
public string? Version { get; init; }
/// <summary>
/// When the SBOM was created.
/// </summary>
public DateTimeOffset? CreatedAt { get; init; }
/// <summary>
/// Component count in SBOM.
/// </summary>
public int ComponentCount { get; init; }
/// <summary>
/// Vulnerability count.
/// </summary>
public int VulnerabilityCount { get; init; }
}
/// <summary>
/// Summary of comparison results.
/// </summary>
public sealed record LineageCompareSummary
{
/// <summary>
/// Components added in target.
/// </summary>
public int ComponentsAdded { get; init; }
/// <summary>
/// Components removed from source.
/// </summary>
public int ComponentsRemoved { get; init; }
/// <summary>
/// Components with version changes.
/// </summary>
public int ComponentsModified { get; init; }
/// <summary>
/// New vulnerabilities in target.
/// </summary>
public int VulnerabilitiesAdded { get; init; }
/// <summary>
/// Vulnerabilities resolved in target.
/// </summary>
public int VulnerabilitiesResolved { get; init; }
/// <summary>
/// VEX status changes.
/// </summary>
public int VexStatusChanges { get; init; }
/// <summary>
/// Reachability changes.
/// </summary>
public int ReachabilityChanges { get; init; }
/// <summary>
/// Attestations available.
/// </summary>
public int AttestationCount { get; init; }
/// <summary>
/// Overall risk trend: "improved", "degraded", or "unchanged".
/// </summary>
public string RiskTrend { get; init; } = "unchanged";
}
/// <summary>
/// SBOM component diff.
/// </summary>
public sealed record LineageSbomDiff
{
/// <summary>
/// Components added in target.
/// </summary>
public IReadOnlyList<LineageComponentChange> Added { get; init; } = Array.Empty<LineageComponentChange>();
/// <summary>
/// Components removed from source.
/// </summary>
public IReadOnlyList<LineageComponentChange> Removed { get; init; } = Array.Empty<LineageComponentChange>();
/// <summary>
/// Components with version changes.
/// </summary>
public IReadOnlyList<LineageComponentModification> Modified { get; init; } = Array.Empty<LineageComponentModification>();
/// <summary>
/// Whether the diff is truncated.
/// </summary>
public bool Truncated { get; init; }
/// <summary>
/// Total count of changes (may be higher than arrays if truncated).
/// </summary>
public int TotalChanges { get; init; }
}
/// <summary>
/// Component change entry.
/// </summary>
public sealed record LineageComponentChange
{
/// <summary>
/// Package URL (PURL).
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Component name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Component version.
/// </summary>
public string? Version { get; init; }
/// <summary>
/// Component type (library, application, etc.).
/// </summary>
public string? Type { get; init; }
/// <summary>
/// License information.
/// </summary>
public string? License { get; init; }
/// <summary>
/// Known vulnerabilities in this component.
/// </summary>
public IReadOnlyList<string>? Vulnerabilities { get; init; }
}
/// <summary>
/// Component modification entry.
/// </summary>
public sealed record LineageComponentModification
{
/// <summary>
/// Package URL (PURL) - common base without version.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Component name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Version in source artifact.
/// </summary>
public required string FromVersion { get; init; }
/// <summary>
/// Version in target artifact.
/// </summary>
public required string ToVersion { get; init; }
/// <summary>
/// Whether this is a major, minor, or patch upgrade.
/// </summary>
public string? UpgradeType { get; init; }
/// <summary>
/// Vulnerabilities fixed by this upgrade.
/// </summary>
public IReadOnlyList<string>? FixedVulnerabilities { get; init; }
/// <summary>
/// New vulnerabilities introduced by this upgrade.
/// </summary>
public IReadOnlyList<string>? IntroducedVulnerabilities { get; init; }
}
/// <summary>
/// VEX delta summary for comparison.
/// </summary>
public sealed record LineageVexDeltaSummary
{
/// <summary>
/// Total VEX status changes.
/// </summary>
public int TotalChanges { get; init; }
/// <summary>
/// Status upgrades (e.g., under_investigation -> not_affected).
/// </summary>
public int StatusUpgrades { get; init; }
/// <summary>
/// Status downgrades (e.g., not_affected -> affected).
/// </summary>
public int StatusDowngrades { get; init; }
/// <summary>
/// Individual VEX status changes.
/// </summary>
public IReadOnlyList<LineageVexChange> Changes { get; init; } = Array.Empty<LineageVexChange>();
/// <summary>
/// Whether the list is truncated.
/// </summary>
public bool Truncated { get; init; }
}
/// <summary>
/// Individual VEX status change.
/// </summary>
public sealed record LineageVexChange
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string Cve { get; init; }
/// <summary>
/// Status in source artifact.
/// </summary>
public required string FromStatus { get; init; }
/// <summary>
/// Status in target artifact.
/// </summary>
public required string ToStatus { get; init; }
/// <summary>
/// Justification for the change.
/// </summary>
public string? Justification { get; init; }
/// <summary>
/// Attestation digest if available.
/// </summary>
public string? AttestationDigest { get; init; }
}
/// <summary>
/// Reachability delta summary for comparison.
/// </summary>
public sealed record LineageReachabilityDeltaSummary
{
/// <summary>
/// Total reachability changes.
/// </summary>
public int TotalChanges { get; init; }
/// <summary>
/// Vulnerabilities that became reachable.
/// </summary>
public int NewlyReachable { get; init; }
/// <summary>
/// Vulnerabilities that became unreachable.
/// </summary>
public int NewlyUnreachable { get; init; }
/// <summary>
/// Individual reachability changes.
/// </summary>
public IReadOnlyList<LineageReachabilityChange> Changes { get; init; } = Array.Empty<LineageReachabilityChange>();
/// <summary>
/// Whether the list is truncated.
/// </summary>
public bool Truncated { get; init; }
}
/// <summary>
/// Individual reachability change.
/// </summary>
public sealed record LineageReachabilityChange
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string Cve { get; init; }
/// <summary>
/// Change type.
/// </summary>
public required string ChangeType { get; init; }
/// <summary>
/// Reachability status in source.
/// </summary>
public required string FromStatus { get; init; }
/// <summary>
/// Reachability status in target.
/// </summary>
public required string ToStatus { get; init; }
/// <summary>
/// Path count change.
/// </summary>
public int PathCountDelta { get; init; }
/// <summary>
/// Brief explanation.
/// </summary>
public string? Explanation { get; init; }
}
/// <summary>
/// Attestation link for comparison.
/// </summary>
public sealed record LineageAttestationLink
{
/// <summary>
/// Attestation digest.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Predicate type.
/// </summary>
public required string PredicateType { get; init; }
/// <summary>
/// When the attestation was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Transparency log entry index.
/// </summary>
public long? TransparencyLogIndex { get; init; }
/// <summary>
/// Brief description.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Replay hash information for comparison.
/// </summary>
public sealed record LineageReplayHashInfo
{
/// <summary>
/// Replay hash for source artifact.
/// </summary>
public string? FromReplayHash { get; init; }
/// <summary>
/// Replay hash for target artifact.
/// </summary>
public string? ToReplayHash { get; init; }
/// <summary>
/// Whether the comparison is reproducible.
/// </summary>
public bool IsReproducible { get; init; }
/// <summary>
/// Verification status.
/// </summary>
public string? VerificationStatus { get; init; }
}

View File

@@ -0,0 +1,102 @@
// -----------------------------------------------------------------------------
// IReplayHashService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-023)
// Task: Compute replay hash per lineage node
// Description: Replay hash = SHA256(sbom_digest + feeds_snapshot_digest +
// policy_version + vex_verdicts_digest + timestamp)
// -----------------------------------------------------------------------------
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for computing deterministic replay hashes for lineage nodes.
/// Replay hashes enable verification that a security evaluation can be reproduced
/// given the same inputs (SBOM, feeds, policy, VEX verdicts).
/// </summary>
internal interface IReplayHashService
{
/// <summary>
/// Computes the replay hash for a lineage node.
/// </summary>
/// <param name="inputs">The inputs used to compute the hash.</param>
/// <returns>Hex-encoded SHA256 hash.</returns>
string ComputeHash(ReplayHashInputs inputs);
/// <summary>
/// Computes the replay hash for a lineage node asynchronously,
/// gathering the required inputs from services.
/// </summary>
/// <param name="sbomDigest">The SBOM digest.</param>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Computed replay hash and the inputs used.</returns>
Task<ReplayHashResult> ComputeHashAsync(
string sbomDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Verifies that a replay hash matches the expected value.
/// </summary>
/// <param name="expectedHash">The expected replay hash.</param>
/// <param name="inputs">The inputs to verify against.</param>
/// <returns>True if the hash matches.</returns>
bool VerifyHash(string expectedHash, ReplayHashInputs inputs);
}
/// <summary>
/// Inputs for computing a replay hash.
/// All inputs are content-addressed digests or version identifiers.
/// </summary>
public sealed record ReplayHashInputs
{
/// <summary>
/// Content digest of the SBOM (sha256:xxxx).
/// </summary>
public required string SbomDigest { get; init; }
/// <summary>
/// Digest of the vulnerability feeds snapshot used for analysis.
/// Computed from feed versions and their content hashes.
/// </summary>
public required string FeedsSnapshotDigest { get; init; }
/// <summary>
/// Version identifier of the policy bundle used.
/// Typically a semantic version or content hash.
/// </summary>
public required string PolicyVersion { get; init; }
/// <summary>
/// Digest of the aggregated VEX verdicts that apply to this SBOM.
/// Computed from consensus projections ordered deterministically.
/// </summary>
public required string VexVerdictsDigest { get; init; }
/// <summary>
/// UTC timestamp when the analysis was performed.
/// Truncated to minute precision for reproducibility.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
}
/// <summary>
/// Result of replay hash computation.
/// </summary>
public sealed record ReplayHashResult
{
/// <summary>
/// The computed replay hash (hex-encoded SHA256).
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// The inputs used to compute the hash.
/// </summary>
public required ReplayHashInputs Inputs { get; init; }
/// <summary>
/// Timestamp when the hash was computed.
/// </summary>
public required DateTimeOffset ComputedAt { get; init; }
}

View File

@@ -0,0 +1,255 @@
// -----------------------------------------------------------------------------
// IReplayVerificationService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-033)
// Task: Replay verification endpoint
// Description: Interface for verifying replay hashes and detecting drift.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for verifying replay hashes and detecting drift in security evaluations.
/// </summary>
public interface IReplayVerificationService
{
/// <summary>
/// Verifies a replay hash by re-computing it with provided or current inputs.
/// </summary>
/// <param name="request">Verification request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result with match status and drift details.</returns>
Task<ReplayVerificationResult> VerifyAsync(
ReplayVerificationRequest request,
CancellationToken ct = default);
/// <summary>
/// Compares two replay hashes and identifies drift between them.
/// </summary>
/// <param name="hashA">First replay hash.</param>
/// <param name="hashB">Second replay hash.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Drift analysis between the two evaluations.</returns>
Task<ReplayDriftAnalysis> CompareDriftAsync(
string hashA,
string hashB,
string tenantId,
CancellationToken ct = default);
}
/// <summary>
/// Request for replay verification.
/// </summary>
public sealed record ReplayVerificationRequest
{
/// <summary>
/// The replay hash to verify.
/// </summary>
public required string ReplayHash { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Optional: SBOM digest to use for verification.
/// If not provided, will try to lookup from stored hash metadata.
/// </summary>
public string? SbomDigest { get; init; }
/// <summary>
/// Optional: Feeds snapshot digest to use.
/// If not provided, uses current feeds.
/// </summary>
public string? FeedsSnapshotDigest { get; init; }
/// <summary>
/// Optional: Policy version to use.
/// If not provided, uses current policy.
/// </summary>
public string? PolicyVersion { get; init; }
/// <summary>
/// Optional: VEX verdicts digest to use.
/// If not provided, uses current VEX state.
/// </summary>
public string? VexVerdictsDigest { get; init; }
/// <summary>
/// Optional: Timestamp to use for verification.
/// If not provided, uses current time.
/// </summary>
public DateTimeOffset? Timestamp { get; init; }
/// <summary>
/// Whether to freeze time to the original evaluation timestamp.
/// </summary>
public bool FreezeTime { get; init; } = true;
/// <summary>
/// Whether to re-evaluate policy with frozen feeds.
/// </summary>
public bool ReEvaluatePolicy { get; init; } = false;
}
/// <summary>
/// Result of replay verification.
/// </summary>
public sealed record ReplayVerificationResult
{
/// <summary>
/// Whether the replay hash matches.
/// </summary>
public required bool IsMatch { get; init; }
/// <summary>
/// The expected replay hash.
/// </summary>
public required string ExpectedHash { get; init; }
/// <summary>
/// The computed replay hash.
/// </summary>
public required string ComputedHash { get; init; }
/// <summary>
/// Overall verification status.
/// </summary>
public required ReplayVerificationStatus Status { get; init; }
/// <summary>
/// The inputs used for the expected hash (from storage).
/// </summary>
public ReplayHashInputs? ExpectedInputs { get; init; }
/// <summary>
/// The inputs used to compute the verification hash.
/// </summary>
public ReplayHashInputs? ComputedInputs { get; init; }
/// <summary>
/// Field-level differences between expected and computed.
/// </summary>
public ImmutableArray<ReplayFieldDrift> Drifts { get; init; } = ImmutableArray<ReplayFieldDrift>.Empty;
/// <summary>
/// When the verification was performed.
/// </summary>
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Optional message with additional context.
/// </summary>
public string? Message { get; init; }
/// <summary>
/// Error message if verification failed.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Verification status enumeration.
/// </summary>
public enum ReplayVerificationStatus
{
/// <summary>
/// Hash matches exactly - evaluation is reproducible.
/// </summary>
Match,
/// <summary>
/// Hash doesn't match - drift detected.
/// </summary>
Drift,
/// <summary>
/// Unable to lookup original inputs.
/// </summary>
InputsNotFound,
/// <summary>
/// Verification failed due to error.
/// </summary>
Error
}
/// <summary>
/// Field-level drift in replay verification.
/// </summary>
public sealed record ReplayFieldDrift
{
/// <summary>
/// Name of the field that drifted.
/// </summary>
public required string FieldName { get; init; }
/// <summary>
/// Expected value (from original evaluation).
/// </summary>
public required string ExpectedValue { get; init; }
/// <summary>
/// Actual/computed value.
/// </summary>
public required string ActualValue { get; init; }
/// <summary>
/// Severity of the drift: "info", "warning", "critical".
/// </summary>
public required string Severity { get; init; }
/// <summary>
/// Human-readable description of the drift impact.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Analysis of drift between two replay evaluations.
/// </summary>
public sealed record ReplayDriftAnalysis
{
/// <summary>
/// First replay hash.
/// </summary>
public required string HashA { get; init; }
/// <summary>
/// Second replay hash.
/// </summary>
public required string HashB { get; init; }
/// <summary>
/// Whether the hashes are identical.
/// </summary>
public required bool IsIdentical { get; init; }
/// <summary>
/// Inputs for first hash.
/// </summary>
public ReplayHashInputs? InputsA { get; init; }
/// <summary>
/// Inputs for second hash.
/// </summary>
public ReplayHashInputs? InputsB { get; init; }
/// <summary>
/// Field-level drifts between A and B.
/// </summary>
public ImmutableArray<ReplayFieldDrift> Drifts { get; init; } = ImmutableArray<ReplayFieldDrift>.Empty;
/// <summary>
/// Summary of drift severity.
/// </summary>
public required string DriftSummary { get; init; }
/// <summary>
/// When the analysis was performed.
/// </summary>
public DateTimeOffset AnalyzedAt { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,100 @@
// -----------------------------------------------------------------------------
// ISbomLineageGraphService.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-013)
// Task: Create lineage graph service interface
// -----------------------------------------------------------------------------
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for querying and computing SBOM lineage graphs.
/// Provides graph traversal, diff computation, and hover card data.
/// </summary>
internal interface ISbomLineageGraphService
{
/// <summary>
/// Gets the full lineage graph for an artifact.
/// </summary>
/// <param name="artifactDigest">The artifact digest to start from.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="options">Query options (depth, include badges, etc.).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Complete lineage graph response.</returns>
Task<SbomLineageGraphResponse?> GetLineageGraphAsync(
string artifactDigest,
string tenantId,
SbomLineageQueryOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Gets the lineage diff between two artifacts.
/// </summary>
/// <param name="fromDigest">Source artifact digest.</param>
/// <param name="toDigest">Target artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Diff response with component and VEX deltas.</returns>
Task<SbomLineageDiffResponse?> GetLineageDiffAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets hover card data for a specific edge.
/// </summary>
/// <param name="fromDigest">Source artifact digest.</param>
/// <param name="toDigest">Target artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Hover card data with summary info.</returns>
Task<SbomLineageHoverCard?> GetHoverCardAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets children of an artifact in the lineage graph.
/// </summary>
Task<IReadOnlyList<SbomLineageNodeExtended>> GetChildrenAsync(
string parentDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets parents of an artifact in the lineage graph.
/// </summary>
Task<IReadOnlyList<SbomLineageNodeExtended>> GetParentsAsync(
string childDigest,
string tenantId,
CancellationToken ct = default);
}
/// <summary>
/// Hover card data for lineage edge.
/// </summary>
internal sealed record SbomLineageHoverCard
{
public required string FromDigest { get; init; }
public required string ToDigest { get; init; }
public required string Relationship { get; init; }
public SbomDiffSummary? ComponentDiff { get; init; }
public VexDeltaHoverSummary? VexDiff { get; init; }
public string? ReplayHash { get; init; }
public DateTimeOffset? FromCreatedAt { get; init; }
public DateTimeOffset? ToCreatedAt { get; init; }
}
/// <summary>
/// VEX delta summary for hover card.
/// </summary>
internal sealed record VexDeltaHoverSummary
{
public int NewVulns { get; init; }
public int ResolvedVulns { get; init; }
public int StatusChanges { get; init; }
public IReadOnlyList<string>? CriticalCves { get; init; }
}

View File

@@ -0,0 +1,324 @@
// -----------------------------------------------------------------------------
// InMemoryLineageCompareCache.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-034)
// Task: Add caching for compare results
// Description: In-memory implementation of lineage compare cache with TTL.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.SbomService.Services;
/// <summary>
/// In-memory implementation of <see cref="ILineageCompareCache"/>.
/// Uses ConcurrentDictionary with TTL-based expiration.
/// </summary>
internal sealed class InMemoryLineageCompareCache : ILineageCompareCache, IDisposable
{
private static readonly ActivitySource ActivitySource = new("StellaOps.SbomService.CompareCache");
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private readonly ILogger<InMemoryLineageCompareCache> _logger;
private readonly CompareCacheOptions _options;
private readonly IClock _clock;
private readonly Timer _cleanupTimer;
private long _cacheHits;
private long _cacheMisses;
private long _invalidations;
public InMemoryLineageCompareCache(
ILogger<InMemoryLineageCompareCache> logger,
IOptions<CompareCacheOptions>? options,
IClock clock)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new CompareCacheOptions();
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
// Periodic cleanup of expired entries
_cleanupTimer = new Timer(
_ => CleanupExpiredEntries(),
null,
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(1));
_logger.LogInformation(
"Compare cache initialized with TTL {TtlMinutes} minutes, max entries {MaxEntries}",
_options.DefaultTtlMinutes,
_options.MaxEntries);
}
/// <inheritdoc />
public Task<LineageCompareResponse?> GetAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default)
{
var key = BuildCacheKey(fromDigest, toDigest, tenantId);
using var activity = ActivitySource.StartActivity("CompareCache.Get");
activity?.SetTag("cache_key", key);
if (_cache.TryGetValue(key, out var entry))
{
if (entry.ExpiresAt > _clock.UtcNow)
{
Interlocked.Increment(ref _cacheHits);
activity?.SetTag("cache_hit", true);
_logger.LogDebug("Cache hit for compare {FromDigest} -> {ToDigest}",
TruncateDigest(fromDigest), TruncateDigest(toDigest));
return Task.FromResult<LineageCompareResponse?>(entry.Value);
}
// Expired - remove
_cache.TryRemove(key, out _);
}
Interlocked.Increment(ref _cacheMisses);
activity?.SetTag("cache_hit", false);
return Task.FromResult<LineageCompareResponse?>(null);
}
/// <inheritdoc />
public Task SetAsync(
string fromDigest,
string toDigest,
string tenantId,
LineageCompareResponse result,
TimeSpan? ttl = null,
CancellationToken ct = default)
{
var key = BuildCacheKey(fromDigest, toDigest, tenantId);
var effectiveTtl = ttl ?? TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
using var activity = ActivitySource.StartActivity("CompareCache.Set");
activity?.SetTag("cache_key", key);
activity?.SetTag("ttl_seconds", effectiveTtl.TotalSeconds);
// Check max entries limit
if (_cache.Count >= _options.MaxEntries)
{
EvictOldestEntries(_options.MaxEntries / 10); // Evict 10%
}
var entry = new CacheEntry
{
Value = result,
FromDigest = fromDigest,
ToDigest = toDigest,
TenantId = tenantId,
CreatedAt = _clock.UtcNow,
ExpiresAt = _clock.UtcNow.Add(effectiveTtl)
};
_cache[key] = entry;
_logger.LogDebug(
"Cached compare result for {FromDigest} -> {ToDigest}, expires at {ExpiresAt}",
TruncateDigest(fromDigest), TruncateDigest(toDigest), entry.ExpiresAt);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> InvalidateForArtifactAsync(
string artifactDigest,
string tenantId,
CancellationToken ct = default)
{
using var activity = ActivitySource.StartActivity("CompareCache.InvalidateArtifact");
activity?.SetTag("artifact_digest", TruncateDigest(artifactDigest));
activity?.SetTag("tenant_id", tenantId);
var invalidated = 0;
var keysToRemove = new List<string>();
foreach (var kvp in _cache)
{
var entry = kvp.Value;
if (entry.TenantId == tenantId &&
(entry.FromDigest == artifactDigest || entry.ToDigest == artifactDigest))
{
keysToRemove.Add(kvp.Key);
}
}
foreach (var key in keysToRemove)
{
if (_cache.TryRemove(key, out _))
{
invalidated++;
}
}
if (invalidated > 0)
{
Interlocked.Add(ref _invalidations, invalidated);
_logger.LogInformation(
"Invalidated {Count} cache entries for artifact {ArtifactDigest}",
invalidated, TruncateDigest(artifactDigest));
}
activity?.SetTag("invalidated_count", invalidated);
return Task.FromResult(invalidated);
}
/// <inheritdoc />
public Task<int> InvalidateForTenantAsync(
string tenantId,
CancellationToken ct = default)
{
using var activity = ActivitySource.StartActivity("CompareCache.InvalidateTenant");
activity?.SetTag("tenant_id", tenantId);
var invalidated = 0;
var keysToRemove = new List<string>();
foreach (var kvp in _cache)
{
if (kvp.Value.TenantId == tenantId)
{
keysToRemove.Add(kvp.Key);
}
}
foreach (var key in keysToRemove)
{
if (_cache.TryRemove(key, out _))
{
invalidated++;
}
}
if (invalidated > 0)
{
Interlocked.Add(ref _invalidations, invalidated);
_logger.LogInformation(
"Invalidated {Count} cache entries for tenant {TenantId}",
invalidated, tenantId);
}
activity?.SetTag("invalidated_count", invalidated);
return Task.FromResult(invalidated);
}
/// <inheritdoc />
public CompareCacheStats GetStats()
{
return new CompareCacheStats
{
TotalEntries = _cache.Count,
CacheHits = Interlocked.Read(ref _cacheHits),
CacheMisses = Interlocked.Read(ref _cacheMisses),
Invalidations = Interlocked.Read(ref _invalidations),
EstimatedMemoryBytes = _cache.Count * 4096 // Rough estimate
};
}
private void CleanupExpiredEntries()
{
var now = _clock.UtcNow;
var expired = 0;
foreach (var kvp in _cache)
{
if (kvp.Value.ExpiresAt <= now)
{
if (_cache.TryRemove(kvp.Key, out _))
{
expired++;
}
}
}
if (expired > 0)
{
_logger.LogDebug("Cleaned up {Count} expired cache entries", expired);
}
}
private void EvictOldestEntries(int count)
{
var oldest = _cache
.OrderBy(kvp => kvp.Value.CreatedAt)
.Take(count)
.Select(kvp => kvp.Key)
.ToList();
var evicted = 0;
foreach (var key in oldest)
{
if (_cache.TryRemove(key, out _))
{
evicted++;
}
}
_logger.LogInformation("Evicted {Count} oldest cache entries", evicted);
}
private static string BuildCacheKey(string fromDigest, string toDigest, string tenantId)
{
// Normalize: always use smaller digest first for bidirectional lookup
var (first, second) = string.CompareOrdinal(fromDigest, toDigest) <= 0
? (fromDigest, toDigest)
: (toDigest, fromDigest);
return $"{tenantId}:{first}:{second}";
}
private static string TruncateDigest(string digest)
{
if (string.IsNullOrEmpty(digest)) return digest;
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
{
return $"{digest[..(colonIndex + 13)]}...";
}
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
}
public void Dispose()
{
_cleanupTimer.Dispose();
}
private sealed class CacheEntry
{
public required LineageCompareResponse Value { get; init; }
public required string FromDigest { get; init; }
public required string ToDigest { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
}
}
/// <summary>
/// Configuration options for the compare cache.
/// </summary>
public sealed class CompareCacheOptions
{
/// <summary>
/// Default TTL in minutes. Default: 10.
/// </summary>
public int DefaultTtlMinutes { get; set; } = 10;
/// <summary>
/// Maximum number of entries. Default: 10000.
/// </summary>
public int MaxEntries { get; set; } = 10000;
/// <summary>
/// Whether to enable cache. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,528 @@
// -----------------------------------------------------------------------------
// LineageCompareService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-028)
// Task: Create GET /api/v1/lineage/compare endpoint
// Description: Implementation of full artifact comparison service.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
using StellaOps.Excititor.Persistence.Repositories;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Implementation of <see cref="ILineageCompareService"/>.
/// Aggregates data from multiple sources to provide comprehensive artifact comparison.
/// </summary>
internal sealed class LineageCompareService : ILineageCompareService
{
private static readonly ActivitySource ActivitySource = new("StellaOps.SbomService.LineageCompare");
private readonly ISbomLineageGraphService _lineageService;
private readonly ISbomLedgerService _ledgerService;
private readonly IVexDeltaRepository? _vexDeltaRepository;
private readonly ILineageCompareCache? _cache;
private readonly ILogger<LineageCompareService> _logger;
public LineageCompareService(
ISbomLineageGraphService lineageService,
ISbomLedgerService ledgerService,
ILogger<LineageCompareService> logger,
IVexDeltaRepository? vexDeltaRepository = null,
ILineageCompareCache? cache = null)
{
_lineageService = lineageService ?? throw new ArgumentNullException(nameof(lineageService));
_ledgerService = ledgerService ?? throw new ArgumentNullException(nameof(ledgerService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_vexDeltaRepository = vexDeltaRepository;
_cache = cache;
}
/// <inheritdoc />
public async Task<LineageCompareResponse?> CompareAsync(
string fromDigest,
string toDigest,
string tenantId,
LineageCompareOptions? options = null,
CancellationToken ct = default)
{
options ??= new LineageCompareOptions();
using var activity = ActivitySource.StartActivity("Compare");
activity?.SetTag("from_digest", fromDigest);
activity?.SetTag("to_digest", toDigest);
activity?.SetTag("tenant_id", tenantId);
// Try cache first (LIN-BE-034)
if (_cache is not null)
{
var cached = await _cache.GetAsync(fromDigest, toDigest, tenantId, ct);
if (cached is not null)
{
_logger.LogDebug("Returning cached compare result for {FromDigest} -> {ToDigest}",
TruncateDigest(fromDigest), TruncateDigest(toDigest));
activity?.SetTag("cache_hit", true);
return cached;
}
activity?.SetTag("cache_hit", false);
}
_logger.LogInformation(
"Computing comparison from {FromDigest} to {ToDigest} for tenant {TenantId}",
TruncateDigest(fromDigest),
TruncateDigest(toDigest),
tenantId);
// Get lineage diff (reuses existing service for SBOM component diff)
var linageDiff = await _lineageService.GetLineageDiffAsync(fromDigest, toDigest, tenantId, ct);
if (linageDiff is null)
{
_logger.LogWarning(
"Lineage diff not found for {FromDigest} to {ToDigest}",
TruncateDigest(fromDigest),
TruncateDigest(toDigest));
return null;
}
// Get artifact metadata from ledger
var fromArtifact = await GetArtifactInfoAsync(fromDigest, tenantId, ct);
var toArtifact = await GetArtifactInfoAsync(toDigest, tenantId, ct);
// Build SBOM diff from lineage diff
LineageSbomDiff? sbomDiff = null;
if (options.IncludeSbomDiff)
{
sbomDiff = BuildSbomDiff(linageDiff, options.MaxComponentChanges);
}
// Get VEX deltas
LineageVexDeltaSummary? vexDeltas = null;
if (options.IncludeVexDeltas && _vexDeltaRepository is not null)
{
vexDeltas = await GetVexDeltasAsync(fromDigest, toDigest, tenantId, options.MaxVexDeltas, ct);
}
else if (options.IncludeVexDeltas && linageDiff.VexDiff is not null && linageDiff.VexDiff.Count > 0)
{
// Fall back to lineage diff VEX data
vexDeltas = BuildVexDeltasFromDiff(linageDiff.VexDiff, options.MaxVexDeltas);
}
// Get reachability deltas (placeholder for now - would need Graph API integration)
LineageReachabilityDeltaSummary? reachabilityDeltas = null;
if (options.IncludeReachabilityDeltas)
{
reachabilityDeltas = await GetReachabilityDeltasAsync(fromDigest, toDigest, tenantId, options.MaxReachabilityDeltas, ct);
}
// Get attestations
IReadOnlyList<LineageAttestationLink>? attestations = null;
if (options.IncludeAttestations)
{
attestations = await GetAttestationsAsync(fromDigest, toDigest, tenantId, ct);
}
// Get replay hashes
LineageReplayHashInfo? replayHashes = null;
if (options.IncludeReplayHashes)
{
replayHashes = await GetReplayHashesAsync(fromDigest, toDigest, tenantId, ct);
}
// Compute summary
var summary = ComputeSummary(sbomDiff, vexDeltas, reachabilityDeltas, attestations);
var result = new LineageCompareResponse
{
FromDigest = fromDigest,
ToDigest = toDigest,
TenantId = tenantId,
ComputedAt = DateTimeOffset.UtcNow,
FromArtifact = fromArtifact,
ToArtifact = toArtifact,
Summary = summary,
SbomDiff = sbomDiff,
VexDeltas = vexDeltas,
ReachabilityDeltas = reachabilityDeltas,
Attestations = attestations,
ReplayHashes = replayHashes
};
// Cache the result (LIN-BE-034)
if (_cache is not null)
{
await _cache.SetAsync(fromDigest, toDigest, tenantId, result, ct: ct);
}
return result;
}
private async Task<LineageCompareArtifactInfo?> GetArtifactInfoAsync(
string artifactDigest,
string tenantId,
CancellationToken ct)
{
var lineage = await _ledgerService.GetLineageAsync(artifactDigest, ct);
if (lineage is null || lineage.Nodes.Count == 0)
{
return new LineageCompareArtifactInfo
{
Digest = artifactDigest,
Name = ExtractArtifactName(artifactDigest)
};
}
// Get the latest node (highest sequence number)
var latestNode = lineage.Nodes.OrderByDescending(n => n.SequenceNumber).First();
return new LineageCompareArtifactInfo
{
Digest = artifactDigest,
SbomDigest = latestNode.Digest,
Name = lineage.ArtifactRef,
Version = latestNode.SequenceNumber.ToString(),
CreatedAt = latestNode.CreatedAtUtc,
ComponentCount = 0, // Not available from SbomLineageNode
VulnerabilityCount = 0 // Not available from SbomLineageNode
};
}
private static LineageSbomDiff BuildSbomDiff(SbomLineageDiffResponse diff, int maxChanges)
{
var added = new List<LineageComponentChange>();
var removed = new List<LineageComponentChange>();
var modified = new List<LineageComponentModification>();
// Convert SBOM diff to our format
foreach (var comp in diff.SbomDiff.Added.Take(maxChanges / 3))
{
added.Add(new LineageComponentChange
{
Purl = comp.Purl ?? comp.Name,
Name = comp.Name,
Version = comp.Version,
Type = null,
License = comp.License
});
}
foreach (var comp in diff.SbomDiff.Removed.Take(maxChanges / 3))
{
removed.Add(new LineageComponentChange
{
Purl = comp.Purl ?? comp.Name,
Name = comp.Name,
Version = comp.Version,
Type = null,
License = comp.License
});
}
foreach (var comp in diff.SbomDiff.VersionChanged.Take(maxChanges / 3))
{
modified.Add(new LineageComponentModification
{
Purl = comp.Purl ?? comp.Name,
Name = comp.Name,
FromVersion = comp.FromVersion ?? "unknown",
ToVersion = comp.ToVersion ?? "unknown",
UpgradeType = DetermineUpgradeType(comp.FromVersion, comp.ToVersion)
});
}
var totalChanges = diff.SbomDiff.Added.Count
+ diff.SbomDiff.Removed.Count
+ diff.SbomDiff.VersionChanged.Count;
return new LineageSbomDiff
{
Added = added,
Removed = removed,
Modified = modified,
Truncated = totalChanges > maxChanges,
TotalChanges = totalChanges
};
}
private async Task<LineageVexDeltaSummary?> GetVexDeltasAsync(
string fromDigest,
string toDigest,
string tenantId,
int maxDeltas,
CancellationToken ct)
{
if (_vexDeltaRepository is null)
{
return null;
}
var deltas = await _vexDeltaRepository.GetDeltasAsync(fromDigest, toDigest, tenantId, ct);
if (deltas.Count == 0)
{
return null;
}
var changes = deltas
.Take(maxDeltas)
.Select(d => new LineageVexChange
{
Cve = d.Cve,
FromStatus = d.FromStatus,
ToStatus = d.ToStatus,
Justification = d.Rationale?.Reason,
AttestationDigest = d.AttestationDigest
})
.ToList();
return new LineageVexDeltaSummary
{
TotalChanges = deltas.Count,
StatusUpgrades = deltas.Count(d => IsStatusUpgrade(d.FromStatus, d.ToStatus)),
StatusDowngrades = deltas.Count(d => IsStatusDowngrade(d.FromStatus, d.ToStatus)),
Changes = changes,
Truncated = deltas.Count > maxDeltas
};
}
private static LineageVexDeltaSummary? BuildVexDeltasFromDiff(IReadOnlyList<VexDeltaSummary> vexDeltas, int maxDeltas)
{
if (vexDeltas.Count == 0)
{
return null;
}
var changes = vexDeltas
.Take(maxDeltas)
.Select(d => new LineageVexChange
{
Cve = d.Cve,
FromStatus = d.FromStatus,
ToStatus = d.ToStatus,
Justification = d.Reason
})
.ToList();
return new LineageVexDeltaSummary
{
TotalChanges = vexDeltas.Count,
StatusUpgrades = vexDeltas.Count(d => IsStatusUpgrade(d.FromStatus, d.ToStatus)),
StatusDowngrades = vexDeltas.Count(d => IsStatusDowngrade(d.FromStatus, d.ToStatus)),
Changes = changes,
Truncated = vexDeltas.Count > maxDeltas
};
}
private async Task<LineageReachabilityDeltaSummary?> GetReachabilityDeltasAsync(
string fromDigest,
string toDigest,
string tenantId,
int maxDeltas,
CancellationToken ct)
{
// TODO: Integrate with Graph API's IReachabilityDeltaService when available
// For now, return empty/null as placeholder
await Task.CompletedTask;
_logger.LogDebug(
"Reachability delta computation not yet integrated for {FromDigest} to {ToDigest}",
TruncateDigest(fromDigest),
TruncateDigest(toDigest));
return null;
}
private async Task<IReadOnlyList<LineageAttestationLink>?> GetAttestationsAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct)
{
// Collect attestation digests from VEX deltas
var attestations = new List<LineageAttestationLink>();
if (_vexDeltaRepository is not null)
{
var deltas = await _vexDeltaRepository.GetDeltasAsync(fromDigest, toDigest, tenantId, ct);
foreach (var delta in deltas.Where(d => !string.IsNullOrEmpty(d.AttestationDigest)))
{
attestations.Add(new LineageAttestationLink
{
Digest = delta.AttestationDigest!,
PredicateType = "stella.ops/vex-delta@v1",
CreatedAt = delta.CreatedAt,
Description = $"VEX delta attestation for {delta.Cve}"
});
}
}
return attestations.Count > 0 ? attestations : null;
}
private async Task<LineageReplayHashInfo?> GetReplayHashesAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct)
{
var fromLineage = await _ledgerService.GetLineageAsync(fromDigest, ct);
var toLineage = await _ledgerService.GetLineageAsync(toDigest, ct);
// SbomLineageNode doesn't have ReplayHash - would need to query lineage graph service
// For now, use the replay hash from the diff response if available
string? fromHash = null;
string? toHash = null;
// Check if we have nodes (indicates artifact exists in lineage)
var hasFrom = fromLineage?.Nodes.Count > 0;
var hasTo = toLineage?.Nodes.Count > 0;
if (!hasFrom && !hasTo)
{
return null;
}
// Replay hashes would come from the lineage graph service extended nodes
// Return status based on whether both artifacts exist in lineage
return new LineageReplayHashInfo
{
FromReplayHash = fromHash,
ToReplayHash = toHash,
IsReproducible = hasFrom && hasTo,
VerificationStatus = hasFrom && hasTo ? "pending" : "partial"
};
}
private static LineageCompareSummary ComputeSummary(
LineageSbomDiff? sbomDiff,
LineageVexDeltaSummary? vexDeltas,
LineageReachabilityDeltaSummary? reachabilityDeltas,
IReadOnlyList<LineageAttestationLink>? attestations)
{
var componentsAdded = sbomDiff?.Added.Count ?? 0;
var componentsRemoved = sbomDiff?.Removed.Count ?? 0;
var componentsModified = sbomDiff?.Modified.Count ?? 0;
var vexChanges = vexDeltas?.TotalChanges ?? 0;
var reachChanges = reachabilityDeltas?.TotalChanges ?? 0;
var attestCount = attestations?.Count ?? 0;
// Determine risk trend
var riskTrend = "unchanged";
var vexUpgrades = vexDeltas?.StatusUpgrades ?? 0;
var vexDowngrades = vexDeltas?.StatusDowngrades ?? 0;
var reachImproved = reachabilityDeltas?.NewlyUnreachable ?? 0;
var reachDegraded = reachabilityDeltas?.NewlyReachable ?? 0;
var improvementScore = vexUpgrades + reachImproved;
var degradationScore = vexDowngrades + reachDegraded;
if (improvementScore > degradationScore)
{
riskTrend = "improved";
}
else if (degradationScore > improvementScore)
{
riskTrend = "degraded";
}
return new LineageCompareSummary
{
ComponentsAdded = componentsAdded,
ComponentsRemoved = componentsRemoved,
ComponentsModified = componentsModified,
VulnerabilitiesAdded = vexDowngrades,
VulnerabilitiesResolved = vexUpgrades,
VexStatusChanges = vexChanges,
ReachabilityChanges = reachChanges,
AttestationCount = attestCount,
RiskTrend = riskTrend
};
}
private static bool IsStatusUpgrade(string fromStatus, string toStatus)
{
// Status upgrades: moving toward "not_affected" or "fixed"
var goodStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"not_affected", "fixed"
};
var badStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"affected", "under_investigation", "unknown"
};
return badStatuses.Contains(fromStatus) && goodStatuses.Contains(toStatus);
}
private static bool IsStatusDowngrade(string fromStatus, string toStatus)
{
var goodStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"not_affected", "fixed"
};
var badStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"affected", "under_investigation", "unknown"
};
return goodStatuses.Contains(fromStatus) && badStatuses.Contains(toStatus);
}
private static string? DetermineUpgradeType(string? fromVersion, string? toVersion)
{
if (string.IsNullOrEmpty(fromVersion) || string.IsNullOrEmpty(toVersion))
{
return null;
}
// Simple semver comparison
var fromParts = fromVersion.Split('.');
var toParts = toVersion.Split('.');
if (fromParts.Length > 0 && toParts.Length > 0)
{
if (int.TryParse(fromParts[0], out var fromMajor) &&
int.TryParse(toParts[0], out var toMajor) &&
toMajor > fromMajor)
{
return "major";
}
if (fromParts.Length > 1 && toParts.Length > 1)
{
if (int.TryParse(fromParts[1], out var fromMinor) &&
int.TryParse(toParts[1], out var toMinor) &&
toMinor > fromMinor)
{
return "minor";
}
}
}
return "patch";
}
private static string ExtractArtifactName(string digest)
{
var colonIndex = digest.IndexOf(':');
return colonIndex >= 0 && digest.Length > colonIndex + 8
? digest[(colonIndex + 1)..(colonIndex + 9)]
: digest;
}
private static string TruncateDigest(string digest)
{
if (string.IsNullOrEmpty(digest))
{
return digest;
}
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
{
return $"{digest[..(colonIndex + 13)]}...";
}
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
}
}

View File

@@ -0,0 +1,309 @@
// -----------------------------------------------------------------------------
// LineageHoverCache.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-015)
// Task: Add Valkey caching for hover card data with 5-minute TTL
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Cache service for lineage hover card data.
/// Implements a 5-minute TTL cache to achieve &lt;150ms response times.
/// </summary>
internal interface ILineageHoverCache
{
/// <summary>
/// Gets a cached hover card if available.
/// </summary>
Task<SbomLineageHoverCard?> GetAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default);
/// <summary>
/// Sets a hover card in cache.
/// </summary>
Task SetAsync(string fromDigest, string toDigest, string tenantId, SbomLineageHoverCard hoverCard, CancellationToken ct = default);
/// <summary>
/// Invalidates cached hover cards for an artifact.
/// </summary>
Task InvalidateAsync(string artifactDigest, string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Cache options for lineage hover cards.
/// </summary>
public sealed record LineageHoverCacheOptions
{
/// <summary>
/// Whether caching is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Time-to-live for hover card cache entries.
/// Default: 5 minutes.
/// </summary>
public TimeSpan Ttl { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Key prefix for hover cache entries.
/// </summary>
public string KeyPrefix { get; init; } = "lineage:hover";
}
/// <summary>
/// Implementation of <see cref="ILineageHoverCache"/> using <see cref="IDistributedCache"/>.
/// Supports Valkey/Redis or any IDistributedCache implementation.
/// </summary>
internal sealed class DistributedLineageHoverCache : ILineageHoverCache
{
private readonly IDistributedCache _cache;
private readonly LineageHoverCacheOptions _options;
private readonly ILogger<DistributedLineageHoverCache> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.SbomService.LineageCache");
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public DistributedLineageHoverCache(
IDistributedCache cache,
IOptions<LineageHoverCacheOptions> options,
ILogger<DistributedLineageHoverCache> logger)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_options = options?.Value ?? new LineageHoverCacheOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<SbomLineageHoverCard?> GetAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default)
{
if (!_options.Enabled)
{
return null;
}
using var activity = _activitySource.StartActivity("hover_cache.get");
activity?.SetTag("from_digest", TruncateDigest(fromDigest));
activity?.SetTag("to_digest", TruncateDigest(toDigest));
activity?.SetTag("tenant", tenantId);
try
{
var key = BuildKey(fromDigest, toDigest, tenantId);
var cached = await _cache.GetStringAsync(key, ct).ConfigureAwait(false);
if (cached is null)
{
activity?.SetTag("cache_hit", false);
_logger.LogDebug("Cache miss for hover card {From} -> {To}", TruncateDigest(fromDigest), TruncateDigest(toDigest));
return null;
}
activity?.SetTag("cache_hit", true);
_logger.LogDebug("Cache hit for hover card {From} -> {To}", TruncateDigest(fromDigest), TruncateDigest(toDigest));
return JsonSerializer.Deserialize<SbomLineageHoverCard>(cached, JsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get hover card from cache");
return null;
}
}
/// <inheritdoc/>
public async Task SetAsync(
string fromDigest,
string toDigest,
string tenantId,
SbomLineageHoverCard hoverCard,
CancellationToken ct = default)
{
if (!_options.Enabled)
{
return;
}
using var activity = _activitySource.StartActivity("hover_cache.set");
activity?.SetTag("from_digest", TruncateDigest(fromDigest));
activity?.SetTag("to_digest", TruncateDigest(toDigest));
activity?.SetTag("tenant", tenantId);
try
{
var key = BuildKey(fromDigest, toDigest, tenantId);
var json = JsonSerializer.Serialize(hoverCard, JsonOptions);
var cacheOptions = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _options.Ttl
};
await _cache.SetStringAsync(key, json, cacheOptions, ct).ConfigureAwait(false);
_logger.LogDebug("Cached hover card {From} -> {To} with TTL {Ttl}", TruncateDigest(fromDigest), TruncateDigest(toDigest), _options.Ttl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set hover card in cache");
}
}
/// <inheritdoc/>
public async Task InvalidateAsync(
string artifactDigest,
string tenantId,
CancellationToken ct = default)
{
if (!_options.Enabled)
{
return;
}
using var activity = _activitySource.StartActivity("hover_cache.invalidate");
activity?.SetTag("artifact_digest", TruncateDigest(artifactDigest));
activity?.SetTag("tenant", tenantId);
// Note: Full pattern-based invalidation requires Valkey SCAN.
// For now, we rely on TTL expiration. Pattern invalidation can be added
// when using direct Valkey client (StackExchange.Redis).
_logger.LogDebug("Hover card invalidation requested for {Artifact} (relying on TTL)", TruncateDigest(artifactDigest));
}
private string BuildKey(string fromDigest, string toDigest, string tenantId)
{
// Format: {prefix}:{tenant}:{from_short}:{to_short}
var fromShort = GetDigestShort(fromDigest);
var toShort = GetDigestShort(toDigest);
return $"{_options.KeyPrefix}:{tenantId}:{fromShort}:{toShort}";
}
private static string GetDigestShort(string digest)
{
// Extract first 16 chars after algorithm prefix for shorter key
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 16)
{
return digest[(colonIndex + 1)..(colonIndex + 17)];
}
return digest.Length > 16 ? digest[..16] : digest;
}
private static string TruncateDigest(string digest)
{
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
{
return $"{digest[..(colonIndex + 13)]}...";
}
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
}
}
/// <summary>
/// In-memory implementation of <see cref="ILineageHoverCache"/> for testing.
/// </summary>
internal sealed class InMemoryLineageHoverCache : ILineageHoverCache
{
private readonly Dictionary<string, (SbomLineageHoverCard Card, DateTimeOffset ExpiresAt)> _cache = new();
private readonly LineageHoverCacheOptions _options;
private readonly object _lock = new();
public InMemoryLineageHoverCache(LineageHoverCacheOptions? options = null)
{
_options = options ?? new LineageHoverCacheOptions();
}
public Task<SbomLineageHoverCard?> GetAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult<SbomLineageHoverCard?>(null);
}
var key = BuildKey(fromDigest, toDigest, tenantId);
lock (_lock)
{
if (_cache.TryGetValue(key, out var entry))
{
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
{
return Task.FromResult<SbomLineageHoverCard?>(entry.Card);
}
_cache.Remove(key);
}
}
return Task.FromResult<SbomLineageHoverCard?>(null);
}
public Task SetAsync(string fromDigest, string toDigest, string tenantId, SbomLineageHoverCard hoverCard, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.CompletedTask;
}
var key = BuildKey(fromDigest, toDigest, tenantId);
var expiresAt = DateTimeOffset.UtcNow.Add(_options.Ttl);
lock (_lock)
{
_cache[key] = (hoverCard, expiresAt);
}
return Task.CompletedTask;
}
public Task InvalidateAsync(string artifactDigest, string tenantId, CancellationToken ct = default)
{
var prefix = $"{_options.KeyPrefix}:{tenantId}:";
var digestShort = GetDigestShort(artifactDigest);
lock (_lock)
{
var keysToRemove = _cache.Keys
.Where(k => k.StartsWith(prefix, StringComparison.Ordinal) && k.Contains(digestShort))
.ToList();
foreach (var key in keysToRemove)
{
_cache.Remove(key);
}
}
return Task.CompletedTask;
}
private string BuildKey(string fromDigest, string toDigest, string tenantId)
{
var fromShort = GetDigestShort(fromDigest);
var toShort = GetDigestShort(toDigest);
return $"{_options.KeyPrefix}:{tenantId}:{fromShort}:{toShort}";
}
private static string GetDigestShort(string digest)
{
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 16)
{
return digest[(colonIndex + 1)..(colonIndex + 17)];
}
return digest.Length > 16 ? digest[..16] : digest;
}
}

View File

@@ -0,0 +1,272 @@
// -----------------------------------------------------------------------------
// ReplayHashService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-023)
// Task: Compute replay hash per lineage node
// Description: Implements deterministic replay hash computation per spec:
// Hash = SHA256(sbom_digest + feeds_snapshot_digest +
// policy_version + vex_verdicts_digest + timestamp)
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Implementation of <see cref="IReplayHashService"/>.
/// Computes deterministic replay hashes for lineage nodes.
/// </summary>
internal sealed class ReplayHashService : IReplayHashService
{
private readonly IFeedsSnapshotService? _feedsService;
private readonly IPolicyVersionService? _policyService;
private readonly IVexVerdictsDigestService? _vexService;
private readonly ILogger<ReplayHashService> _logger;
private readonly TimeProvider _timeProvider;
private readonly ActivitySource _activitySource = new("StellaOps.SbomService.ReplayHash");
// Delimiter used between hash components for determinism
private const char Delimiter = '|';
// Timestamp format for reproducibility (minute precision)
private const string TimestampFormat = "yyyy-MM-ddTHH:mm";
public ReplayHashService(
ILogger<ReplayHashService> logger,
IFeedsSnapshotService? feedsService = null,
IPolicyVersionService? policyService = null,
IVexVerdictsDigestService? vexService = null,
TimeProvider? timeProvider = null)
{
_logger = logger;
_feedsService = feedsService;
_policyService = policyService;
_vexService = vexService;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
public string ComputeHash(ReplayHashInputs inputs)
{
ArgumentNullException.ThrowIfNull(inputs);
using var activity = _activitySource.StartActivity("ComputeReplayHash");
activity?.SetTag("sbom_digest", inputs.SbomDigest);
// Build canonical string representation
// Order: sbom_digest | feeds_snapshot_digest | policy_version | vex_verdicts_digest | timestamp
var canonicalString = BuildCanonicalString(inputs);
// Compute SHA256 hash
var bytes = Encoding.UTF8.GetBytes(canonicalString);
var hash = SHA256.HashData(bytes);
var hexHash = Convert.ToHexStringLower(hash);
activity?.SetTag("replay_hash", hexHash);
_logger.LogDebug(
"Computed replay hash {Hash} for SBOM {SbomDigest}",
hexHash,
inputs.SbomDigest);
return hexHash;
}
/// <inheritdoc/>
public async Task<ReplayHashResult> ComputeHashAsync(
string sbomDigest,
string tenantId,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sbomDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
using var activity = _activitySource.StartActivity("ComputeReplayHashAsync");
activity?.SetTag("sbom_digest", sbomDigest);
activity?.SetTag("tenant_id", tenantId);
var now = _timeProvider.GetUtcNow();
// Gather inputs from services
var feedsDigest = await GetFeedsSnapshotDigestAsync(tenantId, ct);
var policyVersion = await GetPolicyVersionAsync(tenantId, ct);
var vexDigest = await GetVexVerdictsDigestAsync(sbomDigest, tenantId, ct);
var inputs = new ReplayHashInputs
{
SbomDigest = sbomDigest,
FeedsSnapshotDigest = feedsDigest,
PolicyVersion = policyVersion,
VexVerdictsDigest = vexDigest,
Timestamp = TruncateToMinute(now)
};
var hash = ComputeHash(inputs);
return new ReplayHashResult
{
Hash = hash,
Inputs = inputs,
ComputedAt = now
};
}
/// <inheritdoc/>
public bool VerifyHash(string expectedHash, ReplayHashInputs inputs)
{
ArgumentException.ThrowIfNullOrWhiteSpace(expectedHash);
ArgumentNullException.ThrowIfNull(inputs);
var computedHash = ComputeHash(inputs);
var matches = string.Equals(expectedHash, computedHash, StringComparison.OrdinalIgnoreCase);
if (!matches)
{
_logger.LogWarning(
"Replay hash verification failed. Expected={Expected}, Computed={Computed}",
expectedHash,
computedHash);
}
return matches;
}
/// <summary>
/// Builds the canonical string representation for hashing.
/// Components are sorted and concatenated with delimiters for determinism.
/// </summary>
private static string BuildCanonicalString(ReplayHashInputs inputs)
{
var sb = new StringBuilder(512);
// Component 1: SBOM digest (normalized to lowercase)
sb.Append(NormalizeDigest(inputs.SbomDigest));
sb.Append(Delimiter);
// Component 2: Feeds snapshot digest
sb.Append(NormalizeDigest(inputs.FeedsSnapshotDigest));
sb.Append(Delimiter);
// Component 3: Policy version (trimmed, lowercase for consistency)
sb.Append(inputs.PolicyVersion.Trim().ToLowerInvariant());
sb.Append(Delimiter);
// Component 4: VEX verdicts digest
sb.Append(NormalizeDigest(inputs.VexVerdictsDigest));
sb.Append(Delimiter);
// Component 5: Timestamp (minute precision, UTC)
sb.Append(inputs.Timestamp.ToUniversalTime().ToString(TimestampFormat));
return sb.ToString();
}
/// <summary>
/// Normalizes a digest string for consistent hashing.
/// </summary>
private static string NormalizeDigest(string digest)
{
// Handle digest formats: sha256:xxxx, xxxx (bare)
var normalized = digest.Trim().ToLowerInvariant();
// If it doesn't have algorithm prefix, assume sha256
if (!normalized.Contains(':'))
{
normalized = $"sha256:{normalized}";
}
return normalized;
}
/// <summary>
/// Truncates a timestamp to minute precision for reproducibility.
/// </summary>
private static DateTimeOffset TruncateToMinute(DateTimeOffset timestamp)
{
return new DateTimeOffset(
timestamp.Year,
timestamp.Month,
timestamp.Day,
timestamp.Hour,
timestamp.Minute,
0,
TimeSpan.Zero);
}
private async Task<string> GetFeedsSnapshotDigestAsync(string tenantId, CancellationToken ct)
{
if (_feedsService is not null)
{
return await _feedsService.GetCurrentSnapshotDigestAsync(tenantId, ct);
}
// Fallback: return a placeholder indicating feeds service not available
_logger.LogDebug("FeedsSnapshotService not available, using placeholder digest");
return "sha256:feeds-snapshot-unavailable";
}
private async Task<string> GetPolicyVersionAsync(string tenantId, CancellationToken ct)
{
if (_policyService is not null)
{
return await _policyService.GetCurrentVersionAsync(tenantId, ct);
}
// Fallback: return default policy version
_logger.LogDebug("PolicyVersionService not available, using default version");
return "v1.0.0-default";
}
private async Task<string> GetVexVerdictsDigestAsync(
string sbomDigest,
string tenantId,
CancellationToken ct)
{
if (_vexService is not null)
{
return await _vexService.ComputeVerdictsDigestAsync(sbomDigest, tenantId, ct);
}
// Fallback: return a placeholder
_logger.LogDebug("VexVerdictsDigestService not available, using placeholder digest");
return "sha256:vex-verdicts-unavailable";
}
}
/// <summary>
/// Service for getting the current feeds snapshot digest.
/// </summary>
internal interface IFeedsSnapshotService
{
/// <summary>
/// Gets the content digest of the current vulnerability feeds snapshot.
/// </summary>
Task<string> GetCurrentSnapshotDigestAsync(string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Service for getting the current policy version.
/// </summary>
internal interface IPolicyVersionService
{
/// <summary>
/// Gets the current policy bundle version for a tenant.
/// </summary>
Task<string> GetCurrentVersionAsync(string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Service for computing VEX verdicts digest.
/// </summary>
internal interface IVexVerdictsDigestService
{
/// <summary>
/// Computes a content digest of all VEX verdicts applicable to an SBOM.
/// </summary>
Task<string> ComputeVerdictsDigestAsync(
string sbomDigest,
string tenantId,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,348 @@
// -----------------------------------------------------------------------------
// ReplayVerificationService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-033)
// Task: Replay verification endpoint
// Description: Implementation of replay hash verification with drift detection.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Implementation of <see cref="IReplayVerificationService"/>.
/// Verifies replay hashes and detects drift in security evaluations.
/// </summary>
internal sealed class ReplayVerificationService : IReplayVerificationService
{
private static readonly ActivitySource ActivitySource = new("StellaOps.SbomService.ReplayVerification");
private readonly IReplayHashService _hashService;
private readonly ILogger<ReplayVerificationService> _logger;
private readonly IClock _clock;
// In-memory cache of replay hash inputs for demonstration
// In production, would be stored in database
private readonly ConcurrentDictionary<string, ReplayHashInputs> _inputsCache = new();
public ReplayVerificationService(
IReplayHashService hashService,
ILogger<ReplayVerificationService> logger,
IClock clock)
{
_hashService = hashService ?? throw new ArgumentNullException(nameof(hashService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
/// <inheritdoc />
public async Task<ReplayVerificationResult> VerifyAsync(
ReplayVerificationRequest request,
CancellationToken ct = default)
{
using var activity = ActivitySource.StartActivity("VerifyReplayHash");
activity?.SetTag("replay_hash", TruncateHash(request.ReplayHash));
activity?.SetTag("tenant_id", request.TenantId);
_logger.LogInformation(
"Verifying replay hash {ReplayHash} for tenant {TenantId}",
TruncateHash(request.ReplayHash), request.TenantId);
try
{
// Try to lookup original inputs
ReplayHashInputs? expectedInputs = null;
if (_inputsCache.TryGetValue(request.ReplayHash, out var cached))
{
expectedInputs = cached;
}
// Build verification inputs
var verificationInputs = await BuildVerificationInputsAsync(
request,
expectedInputs,
ct);
if (verificationInputs is null)
{
return new ReplayVerificationResult
{
IsMatch = false,
ExpectedHash = request.ReplayHash,
ComputedHash = string.Empty,
Status = ReplayVerificationStatus.InputsNotFound,
Error = "Unable to determine verification inputs. Provide explicit inputs or ensure hash is stored."
};
}
// Compute verification hash
var computedHash = _hashService.ComputeHash(verificationInputs);
// Compare
var isMatch = string.Equals(computedHash, request.ReplayHash, StringComparison.OrdinalIgnoreCase);
// Compute drifts if not matching
var drifts = ImmutableArray<ReplayFieldDrift>.Empty;
if (!isMatch && expectedInputs is not null)
{
drifts = ComputeDrifts(expectedInputs, verificationInputs);
}
var status = isMatch ? ReplayVerificationStatus.Match : ReplayVerificationStatus.Drift;
_logger.LogInformation(
"Replay verification {Status}: expected {Expected}, computed {Computed}",
status, TruncateHash(request.ReplayHash), TruncateHash(computedHash));
return new ReplayVerificationResult
{
IsMatch = isMatch,
ExpectedHash = request.ReplayHash,
ComputedHash = computedHash,
Status = status,
ExpectedInputs = expectedInputs,
ComputedInputs = verificationInputs,
Drifts = drifts,
VerifiedAt = _clock.UtcNow,
Message = isMatch ? "Replay hash verified successfully" : $"Drift detected in {drifts.Length} field(s)"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to verify replay hash {ReplayHash}", request.ReplayHash);
return new ReplayVerificationResult
{
IsMatch = false,
ExpectedHash = request.ReplayHash,
ComputedHash = string.Empty,
Status = ReplayVerificationStatus.Error,
Error = ex.Message
};
}
}
/// <inheritdoc />
public async Task<ReplayDriftAnalysis> CompareDriftAsync(
string hashA,
string hashB,
string tenantId,
CancellationToken ct = default)
{
using var activity = ActivitySource.StartActivity("CompareDrift");
activity?.SetTag("hash_a", TruncateHash(hashA));
activity?.SetTag("hash_b", TruncateHash(hashB));
_logger.LogInformation(
"Comparing drift between {HashA} and {HashB}",
TruncateHash(hashA), TruncateHash(hashB));
// Lookup inputs for both hashes
_inputsCache.TryGetValue(hashA, out var inputsA);
_inputsCache.TryGetValue(hashB, out var inputsB);
var isIdentical = string.Equals(hashA, hashB, StringComparison.OrdinalIgnoreCase);
var drifts = ImmutableArray<ReplayFieldDrift>.Empty;
var driftSummary = "identical";
if (!isIdentical && inputsA is not null && inputsB is not null)
{
drifts = ComputeDrifts(inputsA, inputsB);
driftSummary = SummarizeDrifts(drifts);
}
else if (!isIdentical)
{
driftSummary = "unable to compare - inputs not found";
}
await Task.CompletedTask;
return new ReplayDriftAnalysis
{
HashA = hashA,
HashB = hashB,
IsIdentical = isIdentical,
InputsA = inputsA,
InputsB = inputsB,
Drifts = drifts,
DriftSummary = driftSummary,
AnalyzedAt = _clock.UtcNow
};
}
/// <summary>
/// Stores inputs for a replay hash (for later verification).
/// </summary>
public void StoreInputs(string replayHash, ReplayHashInputs inputs)
{
_inputsCache[replayHash] = inputs;
}
private async Task<ReplayHashInputs?> BuildVerificationInputsAsync(
ReplayVerificationRequest request,
ReplayHashInputs? expectedInputs,
CancellationToken ct)
{
// If we have explicit inputs in request, use them
if (!string.IsNullOrEmpty(request.SbomDigest))
{
// Get current or specified values for other fields
var feedsDigest = request.FeedsSnapshotDigest
?? expectedInputs?.FeedsSnapshotDigest
?? await GetCurrentFeedsDigestAsync(request.TenantId, ct);
var policyVersion = request.PolicyVersion
?? expectedInputs?.PolicyVersion
?? await GetCurrentPolicyVersionAsync(request.TenantId, ct);
var vexDigest = request.VexVerdictsDigest
?? expectedInputs?.VexVerdictsDigest
?? await GetCurrentVexDigestAsync(request.SbomDigest, request.TenantId, ct);
var timestamp = request.Timestamp
?? (request.FreezeTime ? expectedInputs?.Timestamp : null)
?? _clock.UtcNow;
return new ReplayHashInputs
{
SbomDigest = request.SbomDigest,
FeedsSnapshotDigest = feedsDigest,
PolicyVersion = policyVersion,
VexVerdictsDigest = vexDigest,
Timestamp = timestamp
};
}
// If we have expected inputs and should use them
if (expectedInputs is not null)
{
if (request.FreezeTime)
{
return expectedInputs;
}
// Re-compute with current time but same other inputs
return expectedInputs with
{
Timestamp = _clock.UtcNow
};
}
// Cannot determine inputs
return null;
}
private static ImmutableArray<ReplayFieldDrift> ComputeDrifts(
ReplayHashInputs expected,
ReplayHashInputs actual)
{
var drifts = new List<ReplayFieldDrift>();
if (!string.Equals(expected.SbomDigest, actual.SbomDigest, StringComparison.OrdinalIgnoreCase))
{
drifts.Add(new ReplayFieldDrift
{
FieldName = "SbomDigest",
ExpectedValue = expected.SbomDigest,
ActualValue = actual.SbomDigest,
Severity = "critical",
Description = "SBOM content has changed - represents a different artifact or version"
});
}
if (!string.Equals(expected.FeedsSnapshotDigest, actual.FeedsSnapshotDigest, StringComparison.OrdinalIgnoreCase))
{
drifts.Add(new ReplayFieldDrift
{
FieldName = "FeedsSnapshotDigest",
ExpectedValue = expected.FeedsSnapshotDigest,
ActualValue = actual.FeedsSnapshotDigest,
Severity = "warning",
Description = "Vulnerability feeds have been updated since original evaluation"
});
}
if (!string.Equals(expected.PolicyVersion, actual.PolicyVersion, StringComparison.OrdinalIgnoreCase))
{
drifts.Add(new ReplayFieldDrift
{
FieldName = "PolicyVersion",
ExpectedValue = expected.PolicyVersion,
ActualValue = actual.PolicyVersion,
Severity = "warning",
Description = "Policy rules have been modified since original evaluation"
});
}
if (!string.Equals(expected.VexVerdictsDigest, actual.VexVerdictsDigest, StringComparison.OrdinalIgnoreCase))
{
drifts.Add(new ReplayFieldDrift
{
FieldName = "VexVerdictsDigest",
ExpectedValue = expected.VexVerdictsDigest,
ActualValue = actual.VexVerdictsDigest,
Severity = "warning",
Description = "VEX verdicts have changed (new statements or consensus updates)"
});
}
if (expected.Timestamp != actual.Timestamp)
{
drifts.Add(new ReplayFieldDrift
{
FieldName = "Timestamp",
ExpectedValue = expected.Timestamp.ToString("O"),
ActualValue = actual.Timestamp.ToString("O"),
Severity = "info",
Description = "Evaluation timestamp differs (expected when not freezing time)"
});
}
return drifts.ToImmutableArray();
}
private static string SummarizeDrifts(ImmutableArray<ReplayFieldDrift> drifts)
{
if (drifts.IsEmpty)
{
return "no drift detected";
}
var criticalCount = drifts.Count(d => d.Severity == "critical");
var warningCount = drifts.Count(d => d.Severity == "warning");
var infoCount = drifts.Count(d => d.Severity == "info");
var parts = new List<string>();
if (criticalCount > 0) parts.Add($"{criticalCount} critical");
if (warningCount > 0) parts.Add($"{warningCount} warning");
if (infoCount > 0) parts.Add($"{infoCount} info");
return string.Join(", ", parts);
}
private Task<string> GetCurrentFeedsDigestAsync(string tenantId, CancellationToken ct)
{
// In real implementation, would query feeds service
return Task.FromResult($"sha256:feeds-snapshot-{_clock.UtcNow:yyyyMMddHH}");
}
private Task<string> GetCurrentPolicyVersionAsync(string tenantId, CancellationToken ct)
{
// In real implementation, would query policy service
return Task.FromResult("v1.0.0");
}
private Task<string> GetCurrentVexDigestAsync(string sbomDigest, string tenantId, CancellationToken ct)
{
// In real implementation, would query VexLens
return Task.FromResult($"sha256:vex-{sbomDigest[..16]}-current");
}
private static string TruncateHash(string hash)
{
if (string.IsNullOrEmpty(hash)) return hash;
return hash.Length > 16 ? $"{hash[..16]}..." : hash;
}
}

View File

@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
@@ -13,14 +14,23 @@ namespace StellaOps.SbomService.Services;
internal sealed class SbomLedgerService : ISbomLedgerService
{
private readonly ISbomLedgerRepository _repository;
private readonly ISbomLineageEdgeRepository? _lineageEdgeRepository;
private readonly IClock _clock;
private readonly SbomLedgerOptions _options;
private readonly ILogger<SbomLedgerService>? _logger;
public SbomLedgerService(ISbomLedgerRepository repository, IClock clock, IOptions<SbomLedgerOptions> options)
public SbomLedgerService(
ISbomLedgerRepository repository,
IClock clock,
IOptions<SbomLedgerOptions> options,
ISbomLineageEdgeRepository? lineageEdgeRepository = null,
ILogger<SbomLedgerService>? logger = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_options = options?.Value ?? new SbomLedgerOptions();
_lineageEdgeRepository = lineageEdgeRepository;
_logger = logger;
}
public async Task<SbomLedgerVersion> AddVersionAsync(SbomLedgerSubmission submission, CancellationToken cancellationToken)
@@ -36,7 +46,10 @@ internal sealed class SbomLedgerService : ISbomLedgerService
var versionId = Guid.NewGuid();
var createdAt = _clock.UtcNow;
// LIN-BE-003: Resolve parent from ParentVersionId or ParentArtifactDigest
SbomLedgerVersion? parent = null;
Guid? resolvedParentVersionId = submission.ParentVersionId;
if (submission.ParentVersionId.HasValue)
{
parent = await _repository.GetVersionAsync(submission.ParentVersionId.Value, cancellationToken).ConfigureAwait(false);
@@ -45,6 +58,15 @@ internal sealed class SbomLedgerService : ISbomLedgerService
throw new InvalidOperationException($"Parent version '{submission.ParentVersionId}' was not found.");
}
}
else if (!string.IsNullOrWhiteSpace(submission.ParentArtifactDigest))
{
// LIN-BE-003: Lookup parent by digest
parent = await _repository.GetVersionByDigestAsync(submission.ParentArtifactDigest, cancellationToken).ConfigureAwait(false);
if (parent is not null)
{
resolvedParentVersionId = parent.VersionId;
}
}
var version = new SbomLedgerVersion
{
@@ -58,8 +80,13 @@ internal sealed class SbomLedgerService : ISbomLedgerService
Source = submission.Source,
CreatedAtUtc = createdAt,
Provenance = submission.Provenance,
ParentVersionId = parent?.VersionId,
ParentVersionId = resolvedParentVersionId,
ParentDigest = parent?.Digest,
// LIN-BE-003: Lineage ancestry fields
ParentArtifactDigest = submission.ParentArtifactDigest,
BaseImageRef = submission.BaseImageRef,
BaseImageDigest = submission.BaseImageDigest,
BuildId = submission.BuildId ?? submission.Provenance?.CiContext?.BuildId,
Components = submission.Components
};
@@ -68,9 +95,93 @@ internal sealed class SbomLedgerService : ISbomLedgerService
new SbomLedgerAuditEntry(artifact, versionId, "created", createdAt, $"format={submission.Format}"),
cancellationToken).ConfigureAwait(false);
// LIN-BE-006: Persist lineage edges on version creation
if (_lineageEdgeRepository is not null)
{
var tenantId = submission.Provenance?.CiContext?.Repository ?? "default";
await PersistLineageEdgesAsync(version, tenantId, cancellationToken).ConfigureAwait(false);
}
return version;
}
/// <summary>
/// LIN-BE-006: Persist lineage edges for version relationships.
/// Creates edges for: parent (from ParentArtifactDigest), build (same BuildId), base (from BaseImageDigest).
/// </summary>
private async Task PersistLineageEdgesAsync(SbomLedgerVersion version, string tenantId, CancellationToken ct)
{
if (_lineageEdgeRepository is null)
{
return;
}
var edges = new List<SbomLineageEdgeEntity>();
var tenantGuid = ParseTenantIdToGuid(tenantId);
// Parent edge: from parent digest to this artifact
if (!string.IsNullOrWhiteSpace(version.ParentArtifactDigest))
{
edges.Add(new SbomLineageEdgeEntity
{
Id = Guid.NewGuid(),
ParentDigest = version.ParentArtifactDigest,
ChildDigest = version.Digest,
Relationship = LineageRelationship.Parent,
TenantId = tenantGuid,
CreatedAt = version.CreatedAtUtc
});
}
// Base image edge: from base image to this artifact
if (!string.IsNullOrWhiteSpace(version.BaseImageDigest))
{
edges.Add(new SbomLineageEdgeEntity
{
Id = Guid.NewGuid(),
ParentDigest = version.BaseImageDigest,
ChildDigest = version.Digest,
Relationship = LineageRelationship.Base,
TenantId = tenantGuid,
CreatedAt = version.CreatedAtUtc
});
}
// Build edges: link all versions with the same BuildId
if (!string.IsNullOrWhiteSpace(version.BuildId))
{
var siblingVersions = await _repository.GetVersionsAsync(version.ArtifactRef, ct).ConfigureAwait(false);
var buildSiblings = siblingVersions
.Where(v => v.VersionId != version.VersionId &&
!string.IsNullOrWhiteSpace(v.BuildId) &&
string.Equals(v.BuildId, version.BuildId, StringComparison.Ordinal))
.ToList();
foreach (var sibling in buildSiblings)
{
// Link in chronological order (earlier version is parent)
if (sibling.CreatedAtUtc < version.CreatedAtUtc)
{
edges.Add(new SbomLineageEdgeEntity
{
Id = Guid.NewGuid(),
ParentDigest = sibling.Digest,
ChildDigest = version.Digest,
Relationship = LineageRelationship.Build,
TenantId = tenantGuid,
CreatedAt = version.CreatedAtUtc
});
}
}
}
if (edges.Count > 0)
{
var added = await _lineageEdgeRepository.AddRangeAsync(edges, ct).ConfigureAwait(false);
_logger?.LogDebug("Persisted {EdgeCount} lineage edges for version {VersionId}", added, version.VersionId);
}
}
public async Task<SbomVersionHistoryResult?> GetHistoryAsync(string artifactRef, int limit, int offset, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactRef))
@@ -435,4 +546,20 @@ internal sealed class SbomLedgerService : ISbomLedgerService
private static SbomDiffComponent ToDiffComponent(SbomNormalizedComponent component)
=> new(component.Key, component.Name, component.Purl, component.Version, component.License);
/// <summary>
/// Parses tenant ID string to Guid. For non-GUID strings, creates deterministic GUID from hash.
/// </summary>
private static Guid ParseTenantIdToGuid(string tenantId)
{
if (Guid.TryParse(tenantId, out var guid))
{
return guid;
}
// For string-based tenant IDs, generate deterministic GUID from hash
var bytes = System.Text.Encoding.UTF8.GetBytes(tenantId);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return new Guid(hash.Take(16).ToArray());
}
}

View File

@@ -0,0 +1,539 @@
// -----------------------------------------------------------------------------
// SbomLineageGraphService.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-013)
// Updated: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-023) - Replay hash
// Task: Implement SBOM lineage graph service
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Implementation of <see cref="ISbomLineageGraphService"/>.
/// Provides lineage graph traversal and diff computation.
/// </summary>
internal sealed class SbomLineageGraphService : ISbomLineageGraphService
{
private readonly ISbomLineageEdgeRepository _edgeRepository;
private readonly ISbomLedgerRepository _ledgerRepository;
// LIN-BE-015: Hover card cache for <150ms response times
private readonly ILineageHoverCache? _hoverCache;
// LIN-BE-023: Replay hash service for deterministic verification
private readonly IReplayHashService? _replayHashService;
private readonly ILogger<SbomLineageGraphService> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.SbomService.LineageGraph");
public SbomLineageGraphService(
ISbomLineageEdgeRepository edgeRepository,
ISbomLedgerRepository ledgerRepository,
ILogger<SbomLineageGraphService> logger,
ILineageHoverCache? hoverCache = null,
IReplayHashService? replayHashService = null)
{
_edgeRepository = edgeRepository;
_ledgerRepository = ledgerRepository;
_logger = logger;
_hoverCache = hoverCache;
_replayHashService = replayHashService;
}
/// <inheritdoc/>
public async Task<SbomLineageGraphResponse?> GetLineageGraphAsync(
string artifactDigest,
string tenantId,
SbomLineageQueryOptions? options = null,
CancellationToken ct = default)
{
options ??= new SbomLineageQueryOptions();
var tenantGuid = ParseTenantId(tenantId);
if (tenantGuid == Guid.Empty)
{
_logger.LogWarning("Invalid tenant ID format: {TenantId}", tenantId);
return null;
}
// Get all edges in the lineage graph
var edges = await _edgeRepository.GetGraphAsync(
artifactDigest,
tenantGuid,
options.MaxDepth,
ct).ConfigureAwait(false);
if (edges.Count == 0)
{
// Return single-node graph if no edges found
var singleNode = await BuildNodeFromDigestAsync(artifactDigest, tenantId, options, ct);
if (singleNode is null)
{
return null;
}
return new SbomLineageGraphResponse
{
Artifact = artifactDigest,
Nodes = new[] { singleNode },
Edges = Array.Empty<SbomLineageEdgeExtended>()
};
}
// Collect all unique digests
var allDigests = edges
.SelectMany(e => new[] { e.ParentDigest, e.ChildDigest })
.Distinct(StringComparer.Ordinal)
.ToList();
// Build nodes for each digest
var nodes = new List<SbomLineageNodeExtended>();
foreach (var digest in allDigests)
{
var node = await BuildNodeFromDigestAsync(digest, tenantId, options, ct);
if (node is not null)
{
nodes.Add(node);
}
}
// Convert edges to extended format
var extendedEdges = edges
.Select(e => new SbomLineageEdgeExtended
{
From = e.ParentDigest,
To = e.ChildDigest,
Relationship = e.Relationship.ToString().ToLowerInvariant()
})
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.ToList();
return new SbomLineageGraphResponse
{
Artifact = artifactDigest,
Nodes = nodes.OrderBy(n => n.Digest, StringComparer.Ordinal).ToList(),
Edges = extendedEdges
};
}
/// <inheritdoc/>
public async Task<SbomLineageDiffResponse?> GetLineageDiffAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default)
{
// Get versions by digest
var fromVersion = await _ledgerRepository.GetVersionByDigestAsync(fromDigest, ct).ConfigureAwait(false);
var toVersion = await _ledgerRepository.GetVersionByDigestAsync(toDigest, ct).ConfigureAwait(false);
if (fromVersion is null || toVersion is null)
{
_logger.LogWarning(
"Could not find versions for diff: from={FromDigest} to={ToDigest}",
fromDigest,
toDigest);
return null;
}
// Compute component diff
var sbomDiff = ComputeComponentDiff(fromVersion, toVersion);
// Get VEX deltas (placeholder - integrate with VEX delta repository)
var vexDiff = new List<VexDeltaSummary>();
// Compute replay hash
var replayHash = ComputeReplayHash(fromDigest, toDigest, sbomDiff);
return new SbomLineageDiffResponse
{
SbomDiff = sbomDiff,
VexDiff = vexDiff,
ReplayHash = replayHash
};
}
/// <inheritdoc/>
public async Task<SbomLineageHoverCard?> GetHoverCardAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("lineage.hover_card");
activity?.SetTag("from_digest", fromDigest);
activity?.SetTag("to_digest", toDigest);
activity?.SetTag("tenant", tenantId);
// LIN-BE-015: Check cache first
if (_hoverCache is not null)
{
var cached = await _hoverCache.GetAsync(fromDigest, toDigest, tenantId, ct).ConfigureAwait(false);
if (cached is not null)
{
activity?.SetTag("cache_hit", true);
return cached;
}
activity?.SetTag("cache_hit", false);
}
var sw = Stopwatch.StartNew();
var tenantGuid = ParseTenantId(tenantId);
if (tenantGuid == Guid.Empty)
{
return null;
}
// Check edge exists
var edgeExists = await _edgeRepository.ExistsAsync(
fromDigest,
toDigest,
tenantGuid,
ct).ConfigureAwait(false);
if (!edgeExists)
{
return null;
}
// Get diff summary
var diff = await GetLineageDiffAsync(fromDigest, toDigest, tenantId, ct);
if (diff is null)
{
return null;
}
// Get version info for timestamps
var fromVersion = await _ledgerRepository.GetVersionByDigestAsync(fromDigest, ct);
var toVersion = await _ledgerRepository.GetVersionByDigestAsync(toDigest, ct);
// Compute VEX delta summary
var vexSummary = ComputeVexHoverSummary(diff.VexDiff);
// Determine relationship type from edge
var children = await _edgeRepository.GetChildrenAsync(fromDigest, tenantGuid, ct);
var edge = children.FirstOrDefault(e => e.ChildDigest == toDigest);
var relationship = edge?.Relationship.ToString().ToLowerInvariant() ?? "parent";
var hoverCard = new SbomLineageHoverCard
{
FromDigest = fromDigest,
ToDigest = toDigest,
Relationship = relationship,
ComponentDiff = diff.SbomDiff.Summary,
VexDiff = vexSummary,
ReplayHash = diff.ReplayHash,
FromCreatedAt = fromVersion?.CreatedAtUtc,
ToCreatedAt = toVersion?.CreatedAtUtc
};
sw.Stop();
activity?.SetTag("compute_ms", sw.ElapsedMilliseconds);
// LIN-BE-015: Cache the result
if (_hoverCache is not null)
{
await _hoverCache.SetAsync(fromDigest, toDigest, tenantId, hoverCard, ct).ConfigureAwait(false);
}
_logger.LogDebug("Computed hover card in {ElapsedMs}ms", sw.ElapsedMilliseconds);
return hoverCard;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SbomLineageNodeExtended>> GetChildrenAsync(
string parentDigest,
string tenantId,
CancellationToken ct = default)
{
var tenantGuid = ParseTenantId(tenantId);
if (tenantGuid == Guid.Empty)
{
return Array.Empty<SbomLineageNodeExtended>();
}
var edges = await _edgeRepository.GetChildrenAsync(parentDigest, tenantGuid, ct);
var options = new SbomLineageQueryOptions { IncludeBadges = true };
var nodes = new List<SbomLineageNodeExtended>();
foreach (var edge in edges)
{
var node = await BuildNodeFromDigestAsync(edge.ChildDigest, tenantId, options, ct);
if (node is not null)
{
nodes.Add(node);
}
}
return nodes.OrderBy(n => n.Digest, StringComparer.Ordinal).ToList();
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SbomLineageNodeExtended>> GetParentsAsync(
string childDigest,
string tenantId,
CancellationToken ct = default)
{
var tenantGuid = ParseTenantId(tenantId);
if (tenantGuid == Guid.Empty)
{
return Array.Empty<SbomLineageNodeExtended>();
}
var edges = await _edgeRepository.GetParentsAsync(childDigest, tenantGuid, ct);
var options = new SbomLineageQueryOptions { IncludeBadges = true };
var nodes = new List<SbomLineageNodeExtended>();
foreach (var edge in edges)
{
var node = await BuildNodeFromDigestAsync(edge.ParentDigest, tenantId, options, ct);
if (node is not null)
{
nodes.Add(node);
}
}
return nodes.OrderBy(n => n.Digest, StringComparer.Ordinal).ToList();
}
private async Task<SbomLineageNodeExtended?> BuildNodeFromDigestAsync(
string digest,
string tenantId,
SbomLineageQueryOptions options,
CancellationToken ct)
{
var version = await _ledgerRepository.GetVersionByDigestAsync(digest, ct).ConfigureAwait(false);
if (version is null)
{
// Create minimal node for unknown digests
return new SbomLineageNodeExtended
{
Id = Guid.Empty,
Digest = digest,
ArtifactRef = "unknown",
SequenceNumber = 0,
CreatedAt = DateTimeOffset.MinValue,
Source = "unknown"
};
}
var badges = options.IncludeBadges
? new SbomLineageBadges
{
ComponentCount = version.Components.Count,
SignatureStatus = "unsigned" // TODO: integrate with signature service
}
: null;
// LIN-BE-023: Use stored replay hash or compute via service
string? replayHash = null;
if (options.IncludeReplayHash)
{
// Prefer stored hash for performance
replayHash = version.ReplayHash;
// Fallback to service computation if not stored
if (string.IsNullOrEmpty(replayHash) && _replayHashService is not null)
{
try
{
var result = await _replayHashService.ComputeHashAsync(digest, tenantId, ct);
replayHash = result.Hash;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to compute replay hash for {Digest}", digest);
// Fallback to simple hash
replayHash = ComputeNodeReplayHash(version);
}
}
else if (string.IsNullOrEmpty(replayHash))
{
// Fallback to simple hash if service not available
replayHash = ComputeNodeReplayHash(version);
}
}
return new SbomLineageNodeExtended
{
Id = version.VersionId,
Digest = version.Digest,
ArtifactRef = version.ArtifactRef,
SequenceNumber = version.SequenceNumber,
CreatedAt = version.CreatedAtUtc,
Source = version.Source,
Badges = badges,
ReplayHash = replayHash
};
}
private static SbomDiffResult ComputeComponentDiff(
SbomLedgerVersion fromVersion,
SbomLedgerVersion toVersion)
{
var fromComponents = fromVersion.Components.ToDictionary(c => c.Key, StringComparer.Ordinal);
var toComponents = toVersion.Components.ToDictionary(c => c.Key, StringComparer.Ordinal);
var added = new List<SbomDiffComponent>();
var removed = new List<SbomDiffComponent>();
var versionChanged = new List<SbomVersionChange>();
var licenseChanged = new List<SbomLicenseChange>();
// Find added components
foreach (var (key, component) in toComponents)
{
if (!fromComponents.ContainsKey(key))
{
added.Add(new SbomDiffComponent(
component.Key,
component.Name,
component.Purl,
component.Version,
component.License));
}
}
// Find removed components and changes
foreach (var (key, fromComponent) in fromComponents)
{
if (!toComponents.TryGetValue(key, out var toComponent))
{
removed.Add(new SbomDiffComponent(
fromComponent.Key,
fromComponent.Name,
fromComponent.Purl,
fromComponent.Version,
fromComponent.License));
}
else
{
// Check for version change
if (!string.Equals(fromComponent.Version, toComponent.Version, StringComparison.Ordinal))
{
versionChanged.Add(new SbomVersionChange(
fromComponent.Key,
fromComponent.Name,
fromComponent.Purl,
fromComponent.Version,
toComponent.Version));
}
// Check for license change
if (!string.Equals(fromComponent.License, toComponent.License, StringComparison.Ordinal))
{
licenseChanged.Add(new SbomLicenseChange(
fromComponent.Key,
fromComponent.Name,
fromComponent.Purl,
fromComponent.License,
toComponent.License));
}
}
}
return new SbomDiffResult
{
BeforeVersionId = fromVersion.VersionId,
AfterVersionId = toVersion.VersionId,
Added = added.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(),
Removed = removed.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(),
VersionChanged = versionChanged.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(),
LicenseChanged = licenseChanged.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(),
Summary = new SbomDiffSummary(
added.Count,
removed.Count,
versionChanged.Count,
licenseChanged.Count)
};
}
private static string ComputeReplayHash(
string fromDigest,
string toDigest,
SbomDiffResult diff)
{
var sb = new StringBuilder();
sb.Append(fromDigest);
sb.Append('|');
sb.Append(toDigest);
sb.Append('|');
sb.Append(diff.Summary.AddedCount);
sb.Append('|');
sb.Append(diff.Summary.RemovedCount);
sb.Append('|');
sb.Append(diff.Summary.VersionChangedCount);
sb.Append('|');
sb.Append(diff.Summary.LicenseChangedCount);
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
private static string ComputeNodeReplayHash(SbomLedgerVersion version)
{
var sb = new StringBuilder();
sb.Append(version.Digest);
sb.Append('|');
sb.Append(version.Components.Count);
sb.Append('|');
sb.Append(version.Format);
sb.Append('|');
sb.Append(version.FormatVersion);
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
private static VexDeltaHoverSummary ComputeVexHoverSummary(IReadOnlyList<VexDeltaSummary> vexDiffs)
{
var newVulns = 0;
var resolvedVulns = 0;
var statusChanges = 0;
var criticalCves = new List<string>();
foreach (var diff in vexDiffs)
{
statusChanges++;
if (diff.FromStatus == "not_affected" && diff.ToStatus == "affected")
{
newVulns++;
criticalCves.Add(diff.Cve);
}
else if (diff.FromStatus == "affected" &&
(diff.ToStatus == "not_affected" || diff.ToStatus == "fixed"))
{
resolvedVulns++;
}
}
return new VexDeltaHoverSummary
{
NewVulns = newVulns,
ResolvedVulns = resolvedVulns,
StatusChanges = statusChanges,
CriticalCves = criticalCves.Count > 0 ? criticalCves.Take(5).ToList() : null
};
}
private static Guid ParseTenantId(string tenantId)
{
if (Guid.TryParse(tenantId, out var guid))
{
return guid;
}
// For string-based tenant IDs, generate deterministic GUID
var bytes = Encoding.UTF8.GetBytes(tenantId);
var hash = SHA256.HashData(bytes);
return new Guid(hash.Take(16).ToArray());
}
}

View File

@@ -85,7 +85,12 @@ internal sealed class SbomUploadService : ISbomUploadService
Source: request.Source?.Tool ?? "upload",
Provenance: request.Source,
Components: normalized,
ParentVersionId: null);
ParentVersionId: null,
// LIN-BE-003: Lineage ancestry fields
ParentArtifactDigest: request.ParentArtifactDigest,
BaseImageRef: request.BaseImageRef,
BaseImageDigest: request.BaseImageDigest,
BuildId: request.Source?.CiContext?.BuildId);
var ledgerVersion = await _ledgerService.AddVersionAsync(submission, cancellationToken).ConfigureAwait(false);
var analysisJob = await _analysisTrigger.TriggerAsync(request.ArtifactRef.Trim(), ledgerVersion.VersionId, cancellationToken).ConfigureAwait(false);

View File

@@ -12,6 +12,8 @@
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<!-- LIN-BE-028: Lineage compare service needs VEX delta repository -->
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
namespace StellaOps.SbomService.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for SbomService module.
/// This is a stub that will be scaffolded from the PostgreSQL database.
/// </summary>
public class SbomServiceDbContext : DbContext
{
public SbomServiceDbContext(DbContextOptions<SbomServiceDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("sbom");
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -2,23 +2,20 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Storage.Postgres.Repositories;
using StellaOps.SbomService.Persistence.Postgres;
using StellaOps.SbomService.Persistence.Postgres.Repositories;
namespace StellaOps.SbomService.Storage.Postgres;
namespace StellaOps.SbomService.Persistence.Extensions;
/// <summary>
/// Extension methods for configuring SbomService PostgreSQL storage services.
/// Extension methods for configuring SbomService persistence services.
/// </summary>
public static class ServiceCollectionExtensions
public static class SbomServicePersistenceExtensions
{
/// <summary>
/// Adds SbomService PostgreSQL storage services.
/// Adds SbomService PostgreSQL persistence services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddSbomServicePostgresStorage(
public static IServiceCollection AddSbomServicePersistence(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:SbomService")
@@ -38,12 +35,9 @@ public static class ServiceCollectionExtensions
}
/// <summary>
/// Adds SbomService PostgreSQL storage services with explicit options.
/// Adds SbomService PostgreSQL persistence services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddSbomServicePostgresStorage(
public static IServiceCollection AddSbomServicePersistence(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{

View File

@@ -5,7 +5,7 @@ using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
namespace StellaOps.SbomService.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="ICatalogRepository"/>.

View File

@@ -4,7 +4,7 @@ using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
namespace StellaOps.SbomService.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IComponentLookupRepository"/>.

View File

@@ -4,7 +4,7 @@ using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
namespace StellaOps.SbomService.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IEntrypointRepository"/>.

View File

@@ -4,7 +4,7 @@ using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
namespace StellaOps.SbomService.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IOrchestratorControlRepository"/>.

View File

@@ -4,7 +4,7 @@ using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
namespace StellaOps.SbomService.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IOrchestratorRepository"/>.

View File

@@ -6,7 +6,7 @@ using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
namespace StellaOps.SbomService.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IProjectionRepository"/>.

View File

@@ -0,0 +1,342 @@
// -----------------------------------------------------------------------------
// PostgresSbomLineageEdgeRepository.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-005)
// Task: Implement ISbomLineageEdgeRepository with PostgreSQL
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.SbomService.Persistence.Repositories;
namespace StellaOps.SbomService.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="ISbomLineageEdgeRepository"/>.
/// Uses the sbom_lineage_edges table for storing parent-child relationships.
/// </summary>
public sealed class PostgresSbomLineageEdgeRepository : RepositoryBase<SbomServiceDataSource>, ISbomLineageEdgeRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private bool _tableInitialized;
public PostgresSbomLineageEdgeRepository(SbomServiceDataSource dataSource, ILogger<PostgresSbomLineageEdgeRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc/>
public async Task<bool> AddAsync(LineageEdge edge, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
INSERT INTO sbom.lineage_edges (id, parent_digest, child_digest, relationship, tenant_id, created_at, metadata)
VALUES (@id, @parent_digest, @child_digest, @relationship, @tenant_id, @created_at, @metadata)
ON CONFLICT (parent_digest, child_digest, tenant_id) DO NOTHING
RETURNING id";
await using var connection = await DataSource.OpenConnectionAsync(edge.TenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@id", edge.Id);
AddParameter(command, "@parent_digest", edge.ParentDigest);
AddParameter(command, "@child_digest", edge.ChildDigest);
AddParameter(command, "@relationship", edge.Relationship.ToString().ToLowerInvariant());
AddParameter(command, "@tenant_id", edge.TenantId);
AddParameter(command, "@created_at", edge.CreatedAt);
AddJsonParameter(command, "@metadata", edge.Metadata);
var result = await command.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is not null;
}
/// <inheritdoc/>
public async Task<int> AddBatchAsync(IEnumerable<LineageEdge> edges, CancellationToken ct = default)
{
var edgeList = edges.ToList();
if (edgeList.Count == 0)
{
return 0;
}
await EnsureTableAsync(ct).ConfigureAwait(false);
var tenantId = edgeList[0].TenantId;
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
var count = 0;
foreach (var edge in edgeList)
{
const string sql = @"
INSERT INTO sbom.lineage_edges (id, parent_digest, child_digest, relationship, tenant_id, created_at, metadata)
VALUES (@id, @parent_digest, @child_digest, @relationship, @tenant_id, @created_at, @metadata)
ON CONFLICT (parent_digest, child_digest, tenant_id) DO NOTHING";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
AddParameter(command, "@id", edge.Id);
AddParameter(command, "@parent_digest", edge.ParentDigest);
AddParameter(command, "@child_digest", edge.ChildDigest);
AddParameter(command, "@relationship", edge.Relationship.ToString().ToLowerInvariant());
AddParameter(command, "@tenant_id", edge.TenantId);
AddParameter(command, "@created_at", edge.CreatedAt);
AddJsonParameter(command, "@metadata", edge.Metadata);
var affected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
count += affected;
}
await transaction.CommitAsync(ct).ConfigureAwait(false);
return count;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<LineageEdge>> GetChildrenAsync(string parentDigest, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, parent_digest, child_digest, relationship, tenant_id, created_at, metadata
FROM sbom.lineage_edges
WHERE parent_digest = @parent_digest AND tenant_id = @tenant_id
ORDER BY created_at DESC, child_digest";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@parent_digest", parentDigest);
AddParameter(command, "@tenant_id", tenantId);
return await ReadEdgesAsync(command, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<LineageEdge>> GetParentsAsync(string childDigest, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, parent_digest, child_digest, relationship, tenant_id, created_at, metadata
FROM sbom.lineage_edges
WHERE child_digest = @child_digest AND tenant_id = @tenant_id
ORDER BY created_at DESC, parent_digest";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@child_digest", childDigest);
AddParameter(command, "@tenant_id", tenantId);
return await ReadEdgesAsync(command, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<LineageGraph> GetGraphAsync(string artifactDigest, string tenantId, int maxDepth = 5, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
var nodes = new Dictionary<string, LineageNode>();
var edges = new List<LineageEdge>();
// Add root node
nodes[artifactDigest] = new LineageNode
{
Digest = artifactDigest,
Distance = 0,
Direction = LineageDirection.Root
};
// Traverse ancestors (parents)
await TraverseAsync(artifactDigest, tenantId, maxDepth, isAncestor: true, nodes, edges, ct).ConfigureAwait(false);
// Traverse descendants (children)
await TraverseAsync(artifactDigest, tenantId, maxDepth, isAncestor: false, nodes, edges, ct).ConfigureAwait(false);
var maxDistance = nodes.Values.Select(n => n.Distance).DefaultIfEmpty(0).Max();
return new LineageGraph
{
RootDigest = artifactDigest,
Nodes = nodes.Values.OrderBy(n => n.Distance).ThenBy(n => n.Digest, StringComparer.Ordinal).ToList(),
Edges = edges.OrderBy(e => e.CreatedAt).ToList(),
Depth = maxDistance
};
}
/// <inheritdoc/>
public async Task<bool> ExistsAsync(string parentDigest, string childDigest, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT EXISTS(
SELECT 1 FROM sbom.lineage_edges
WHERE parent_digest = @parent_digest AND child_digest = @child_digest AND tenant_id = @tenant_id
)";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@parent_digest", parentDigest);
AddParameter(command, "@child_digest", childDigest);
AddParameter(command, "@tenant_id", tenantId);
var result = await command.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is true;
}
/// <inheritdoc/>
public async Task<int> DeleteForArtifactAsync(string artifactDigest, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
DELETE FROM sbom.lineage_edges
WHERE (parent_digest = @digest OR child_digest = @digest) AND tenant_id = @tenant_id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@digest", artifactDigest);
AddParameter(command, "@tenant_id", tenantId);
return await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
private async Task TraverseAsync(
string startDigest,
string tenantId,
int maxDepth,
bool isAncestor,
Dictionary<string, LineageNode> nodes,
List<LineageEdge> edges,
CancellationToken ct)
{
var queue = new Queue<(string digest, int depth)>();
queue.Enqueue((startDigest, 0));
var visited = new HashSet<string> { startDigest };
while (queue.Count > 0)
{
var (currentDigest, currentDepth) = queue.Dequeue();
if (currentDepth >= maxDepth)
{
continue;
}
var relatedEdges = isAncestor
? await GetParentsAsync(currentDigest, tenantId, ct).ConfigureAwait(false)
: await GetChildrenAsync(currentDigest, tenantId, ct).ConfigureAwait(false);
foreach (var edge in relatedEdges)
{
edges.Add(edge);
var relatedDigest = isAncestor ? edge.ParentDigest : edge.ChildDigest;
if (visited.Add(relatedDigest))
{
nodes[relatedDigest] = new LineageNode
{
Digest = relatedDigest,
Distance = currentDepth + 1,
Direction = isAncestor ? LineageDirection.Ancestor : LineageDirection.Descendant
};
queue.Enqueue((relatedDigest, currentDepth + 1));
}
}
}
}
private async Task<List<LineageEdge>> ReadEdgesAsync(NpgsqlCommand command, CancellationToken ct)
{
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
var results = new List<LineageEdge>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(MapEdge(reader));
}
return results;
}
private static LineageEdge MapEdge(NpgsqlDataReader reader)
{
var relationshipStr = reader.GetString(reader.GetOrdinal("relationship"));
var relationship = Enum.TryParse<LineageRelationship>(relationshipStr, ignoreCase: true, out var rel)
? rel
: LineageRelationship.Parent;
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
? null
: reader.GetString(reader.GetOrdinal("metadata"));
var metadata = string.IsNullOrEmpty(metadataJson)
? null
: JsonSerializer.Deserialize<Dictionary<string, string>>(metadataJson, JsonOptions);
return new LineageEdge
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
ParentDigest = reader.GetString(reader.GetOrdinal("parent_digest")),
ChildDigest = reader.GetString(reader.GetOrdinal("child_digest")),
Relationship = relationship,
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("created_at")),
Metadata = metadata
};
}
private void AddJsonParameter(NpgsqlCommand command, string name, object? value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.NpgsqlDbType = NpgsqlDbType.Jsonb;
parameter.Value = value is null ? DBNull.Value : JsonSerializer.Serialize(value, JsonOptions);
command.Parameters.Add(parameter);
}
private async Task EnsureTableAsync(CancellationToken ct)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS sbom;
CREATE TABLE IF NOT EXISTS sbom.lineage_edges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_digest TEXT NOT NULL,
child_digest TEXT NOT NULL,
relationship TEXT NOT NULL DEFAULT 'parent',
tenant_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB,
CONSTRAINT uq_lineage_edge UNIQUE (parent_digest, child_digest, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_lineage_edges_parent ON sbom.lineage_edges (parent_digest, tenant_id);
CREATE INDEX IF NOT EXISTS idx_lineage_edges_child ON sbom.lineage_edges (child_digest, tenant_id);
CREATE INDEX IF NOT EXISTS idx_lineage_edges_tenant ON sbom.lineage_edges (tenant_id);
CREATE INDEX IF NOT EXISTS idx_lineage_edges_created ON sbom.lineage_edges (created_at DESC);
";
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -0,0 +1,340 @@
// -----------------------------------------------------------------------------
// PostgresSbomVerdictLinkRepository.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-011)
// Task: Implement ISbomVerdictLinkRepository with PostgreSQL
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="ISbomVerdictLinkRepository"/>.
/// Uses the sbom_verdict_links table for linking SBOMs to VEX verdicts.
/// </summary>
public sealed class PostgresSbomVerdictLinkRepository : RepositoryBase<SbomServiceDataSource>, ISbomVerdictLinkRepository
{
private bool _tableInitialized;
public PostgresSbomVerdictLinkRepository(SbomServiceDataSource dataSource, ILogger<PostgresSbomVerdictLinkRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc/>
public async Task<bool> LinkAsync(SbomVerdictLink link, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
INSERT INTO sbom.verdict_links (
id, sbom_version_id, cve, consensus_projection_id,
verdict_status, confidence_score, tenant_id, linked_at,
artifact_digest, component_purl, severity, reachability_confirmed
)
VALUES (
@id, @sbom_version_id, @cve, @consensus_projection_id,
@verdict_status, @confidence_score, @tenant_id, @linked_at,
@artifact_digest, @component_purl, @severity, @reachability_confirmed
)
ON CONFLICT (sbom_version_id, cve, tenant_id) DO UPDATE SET
consensus_projection_id = EXCLUDED.consensus_projection_id,
verdict_status = EXCLUDED.verdict_status,
confidence_score = EXCLUDED.confidence_score,
linked_at = EXCLUDED.linked_at,
reachability_confirmed = EXCLUDED.reachability_confirmed
RETURNING id";
await using var connection = await DataSource.OpenConnectionAsync(link.TenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddLinkParameters(command, link);
var result = await command.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is not null;
}
/// <inheritdoc/>
public async Task<int> LinkBatchAsync(IEnumerable<SbomVerdictLink> links, CancellationToken ct = default)
{
var linkList = links.ToList();
if (linkList.Count == 0)
{
return 0;
}
await EnsureTableAsync(ct).ConfigureAwait(false);
var tenantId = linkList[0].TenantId;
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
var count = 0;
foreach (var link in linkList)
{
const string sql = @"
INSERT INTO sbom.verdict_links (
id, sbom_version_id, cve, consensus_projection_id,
verdict_status, confidence_score, tenant_id, linked_at,
artifact_digest, component_purl, severity, reachability_confirmed
)
VALUES (
@id, @sbom_version_id, @cve, @consensus_projection_id,
@verdict_status, @confidence_score, @tenant_id, @linked_at,
@artifact_digest, @component_purl, @severity, @reachability_confirmed
)
ON CONFLICT (sbom_version_id, cve, tenant_id) DO NOTHING";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
AddLinkParameters(command, link);
var affected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
count += affected;
}
await transaction.CommitAsync(ct).ConfigureAwait(false);
return count;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SbomVerdictLink>> GetVerdictsBySbomAsync(Guid sbomVersionId, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, sbom_version_id, cve, consensus_projection_id,
verdict_status, confidence_score, tenant_id, linked_at,
artifact_digest, component_purl, severity, reachability_confirmed
FROM sbom.verdict_links
WHERE sbom_version_id = @sbom_version_id AND tenant_id = @tenant_id
ORDER BY severity DESC NULLS LAST, cve";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@sbom_version_id", sbomVersionId);
AddParameter(command, "@tenant_id", tenantId);
return await ReadLinksAsync(command, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SbomVerdictLink>> GetSbomsByCveAsync(string cve, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, sbom_version_id, cve, consensus_projection_id,
verdict_status, confidence_score, tenant_id, linked_at,
artifact_digest, component_purl, severity, reachability_confirmed
FROM sbom.verdict_links
WHERE cve = @cve AND tenant_id = @tenant_id
ORDER BY linked_at DESC";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@cve", cve);
AddParameter(command, "@tenant_id", tenantId);
return await ReadLinksAsync(command, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SbomVerdictLink>> GetSbomsByStatusAsync(string verdictStatus, string tenantId, int limit = 100, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT id, sbom_version_id, cve, consensus_projection_id,
verdict_status, confidence_score, tenant_id, linked_at,
artifact_digest, component_purl, severity, reachability_confirmed
FROM sbom.verdict_links
WHERE verdict_status = @verdict_status AND tenant_id = @tenant_id
ORDER BY linked_at DESC
LIMIT @limit";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@verdict_status", verdictStatus);
AddParameter(command, "@tenant_id", tenantId);
AddParameter(command, "@limit", limit);
return await ReadLinksAsync(command, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<bool> UpdateAsync(SbomVerdictLink link, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
UPDATE sbom.verdict_links SET
consensus_projection_id = @consensus_projection_id,
verdict_status = @verdict_status,
confidence_score = @confidence_score,
linked_at = @linked_at,
reachability_confirmed = @reachability_confirmed
WHERE sbom_version_id = @sbom_version_id AND cve = @cve AND tenant_id = @tenant_id";
await using var connection = await DataSource.OpenConnectionAsync(link.TenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@sbom_version_id", link.SbomVersionId);
AddParameter(command, "@cve", link.Cve);
AddParameter(command, "@tenant_id", link.TenantId);
AddParameter(command, "@consensus_projection_id", link.ConsensusProjectionId ?? (object)DBNull.Value);
AddParameter(command, "@verdict_status", link.VerdictStatus);
AddParameter(command, "@confidence_score", link.ConfidenceScore);
AddParameter(command, "@linked_at", link.LinkedAt);
AddParameter(command, "@reachability_confirmed", link.ReachabilityConfirmed ?? (object)DBNull.Value);
var affected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return affected > 0;
}
/// <inheritdoc/>
public async Task<int> DeleteForSbomAsync(Guid sbomVersionId, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
DELETE FROM sbom.verdict_links
WHERE sbom_version_id = @sbom_version_id AND tenant_id = @tenant_id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@sbom_version_id", sbomVersionId);
AddParameter(command, "@tenant_id", tenantId);
return await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<bool> ExistsAsync(Guid sbomVersionId, string cve, string tenantId, CancellationToken ct = default)
{
await EnsureTableAsync(ct).ConfigureAwait(false);
const string sql = @"
SELECT EXISTS(
SELECT 1 FROM sbom.verdict_links
WHERE sbom_version_id = @sbom_version_id AND cve = @cve AND tenant_id = @tenant_id
)";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, ct).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@sbom_version_id", sbomVersionId);
AddParameter(command, "@cve", cve);
AddParameter(command, "@tenant_id", tenantId);
var result = await command.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is true;
}
private void AddLinkParameters(NpgsqlCommand command, SbomVerdictLink link)
{
AddParameter(command, "@id", link.Id);
AddParameter(command, "@sbom_version_id", link.SbomVersionId);
AddParameter(command, "@cve", link.Cve);
AddParameter(command, "@consensus_projection_id", link.ConsensusProjectionId ?? (object)DBNull.Value);
AddParameter(command, "@verdict_status", link.VerdictStatus);
AddParameter(command, "@confidence_score", link.ConfidenceScore);
AddParameter(command, "@tenant_id", link.TenantId);
AddParameter(command, "@linked_at", link.LinkedAt);
AddParameter(command, "@artifact_digest", link.ArtifactDigest ?? (object)DBNull.Value);
AddParameter(command, "@component_purl", link.ComponentPurl ?? (object)DBNull.Value);
AddParameter(command, "@severity", link.Severity ?? (object)DBNull.Value);
AddParameter(command, "@reachability_confirmed", link.ReachabilityConfirmed ?? (object)DBNull.Value);
}
private async Task<List<SbomVerdictLink>> ReadLinksAsync(NpgsqlCommand command, CancellationToken ct)
{
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
var results = new List<SbomVerdictLink>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(MapLink(reader));
}
return results;
}
private static SbomVerdictLink MapLink(NpgsqlDataReader reader)
{
return new SbomVerdictLink
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
SbomVersionId = reader.GetGuid(reader.GetOrdinal("sbom_version_id")),
Cve = reader.GetString(reader.GetOrdinal("cve")),
ConsensusProjectionId = reader.IsDBNull(reader.GetOrdinal("consensus_projection_id"))
? null
: reader.GetGuid(reader.GetOrdinal("consensus_projection_id")),
VerdictStatus = reader.GetString(reader.GetOrdinal("verdict_status")),
ConfidenceScore = reader.GetDouble(reader.GetOrdinal("confidence_score")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
LinkedAt = reader.GetDateTime(reader.GetOrdinal("linked_at")),
ArtifactDigest = reader.IsDBNull(reader.GetOrdinal("artifact_digest"))
? null
: reader.GetString(reader.GetOrdinal("artifact_digest")),
ComponentPurl = reader.IsDBNull(reader.GetOrdinal("component_purl"))
? null
: reader.GetString(reader.GetOrdinal("component_purl")),
Severity = reader.IsDBNull(reader.GetOrdinal("severity"))
? null
: reader.GetString(reader.GetOrdinal("severity")),
ReachabilityConfirmed = reader.IsDBNull(reader.GetOrdinal("reachability_confirmed"))
? null
: reader.GetBoolean(reader.GetOrdinal("reachability_confirmed"))
};
}
private async Task EnsureTableAsync(CancellationToken ct)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS sbom;
CREATE TABLE IF NOT EXISTS sbom.verdict_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sbom_version_id UUID NOT NULL,
cve TEXT NOT NULL,
consensus_projection_id UUID,
verdict_status TEXT NOT NULL,
confidence_score DOUBLE PRECISION NOT NULL DEFAULT 0,
tenant_id TEXT NOT NULL,
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
artifact_digest TEXT,
component_purl TEXT,
severity TEXT,
reachability_confirmed BOOLEAN,
CONSTRAINT uq_verdict_link UNIQUE (sbom_version_id, cve, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_verdict_links_sbom ON sbom.verdict_links (sbom_version_id, tenant_id);
CREATE INDEX IF NOT EXISTS idx_verdict_links_cve ON sbom.verdict_links (cve, tenant_id);
CREATE INDEX IF NOT EXISTS idx_verdict_links_status ON sbom.verdict_links (verdict_status, tenant_id);
CREATE INDEX IF NOT EXISTS idx_verdict_links_tenant ON sbom.verdict_links (tenant_id);
CREATE INDEX IF NOT EXISTS idx_verdict_links_linked ON sbom.verdict_links (linked_at DESC);
CREATE INDEX IF NOT EXISTS idx_verdict_links_digest ON sbom.verdict_links (artifact_digest) WHERE artifact_digest IS NOT NULL;
";
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -4,7 +4,7 @@ using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.SbomService.Storage.Postgres;
namespace StellaOps.SbomService.Persistence.Postgres;
/// <summary>
/// PostgreSQL data source for SbomService module.

View File

@@ -0,0 +1,178 @@
// -----------------------------------------------------------------------------
// ISbomLineageEdgeRepository.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-005)
// Task: Implement ISbomLineageEdgeRepository interface
// -----------------------------------------------------------------------------
namespace StellaOps.SbomService.Persistence.Repositories;
/// <summary>
/// Repository for SBOM lineage edges (parent-child relationships).
/// </summary>
public interface ISbomLineageEdgeRepository
{
/// <summary>
/// Adds a lineage edge between two artifacts.
/// </summary>
Task<bool> AddAsync(LineageEdge edge, CancellationToken ct = default);
/// <summary>
/// Adds multiple lineage edges in a batch.
/// </summary>
Task<int> AddBatchAsync(IEnumerable<LineageEdge> edges, CancellationToken ct = default);
/// <summary>
/// Gets all children of an artifact.
/// </summary>
Task<IReadOnlyList<LineageEdge>> GetChildrenAsync(string parentDigest, string tenantId, CancellationToken ct = default);
/// <summary>
/// Gets all parents of an artifact.
/// </summary>
Task<IReadOnlyList<LineageEdge>> GetParentsAsync(string childDigest, string tenantId, CancellationToken ct = default);
/// <summary>
/// Gets the full lineage graph for an artifact (ancestors and descendants).
/// </summary>
Task<LineageGraph> GetGraphAsync(string artifactDigest, string tenantId, int maxDepth = 5, CancellationToken ct = default);
/// <summary>
/// Checks if an edge exists.
/// </summary>
Task<bool> ExistsAsync(string parentDigest, string childDigest, string tenantId, CancellationToken ct = default);
/// <summary>
/// Deletes edges for an artifact.
/// </summary>
Task<int> DeleteForArtifactAsync(string artifactDigest, string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Represents a lineage edge between two SBOM artifacts.
/// </summary>
public sealed record LineageEdge
{
/// <summary>
/// Unique edge identifier.
/// </summary>
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// Parent artifact digest (sha256:...).
/// </summary>
public required string ParentDigest { get; init; }
/// <summary>
/// Child artifact digest (sha256:...).
/// </summary>
public required string ChildDigest { get; init; }
/// <summary>
/// Type of relationship.
/// </summary>
public required LineageRelationship Relationship { get; init; }
/// <summary>
/// Tenant identifier for multi-tenancy.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// When the edge was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Optional metadata about the relationship.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Type of lineage relationship.
/// </summary>
public enum LineageRelationship
{
/// <summary>
/// Direct parent-child relationship (e.g., image built FROM base).
/// </summary>
Parent = 0,
/// <summary>
/// Build dependency (e.g., compiler used to build artifact).
/// </summary>
Build = 1,
/// <summary>
/// Base image relationship.
/// </summary>
Base = 2
}
/// <summary>
/// Direction in the lineage graph.
/// </summary>
public enum LineageDirection
{
/// <summary>
/// The root node of the traversal.
/// </summary>
Root = 0,
/// <summary>
/// Ancestor (parent, grandparent, etc.).
/// </summary>
Ancestor = 1,
/// <summary>
/// Descendant (child, grandchild, etc.).
/// </summary>
Descendant = 2
}
/// <summary>
/// A node in the lineage graph traversal.
/// </summary>
public sealed record LineageNode
{
/// <summary>
/// Artifact digest.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Distance from the root.
/// </summary>
public required int Distance { get; init; }
/// <summary>
/// Direction from the root (ancestor or descendant).
/// </summary>
public required LineageDirection Direction { get; init; }
}
/// <summary>
/// Represents a lineage graph rooted at a specific artifact.
/// </summary>
public sealed record LineageGraph
{
/// <summary>
/// Root artifact digest.
/// </summary>
public required string RootDigest { get; init; }
/// <summary>
/// All nodes in the graph.
/// </summary>
public required IReadOnlyList<LineageNode> Nodes { get; init; }
/// <summary>
/// All edges in the graph.
/// </summary>
public required IReadOnlyList<LineageEdge> Edges { get; init; }
/// <summary>
/// Maximum depth of the graph.
/// </summary>
public required int Depth { get; init; }
}

View File

@@ -0,0 +1,120 @@
// -----------------------------------------------------------------------------
// ISbomVerdictLinkRepository.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-011)
// Task: Implement ISbomVerdictLinkRepository interface
// -----------------------------------------------------------------------------
namespace StellaOps.SbomService.Repositories;
/// <summary>
/// Repository for linking SBOM versions to VEX consensus verdicts.
/// Enables tracking of which VEX decisions apply to which SBOMs.
/// </summary>
public interface ISbomVerdictLinkRepository
{
/// <summary>
/// Links a verdict to an SBOM version.
/// </summary>
Task<bool> LinkAsync(SbomVerdictLink link, CancellationToken ct = default);
/// <summary>
/// Links multiple verdicts in a batch.
/// </summary>
Task<int> LinkBatchAsync(IEnumerable<SbomVerdictLink> links, CancellationToken ct = default);
/// <summary>
/// Gets all verdicts linked to an SBOM version.
/// </summary>
Task<IReadOnlyList<SbomVerdictLink>> GetVerdictsBySbomAsync(Guid sbomVersionId, string tenantId, CancellationToken ct = default);
/// <summary>
/// Gets all SBOM versions linked to a specific CVE.
/// </summary>
Task<IReadOnlyList<SbomVerdictLink>> GetSbomsByCveAsync(string cve, string tenantId, CancellationToken ct = default);
/// <summary>
/// Gets all SBOM versions with a specific verdict status.
/// </summary>
Task<IReadOnlyList<SbomVerdictLink>> GetSbomsByStatusAsync(string verdictStatus, string tenantId, int limit = 100, CancellationToken ct = default);
/// <summary>
/// Updates a verdict link (e.g., when status changes).
/// </summary>
Task<bool> UpdateAsync(SbomVerdictLink link, CancellationToken ct = default);
/// <summary>
/// Deletes verdict links for an SBOM version.
/// </summary>
Task<int> DeleteForSbomAsync(Guid sbomVersionId, string tenantId, CancellationToken ct = default);
/// <summary>
/// Checks if a link exists.
/// </summary>
Task<bool> ExistsAsync(Guid sbomVersionId, string cve, string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Link between an SBOM version and a VEX consensus verdict.
/// </summary>
public sealed record SbomVerdictLink
{
/// <summary>
/// Unique link identifier.
/// </summary>
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// SBOM version identifier.
/// </summary>
public required Guid SbomVersionId { get; init; }
/// <summary>
/// CVE identifier.
/// </summary>
public required string Cve { get; init; }
/// <summary>
/// VEX consensus projection identifier.
/// </summary>
public Guid? ConsensusProjectionId { get; init; }
/// <summary>
/// Verdict status (affected, not_affected, fixed, under_investigation).
/// </summary>
public required string VerdictStatus { get; init; }
/// <summary>
/// Confidence score from consensus (0-1).
/// </summary>
public double ConfidenceScore { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// When the link was created.
/// </summary>
public DateTimeOffset LinkedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// SBOM artifact digest for cross-reference.
/// </summary>
public string? ArtifactDigest { get; init; }
/// <summary>
/// Component PURL affected by this CVE.
/// </summary>
public string? ComponentPurl { get; init; }
/// <summary>
/// Severity from advisory.
/// </summary>
public string? Severity { get; init; }
/// <summary>
/// Whether reachability was confirmed.
/// </summary>
public bool? ReachabilityConfirmed { get; init; }
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.SbomService.Persistence</RootNamespace>
<AssemblyName>StellaOps.SbomService.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps SbomService module</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.SbomService\StellaOps.SbomService.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
</Project>

View File

@@ -2,11 +2,12 @@ using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using MicrosoftOptions = Microsoft.Extensions.Options;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Storage.Postgres.Repositories;
using StellaOps.SbomService.Persistence.Postgres.Repositories;
using StellaOps.SbomService.Persistence.Postgres;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Storage.Postgres.Tests;
namespace StellaOps.SbomService.Persistence.Tests;
[Collection(SbomServicePostgresCollection.Name)]
public sealed class PostgresEntrypointRepositoryTests : IAsyncLifetime

View File

@@ -2,11 +2,12 @@ using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using MicrosoftOptions = Microsoft.Extensions.Options;
using StellaOps.SbomService.Services;
using StellaOps.SbomService.Storage.Postgres.Repositories;
using StellaOps.SbomService.Persistence.Postgres.Repositories;
using StellaOps.SbomService.Persistence.Postgres;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Storage.Postgres.Tests;
namespace StellaOps.SbomService.Persistence.Tests;
[Collection(SbomServicePostgresCollection.Name)]
public sealed class PostgresOrchestratorControlRepositoryTests : IAsyncLifetime
@@ -102,6 +103,6 @@ public sealed class PostgresOrchestratorControlRepositoryTests : IAsyncLifetime
var states = await _repository.ListAsync(CancellationToken.None);
// Assert
states.Should().HaveCountGreaterOrEqualTo(2);
states.Should().HaveCountGreaterThanOrEqualTo(2);
}
}

View File

@@ -1,8 +1,9 @@
using System.Reflection;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.SbomService.Persistence.Postgres;
using Xunit;
namespace StellaOps.SbomService.Storage.Postgres.Tests;
namespace StellaOps.SbomService.Persistence.Tests;
/// <summary>
/// PostgreSQL integration test fixture for the SbomService module.

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.SbomService.Persistence.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.SbomService.Persistence\StellaOps.SbomService.Persistence.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>