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

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

View File

@@ -8,8 +8,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -1,24 +0,0 @@
# Signals Storage Postgres Guild Charter
## Mission
Provide deterministic, offline-first PostgreSQL persistence for Signals, including call graph storage/projection, unknowns registry/scoring, and reachability facts needed by Scanner and Policy.
## Scope
- PostgreSQL schema owned by Signals (default schema: `signals`).
- Embedded SQL migrations under `Migrations/*.sql`, executed via `AddStartupMigrations`.
- Repository implementations under `Repositories/` (query + ingestion/sync).
## Required Reading
- `docs/modules/platform/architecture-overview.md`
- `docs/signals/reachability.md`
- `docs/signals/callgraph-formats.md`
- `docs/signals/runtime-facts.md`
- `docs/signals/unknowns-registry.md`
- Current sprint file: `docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md`
## Working Agreement
1. Update task state to `DOING`/`DONE` in both `/docs/implplan/SPRINT_*.md` and local `TASKS.md`.
2. Keep outputs deterministic: stable ordering, canonical JSON where applicable, UTC timestamps only.
3. Prefer additive, non-breaking startup migrations; avoid long-running data rewrites at startup.
4. Maintain offline posture: no network I/O, no external schema downloads.
5. Changes must be covered by tests (integration preferred for migrations + repositories).

View File

@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.Signals.Storage.Postgres</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Signals/StellaOps.Signals.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\V0000_001__extensions.sql" LogicalName="%(Filename)%(Extension)" />
<EmbeddedResource Include="Migrations\V3102_001__callgraph_relational_tables.sql" LogicalName="%(Filename)%(Extension)" />
</ItemGroup>
</Project>

View File

@@ -1,132 +1,351 @@

Microsoft Visual Studio Solution File, Format Version 12.00
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.Signals", "StellaOps.Signals\StellaOps.Signals.csproj", "{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{4933EA43-D891-4080-A644-5D14F680F6F1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{4B663300-18DB-44DA-95FB-7C2B02D7BF69}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{60D01EF6-9E65-447D-86DC-B140731B5513}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals.Tests", "__Tests\StellaOps.Signals.Tests\StellaOps.Signals.Tests.csproj", "{1AB74DBC-22F8-48B8-B921-2367FFD67866}"
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
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|x64.ActiveCfg = Debug|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|x64.Build.0 = Debug|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|x86.ActiveCfg = Debug|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|x86.Build.0 = Debug|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|Any CPU.Build.0 = Release|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|x64.ActiveCfg = Release|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|x64.Build.0 = Release|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|x86.ActiveCfg = Release|Any CPU
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|x86.Build.0 = Release|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|x64.ActiveCfg = Debug|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|x64.Build.0 = Debug|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|x86.ActiveCfg = Debug|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|x86.Build.0 = Debug|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|Any CPU.Build.0 = Release|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|x64.ActiveCfg = Release|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|x64.Build.0 = Release|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|x86.ActiveCfg = Release|Any CPU
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|x86.Build.0 = Release|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|x64.ActiveCfg = Debug|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|x64.Build.0 = Debug|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|x86.ActiveCfg = Debug|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|x86.Build.0 = Debug|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|Any CPU.Build.0 = Release|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|x64.ActiveCfg = Release|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|x64.Build.0 = Release|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|x86.ActiveCfg = Release|Any CPU
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|x86.Build.0 = Release|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|x64.ActiveCfg = Debug|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|x64.Build.0 = Debug|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|x86.ActiveCfg = Debug|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|x86.Build.0 = Debug|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|Any CPU.Build.0 = Release|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|x64.ActiveCfg = Release|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|x64.Build.0 = Release|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|x86.ActiveCfg = Release|Any CPU
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|x86.Build.0 = Release|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|Any CPU.Build.0 = Debug|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|x64.ActiveCfg = Debug|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|x64.Build.0 = Debug|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|x86.ActiveCfg = Debug|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|x86.Build.0 = Debug|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|Any CPU.ActiveCfg = Release|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|Any CPU.Build.0 = Release|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|x64.ActiveCfg = Release|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|x64.Build.0 = Release|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|x86.ActiveCfg = Release|Any CPU
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|x86.Build.0 = Release|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|x64.ActiveCfg = Debug|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|x64.Build.0 = Debug|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|x86.ActiveCfg = Debug|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|x86.Build.0 = Debug|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|Any CPU.Build.0 = Release|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|x64.ActiveCfg = Release|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|x64.Build.0 = Release|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|x86.ActiveCfg = Release|Any CPU
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|x86.Build.0 = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x64.ActiveCfg = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x64.Build.0 = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.ActiveCfg = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.Build.0 = Debug|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.Build.0 = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.ActiveCfg = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.Build.0 = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.ActiveCfg = Release|Any CPU
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.Build.0 = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x64.ActiveCfg = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x64.Build.0 = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x86.ActiveCfg = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x86.Build.0 = Debug|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|Any CPU.Build.0 = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x64.ActiveCfg = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x64.Build.0 = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x86.ActiveCfg = Release|Any CPU
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signals", "StellaOps.Signals", "{B0689D9E-3BDA-FABC-44A4-6923936DCDCD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signals.Scheduler", "StellaOps.Signals.Scheduler", "{659FE810-EE9F-BDCF-6E27-ED41900D2EB3}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}"
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.Auth.ServerIntegration", "StellaOps.Auth.ServerIntegration", "{7E890DF9-B715-B6DF-2498-FD74DDA87D71}"
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}") = "Router", "Router", "{FC018E5B-1E2F-DE19-1E97-0C845058C469}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1BE5B76C-B486-560B-6CB2-44C6537249AA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging", "StellaOps.Messaging", "{F4F1CBE2-1CDD-CAA4-41F0-266DB4677C05}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scheduler", "Scheduler", "{B24B448A-28D8-778E-DCC1-FCF4A0916DF5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{BF1AF1AB-97A8-BD70-63F2-E028DE8EE90F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Scheduler.Models", "StellaOps.Scheduler.Models", "{3DB6D7AE-8187-5324-1208-D6090D5324C6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Scheduler.Queue", "StellaOps.Scheduler.Queue", "{9C593CC5-AC88-E301-C4BB-3B1F8853C7AD}"
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.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.Signals.Persistence", "StellaOps.Signals.Persistence", "{BDB22A3D-689E-6CEA-2118-426BB4184A9C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signals.Persistence.Tests", "StellaOps.Signals.Persistence.Tests", "{3CD38B98-43EE-75A6-56B5-523053F0CAB0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signals.Tests", "StellaOps.Signals.Tests", "{CCF37C5D-2762-B793-EC2B-4B3AF6C6BFB6}"
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.Auth.ServerIntegration", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}"
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.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.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.Messaging", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{97998C88-E6E1-D5E2-B632-537B58E00CBF}"
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.Scheduler.Models", "E:\dev\git.stella-ops.org\src\Scheduler\__Libraries\StellaOps.Scheduler.Models\StellaOps.Scheduler.Models.csproj", "{1F372AB9-D8DD-D295-1D5E-CB5D454CBB24}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.Queue", "E:\dev\git.stella-ops.org\src\Scheduler\__Libraries\StellaOps.Scheduler.Queue\StellaOps.Scheduler.Queue.csproj", "{CB42DA2A-D081-A7B3-DE34-AC200FE30B6E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals", "StellaOps.Signals\StellaOps.Signals.csproj", "{A79CBC0C-5313-4ECF-A24E-27CE236BCF2C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals.Persistence", "__Libraries\StellaOps.Signals.Persistence\StellaOps.Signals.Persistence.csproj", "{EB1A9331-4A47-4C55-8189-C219B35E1B19}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals.Persistence.Tests", "__Tests\StellaOps.Signals.Persistence.Tests\StellaOps.Signals.Persistence.Tests.csproj", "{4D014382-FB30-131A-F8A7-A14DB59403B7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals.Scheduler", "StellaOps.Signals.Scheduler\StellaOps.Signals.Scheduler.csproj", "{B1872175-6B98-BD4B-7D14-4A5401DA78DD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals.Tests", "__Tests\StellaOps.Signals.Tests\StellaOps.Signals.Tests.csproj", "{01EE35B6-00AA-EA31-F2BB-D8C68525CB59}"
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
{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
{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.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
{92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Debug|Any CPU.Build.0 = Debug|Any CPU
{92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.ActiveCfg = Release|Any CPU
{92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.Build.0 = Release|Any CPU
{F664A948-E352-5808-E780-77A03F19E93E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F664A948-E352-5808-E780-77A03F19E93E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F664A948-E352-5808-E780-77A03F19E93E}.Release|Any CPU.ActiveCfg = Release|Any CPU

View File

@@ -875,6 +875,7 @@ signalsGroup.MapPost("/reachability/recompute", async Task<IResult> (
app.Run();
// Make Program class public for WebApplicationFactory<Program> test support
public partial class Program
{
internal static bool TryAuthorize(HttpContext httpContext, string requiredScope, bool fallbackAllowed, out IResult? failure)
@@ -935,3 +936,5 @@ public partial class Program
return false;
}
}
// Primary Program partial declaration merged above

View File

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

View File

@@ -208,10 +208,10 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
return new CallgraphIngestResponse(
document.Id,
document.Artifact.Path,
document.Artifact.Hash,
document.Artifact.CasUri,
document.GraphHash,
document.Artifact.ManifestCasUri,
document.Artifact.Hash ?? string.Empty,
document.Artifact.CasUri ?? string.Empty,
document.GraphHash ?? string.Empty,
document.Artifact.ManifestCasUri ?? string.Empty,
schemaVersion,
document.Nodes.Count,
document.Edges.Count,
@@ -288,7 +288,7 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
.Append(node.Flags).Append('|')
.Append(Join(node.Evidence)).Append('|')
.Append(JoinDict(node.Analyzer)).Append('|')
.Append(JoinDict(node.Attributes))
.Append(JoinDict(node.Attributes as IReadOnlyDictionary<string, string?>))
.AppendLine();
}

View File

@@ -405,7 +405,7 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
var map = new Dictionary<(string From, string To), EdgeGateInfo>();
foreach (var edge in document.Edges)
{
if (blocked.Contains((edge.SourceId, edge.TargetId)))
if (blocked.Contains((edge.SourceId ?? string.Empty, edge.TargetId ?? string.Empty)))
{
continue;
}

View File

@@ -10,13 +10,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,439 @@
// <copyright file="AirGapProbeLoader.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Signals.Ebpf.Probes;
using System.IO.Compression;
using System.Reflection;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
/// <summary>
/// Pre-compiled CO-RE probe loader for air-gapped environments.
/// </summary>
/// <remarks>
/// Probes are compiled during CI and embedded in the assembly or loaded
/// from a sealed probe bundle. This allows eBPF functionality without
/// requiring build tools or internet access at runtime.
/// </remarks>
public sealed class AirGapProbeLoader : IAirGapProbeLoader
{
private const string ProbeResourcePrefix = "StellaOps.Signals.Ebpf.Probes.Compiled.";
private const string ProbeBundleFileName = "ebpf-probes.bundle";
private const string ManifestFileName = "manifest.json";
private readonly ILogger<AirGapProbeLoader> _logger;
private readonly string _bundlePath;
private readonly AirGapProbeLoaderOptions _options;
private ProbeManifest? _manifest;
private bool _initialized;
public AirGapProbeLoader(
ILogger<AirGapProbeLoader> logger,
AirGapProbeLoaderOptions? options = null)
{
_logger = logger;
_options = options ?? new AirGapProbeLoaderOptions();
_bundlePath = _options.ProbeBundlePath ?? GetDefaultBundlePath();
}
/// <summary>
/// Initializes the probe loader by loading the manifest.
/// </summary>
public async Task InitializeAsync(CancellationToken ct = default)
{
if (_initialized)
{
return;
}
_logger.LogInformation("Initializing air-gap probe loader from {BundlePath}", _bundlePath);
// Try embedded resources first
var embeddedManifest = await TryLoadEmbeddedManifestAsync(ct);
if (embeddedManifest is not null)
{
_manifest = embeddedManifest;
_logger.LogInformation(
"Loaded {ProbeCount} probes from embedded resources",
_manifest.Probes.Count);
_initialized = true;
return;
}
// Try bundle file
if (File.Exists(_bundlePath))
{
_manifest = await LoadBundleManifestAsync(_bundlePath, ct);
_logger.LogInformation(
"Loaded {ProbeCount} probes from bundle at {BundlePath}",
_manifest.Probes.Count,
_bundlePath);
_initialized = true;
return;
}
// No probes available
_manifest = new ProbeManifest { Probes = [] };
_logger.LogWarning("No pre-compiled probes found. eBPF functionality will be limited.");
_initialized = true;
}
/// <summary>
/// Gets available probes for the current system.
/// </summary>
public IReadOnlyList<ProbeInfo> GetAvailableProbes()
{
EnsureInitialized();
var kernelVersion = GetKernelVersion();
return _manifest!.Probes
.Where(p => IsCompatible(p, kernelVersion))
.ToList();
}
/// <summary>
/// Loads a specific probe by name.
/// </summary>
public async Task<ProbeData> LoadProbeAsync(
string probeName,
CancellationToken ct = default)
{
EnsureInitialized();
var probeInfo = _manifest!.Probes
.FirstOrDefault(p => p.Name.Equals(probeName, StringComparison.OrdinalIgnoreCase));
if (probeInfo is null)
{
throw new InvalidOperationException($"Probe not found: {probeName}");
}
_logger.LogDebug("Loading probe {ProbeName} ({Size} bytes)", probeName, probeInfo.Size);
byte[] probeBytes;
// Try embedded resources
probeBytes = await TryLoadEmbeddedProbeAsync(probeInfo.ResourcePath, ct);
if (probeBytes.Length > 0)
{
return CreateProbeData(probeInfo, probeBytes);
}
// Try bundle file
probeBytes = await LoadFromBundleAsync(probeInfo.BundlePath, ct);
if (probeBytes.Length > 0)
{
return CreateProbeData(probeInfo, probeBytes);
}
throw new InvalidOperationException($"Could not load probe: {probeName}");
}
/// <summary>
/// Verifies the integrity of all probes.
/// </summary>
public async Task<ProbeVerificationResult> VerifyIntegrityAsync(CancellationToken ct = default)
{
EnsureInitialized();
var results = new List<(string Name, bool Valid, string? Error)>();
foreach (var probe in _manifest!.Probes)
{
try
{
var data = await LoadProbeAsync(probe.Name, ct);
var actualHash = ComputeHash(data.Bytes);
if (!actualHash.Equals(probe.Sha256Hash, StringComparison.OrdinalIgnoreCase))
{
results.Add((probe.Name, false, $"Hash mismatch: expected {probe.Sha256Hash}, got {actualHash}"));
}
else
{
results.Add((probe.Name, true, null));
}
}
catch (Exception ex)
{
results.Add((probe.Name, false, ex.Message));
}
}
return new ProbeVerificationResult
{
TotalProbes = results.Count,
ValidProbes = results.Count(r => r.Valid),
InvalidProbes = results.Where(r => !r.Valid).Select(r => (r.Name, r.Error!)).ToList(),
};
}
/// <summary>
/// Creates a probe bundle for offline deployment.
/// </summary>
public static async Task CreateBundleAsync(
string outputPath,
IEnumerable<(string Name, byte[] Data, ProbeInfo Info)> probes,
CancellationToken ct = default)
{
using var archive = ZipFile.Open(outputPath, ZipArchiveMode.Create);
var manifest = new ProbeManifest
{
Version = "1.0.0",
CreatedAt = DateTimeOffset.UtcNow,
Probes = [],
};
foreach (var (name, data, info) in probes)
{
var entryName = $"probes/{name}.bpf.o";
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
await using var stream = entry.Open();
await stream.WriteAsync(data, ct);
manifest.Probes.Add(info with
{
BundlePath = entryName,
Sha256Hash = ComputeHash(data),
});
}
// Write manifest
var manifestEntry = archive.CreateEntry(ManifestFileName, CompressionLevel.Optimal);
await using var manifestStream = manifestEntry.Open();
await System.Text.Json.JsonSerializer.SerializeAsync(manifestStream, manifest, cancellationToken: ct);
}
private void EnsureInitialized()
{
if (!_initialized)
{
throw new InvalidOperationException("Probe loader not initialized. Call InitializeAsync first.");
}
}
private async Task<ProbeManifest?> TryLoadEmbeddedManifestAsync(CancellationToken ct)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"{ProbeResourcePrefix}manifest.json";
await using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
return null;
}
return await System.Text.Json.JsonSerializer.DeserializeAsync<ProbeManifest>(stream, cancellationToken: ct);
}
private async Task<ProbeManifest> LoadBundleManifestAsync(string bundlePath, CancellationToken ct)
{
using var archive = ZipFile.OpenRead(bundlePath);
var manifestEntry = archive.GetEntry(ManifestFileName);
if (manifestEntry is null)
{
throw new InvalidOperationException($"Bundle missing manifest: {bundlePath}");
}
await using var stream = manifestEntry.Open();
return await System.Text.Json.JsonSerializer.DeserializeAsync<ProbeManifest>(stream, cancellationToken: ct)
?? throw new InvalidOperationException("Invalid manifest");
}
private async Task<byte[]> TryLoadEmbeddedProbeAsync(string? resourcePath, CancellationToken ct)
{
if (string.IsNullOrEmpty(resourcePath))
{
return [];
}
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"{ProbeResourcePrefix}{resourcePath}";
await using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
return [];
}
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream, ct);
return memoryStream.ToArray();
}
private async Task<byte[]> LoadFromBundleAsync(string? bundleEntryPath, CancellationToken ct)
{
if (string.IsNullOrEmpty(bundleEntryPath) || !File.Exists(_bundlePath))
{
return [];
}
using var archive = ZipFile.OpenRead(_bundlePath);
var entry = archive.GetEntry(bundleEntryPath);
if (entry is null)
{
return [];
}
await using var stream = entry.Open();
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream, ct);
return memoryStream.ToArray();
}
private static ProbeData CreateProbeData(ProbeInfo info, byte[] bytes)
{
return new ProbeData
{
Name = info.Name,
Bytes = bytes,
BtfRequired = info.BtfRequired,
MinKernelVersion = info.MinKernelVersion,
ProbeTypes = info.ProbeTypes,
};
}
private static string GetDefaultBundlePath()
{
var locations = new[]
{
Path.Combine(AppContext.BaseDirectory, ProbeBundleFileName),
Path.Combine("/usr/share/stellaops/probes", ProbeBundleFileName),
Path.Combine("/opt/stellaops/probes", ProbeBundleFileName),
};
return locations.FirstOrDefault(File.Exists) ?? locations[0];
}
private static bool IsCompatible(ProbeInfo probe, Version? kernelVersion)
{
if (kernelVersion is null)
{
return true; // Can't determine, assume compatible
}
if (!Version.TryParse(probe.MinKernelVersion, out var minVersion))
{
return true;
}
return kernelVersion >= minVersion;
}
private static Version? GetKernelVersion()
{
try
{
if (!File.Exists("/proc/version"))
{
return null;
}
var versionString = File.ReadAllText("/proc/version");
// Extract version like "5.15.0-generic"
var match = System.Text.RegularExpressions.Regex.Match(
versionString,
@"(\d+\.\d+\.\d+)");
if (match.Success && Version.TryParse(match.Groups[1].Value, out var version))
{
return version;
}
}
catch
{
// Ignore errors
}
return null;
}
private static string ComputeHash(byte[] data)
{
var hashBytes = SHA256.HashData(data);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
/// <summary>
/// Interface for air-gap probe loading.
/// </summary>
public interface IAirGapProbeLoader
{
Task InitializeAsync(CancellationToken ct = default);
IReadOnlyList<ProbeInfo> GetAvailableProbes();
Task<ProbeData> LoadProbeAsync(string probeName, CancellationToken ct = default);
Task<ProbeVerificationResult> VerifyIntegrityAsync(CancellationToken ct = default);
}
/// <summary>
/// Options for air-gap probe loader.
/// </summary>
public sealed record AirGapProbeLoaderOptions
{
/// <summary>
/// Path to the probe bundle file.
/// </summary>
public string? ProbeBundlePath { get; init; }
/// <summary>
/// Whether to use embedded probes.
/// </summary>
public bool UseEmbeddedProbes { get; init; } = true;
}
/// <summary>
/// Manifest of available probes.
/// </summary>
public sealed record ProbeManifest
{
public string Version { get; init; } = "1.0.0";
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public IList<ProbeInfo> Probes { get; init; } = [];
}
/// <summary>
/// Information about a probe.
/// </summary>
public sealed record ProbeInfo
{
public required string Name { get; init; }
public required string Description { get; init; }
public required string MinKernelVersion { get; init; }
public required bool BtfRequired { get; init; }
public required IReadOnlyList<string> ProbeTypes { get; init; }
public required long Size { get; init; }
public required string Sha256Hash { get; init; }
public string? ResourcePath { get; init; }
public string? BundlePath { get; init; }
}
/// <summary>
/// Loaded probe data.
/// </summary>
public sealed record ProbeData
{
public required string Name { get; init; }
public required byte[] Bytes { get; init; }
public required bool BtfRequired { get; init; }
public required string MinKernelVersion { get; init; }
public required IReadOnlyList<string> ProbeTypes { get; init; }
}
/// <summary>
/// Result of probe verification.
/// </summary>
public sealed record ProbeVerificationResult
{
public required int TotalProbes { get; init; }
public required int ValidProbes { get; init; }
public required IReadOnlyList<(string Name, string Error)> InvalidProbes { get; init; }
public bool AllValid => ValidProbes == TotalProbes;
}

View File

@@ -0,0 +1,494 @@
// <copyright file="CoreProbeLoader.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Signals.Ebpf.Probes;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Ebpf.Services;
/// <summary>
/// CO-RE (Compile Once, Run Everywhere) eBPF probe loader.
/// </summary>
/// <remarks>
/// Uses pre-compiled BTF-enabled probes that work across kernel versions.
/// Probes are embedded as resources and loaded at runtime.
/// </remarks>
public sealed class CoreProbeLoader : IEbpfProbeLoader
{
private const string FunctionTracerProbe = "function_tracer.bpf.o";
private readonly ILogger<CoreProbeLoader> _logger;
private readonly ISymbolResolver _symbolResolver;
private readonly string _probeDirectory;
private readonly Dictionary<Guid, ProbeSession> _sessions;
private readonly object _sessionsLock = new();
public CoreProbeLoader(
ILogger<CoreProbeLoader> logger,
ISymbolResolver symbolResolver,
string? probeDirectory = null)
{
_logger = logger;
_symbolResolver = symbolResolver;
_probeDirectory = probeDirectory ?? GetDefaultProbeDirectory();
_sessions = [];
}
/// <inheritdoc />
public async Task<EbpfProbeHandle> LoadAndAttachAsync(
string containerId,
RuntimeSignalOptions options,
CancellationToken ct = default)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
throw new PlatformNotSupportedException("eBPF probes require Linux.");
}
var probeId = Guid.NewGuid();
_logger.LogInformation(
"Loading CO-RE probes for container {ContainerId}, probe session {ProbeId}",
containerId,
probeId);
// Get PIDs for the container
var pids = await GetContainerPidsAsync(containerId, ct);
if (pids.Count == 0)
{
throw new InvalidOperationException(
$"No processes found for container {containerId}");
}
// Load the probe object file
var probePath = Path.Combine(_probeDirectory, FunctionTracerProbe);
if (!File.Exists(probePath))
{
throw new FileNotFoundException(
$"eBPF probe object not found: {probePath}",
probePath);
}
// In real implementation, this would:
// 1. Load BPF object using libbpf
// 2. Create ring buffer map
// 3. Attach uprobes to target functions
// For this implementation, we simulate the loading
var session = new ProbeSession
{
ProbeId = probeId,
ContainerId = containerId,
TracedPids = pids,
RingBuffer = new SimulatedRingBuffer(options.RingBufferSize),
Options = options,
AttachedAt = DateTimeOffset.UtcNow,
};
lock (_sessionsLock)
{
_sessions[probeId] = session;
}
_logger.LogInformation(
"Attached probes to {PidCount} processes in container {ContainerId}",
pids.Count,
containerId);
return new EbpfProbeHandle
{
ProbeId = probeId,
ContainerId = containerId,
ProbeFds = [], // Would contain actual FDs in real impl
RingBufferFd = 0,
MapFds = new Dictionary<string, int>(),
TracedPids = pids,
};
}
/// <inheritdoc />
public Task DetachAsync(EbpfProbeHandle handle, CancellationToken ct = default)
{
_logger.LogInformation(
"Detaching probes for session {ProbeId}",
handle.ProbeId);
lock (_sessionsLock)
{
if (_sessions.Remove(handle.ProbeId, out var session))
{
session.RingBuffer.Dispose();
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async IAsyncEnumerable<ReadOnlyMemory<byte>> ReadEventsAsync(
EbpfProbeHandle handle,
[EnumeratorCancellation] CancellationToken ct = default)
{
ProbeSession? session;
lock (_sessionsLock)
{
if (!_sessions.TryGetValue(handle.ProbeId, out session))
{
yield break;
}
}
// Read events from ring buffer
await foreach (var evt in session.RingBuffer.ReadAsync(ct))
{
yield return evt;
}
}
/// <inheritdoc />
public (string? Symbol, string? Library, string? Purl) ResolveSymbol(int pid, ulong address)
{
return _symbolResolver.Resolve(pid, address);
}
/// <inheritdoc />
public double GetBufferUtilization(EbpfProbeHandle handle)
{
lock (_sessionsLock)
{
if (_sessions.TryGetValue(handle.ProbeId, out var session))
{
return session.RingBuffer.Utilization;
}
}
return 0;
}
/// <inheritdoc />
public double GetCpuOverhead(EbpfProbeHandle handle)
{
// In real implementation, would read from BPF stats
return 0.1; // Simulated 0.1% overhead
}
/// <inheritdoc />
public long GetMemoryUsage(EbpfProbeHandle handle)
{
lock (_sessionsLock)
{
if (_sessions.TryGetValue(handle.ProbeId, out var session))
{
return session.RingBuffer.Size;
}
}
return 0;
}
/// <inheritdoc />
public IReadOnlyList<ProbeType> GetSupportedProbeTypes()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return [];
}
var types = new List<ProbeType> { ProbeType.Uprobe, ProbeType.Uretprobe };
// Check for USDT support
if (File.Exists("/sys/kernel/debug/tracing/uprobe_events"))
{
types.Add(ProbeType.Usdt);
}
return types;
}
private static string GetDefaultProbeDirectory()
{
// Look for probes in standard locations
var locations = new[]
{
"/usr/share/stellaops/probes",
"/opt/stellaops/probes",
Path.Combine(AppContext.BaseDirectory, "probes"),
};
foreach (var loc in locations)
{
if (Directory.Exists(loc))
{
return loc;
}
}
return locations[^1];
}
private static async Task<IReadOnlyList<int>> GetContainerPidsAsync(
string containerId,
CancellationToken ct)
{
// Try to find PIDs from cgroup
var cgroupPath = $"/sys/fs/cgroup/system.slice/docker-{containerId}.scope/cgroup.procs";
if (!File.Exists(cgroupPath))
{
// Try containerd path
cgroupPath = $"/sys/fs/cgroup/system.slice/containerd-{containerId}.scope/cgroup.procs";
}
if (!File.Exists(cgroupPath))
{
// Fall back to /proc scanning
return await ScanProcForContainerAsync(containerId, ct);
}
var pids = new List<int>();
await foreach (var line in File.ReadLinesAsync(cgroupPath, ct))
{
if (int.TryParse(line, out var pid))
{
pids.Add(pid);
}
}
return pids;
}
private static async Task<IReadOnlyList<int>> ScanProcForContainerAsync(
string containerId,
CancellationToken ct)
{
var pids = new List<int>();
if (!Directory.Exists("/proc"))
{
return pids;
}
foreach (var dir in Directory.GetDirectories("/proc"))
{
var name = Path.GetFileName(dir);
if (!int.TryParse(name, out var pid))
{
continue;
}
var cgroupFile = Path.Combine(dir, "cgroup");
if (!File.Exists(cgroupFile))
{
continue;
}
try
{
var content = await File.ReadAllTextAsync(cgroupFile, ct);
if (content.Contains(containerId, StringComparison.OrdinalIgnoreCase))
{
pids.Add(pid);
}
}
catch
{
// Process may have exited
}
}
return pids;
}
private sealed class ProbeSession
{
public required Guid ProbeId { get; init; }
public required string ContainerId { get; init; }
public required IReadOnlyList<int> TracedPids { get; init; }
public required SimulatedRingBuffer RingBuffer { get; init; }
public required RuntimeSignalOptions Options { get; init; }
public required DateTimeOffset AttachedAt { get; init; }
}
/// <summary>
/// Simulated ring buffer for development/testing.
/// In production, this would be backed by actual BPF ring buffer.
/// </summary>
private sealed class SimulatedRingBuffer : IDisposable
{
private readonly int _size;
private readonly System.Threading.Channels.Channel<ReadOnlyMemory<byte>> _channel;
private bool _disposed;
public SimulatedRingBuffer(int size)
{
_size = size;
_channel = System.Threading.Channels.Channel.CreateBounded<ReadOnlyMemory<byte>>(
new System.Threading.Channels.BoundedChannelOptions(1000)
{
FullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest,
});
}
public int Size => _size;
public double Utilization => 0.1; // Simulated
public async IAsyncEnumerable<ReadOnlyMemory<byte>> ReadAsync(
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var item in _channel.Reader.ReadAllAsync(ct))
{
yield return item;
}
}
public void Dispose()
{
if (!_disposed)
{
_channel.Writer.TryComplete();
_disposed = true;
}
}
}
}
/// <summary>
/// Symbol resolver interface.
/// </summary>
public interface ISymbolResolver
{
/// <summary>
/// Resolves an address to symbol information.
/// </summary>
(string? Symbol, string? Library, string? Purl) Resolve(int pid, ulong address);
}
/// <summary>
/// ELF symbol resolver using /proc/pid/maps and symbol tables.
/// </summary>
public sealed class ElfSymbolResolver : ISymbolResolver
{
private readonly ILogger<ElfSymbolResolver> _logger;
private readonly Dictionary<int, ProcessMaps> _processCache;
private readonly object _cacheLock = new();
public ElfSymbolResolver(ILogger<ElfSymbolResolver> logger)
{
_logger = logger;
_processCache = [];
}
public (string? Symbol, string? Library, string? Purl) Resolve(int pid, ulong address)
{
try
{
var maps = GetProcessMaps(pid);
// Find the mapping containing this address
foreach (var mapping in maps.Mappings)
{
if (address >= mapping.StartAddress && address < mapping.EndAddress)
{
var offset = address - mapping.StartAddress + mapping.FileOffset;
// In real impl, would read ELF symbol table
return (null, mapping.Pathname, null);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to resolve symbol for PID {Pid} address {Address:X16}", pid, address);
}
return (null, null, null);
}
private ProcessMaps GetProcessMaps(int pid)
{
lock (_cacheLock)
{
if (_processCache.TryGetValue(pid, out var cached))
{
return cached;
}
}
var maps = ParseMaps(pid);
lock (_cacheLock)
{
_processCache[pid] = maps;
}
return maps;
}
private static ProcessMaps ParseMaps(int pid)
{
var mapsPath = $"/proc/{pid}/maps";
var mappings = new List<MemoryMapping>();
if (!File.Exists(mapsPath))
{
return new ProcessMaps { Mappings = mappings };
}
foreach (var line in File.ReadLines(mapsPath))
{
// Parse: address perms offset dev inode pathname
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 5)
{
continue;
}
var addrParts = parts[0].Split('-');
if (addrParts.Length != 2)
{
continue;
}
if (!ulong.TryParse(addrParts[0], System.Globalization.NumberStyles.HexNumber, null, out var start))
{
continue;
}
if (!ulong.TryParse(addrParts[1], System.Globalization.NumberStyles.HexNumber, null, out var end))
{
continue;
}
_ = ulong.TryParse(parts[2], System.Globalization.NumberStyles.HexNumber, null, out var offset);
var pathname = parts.Length > 5 ? parts[5] : null;
mappings.Add(new MemoryMapping
{
StartAddress = start,
EndAddress = end,
FileOffset = offset,
Pathname = pathname,
});
}
return new ProcessMaps { Mappings = mappings };
}
private sealed record ProcessMaps
{
public required IReadOnlyList<MemoryMapping> Mappings { get; init; }
}
private sealed record MemoryMapping
{
public required ulong StartAddress { get; init; }
public required ulong EndAddress { get; init; }
public required ulong FileOffset { get; init; }
public string? Pathname { get; init; }
}
}

View File

@@ -0,0 +1,106 @@
// <copyright file="IEbpfProbeLoader.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Signals.Ebpf.Probes;
using StellaOps.Signals.Ebpf.Services;
/// <summary>
/// Loads and manages eBPF probes for runtime signal collection.
/// </summary>
public interface IEbpfProbeLoader
{
/// <summary>
/// Loads and attaches eBPF probes to a container's processes.
/// </summary>
/// <param name="containerId">Container ID to attach to.</param>
/// <param name="options">Collection options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Handle to the attached probes.</returns>
Task<EbpfProbeHandle> LoadAndAttachAsync(
string containerId,
RuntimeSignalOptions options,
CancellationToken ct = default);
/// <summary>
/// Detaches and unloads probes.
/// </summary>
/// <param name="handle">Probe handle.</param>
/// <param name="ct">Cancellation token.</param>
Task DetachAsync(EbpfProbeHandle handle, CancellationToken ct = default);
/// <summary>
/// Reads events from the probe ring buffer.
/// </summary>
/// <param name="handle">Probe handle.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Async enumerable of raw event data.</returns>
IAsyncEnumerable<ReadOnlyMemory<byte>> ReadEventsAsync(
EbpfProbeHandle handle,
CancellationToken ct = default);
/// <summary>
/// Resolves a function address to symbol, library, and PURL.
/// </summary>
/// <param name="pid">Process ID.</param>
/// <param name="address">Function address.</param>
/// <returns>Tuple of (symbol, library, purl) or nulls if unresolved.</returns>
(string? Symbol, string? Library, string? Purl) ResolveSymbol(int pid, ulong address);
/// <summary>
/// Gets ring buffer utilization as a percentage (0.0-1.0).
/// </summary>
double GetBufferUtilization(EbpfProbeHandle handle);
/// <summary>
/// Gets CPU overhead percentage from probes.
/// </summary>
double GetCpuOverhead(EbpfProbeHandle handle);
/// <summary>
/// Gets memory usage in bytes.
/// </summary>
long GetMemoryUsage(EbpfProbeHandle handle);
/// <summary>
/// Gets supported probe types on this system.
/// </summary>
IReadOnlyList<ProbeType> GetSupportedProbeTypes();
}
/// <summary>
/// Handle to loaded eBPF probes.
/// </summary>
public sealed record EbpfProbeHandle
{
/// <summary>
/// Unique probe session ID.
/// </summary>
public required Guid ProbeId { get; init; }
/// <summary>
/// Container ID probes are attached to.
/// </summary>
public required string ContainerId { get; init; }
/// <summary>
/// File descriptors for attached probes.
/// </summary>
internal IReadOnlyList<int> ProbeFds { get; init; } = Array.Empty<int>();
/// <summary>
/// Ring buffer file descriptor.
/// </summary>
internal int RingBufferFd { get; init; }
/// <summary>
/// Map file descriptors.
/// </summary>
internal IReadOnlyDictionary<string, int> MapFds { get; init; } = new Dictionary<string, int>();
/// <summary>
/// Process IDs being traced.
/// </summary>
public required IReadOnlyList<int> TracedPids { get; init; }
}

View File

@@ -0,0 +1,231 @@
// <copyright file="RuntimeCallEvent.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Signals.Ebpf.Schema;
using System.Text.Json.Serialization;
/// <summary>
/// Event emitted when a function call is observed via eBPF.
/// </summary>
/// <remarks>
/// This record is the deserialized form of events from the eBPF ring buffer.
/// The schema is designed for efficient serialization and deterministic ordering.
/// </remarks>
public sealed record RuntimeCallEvent
{
/// <summary>
/// Unique event identifier.
/// </summary>
public required Guid EventId { get; init; }
/// <summary>
/// Container ID where the call was observed.
/// </summary>
public required string ContainerId { get; init; }
/// <summary>
/// Process ID within the container.
/// </summary>
public required int Pid { get; init; }
/// <summary>
/// Thread ID.
/// </summary>
public required int Tid { get; init; }
/// <summary>
/// Timestamp in nanoseconds since boot.
/// </summary>
public required ulong TimestampNs { get; init; }
/// <summary>
/// Called function symbol name (if resolved).
/// </summary>
public string? Symbol { get; init; }
/// <summary>
/// Called function address.
/// </summary>
public required ulong FunctionAddress { get; init; }
/// <summary>
/// Call stack (addresses from bottom to top).
/// </summary>
public required IReadOnlyList<ulong> StackTrace { get; init; }
/// <summary>
/// Runtime type (native, jvm, node, python, dotnet, go).
/// </summary>
public required RuntimeType RuntimeType { get; init; }
/// <summary>
/// Library/module containing the function.
/// </summary>
public string? Library { get; init; }
/// <summary>
/// Package URL if resolvable.
/// </summary>
public string? Purl { get; init; }
/// <summary>
/// UTC timestamp when this event was received by the collector.
/// </summary>
public DateTimeOffset ReceivedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Runtime type detected from process characteristics.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum RuntimeType
{
/// <summary>Native binary (ELF/PE/Mach-O).</summary>
Native = 0,
/// <summary>Java Virtual Machine.</summary>
Jvm = 1,
/// <summary>Node.js / V8.</summary>
Node = 2,
/// <summary>Python interpreter.</summary>
Python = 3,
/// <summary>.NET runtime (CoreCLR).</summary>
DotNet = 4,
/// <summary>Go runtime.</summary>
Go = 5,
/// <summary>Ruby interpreter.</summary>
Ruby = 6,
/// <summary>Unknown or unidentified runtime.</summary>
Unknown = 255,
}
/// <summary>
/// Observed call path from runtime signals.
/// </summary>
public sealed record ObservedCallPath
{
/// <summary>
/// Symbols in the call path (entry point to vulnerable function).
/// </summary>
public required IReadOnlyList<string> Symbols { get; init; }
/// <summary>
/// Number of times this path was observed.
/// </summary>
public required int ObservationCount { get; init; }
/// <summary>
/// Package URL if resolvable.
/// </summary>
public string? Purl { get; init; }
/// <summary>
/// Runtime type where this path was observed.
/// </summary>
public RuntimeType RuntimeType { get; init; } = RuntimeType.Unknown;
/// <summary>
/// First observation timestamp.
/// </summary>
public DateTimeOffset FirstObservedAt { get; init; }
/// <summary>
/// Last observation timestamp.
/// </summary>
public DateTimeOffset LastObservedAt { get; init; }
}
/// <summary>
/// Summary of runtime signals collected for a container.
/// </summary>
public sealed record RuntimeSignalSummary
{
/// <summary>
/// Container ID.
/// </summary>
public required string ContainerId { get; init; }
/// <summary>
/// When signal collection started.
/// </summary>
public required DateTimeOffset StartedAt { get; init; }
/// <summary>
/// When signal collection stopped.
/// </summary>
public required DateTimeOffset StoppedAt { get; init; }
/// <summary>
/// Total events captured.
/// </summary>
public required long TotalEvents { get; init; }
/// <summary>
/// Aggregated call paths.
/// </summary>
public required IReadOnlyList<ObservedCallPath> CallPaths { get; init; }
/// <summary>
/// Unique symbols observed.
/// </summary>
public required IReadOnlyList<string> ObservedSymbols { get; init; }
/// <summary>
/// Events that were dropped due to rate limiting.
/// </summary>
public long DroppedEvents { get; init; }
/// <summary>
/// Runtime types detected in this container.
/// </summary>
public IReadOnlyList<RuntimeType> DetectedRuntimes { get; init; } = [];
}
/// <summary>
/// Statistics about signal collection.
/// </summary>
public sealed record SignalStatistics
{
/// <summary>
/// Total events received.
/// </summary>
public required long TotalEvents { get; init; }
/// <summary>
/// Events per second (current rate).
/// </summary>
public required double EventsPerSecond { get; init; }
/// <summary>
/// Unique call paths observed.
/// </summary>
public required int UniqueCallPaths { get; init; }
/// <summary>
/// Ring buffer utilization percentage.
/// </summary>
public required double BufferUtilization { get; init; }
/// <summary>
/// Events dropped due to rate limiting.
/// </summary>
public required long DroppedEvents { get; init; }
/// <summary>
/// CPU overhead percentage from eBPF probes.
/// </summary>
public double CpuOverheadPercent { get; init; }
/// <summary>
/// Memory usage in bytes for signal collection.
/// </summary>
public long MemoryUsageBytes { get; init; }
}

View File

@@ -0,0 +1,152 @@
// <copyright file="IRuntimeSignalCollector.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Signals.Ebpf.Services;
using StellaOps.Signals.Ebpf.Schema;
/// <summary>
/// Collects runtime function call signals via eBPF probes.
/// </summary>
public interface IRuntimeSignalCollector
{
/// <summary>
/// Starts collecting runtime signals for a container.
/// </summary>
/// <param name="containerId">Container ID to attach probes to.</param>
/// <param name="options">Collection options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Handle to the collection session.</returns>
Task<SignalCollectionHandle> StartCollectionAsync(
string containerId,
RuntimeSignalOptions options,
CancellationToken ct = default);
/// <summary>
/// Stops collection and returns aggregated signals.
/// </summary>
/// <param name="handle">Collection handle from <see cref="StartCollectionAsync"/>.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Summary of collected signals.</returns>
Task<RuntimeSignalSummary> StopCollectionAsync(
SignalCollectionHandle handle,
CancellationToken ct = default);
/// <summary>
/// Gets current signal statistics without stopping collection.
/// </summary>
/// <param name="handle">Collection handle.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Current statistics.</returns>
Task<SignalStatistics> GetStatisticsAsync(
SignalCollectionHandle handle,
CancellationToken ct = default);
/// <summary>
/// Checks if eBPF is supported on the current system.
/// </summary>
/// <returns>True if eBPF probes can be loaded.</returns>
bool IsSupported();
/// <summary>
/// Gets available probe types on this system.
/// </summary>
/// <returns>List of supported probe types.</returns>
IReadOnlyList<ProbeType> GetSupportedProbeTypes();
}
/// <summary>
/// Handle to an active signal collection session.
/// </summary>
public sealed record SignalCollectionHandle
{
/// <summary>
/// Unique session identifier.
/// </summary>
public required Guid SessionId { get; init; }
/// <summary>
/// Container ID being monitored.
/// </summary>
public required string ContainerId { get; init; }
/// <summary>
/// When collection started.
/// </summary>
public required DateTimeOffset StartedAt { get; init; }
/// <summary>
/// Options used for this session.
/// </summary>
public required RuntimeSignalOptions Options { get; init; }
}
/// <summary>
/// Options for runtime signal collection.
/// </summary>
public sealed record RuntimeSignalOptions
{
/// <summary>
/// Target functions to trace (by symbol pattern).
/// Empty list means trace all functions (not recommended in production).
/// </summary>
public IReadOnlyList<string> TargetSymbols { get; init; } = [];
/// <summary>
/// Maximum events per second (rate limiting).
/// </summary>
public int MaxEventsPerSecond { get; init; } = 10000;
/// <summary>
/// Collection duration limit. Null means unlimited.
/// </summary>
public TimeSpan? MaxDuration { get; init; }
/// <summary>
/// Runtime types to instrument.
/// </summary>
public IReadOnlyList<RuntimeType> RuntimeTypes { get; init; } =
[RuntimeType.Native, RuntimeType.Node, RuntimeType.Python];
/// <summary>
/// Whether to resolve symbols from addresses.
/// </summary>
public bool ResolveSymbols { get; init; } = true;
/// <summary>
/// Maximum stack depth to capture.
/// </summary>
public int MaxStackDepth { get; init; } = 16;
/// <summary>
/// Ring buffer size in bytes.
/// </summary>
public int RingBufferSize { get; init; } = 256 * 1024;
/// <summary>
/// Sample rate (1 = every call, 10 = every 10th call).
/// </summary>
public int SampleRate { get; init; } = 1;
}
/// <summary>
/// Types of eBPF probes available.
/// </summary>
public enum ProbeType
{
/// <summary>User-space function entry probe.</summary>
Uprobe,
/// <summary>User-space function return probe.</summary>
Uretprobe,
/// <summary>User-space static tracepoint.</summary>
Usdt,
/// <summary>Kernel tracepoint.</summary>
Tracepoint,
/// <summary>Kernel kprobe.</summary>
Kprobe,
}

View File

@@ -0,0 +1,475 @@
// <copyright file="RuntimeSignalCollector.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Signals.Ebpf.Services;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Ebpf.Probes;
using StellaOps.Signals.Ebpf.Schema;
/// <summary>
/// Collects runtime function call signals via eBPF probes.
/// </summary>
/// <remarks>
/// This implementation uses the CO-RE (Compile Once, Run Everywhere) model
/// to load pre-compiled eBPF probes that work across different kernel versions.
/// </remarks>
public sealed class RuntimeSignalCollector : IRuntimeSignalCollector, IDisposable
{
private readonly ILogger<RuntimeSignalCollector> _logger;
private readonly IEbpfProbeLoader _probeLoader;
private readonly ConcurrentDictionary<Guid, CollectionSession> _activeSessions;
private readonly bool _isSupported;
private bool _disposed;
public RuntimeSignalCollector(
ILogger<RuntimeSignalCollector> logger,
IEbpfProbeLoader probeLoader)
{
_logger = logger;
_probeLoader = probeLoader;
_activeSessions = new ConcurrentDictionary<Guid, CollectionSession>();
_isSupported = CheckEbpfSupport();
}
/// <inheritdoc />
public async Task<SignalCollectionHandle> StartCollectionAsync(
string containerId,
RuntimeSignalOptions options,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(containerId);
ArgumentNullException.ThrowIfNull(options);
if (!_isSupported)
{
throw new PlatformNotSupportedException(
"eBPF is not supported on this platform. Linux 5.8+ with BTF enabled is required.");
}
_logger.LogInformation(
"Starting signal collection for container {ContainerId} with {SymbolCount} target symbols",
containerId,
options.TargetSymbols.Count);
var sessionId = Guid.NewGuid();
var startedAt = DateTimeOffset.UtcNow;
// Load and attach eBPF probes
var probeHandle = await _probeLoader.LoadAndAttachAsync(
containerId,
options,
ct);
var session = new CollectionSession
{
SessionId = sessionId,
ContainerId = containerId,
StartedAt = startedAt,
Options = options,
ProbeHandle = probeHandle,
Events = new ConcurrentQueue<RuntimeCallEvent>(),
TotalEvents = 0,
DroppedEvents = 0,
};
// Start event processing
session.ProcessingCts = new CancellationTokenSource();
session.ProcessingTask = ProcessEventsAsync(session, session.ProcessingCts.Token);
if (!_activeSessions.TryAdd(sessionId, session))
{
await _probeLoader.DetachAsync(probeHandle, ct);
throw new InvalidOperationException("Failed to register collection session.");
}
_logger.LogInformation(
"Signal collection started for container {ContainerId}, session {SessionId}",
containerId,
sessionId);
return new SignalCollectionHandle
{
SessionId = sessionId,
ContainerId = containerId,
StartedAt = startedAt,
Options = options,
};
}
/// <inheritdoc />
public async Task<RuntimeSignalSummary> StopCollectionAsync(
SignalCollectionHandle handle,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(handle);
if (!_activeSessions.TryRemove(handle.SessionId, out var session))
{
throw new InvalidOperationException(
$"No active session found for {handle.SessionId}");
}
_logger.LogInformation(
"Stopping signal collection for session {SessionId}",
handle.SessionId);
// Stop event processing
await session.ProcessingCts.CancelAsync();
try
{
await session.ProcessingTask.WaitAsync(TimeSpan.FromSeconds(5), ct);
}
catch (OperationCanceledException)
{
// Expected
}
catch (TimeoutException)
{
_logger.LogWarning("Event processing did not stop gracefully");
}
// Detach probes
await _probeLoader.DetachAsync(session.ProbeHandle, ct);
var stoppedAt = DateTimeOffset.UtcNow;
// Aggregate call paths
var callPaths = AggregateCallPaths(session.Events);
var observedSymbols = ExtractUniqueSymbols(session.Events);
var detectedRuntimes = DetectRuntimes(session.Events);
session.ProcessingCts.Dispose();
_logger.LogInformation(
"Signal collection stopped for session {SessionId}: {EventCount} events, {CallPathCount} call paths",
handle.SessionId,
session.TotalEvents,
callPaths.Count);
return new RuntimeSignalSummary
{
ContainerId = handle.ContainerId,
StartedAt = session.StartedAt,
StoppedAt = stoppedAt,
TotalEvents = session.TotalEvents,
CallPaths = callPaths,
ObservedSymbols = observedSymbols,
DroppedEvents = session.DroppedEvents,
DetectedRuntimes = detectedRuntimes,
};
}
/// <inheritdoc />
public Task<SignalStatistics> GetStatisticsAsync(
SignalCollectionHandle handle,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(handle);
if (!_activeSessions.TryGetValue(handle.SessionId, out var session))
{
throw new InvalidOperationException(
$"No active session found for {handle.SessionId}");
}
var elapsed = DateTimeOffset.UtcNow - session.StartedAt;
var eventsPerSecond = elapsed.TotalSeconds > 0
? session.TotalEvents / elapsed.TotalSeconds
: 0;
var uniqueCallPaths = CountUniqueCallPaths(session.Events);
var bufferUtilization = _probeLoader.GetBufferUtilization(session.ProbeHandle);
return Task.FromResult(new SignalStatistics
{
TotalEvents = session.TotalEvents,
EventsPerSecond = eventsPerSecond,
UniqueCallPaths = uniqueCallPaths,
BufferUtilization = bufferUtilization,
DroppedEvents = session.DroppedEvents,
CpuOverheadPercent = _probeLoader.GetCpuOverhead(session.ProbeHandle),
MemoryUsageBytes = _probeLoader.GetMemoryUsage(session.ProbeHandle),
});
}
/// <inheritdoc />
public bool IsSupported() => _isSupported;
/// <inheritdoc />
public IReadOnlyList<ProbeType> GetSupportedProbeTypes()
{
if (!_isSupported)
{
return [];
}
return _probeLoader.GetSupportedProbeTypes();
}
public void Dispose()
{
if (_disposed)
{
return;
}
foreach (var (sessionId, session) in _activeSessions)
{
try
{
session.ProcessingCts.Cancel();
session.ProcessingCts.Dispose();
_probeLoader.DetachAsync(session.ProbeHandle, CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error disposing session {SessionId}", sessionId);
}
}
_activeSessions.Clear();
_disposed = true;
}
private static bool CheckEbpfSupport()
{
// eBPF is only supported on Linux 5.8+ with BTF
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return false;
}
// Check for BTF support by looking for /sys/kernel/btf/vmlinux
return File.Exists("/sys/kernel/btf/vmlinux");
}
private async Task ProcessEventsAsync(CollectionSession session, CancellationToken ct)
{
var rateLimiter = new RateLimiter(session.Options.MaxEventsPerSecond);
await foreach (var rawEvent in _probeLoader.ReadEventsAsync(session.ProbeHandle, ct))
{
if (ct.IsCancellationRequested)
{
break;
}
if (!rateLimiter.TryAcquire())
{
Interlocked.Increment(ref session.DroppedEvents);
continue;
}
var callEvent = ParseEvent(rawEvent, session.ContainerId, session.Options);
if (callEvent is not null)
{
session.Events.Enqueue(callEvent);
Interlocked.Increment(ref session.TotalEvents);
// Limit memory usage by trimming old events
while (session.Events.Count > 100_000 && session.Events.TryDequeue(out _))
{
}
}
}
}
private RuntimeCallEvent? ParseEvent(
ReadOnlyMemory<byte> rawEvent,
string containerId,
RuntimeSignalOptions options)
{
// Parse the binary event from eBPF ring buffer
// This is a simplified implementation - real impl would use MemoryMarshal
if (rawEvent.Length < 32)
{
return null;
}
var span = rawEvent.Span;
var timestampNs = BitConverter.ToUInt64(span[..8]);
var pid = BitConverter.ToInt32(span[8..12]);
var tid = BitConverter.ToInt32(span[12..16]);
var functionAddress = BitConverter.ToUInt64(span[16..24]);
var stackDepth = BitConverter.ToInt32(span[24..28]);
var runtimeType = (RuntimeType)span[28];
// Parse stack trace
var stackTrace = new List<ulong>(Math.Min(stackDepth, options.MaxStackDepth));
var stackOffset = 32;
for (var i = 0; i < stackDepth && i < options.MaxStackDepth; i++)
{
if (stackOffset + 8 > rawEvent.Length)
{
break;
}
stackTrace.Add(BitConverter.ToUInt64(span[stackOffset..(stackOffset + 8)]));
stackOffset += 8;
}
// Resolve symbol if enabled
string? symbol = null;
string? library = null;
string? purl = null;
if (options.ResolveSymbols)
{
(symbol, library, purl) = _probeLoader.ResolveSymbol(
pid, functionAddress);
}
return new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
ContainerId = containerId,
Pid = pid,
Tid = tid,
TimestampNs = timestampNs,
Symbol = symbol,
FunctionAddress = functionAddress,
StackTrace = stackTrace,
RuntimeType = runtimeType,
Library = library,
Purl = purl,
ReceivedAt = DateTimeOffset.UtcNow,
};
}
private static IReadOnlyList<ObservedCallPath> AggregateCallPaths(
ConcurrentQueue<RuntimeCallEvent> events)
{
var pathCounts = new Dictionary<string, (List<string> Symbols, int Count, string? Purl, RuntimeType Runtime, DateTimeOffset First, DateTimeOffset Last)>();
foreach (var evt in events)
{
if (evt.Symbol is null)
{
continue;
}
// Create path key from stack symbols
var symbols = evt.StackTrace
.Select(addr => addr.ToString("X16"))
.Prepend(evt.Symbol)
.ToList();
var pathKey = string.Join("->", symbols);
if (pathCounts.TryGetValue(pathKey, out var existing))
{
pathCounts[pathKey] = (
existing.Symbols,
existing.Count + 1,
existing.Purl ?? evt.Purl,
existing.Runtime,
existing.First,
evt.ReceivedAt);
}
else
{
pathCounts[pathKey] = (symbols, 1, evt.Purl, evt.RuntimeType, evt.ReceivedAt, evt.ReceivedAt);
}
}
return pathCounts.Values
.OrderByDescending(p => p.Count)
.Take(1000) // Limit to top 1000 paths
.Select(p => new ObservedCallPath
{
Symbols = p.Symbols,
ObservationCount = p.Count,
Purl = p.Purl,
RuntimeType = p.Runtime,
FirstObservedAt = p.First,
LastObservedAt = p.Last,
})
.ToList();
}
private static IReadOnlyList<string> ExtractUniqueSymbols(
ConcurrentQueue<RuntimeCallEvent> events)
{
return events
.Where(e => e.Symbol is not null)
.Select(e => e.Symbol!)
.Distinct()
.OrderBy(s => s, StringComparer.Ordinal)
.ToList();
}
private static IReadOnlyList<RuntimeType> DetectRuntimes(
ConcurrentQueue<RuntimeCallEvent> events)
{
return events
.Select(e => e.RuntimeType)
.Where(r => r != RuntimeType.Unknown)
.Distinct()
.OrderBy(r => r)
.ToList();
}
private static int CountUniqueCallPaths(ConcurrentQueue<RuntimeCallEvent> events)
{
var pathKeys = new HashSet<string>();
foreach (var evt in events)
{
if (evt.Symbol is null)
{
continue;
}
var pathKey = $"{evt.Symbol}->{string.Join("->", evt.StackTrace.Take(3))}";
pathKeys.Add(pathKey);
}
return pathKeys.Count;
}
private sealed class CollectionSession
{
public required Guid SessionId { get; init; }
public required string ContainerId { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public required RuntimeSignalOptions Options { get; init; }
public required EbpfProbeHandle ProbeHandle { get; init; }
public required ConcurrentQueue<RuntimeCallEvent> Events { get; init; }
public long TotalEvents;
public long DroppedEvents;
public CancellationTokenSource ProcessingCts { get; set; } = null!;
public Task ProcessingTask { get; set; } = null!;
}
private sealed class RateLimiter
{
private readonly int _maxPerSecond;
private long _count;
private long _windowStart;
public RateLimiter(int maxPerSecond)
{
_maxPerSecond = maxPerSecond;
_windowStart = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
public bool TryAcquire()
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now > Interlocked.Read(ref _windowStart))
{
Interlocked.Exchange(ref _windowStart, now);
Interlocked.Exchange(ref _count, 0);
}
return Interlocked.Increment(ref _count) <= _maxPerSecond;
}
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Signals.Ebpf</RootNamespace>
<AssemblyName>StellaOps.Signals.Ebpf</AssemblyName>
<Description>eBPF runtime signal collection for StellaOps</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

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

View File

@@ -0,0 +1,87 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Persistence.Postgres;
using StellaOps.Signals.Persistence.Postgres.Repositories;
using StellaOps.Signals.Services;
namespace StellaOps.Signals.Persistence.Extensions;
/// <summary>
/// Extension methods for configuring Signals persistence services.
/// </summary>
public static class SignalsPersistenceExtensions
{
/// <summary>
/// Adds Signals 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 AddSignalsPersistence(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:Signals")
{
services.Configure<PostgresOptions>(configuration.GetSection(sectionName));
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<SignalsDataSource>();
services.AddStartupMigrations(
SignalsDataSource.DefaultSchemaName,
"Signals.Persistence",
typeof(SignalsDataSource).Assembly);
// Register repositories
services.AddSingleton<ICallgraphRepository, PostgresCallgraphRepository>();
services.AddSingleton<IReachabilityFactRepository, PostgresReachabilityFactRepository>();
services.AddSingleton<IUnknownsRepository, PostgresUnknownsRepository>();
services.AddSingleton<IReachabilityStoreRepository, PostgresReachabilityStoreRepository>();
services.AddSingleton<IDeploymentRefsRepository, PostgresDeploymentRefsRepository>();
services.AddSingleton<IGraphMetricsRepository, PostgresGraphMetricsRepository>();
services.AddSingleton<ICallGraphQueryRepository, PostgresCallGraphQueryRepository>();
services.AddSingleton<ICallGraphProjectionRepository, PostgresCallGraphProjectionRepository>();
services.AddSingleton<ICallGraphSyncService, CallGraphSyncService>();
return services;
}
/// <summary>
/// Adds Signals 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 AddSignalsPersistence(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
services.Configure(configureOptions);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<SignalsDataSource>();
services.AddStartupMigrations(
SignalsDataSource.DefaultSchemaName,
"Signals.Persistence",
typeof(SignalsDataSource).Assembly);
// Register repositories
services.AddSingleton<ICallgraphRepository, PostgresCallgraphRepository>();
services.AddSingleton<IReachabilityFactRepository, PostgresReachabilityFactRepository>();
services.AddSingleton<IUnknownsRepository, PostgresUnknownsRepository>();
services.AddSingleton<IReachabilityStoreRepository, PostgresReachabilityStoreRepository>();
services.AddSingleton<IDeploymentRefsRepository, PostgresDeploymentRefsRepository>();
services.AddSingleton<IGraphMetricsRepository, PostgresGraphMetricsRepository>();
services.AddSingleton<ICallGraphQueryRepository, PostgresCallGraphQueryRepository>();
services.AddSingleton<ICallGraphProjectionRepository, PostgresCallGraphProjectionRepository>();
services.AddSingleton<ICallGraphSyncService, CallGraphSyncService>();
return services;
}
}

View File

@@ -0,0 +1,507 @@
-- Signals Schema Migration 001: Initial Schema (Compacted)
-- Consolidated from V0000_001__extensions.sql, V1102_001__unknowns_scoring_schema.sql,
-- V1105_001__deploy_refs_graph_metrics.sql, and V3102_001__callgraph_relational_tables.sql
-- for 1.0.0 release
-- Creates the signals schema for call graphs, reachability, unknowns scoring, and runtime facts
-- ============================================================================
-- Extensions
-- ============================================================================
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ============================================================================
-- Schema Creation
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS signals;
-- ============================================================================
-- Scan Tracking
-- ============================================================================
CREATE TABLE IF NOT EXISTS signals.scans (
scan_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
artifact_digest TEXT NOT NULL,
repo_uri TEXT,
commit_sha TEXT,
sbom_digest TEXT,
policy_digest TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
error_message TEXT,
CONSTRAINT scans_artifact_sbom_unique UNIQUE (artifact_digest, sbom_digest)
);
CREATE INDEX IF NOT EXISTS idx_scans_status ON signals.scans(status);
CREATE INDEX IF NOT EXISTS idx_scans_artifact ON signals.scans(artifact_digest);
CREATE INDEX IF NOT EXISTS idx_scans_commit ON signals.scans(commit_sha) WHERE commit_sha IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_scans_created ON signals.scans(created_at DESC);
COMMENT ON TABLE signals.scans IS 'Tracks scan context for call graph analysis';
-- ============================================================================
-- Artifacts
-- ============================================================================
CREATE TABLE IF NOT EXISTS signals.artifacts (
artifact_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scan_id UUID NOT NULL REFERENCES signals.scans(scan_id) ON DELETE CASCADE,
artifact_key TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('assembly', 'jar', 'module', 'binary', 'script')),
sha256 TEXT NOT NULL,
purl TEXT,
build_id TEXT,
file_path TEXT,
size_bytes BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT artifacts_scan_key_unique UNIQUE (scan_id, artifact_key)
);
CREATE INDEX IF NOT EXISTS idx_artifacts_scan ON signals.artifacts(scan_id);
CREATE INDEX IF NOT EXISTS idx_artifacts_sha256 ON signals.artifacts(sha256);
CREATE INDEX IF NOT EXISTS idx_artifacts_purl ON signals.artifacts(purl) WHERE purl IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_artifacts_build_id ON signals.artifacts(build_id) WHERE build_id IS NOT NULL;
COMMENT ON TABLE signals.artifacts IS 'Individual artifacts (assemblies, JARs, modules) within a scan';
-- ============================================================================
-- Call Graph Nodes
-- ============================================================================
CREATE TABLE IF NOT EXISTS signals.cg_nodes (
id BIGSERIAL PRIMARY KEY,
scan_id UUID NOT NULL REFERENCES signals.scans(scan_id) ON DELETE CASCADE,
node_id TEXT NOT NULL,
artifact_key TEXT,
symbol_key TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'unknown'
CHECK (visibility IN ('public', 'internal', 'protected', 'private', 'unknown')),
is_entrypoint_candidate BOOLEAN NOT NULL DEFAULT FALSE,
purl TEXT,
symbol_digest TEXT,
flags INT NOT NULL DEFAULT 0,
attributes JSONB,
CONSTRAINT cg_nodes_scan_node_unique UNIQUE (scan_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_cg_nodes_scan ON signals.cg_nodes(scan_id);
CREATE INDEX IF NOT EXISTS idx_cg_nodes_symbol_key ON signals.cg_nodes(symbol_key);
CREATE INDEX IF NOT EXISTS idx_cg_nodes_purl ON signals.cg_nodes(purl) WHERE purl IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_cg_nodes_entrypoint ON signals.cg_nodes(scan_id, is_entrypoint_candidate)
WHERE is_entrypoint_candidate = TRUE;
CREATE INDEX IF NOT EXISTS idx_cg_nodes_symbol_fts ON signals.cg_nodes
USING gin(to_tsvector('simple', symbol_key));
COMMENT ON TABLE signals.cg_nodes IS 'Individual nodes (symbols) in call graphs';
COMMENT ON COLUMN signals.cg_nodes.visibility IS 'Symbol visibility: public, internal, protected, private, unknown';
COMMENT ON COLUMN signals.cg_nodes.flags IS 'Bitfield for node properties (static, virtual, async, etc.)';
-- ============================================================================
-- Call Graph Edges
-- ============================================================================
CREATE TABLE IF NOT EXISTS signals.cg_edges (
id BIGSERIAL PRIMARY KEY,
scan_id UUID NOT NULL REFERENCES signals.scans(scan_id) ON DELETE CASCADE,
from_node_id TEXT NOT NULL,
to_node_id TEXT NOT NULL,
kind SMALLINT NOT NULL DEFAULT 0, -- 0=static, 1=heuristic, 2=runtime
reason SMALLINT NOT NULL DEFAULT 0, -- EdgeReason enum value
weight REAL NOT NULL DEFAULT 1.0,
offset_bytes INT,
is_resolved BOOLEAN NOT NULL DEFAULT TRUE,
provenance TEXT,
CONSTRAINT cg_edges_unique UNIQUE (scan_id, from_node_id, to_node_id, kind, reason)
);
CREATE INDEX IF NOT EXISTS idx_cg_edges_scan ON signals.cg_edges(scan_id);
CREATE INDEX IF NOT EXISTS idx_cg_edges_from ON signals.cg_edges(scan_id, from_node_id);
CREATE INDEX IF NOT EXISTS idx_cg_edges_to ON signals.cg_edges(scan_id, to_node_id);
CREATE INDEX IF NOT EXISTS idx_cg_edges_traversal ON signals.cg_edges(scan_id, from_node_id)
INCLUDE (to_node_id, kind, weight);
COMMENT ON TABLE signals.cg_edges IS 'Call edges between nodes in the call graph';
COMMENT ON COLUMN signals.cg_edges.kind IS 'Edge kind: 0=static, 1=heuristic, 2=runtime';
COMMENT ON COLUMN signals.cg_edges.reason IS 'EdgeReason enum value explaining why this edge exists';
-- ============================================================================
-- Entrypoints
-- ============================================================================
CREATE TABLE IF NOT EXISTS signals.entrypoints (
id BIGSERIAL PRIMARY KEY,
scan_id UUID NOT NULL REFERENCES signals.scans(scan_id) ON DELETE CASCADE,
node_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'http', 'grpc', 'cli', 'job', 'event', 'message_queue',
'timer', 'test', 'main', 'module_init', 'static_constructor', 'unknown'
)),
framework TEXT,
route TEXT,
http_method TEXT,
phase TEXT NOT NULL DEFAULT 'runtime'
CHECK (phase IN ('module_init', 'app_start', 'runtime', 'shutdown')),
order_idx INT NOT NULL DEFAULT 0,
CONSTRAINT entrypoints_scan_node_unique UNIQUE (scan_id, node_id, kind)
);
CREATE INDEX IF NOT EXISTS idx_entrypoints_scan ON signals.entrypoints(scan_id);
CREATE INDEX IF NOT EXISTS idx_entrypoints_kind ON signals.entrypoints(kind);
CREATE INDEX IF NOT EXISTS idx_entrypoints_route ON signals.entrypoints(route) WHERE route IS NOT NULL;
COMMENT ON TABLE signals.entrypoints IS 'Framework-aware entrypoints detected in the call graph';
COMMENT ON COLUMN signals.entrypoints.phase IS 'Execution phase: module_init, app_start, runtime, shutdown';
-- ============================================================================
-- Symbol-to-Component Mapping
-- ============================================================================
CREATE TABLE IF NOT EXISTS signals.symbol_component_map (
id BIGSERIAL PRIMARY KEY,
scan_id UUID NOT NULL REFERENCES signals.scans(scan_id) ON DELETE CASCADE,
node_id TEXT NOT NULL,
purl TEXT NOT NULL,
mapping_kind TEXT NOT NULL CHECK (mapping_kind IN (
'exact', 'assembly', 'namespace', 'heuristic'
)),
confidence REAL NOT NULL DEFAULT 1.0,
evidence JSONB,
CONSTRAINT symbol_component_map_unique UNIQUE (scan_id, node_id, purl)
);
CREATE INDEX IF NOT EXISTS idx_symbol_component_scan ON signals.symbol_component_map(scan_id);
CREATE INDEX IF NOT EXISTS idx_symbol_component_purl ON signals.symbol_component_map(purl);
CREATE INDEX IF NOT EXISTS idx_symbol_component_node ON signals.symbol_component_map(scan_id, node_id);
COMMENT ON TABLE signals.symbol_component_map IS 'Maps symbols to SBOM components for vulnerability correlation';
COMMENT ON COLUMN signals.symbol_component_map.mapping_kind IS 'How the mapping was determined: exact, assembly, namespace, heuristic';
-- ============================================================================
-- Reachability Results
-- ============================================================================
-- Component-level reachability status
CREATE TABLE IF NOT EXISTS signals.reachability_components (
id BIGSERIAL PRIMARY KEY,
scan_id UUID NOT NULL REFERENCES signals.scans(scan_id) ON DELETE CASCADE,
purl TEXT NOT NULL,
status SMALLINT NOT NULL DEFAULT 0, -- ReachabilityStatus enum
lattice_state TEXT,
confidence REAL NOT NULL DEFAULT 0,
why JSONB,
evidence JSONB,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT reachability_components_unique UNIQUE (scan_id, purl)
);
CREATE INDEX IF NOT EXISTS idx_reachability_components_scan ON signals.reachability_components(scan_id);
CREATE INDEX IF NOT EXISTS idx_reachability_components_purl ON signals.reachability_components(purl);
CREATE INDEX IF NOT EXISTS idx_reachability_components_status ON signals.reachability_components(status);
COMMENT ON TABLE signals.reachability_components IS 'Component-level reachability status for each scan';
-- CVE-level reachability findings
CREATE TABLE IF NOT EXISTS signals.reachability_findings (
id BIGSERIAL PRIMARY KEY,
scan_id UUID NOT NULL REFERENCES signals.scans(scan_id) ON DELETE CASCADE,
cve_id TEXT NOT NULL,
purl TEXT NOT NULL,
status SMALLINT NOT NULL DEFAULT 0,
lattice_state TEXT,
confidence REAL NOT NULL DEFAULT 0,
path_witness TEXT[],
why JSONB,
evidence JSONB,
spine_id UUID,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT reachability_findings_unique UNIQUE (scan_id, cve_id, purl)
);
CREATE INDEX IF NOT EXISTS idx_reachability_findings_scan ON signals.reachability_findings(scan_id);
CREATE INDEX IF NOT EXISTS idx_reachability_findings_cve ON signals.reachability_findings(cve_id);
CREATE INDEX IF NOT EXISTS idx_reachability_findings_purl ON signals.reachability_findings(purl);
CREATE INDEX IF NOT EXISTS idx_reachability_findings_status ON signals.reachability_findings(status);
COMMENT ON TABLE signals.reachability_findings IS 'CVE-level reachability findings with path witnesses';
COMMENT ON COLUMN signals.reachability_findings.path_witness IS 'Array of node IDs forming the reachability path';
-- ============================================================================
-- Runtime Samples
-- ============================================================================
CREATE TABLE IF NOT EXISTS signals.runtime_samples (
id BIGSERIAL PRIMARY KEY,
scan_id UUID NOT NULL REFERENCES signals.scans(scan_id) ON DELETE CASCADE,
collected_at TIMESTAMPTZ NOT NULL,
env_hash TEXT,
timestamp TIMESTAMPTZ NOT NULL,
pid INT,
thread_id INT,
frames TEXT[] NOT NULL,
weight REAL NOT NULL DEFAULT 1.0,
container_id TEXT,
pod_name TEXT
);
CREATE INDEX IF NOT EXISTS idx_runtime_samples_scan ON signals.runtime_samples(scan_id);
CREATE INDEX IF NOT EXISTS idx_runtime_samples_collected ON signals.runtime_samples(collected_at DESC);
CREATE INDEX IF NOT EXISTS idx_runtime_samples_frames ON signals.runtime_samples USING gin(frames);
COMMENT ON TABLE signals.runtime_samples IS 'Stack trace samples from runtime evidence collection';
-- ============================================================================
-- Deployment References (Popularity Scoring)
-- ============================================================================
CREATE TABLE IF NOT EXISTS signals.deploy_refs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
purl TEXT NOT NULL,
purl_version TEXT,
image_id TEXT NOT NULL,
image_digest TEXT,
environment TEXT NOT NULL DEFAULT 'unknown'
CONSTRAINT chk_environment CHECK (environment IN ('production', 'staging', 'development', 'test', 'unknown')),
namespace TEXT,
cluster TEXT,
region TEXT,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_deploy_refs_purl_image_env UNIQUE (purl, image_id, environment)
);
CREATE INDEX IF NOT EXISTS idx_deploy_refs_purl ON signals.deploy_refs(purl);
CREATE INDEX IF NOT EXISTS idx_deploy_refs_purl_version ON signals.deploy_refs(purl, purl_version)
WHERE purl_version IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_deploy_refs_last_seen ON signals.deploy_refs(last_seen_at);
CREATE INDEX IF NOT EXISTS idx_deploy_refs_environment ON signals.deploy_refs(environment);
CREATE INDEX IF NOT EXISTS idx_deploy_refs_active ON signals.deploy_refs(purl, last_seen_at)
WHERE last_seen_at > NOW() - INTERVAL '30 days';
COMMENT ON TABLE signals.deploy_refs IS 'Tracks package deployments across images and environments for popularity scoring (P factor).';
COMMENT ON COLUMN signals.deploy_refs.purl IS 'Package URL (PURL) identifier, e.g., pkg:npm/lodash@4.17.21';
COMMENT ON COLUMN signals.deploy_refs.environment IS 'Deployment environment: production (highest weight), staging, development, test, unknown';
COMMENT ON COLUMN signals.deploy_refs.first_seen_at IS 'When this package was first observed in this image/environment';
COMMENT ON COLUMN signals.deploy_refs.last_seen_at IS 'Most recent observation timestamp; used for active deployment filtering';
-- ============================================================================
-- Graph Metrics (Centrality Scoring)
-- ============================================================================
CREATE TABLE IF NOT EXISTS signals.graph_metrics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
node_id TEXT NOT NULL,
callgraph_id TEXT NOT NULL,
node_type TEXT NOT NULL DEFAULT 'symbol'
CONSTRAINT chk_node_type CHECK (node_type IN ('symbol', 'package', 'function', 'class', 'method')),
degree_centrality INT NOT NULL DEFAULT 0,
in_degree INT NOT NULL DEFAULT 0,
out_degree INT NOT NULL DEFAULT 0,
betweenness_centrality FLOAT NOT NULL DEFAULT 0.0,
closeness_centrality FLOAT,
eigenvector_centrality FLOAT,
normalized_betweenness FLOAT
CONSTRAINT chk_norm_betweenness CHECK (normalized_betweenness IS NULL OR (normalized_betweenness >= 0.0 AND normalized_betweenness <= 1.0)),
normalized_degree FLOAT
CONSTRAINT chk_norm_degree CHECK (normalized_degree IS NULL OR (normalized_degree >= 0.0 AND normalized_degree <= 1.0)),
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
computation_duration_ms INT,
algorithm_version TEXT NOT NULL DEFAULT '1.0',
total_nodes INT,
total_edges INT,
CONSTRAINT uq_graph_metrics_node_graph UNIQUE (node_id, callgraph_id)
);
CREATE INDEX IF NOT EXISTS idx_graph_metrics_node ON signals.graph_metrics(node_id);
CREATE INDEX IF NOT EXISTS idx_graph_metrics_callgraph ON signals.graph_metrics(callgraph_id);
CREATE INDEX IF NOT EXISTS idx_graph_metrics_betweenness ON signals.graph_metrics(betweenness_centrality DESC);
CREATE INDEX IF NOT EXISTS idx_graph_metrics_computed ON signals.graph_metrics(computed_at);
CREATE INDEX IF NOT EXISTS idx_graph_metrics_high_centrality ON signals.graph_metrics(callgraph_id, normalized_betweenness DESC)
WHERE normalized_betweenness > 0.5;
COMMENT ON TABLE signals.graph_metrics IS 'Stores computed graph centrality metrics for call graph nodes (C factor).';
COMMENT ON COLUMN signals.graph_metrics.node_id IS 'Symbol identifier from call graph, matches SymbolId format';
COMMENT ON COLUMN signals.graph_metrics.betweenness_centrality IS 'Raw betweenness centrality: number of shortest paths passing through this node';
COMMENT ON COLUMN signals.graph_metrics.normalized_betweenness IS 'Betweenness normalized to 0.0-1.0 range: raw / max(raw) across graph';
COMMENT ON COLUMN signals.graph_metrics.algorithm_version IS 'Version of centrality algorithm used (e.g., "brandes-1.0")';
-- ============================================================================
-- Unknowns Table (Full Scoring Schema)
-- ============================================================================
CREATE TABLE IF NOT EXISTS signals.unknowns (
id TEXT NOT NULL,
subject_key TEXT NOT NULL,
callgraph_id TEXT,
symbol_id TEXT,
code_id TEXT,
purl TEXT,
purl_version TEXT,
edge_from TEXT,
edge_to TEXT,
reason TEXT,
-- Scoring factors (range: 0.0 - 1.0)
popularity_p FLOAT DEFAULT 0.0
CONSTRAINT chk_popularity_range CHECK (popularity_p >= 0.0 AND popularity_p <= 1.0),
deployment_count INT DEFAULT 0,
exploit_potential_e FLOAT DEFAULT 0.0
CONSTRAINT chk_exploit_range CHECK (exploit_potential_e >= 0.0 AND exploit_potential_e <= 1.0),
uncertainty_u FLOAT DEFAULT 0.0
CONSTRAINT chk_uncertainty_range CHECK (uncertainty_u >= 0.0 AND uncertainty_u <= 1.0),
centrality_c FLOAT DEFAULT 0.0
CONSTRAINT chk_centrality_range CHECK (centrality_c >= 0.0 AND centrality_c <= 1.0),
degree_centrality INT DEFAULT 0,
betweenness_centrality FLOAT DEFAULT 0.0,
staleness_s FLOAT DEFAULT 0.0
CONSTRAINT chk_staleness_range CHECK (staleness_s >= 0.0 AND staleness_s <= 1.0),
days_since_analysis INT DEFAULT 0,
-- Composite score and band
score FLOAT DEFAULT 0.0
CONSTRAINT chk_score_range CHECK (score >= 0.0 AND score <= 1.0),
band TEXT DEFAULT 'cold'
CONSTRAINT chk_band_value CHECK (band IN ('hot', 'warm', 'cold')),
-- JSONB columns
unknown_flags JSONB DEFAULT '{}'::jsonb,
normalization_trace JSONB,
-- Rescan scheduling
rescan_attempts INT DEFAULT 0,
last_rescan_result TEXT,
next_scheduled_rescan TIMESTAMPTZ,
last_analyzed_at TIMESTAMPTZ,
-- Graph slice reference
graph_slice_hash BYTEA,
evidence_set_hash BYTEA,
callgraph_attempt_hash BYTEA,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (subject_key, id)
);
CREATE INDEX IF NOT EXISTS idx_unknowns_band ON signals.unknowns(band);
CREATE INDEX IF NOT EXISTS idx_unknowns_score_desc ON signals.unknowns(score DESC);
CREATE INDEX IF NOT EXISTS idx_unknowns_band_score ON signals.unknowns(band, score DESC);
CREATE INDEX IF NOT EXISTS idx_unknowns_next_rescan ON signals.unknowns(next_scheduled_rescan)
WHERE next_scheduled_rescan IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_unknowns_hot_band ON signals.unknowns(score DESC)
WHERE band = 'hot';
CREATE INDEX IF NOT EXISTS idx_unknowns_purl ON signals.unknowns(purl);
CREATE INDEX IF NOT EXISTS idx_unknowns_flags_gin ON signals.unknowns USING GIN (unknown_flags);
COMMENT ON COLUMN signals.unknowns.popularity_p IS
'Deployment impact score (P). Formula: min(1, log10(1 + deployments)/log10(1 + 100))';
COMMENT ON COLUMN signals.unknowns.exploit_potential_e IS
'Exploit consequence potential (E). Based on CVE severity, KEV status.';
COMMENT ON COLUMN signals.unknowns.uncertainty_u IS
'Uncertainty density (U). Aggregated from flags: no_provenance(0.30), version_range(0.25), conflicting_feeds(0.20), missing_vector(0.15), unreachable_source(0.10)';
COMMENT ON COLUMN signals.unknowns.centrality_c IS
'Graph centrality (C). Normalized betweenness centrality.';
COMMENT ON COLUMN signals.unknowns.staleness_s IS
'Evidence staleness (S). Formula: min(1, age_days / 14)';
COMMENT ON COLUMN signals.unknowns.score IS
'Composite score: clamp01(wP*P + wE*E + wU*U + wC*C + wS*S). Default weights: wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10';
COMMENT ON COLUMN signals.unknowns.band IS
'Triage band. HOT (>=0.70): immediate rescan. WARM (0.40-0.69): scheduled 12-72h. COLD (<0.40): weekly batch.';
COMMENT ON COLUMN signals.unknowns.unknown_flags IS
'JSONB flags: {no_provenance_anchor, version_range, conflicting_feeds, missing_vector, unreachable_source_advisory, dynamic_call_target, external_assembly}';
COMMENT ON COLUMN signals.unknowns.normalization_trace IS
'JSONB trace of scoring computation for audit/debugging. Includes raw values, normalized values, weights, and formula.';
-- ============================================================================
-- Helper Views
-- ============================================================================
-- Deployment counts per package (for popularity scoring)
CREATE OR REPLACE VIEW signals.deploy_counts AS
SELECT
purl,
COUNT(DISTINCT image_id) as image_count,
COUNT(DISTINCT environment) as env_count,
COUNT(*) as total_deployments,
MAX(last_seen_at) as last_deployment,
MIN(first_seen_at) as first_deployment
FROM signals.deploy_refs
WHERE last_seen_at > NOW() - INTERVAL '30 days'
GROUP BY purl;
COMMENT ON VIEW signals.deploy_counts IS
'Aggregated deployment counts per package for popularity scoring. Only includes active deployments (last 30 days).';
-- High-centrality nodes per graph
CREATE OR REPLACE VIEW signals.high_centrality_nodes AS
SELECT
callgraph_id,
node_id,
node_type,
betweenness_centrality,
normalized_betweenness,
degree_centrality,
computed_at
FROM signals.graph_metrics
WHERE normalized_betweenness > 0.5
ORDER BY callgraph_id, normalized_betweenness DESC;
COMMENT ON VIEW signals.high_centrality_nodes IS
'Nodes with normalized betweenness > 0.5, sorted by centrality within each graph.';
-- ============================================================================
-- Materialized Views for Analytics
-- ============================================================================
-- Daily scan statistics
CREATE MATERIALIZED VIEW IF NOT EXISTS signals.scan_stats_daily AS
SELECT
DATE_TRUNC('day', created_at) AS day,
COUNT(*) AS total_scans,
COUNT(*) FILTER (WHERE status = 'completed') AS completed_scans,
COUNT(*) FILTER (WHERE status = 'failed') AS failed_scans,
AVG(EXTRACT(EPOCH FROM (completed_at - created_at))) FILTER (WHERE status = 'completed') AS avg_duration_seconds
FROM signals.scans
GROUP BY DATE_TRUNC('day', created_at)
ORDER BY day DESC;
CREATE UNIQUE INDEX IF NOT EXISTS idx_scan_stats_daily_day ON signals.scan_stats_daily(day);
-- CVE reachability summary
CREATE MATERIALIZED VIEW IF NOT EXISTS signals.cve_reachability_summary AS
SELECT
cve_id,
COUNT(DISTINCT scan_id) AS affected_scans,
COUNT(DISTINCT purl) AS affected_components,
COUNT(*) FILTER (WHERE status = 2) AS reachable_count, -- REACHABLE_STATIC
COUNT(*) FILTER (WHERE status = 3) AS proven_count, -- REACHABLE_PROVEN
COUNT(*) FILTER (WHERE status = 0) AS unreachable_count,
AVG(confidence) AS avg_confidence,
MAX(computed_at) AS last_updated
FROM signals.reachability_findings
GROUP BY cve_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_cve_reachability_summary_cve ON signals.cve_reachability_summary(cve_id);
-- ============================================================================
-- Refresh Function for Materialized Views
-- ============================================================================
CREATE OR REPLACE FUNCTION signals.refresh_analytics_views()
RETURNS void AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY signals.scan_stats_daily;
REFRESH MATERIALIZED VIEW CONCURRENTLY signals.cve_reachability_summary;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION signals.refresh_analytics_views IS
'Refreshes all analytics materialized views concurrently';

View File

@@ -0,0 +1,23 @@
# Archived Pre-1.0 Migrations
This directory contains the original migrations that were compacted into `001_initial_schema.sql`
for the 1.0.0 release.
## Original Files
- `V0000_001__extensions.sql` - PostgreSQL extensions (pgcrypto)
- `V1102_001__unknowns_scoring_schema.sql` - Unknowns scoring columns extension
- `V1105_001__deploy_refs_graph_metrics.sql` - Deployment references and graph metrics tables
- `V3102_001__callgraph_relational_tables.sql` - Call graph relational tables (scans, artifacts, cg_nodes, cg_edges, entrypoints, etc.)
## Why Archived
Pre-1.0, the schema evolved incrementally with Flyway-style naming. For 1.0.0, migrations were:
- Renamed to standard `NNN_description.sql` format
- Compacted into a single initial schema
- This simplifies new deployments and provides a cleaner upgrade path
## For Existing Deployments
If upgrading from pre-1.0, run the reset script directly with psql:
```bash
psql -h <host> -U <user> -d <db> -f devops/scripts/migrations-reset-pre-1.0.sql
```
This updates `schema_migrations` to recognize the compacted schema.

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signals.Storage.Postgres.Repositories;
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// Repository for querying call graph data across scans.

View File

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

View File

@@ -6,7 +6,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Signals.Storage.Postgres.Repositories;
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// Repository for querying call graph data across scans.

View File

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

View File

@@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Persistence;
namespace StellaOps.Signals.Storage.Postgres.Repositories;
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IDeploymentRefsRepository"/>.

View File

@@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Persistence;
namespace StellaOps.Signals.Storage.Postgres.Repositories;
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IGraphMetricsRepository"/>.

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,9 @@ using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using StellaOps.Signals.Storage.Postgres.Repositories;
using StellaOps.Signals.Persistence.Postgres.Repositories;
namespace StellaOps.Signals.Storage.Postgres;
namespace StellaOps.Signals.Persistence.Postgres;
/// <summary>
/// Extension methods for configuring Signals PostgreSQL storage services.

View File

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

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Signals.Persistence</RootNamespace>
<AssemblyName>StellaOps.Signals.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps Signals module</Description>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<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.Signals\StellaOps.Signals.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,316 @@
// <copyright file="EbpfSignalMergerTests.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Signals.Ebpf.Tests;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Reachability.Runtime;
using StellaOps.Scanner.Reachability.Slices;
using StellaOps.Signals.Ebpf.Schema;
using Xunit;
/// <summary>
/// Tests for <see cref="EbpfSignalMerger"/>.
/// </summary>
public sealed class EbpfSignalMergerTests
{
private readonly EbpfSignalMerger _merger;
private readonly RuntimeStaticMerger _baseMerger;
public EbpfSignalMergerTests()
{
_baseMerger = new RuntimeStaticMerger();
_merger = new EbpfSignalMerger(
_baseMerger,
NullLogger<EbpfSignalMerger>.Instance);
}
[Fact]
public void Merge_WithNoSignals_ReturnsSameGraph()
{
var graph = CreateTestGraph();
var result = _merger.Merge(graph, null);
Assert.Same(graph, result.MergedGraph);
Assert.Empty(result.Evidence);
Assert.Equal(2, result.Statistics.StaticEdgeCount);
Assert.Equal(0, result.Statistics.RuntimeEventCount);
}
[Fact]
public void Merge_WithEmptySignals_ReturnsSameGraph()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 0,
CallPaths = [],
ObservedSymbols = [],
};
var result = _merger.Merge(graph, signals);
Assert.Same(graph, result.MergedGraph);
Assert.Empty(result.Evidence);
}
[Fact]
public void Merge_WithMatchingSignals_CreatesConfirmedEvidence()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
CallPaths = new List<ObservedCallPath>
{
new()
{
Symbols = ["main", "processRequest"],
ObservationCount = 50,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastObservedAt = DateTimeOffset.UtcNow,
},
},
ObservedSymbols = ["main", "processRequest"],
};
var result = _merger.Merge(graph, signals);
Assert.NotEmpty(result.Evidence);
Assert.Contains(result.Evidence, e =>
e.Type == RuntimeEvidenceType.RuntimeConfirmed);
}
[Fact]
public void Merge_WithRuntimeOnlyPath_CreatesRuntimeOnlyEvidence()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
CallPaths = new List<ObservedCallPath>
{
new()
{
// Path not in static graph
Symbols = ["dynamic_dispatch", "hidden_method"],
ObservationCount = 20,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastObservedAt = DateTimeOffset.UtcNow,
},
},
ObservedSymbols = ["dynamic_dispatch", "hidden_method"],
};
var result = _merger.Merge(graph, signals);
Assert.Contains(result.Evidence, e =>
e.Type == RuntimeEvidenceType.RuntimeOnly);
Assert.True(result.Statistics.RuntimeOnlyPathCount > 0);
}
[Fact]
public void Merge_WithDetectedRuntimes_CreatesRuntimeDetectedEvidence()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
CallPaths = [],
ObservedSymbols = [],
DetectedRuntimes = [RuntimeType.Node, RuntimeType.Python],
};
var result = _merger.Merge(graph, signals);
Assert.Contains(result.Evidence, e =>
e.Type == RuntimeEvidenceType.RuntimeDetected &&
e.RuntimeType == "Node");
Assert.Contains(result.Evidence, e =>
e.Type == RuntimeEvidenceType.RuntimeDetected &&
e.RuntimeType == "Python");
}
[Fact]
public void ValidatePath_WithValidPath_ReturnsConfirmed()
{
var graph = CreateTestGraph();
var path = new ObservedCallPath
{
Symbols = ["main", "processRequest"],
ObservationCount = 10,
FirstObservedAt = DateTimeOffset.UtcNow,
LastObservedAt = DateTimeOffset.UtcNow,
};
var result = _merger.ValidatePath(graph, path);
Assert.True(result.IsValid);
Assert.Equal(PathType.Confirmed, result.PathType);
Assert.Equal(1.0, result.MatchRatio);
}
[Fact]
public void ValidatePath_WithUnknownPath_ReturnsRuntimeOnly()
{
var graph = CreateTestGraph();
var path = new ObservedCallPath
{
Symbols = ["unknown", "method"],
ObservationCount = 10,
FirstObservedAt = DateTimeOffset.UtcNow,
LastObservedAt = DateTimeOffset.UtcNow,
};
var result = _merger.ValidatePath(graph, path);
Assert.True(result.IsValid);
Assert.Equal(PathType.RuntimeOnly, result.PathType);
Assert.Equal(0.0, result.MatchRatio);
}
[Fact]
public void ValidatePath_WithShortPath_ReturnsInvalid()
{
var graph = CreateTestGraph();
var path = new ObservedCallPath
{
Symbols = ["single"],
ObservationCount = 10,
FirstObservedAt = DateTimeOffset.UtcNow,
LastObservedAt = DateTimeOffset.UtcNow,
};
var result = _merger.ValidatePath(graph, path);
Assert.False(result.IsValid);
Assert.Equal(PathType.Invalid, result.PathType);
}
[Fact]
public void Merge_StatisticsAreAccurate()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 500,
CallPaths = new List<ObservedCallPath>
{
new()
{
Symbols = ["main", "processRequest"],
ObservationCount = 100,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastObservedAt = DateTimeOffset.UtcNow,
},
},
ObservedSymbols = ["main", "processRequest"],
DroppedEvents = 10,
};
var result = _merger.Merge(graph, signals);
Assert.Equal(2, result.Statistics.StaticEdgeCount);
Assert.Equal(500, result.Statistics.RuntimeEventCount);
Assert.Equal(1, result.Statistics.CallPathCount);
Assert.Equal(10, result.Statistics.DroppedEventCount);
}
[Fact]
public void RuntimeEvidence_ContainsContainerId()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "my-container-id",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
CallPaths = new List<ObservedCallPath>
{
new()
{
Symbols = ["main", "processRequest"],
ObservationCount = 10,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastObservedAt = DateTimeOffset.UtcNow,
},
},
ObservedSymbols = [],
};
var result = _merger.Merge(graph, signals);
Assert.All(result.Evidence, e =>
Assert.Equal("my-container-id", e.ContainerId));
}
[Fact]
public void EvidenceSource_IsEbpf()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
CallPaths = new List<ObservedCallPath>
{
new()
{
Symbols = ["main", "processRequest"],
ObservationCount = 10,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastObservedAt = DateTimeOffset.UtcNow,
},
},
ObservedSymbols = [],
};
var result = _merger.Merge(graph, signals);
Assert.All(result.Evidence, e =>
Assert.Equal(EvidenceSource.Ebpf, e.Source));
}
private static RichGraph CreateTestGraph()
{
return new RichGraph(
Nodes: new List<RichGraphNode>
{
new("main", "main", null, null, "native", "entrypoint", null, null, null, null, null),
new("processRequest", "processRequest", null, null, "native", "function", null, null, null, null, null),
new("handleError", "handleError", null, null, "native", "function", null, null, null, null, null),
},
Edges: new List<RichGraphEdge>
{
new("main", "processRequest", "call", null, null, null, 1.0, null),
new("processRequest", "handleError", "call", null, null, null, 0.8, null),
},
Roots: new List<RichGraphRoot>
{
new("main", "main", "entrypoint")
},
Analyzer: new RichGraphAnalyzer("test-analyzer", "1.0.0", null)
);
}
}

View File

@@ -0,0 +1,209 @@
// <copyright file="RuntimeSignalCollectorTests.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Signals.Ebpf.Tests;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Signals.Ebpf.Probes;
using StellaOps.Signals.Ebpf.Schema;
using StellaOps.Signals.Ebpf.Services;
using Xunit;
/// <summary>
/// Tests for <see cref="RuntimeSignalCollector"/>.
/// </summary>
public sealed class RuntimeSignalCollectorTests
{
private readonly RuntimeSignalCollector _collector;
private readonly MockProbeLoader _probeLoader;
public RuntimeSignalCollectorTests()
{
_probeLoader = new MockProbeLoader();
_collector = new RuntimeSignalCollector(
NullLogger<RuntimeSignalCollector>.Instance,
_probeLoader);
}
[Fact]
public void IsSupported_ReturnsCorrectValue()
{
// On non-Linux systems, eBPF is not supported
var isSupported = _collector.IsSupported();
// This will be false on Windows/macOS test runners
Assert.True(isSupported == false || Environment.OSVersion.Platform == PlatformID.Unix);
}
[Fact]
public void GetSupportedProbeTypes_ReturnsEmptyOnUnsupportedPlatform()
{
var probeTypes = _collector.GetSupportedProbeTypes();
// On non-Linux, should be empty
if (!_collector.IsSupported())
{
Assert.Empty(probeTypes);
}
}
[Fact]
public void RuntimeCallEvent_HasCorrectProperties()
{
var evt = new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
ContainerId = "container-123",
Pid = 1234,
Tid = 5678,
TimestampNs = 1000000000,
Symbol = "malloc",
FunctionAddress = 0x7fff12345678,
StackTrace = [0x7fff00001000, 0x7fff00002000],
RuntimeType = RuntimeType.Native,
Library = "libc.so.6",
Purl = "pkg:deb/ubuntu/libc6@2.31",
};
Assert.Equal("container-123", evt.ContainerId);
Assert.Equal(1234, evt.Pid);
Assert.Equal("malloc", evt.Symbol);
Assert.Equal(RuntimeType.Native, evt.RuntimeType);
Assert.Equal(2, evt.StackTrace.Count);
}
[Fact]
public void RuntimeSignalSummary_HasCorrectProperties()
{
var summary = new RuntimeSignalSummary
{
ContainerId = "container-456",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 1000,
CallPaths = new List<ObservedCallPath>
{
new()
{
Symbols = ["main", "processRequest", "vulnerable_func"],
ObservationCount = 50,
Purl = "pkg:npm/lodash@4.17.20",
},
},
ObservedSymbols = ["main", "processRequest", "vulnerable_func"],
DroppedEvents = 10,
DetectedRuntimes = [RuntimeType.Node],
};
Assert.Equal(1000, summary.TotalEvents);
Assert.Single(summary.CallPaths);
Assert.Equal(3, summary.CallPaths[0].Symbols.Count);
Assert.Equal(50, summary.CallPaths[0].ObservationCount);
}
[Fact]
public void RuntimeSignalOptions_HasDefaultValues()
{
var options = new RuntimeSignalOptions();
Assert.Empty(options.TargetSymbols);
Assert.Equal(10000, options.MaxEventsPerSecond);
Assert.Null(options.MaxDuration);
Assert.True(options.ResolveSymbols);
Assert.Equal(16, options.MaxStackDepth);
Assert.Equal(256 * 1024, options.RingBufferSize);
Assert.Equal(1, options.SampleRate);
}
[Fact]
public void SignalStatistics_CapturesMetrics()
{
var stats = new SignalStatistics
{
TotalEvents = 5000,
EventsPerSecond = 250.5,
UniqueCallPaths = 42,
BufferUtilization = 0.35,
DroppedEvents = 5,
CpuOverheadPercent = 0.1,
MemoryUsageBytes = 1024 * 1024,
};
Assert.Equal(5000, stats.TotalEvents);
Assert.Equal(250.5, stats.EventsPerSecond);
Assert.Equal(42, stats.UniqueCallPaths);
Assert.Equal(0.35, stats.BufferUtilization);
}
[Fact]
public void ObservedCallPath_TracksObservations()
{
var path = new ObservedCallPath
{
Symbols = ["entry", "middle", "vulnerable"],
ObservationCount = 100,
Purl = "pkg:golang/example.com/pkg@1.0.0",
RuntimeType = RuntimeType.Go,
FirstObservedAt = DateTimeOffset.UtcNow.AddHours(-1),
LastObservedAt = DateTimeOffset.UtcNow,
};
Assert.Equal(3, path.Symbols.Count);
Assert.Equal(100, path.ObservationCount);
Assert.Equal(RuntimeType.Go, path.RuntimeType);
Assert.True(path.LastObservedAt > path.FirstObservedAt);
}
[Theory]
[InlineData(RuntimeType.Native, "Native")]
[InlineData(RuntimeType.Jvm, "Jvm")]
[InlineData(RuntimeType.Node, "Node")]
[InlineData(RuntimeType.Python, "Python")]
[InlineData(RuntimeType.DotNet, "DotNet")]
[InlineData(RuntimeType.Go, "Go")]
[InlineData(RuntimeType.Ruby, "Ruby")]
[InlineData(RuntimeType.Unknown, "Unknown")]
public void RuntimeType_HasCorrectStringRepresentation(RuntimeType type, string expected)
{
Assert.Equal(expected, type.ToString());
}
private sealed class MockProbeLoader : IEbpfProbeLoader
{
public Task<EbpfProbeHandle> LoadAndAttachAsync(
string containerId,
RuntimeSignalOptions options,
CancellationToken ct = default)
{
return Task.FromResult(new EbpfProbeHandle
{
ProbeId = Guid.NewGuid(),
ContainerId = containerId,
TracedPids = [],
});
}
public Task DetachAsync(EbpfProbeHandle handle, CancellationToken ct = default)
=> Task.CompletedTask;
public async IAsyncEnumerable<ReadOnlyMemory<byte>> ReadEventsAsync(
EbpfProbeHandle handle,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
await Task.Yield();
yield break;
}
public (string? Symbol, string? Library, string? Purl) ResolveSymbol(int pid, ulong address)
=> (null, null, null);
public double GetBufferUtilization(EbpfProbeHandle handle) => 0.0;
public double GetCpuOverhead(EbpfProbeHandle handle) => 0.0;
public long GetMemoryUsage(EbpfProbeHandle handle) => 0;
public IReadOnlyList<ProbeType> GetSupportedProbeTypes() => [];
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Signals.Ebpf\StellaOps.Signals.Ebpf.csproj" />
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
</ItemGroup>
</Project>

View File

@@ -4,13 +4,14 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MicrosoftOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence.Postgres;
using StellaOps.Signals.Persistence.Postgres.Repositories;
using StellaOps.Signals.Services;
using StellaOps.Signals.Storage.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
using Xunit.Abstractions;
using StellaOps.TestKit;
namespace StellaOps.Signals.Storage.Postgres.Tests;
namespace StellaOps.Signals.Persistence.Tests;
/// <summary>
/// Integration tests for callgraph projection to relational tables.

View File

@@ -6,12 +6,13 @@ using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using MicrosoftOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence.Postgres;
using StellaOps.Signals.Persistence.Postgres.Repositories;
using StellaOps.Signals.Services;
using StellaOps.Signals.Storage.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Signals.Storage.Postgres.Tests;
namespace StellaOps.Signals.Persistence.Tests;
[Collection(SignalsPostgresCollection.Name)]
public sealed class CallGraphSyncServiceTests : IAsyncLifetime

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
<?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" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Signals.Persistence\StellaOps.Signals.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>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
@@ -136,7 +136,6 @@ public class CallgraphIngestionServiceTests
if (request.ManifestContent is not null)
{
using var manifestMs = new MemoryStream();
using StellaOps.TestKit;
request.ManifestContent.CopyTo(manifestMs);
manifests[request.Hash] = manifestMs.ToArray();
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.Text;
using System.Text.Json;
@@ -244,7 +244,6 @@ public class EdgeBundleIngestionServiceTests
using var stream1 = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle1)));
using var stream2 = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle2)));
using StellaOps.TestKit;
// Act
await _service.IngestAsync(TestTenantId, stream1, null);
await _service.IngestAsync(TestTenantId, stream2, null);

View File

@@ -321,7 +321,7 @@ public class InMemoryEvidenceWeightPolicyProviderTests
}
[Fact]
public void Clear_RemovesAllPolicies()
public async Task Clear_RemovesAllPolicies()
{
var provider = new InMemoryEvidenceWeightPolicyProvider();
provider.SetPolicy(new EvidenceWeightPolicy
@@ -339,7 +339,7 @@ public class InMemoryEvidenceWeightPolicyProviderTests
provider.Clear();
provider.PolicyExistsAsync(null, "production").Result.Should().BeFalse();
provider.PolicyExistsAsync(null, "development").Result.Should().BeFalse();
(await provider.PolicyExistsAsync(null, "production")).Should().BeFalse();
(await provider.PolicyExistsAsync(null, "development")).Should().BeFalse();
}
}

View File

@@ -31,7 +31,7 @@ public class EvidenceWeightedScoreCalculatorTests
var result = _calculator.Calculate(input, _defaultPolicy);
// Without MIT, sum of weights = 0.95 (default) → 95%
result.Score.Should().BeGreaterOrEqualTo(90);
result.Score.Should().BeGreaterThanOrEqualTo(90);
result.Bucket.Should().Be(ScoreBucket.ActNow);
}
@@ -217,7 +217,7 @@ public class EvidenceWeightedScoreCalculatorTests
var result = _calculator.Calculate(input, _defaultPolicy);
result.Score.Should().BeLessOrEqualTo(15);
result.Score.Should().BeLessThanOrEqualTo(15);
result.Caps.NotAffectedCap.Should().BeTrue();
result.Flags.Should().Contain("vendor-na");
}
@@ -229,7 +229,7 @@ public class EvidenceWeightedScoreCalculatorTests
var result = _calculator.Calculate(input, _defaultPolicy);
result.Score.Should().BeGreaterOrEqualTo(60);
result.Score.Should().BeGreaterThanOrEqualTo(60);
result.Caps.RuntimeFloor.Should().BeTrue();
}
@@ -242,7 +242,7 @@ public class EvidenceWeightedScoreCalculatorTests
var result = _calculator.Calculate(input, _defaultPolicy);
// Since RTS >= 0.8, runtime floor should apply (floor at 60)
result.Score.Should().BeGreaterOrEqualTo(60);
result.Score.Should().BeGreaterThanOrEqualTo(60);
result.Caps.RuntimeFloor.Should().BeTrue();
// Speculative cap shouldn't apply because RTS > 0
result.Caps.SpeculativeCap.Should().BeFalse();
@@ -311,7 +311,7 @@ public class EvidenceWeightedScoreCalculatorTests
var result = _calculator.Calculate(input, _defaultPolicy);
result.Should().NotBeNull();
result.Score.Should().BeGreaterOrEqualTo(0);
result.Score.Should().BeGreaterThanOrEqualTo(0);
}
[Fact]

View File

@@ -57,7 +57,7 @@ public class GroundTruthValidatorTests
/// </summary>
[Theory]
[MemberData(nameof(GetGroundTruthSamples))]
public void GroundTruth_HasValidUncertaintyTiers(string samplePath, GroundTruthDocument document)
public void GroundTruth_HasValidUncertaintyTiers(string _samplePath, GroundTruthDocument document)
{
if (document.ExpectedUncertainty is null)
{
@@ -139,7 +139,7 @@ public class GroundTruthValidatorTests
/// </summary>
[Theory]
[MemberData(nameof(GetGroundTruthSamples))]
public void GroundTruth_EntryPointsHaveValidPhases(string samplePath, GroundTruthDocument document)
public void GroundTruth_EntryPointsHaveValidPhases(string _samplePath, GroundTruthDocument document)
{
var validPhases = new[] { "load", "init", "runtime", "main", "fini" };

View File

@@ -140,7 +140,7 @@ public class ReachabilityLatticeStateExtensionsTests
[InlineData(null, ReachabilityLatticeState.Unknown)]
public void FromCode_ReturnsExpectedState(string? code, ReachabilityLatticeState expected)
{
Assert.Equal(expected, ReachabilityLatticeStateExtensions.FromCode(code));
Assert.Equal(expected, ReachabilityLatticeStateExtensions.FromCode(code!));
}
[Trait("Category", TestCategories.Unit)]

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.IO.Compression;
using System.Text.Json;
@@ -88,7 +88,6 @@ public class ReachabilityUnionIngestionServiceTests
private static string ComputeSha(string content)
{
using var sha = System.Security.Cryptography.SHA256.Create();
using StellaOps.TestKit;
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
return Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Linq;
@@ -48,7 +48,6 @@ public class RouterEventsPublisherTests
var options = CreateOptions();
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom");
using var httpClient = new HttpClient(handler) { BaseAddress = new Uri(options.Events.Router.BaseUrl) };
using StellaOps.TestKit;
var logger = new ListLogger<RouterEventsPublisher>();
var builder = new ReachabilityFactEventBuilder(options, TimeProvider.System);
var publisher = new RouterEventsPublisher(builder, options, httpClient, logger);

View File

@@ -1,4 +1,4 @@
using System.IO.Compression;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
@@ -252,7 +252,6 @@ public class RuntimeFactsBatchIngestionTests
public async Task<StoredRuntimeFactsArtifact> SaveAsync(RuntimeFactsArtifactSaveRequest request, Stream content, CancellationToken cancellationToken)
{
using var ms = new MemoryStream();
using StellaOps.TestKit;
await content.CopyToAsync(ms, cancellationToken);
var artifact = new StoredRuntimeFactsArtifact(

View File

@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -48,7 +48,6 @@ public sealed class SimpleJsonCallgraphParserGateTests
var parser = new SimpleJsonCallgraphParser("csharp");
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json), writable: false);
using StellaOps.TestKit;
var parsed = await parser.ParseAsync(stream, CancellationToken.None);
parsed.Edges.Should().ContainSingle();

View File

@@ -6,24 +6,27 @@
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Disable Concelier shared test infra to avoid pulling unrelated projects into the Signals test graph -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" />
<!-- FsCheck for property-based testing (EvidenceWeightedScore) -->
<PackageReference Include="FsCheck" Version="3.0.0-rc3" />
<PackageReference Include="FsCheck.Xunit" Version="3.0.0-rc3" />
<PackageReference Include="FsCheck" />
<PackageReference Include="FsCheck.Xunit" />
<!-- Verify for snapshot testing (EvidenceWeightedScore) -->
<PackageReference Include="Verify.Xunit" Version="28.7.2" />
<PackageReference Include="Verify.Xunit" />
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Signals/StellaOps.Signals.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -310,7 +310,6 @@ public class UnknownsDecayServiceTests
}
using var cts = new CancellationTokenSource();
using StellaOps.TestKit;
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() =>