Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.SbomService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62535;http://localhost:62537"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
@@ -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"/>.
|
||||
@@ -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"/>.
|
||||
@@ -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"/>.
|
||||
@@ -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"/>.
|
||||
@@ -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"/>.
|
||||
@@ -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"/>.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user