feat: Implement policy attestation features and service account delegation
- Added new policy scopes: `policy:publish` and `policy:promote` with interactive-only enforcement. - Introduced metadata parameters for policy actions: `policy_reason`, `policy_ticket`, and `policy_digest`. - Enhanced token validation to require fresh authentication for policy attestation tokens. - Updated grant handlers to enforce policy scope checks and log audit information. - Implemented service account delegation configuration, including quotas and validation. - Seeded service accounts during application initialization based on configuration. - Updated documentation and tasks to reflect new features and changes.
This commit is contained in:
193
src/AdvisoryAI/StellaOps.AdvisoryAI.sln
Normal file
193
src/AdvisoryAI/StellaOps.AdvisoryAI.sln
Normal file
@@ -0,0 +1,193 @@
|
||||
|
||||
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.AdvisoryAI", "StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj", "{E41E2FDA-3827-4B18-8596-B25BDE882D5F}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.Tests", "__Tests\StellaOps.AdvisoryAI.Tests\StellaOps.AdvisoryAI.Tests.csproj", "{F6860DE5-0C7C-4848-8356-7555E3C391A3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Testing", "..\Concelier\__Libraries\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj", "{B53E4FED-8988-4354-8D1A-D3C618DBFD78}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "..\Concelier\__Libraries\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{E98A7C01-1619-41A0-A586-84EF9952F75D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Storage.Mongo", "..\Concelier\__Libraries\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj", "{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{BBB5CD3C-866A-4298-ACE1-598413631CF5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normalization", "..\Concelier\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj", "{1313202A-E8A8-41E3-80BC-472096074681}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{F567F20C-552F-4761-941A-0552CEF68160}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "..\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{C8CE71D3-952A-43F7-9346-20113E37F672}"
|
||||
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
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E41E2FDA-3827-4B18-8596-B25BDE882D5F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B53E4FED-8988-4354-8D1A-D3C618DBFD78}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x64.Build.0 = Release|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BBB5CD3C-866A-4298-ACE1-598413631CF5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7E3D9A33-BD0E-424A-88E6-F4440E386A3C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1313202A-E8A8-41E3-80BC-472096074681}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1CC5F6F8-DF9A-4BCC-8C69-79E2DF604F6D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F567F20C-552F-4761-941A-0552CEF68160}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{F6860DE5-0C7C-4848-8356-7555E3C391A3} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Abstractions;
|
||||
|
||||
public interface ISbomContextRetriever
|
||||
{
|
||||
Task<SbomContextResult> RetrieveAsync(SbomContextRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the inputs required to build SBOM-derived context for Advisory AI prompts.
|
||||
/// </summary>
|
||||
public sealed class SbomContextRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of version timeline entries we will ever request from the SBOM service.
|
||||
/// </summary>
|
||||
public const int TimelineLimitCeiling = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of dependency paths we will ever request from the SBOM service.
|
||||
/// </summary>
|
||||
public const int DependencyPathLimitCeiling = 200;
|
||||
|
||||
public SbomContextRequest(
|
||||
string artifactId,
|
||||
string? purl = null,
|
||||
int maxTimelineEntries = 50,
|
||||
int maxDependencyPaths = 25,
|
||||
bool includeEnvironmentFlags = true,
|
||||
bool includeBlastRadius = true)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
|
||||
|
||||
ArtifactId = artifactId.Trim();
|
||||
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
|
||||
MaxTimelineEntries = NormalizeLimit(maxTimelineEntries, TimelineLimitCeiling);
|
||||
MaxDependencyPaths = NormalizeLimit(maxDependencyPaths, DependencyPathLimitCeiling);
|
||||
IncludeEnvironmentFlags = includeEnvironmentFlags;
|
||||
IncludeBlastRadius = includeBlastRadius;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The advisory artifact identifier (e.g. internal scan ID).
|
||||
/// </summary>
|
||||
public string ArtifactId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional package URL used to scope SBOM data.
|
||||
/// </summary>
|
||||
public string? Purl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of timeline entries that should be returned. Set to 0 to disable.
|
||||
/// </summary>
|
||||
public int MaxTimelineEntries { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of dependency paths that should be returned. Set to 0 to disable.
|
||||
/// </summary>
|
||||
public int MaxDependencyPaths { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether environment feature flags (prod/staging/etc.) should be returned.
|
||||
/// </summary>
|
||||
public bool IncludeEnvironmentFlags { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether blast radius summaries should be returned.
|
||||
/// </summary>
|
||||
public bool IncludeBlastRadius { get; }
|
||||
|
||||
private static int NormalizeLimit(int requested, int ceiling)
|
||||
{
|
||||
if (requested <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (requested > ceiling)
|
||||
{
|
||||
return ceiling;
|
||||
}
|
||||
|
||||
return requested;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chunking;
|
||||
|
||||
internal sealed class CsafDocumentChunker : IDocumentChunker
|
||||
{
|
||||
private static readonly ImmutableHashSet<string> SupportedNoteCategories =
|
||||
ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, "summary", "description", "remediation");
|
||||
|
||||
public bool CanHandle(DocumentFormat format) => format == DocumentFormat.Csaf;
|
||||
|
||||
public IEnumerable<AdvisoryChunk> Chunk(AdvisoryDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
using var jsonDocument = JsonDocument.Parse(document.Content);
|
||||
var root = jsonDocument.RootElement;
|
||||
|
||||
var chunkIndex = 0;
|
||||
var sectionStack = new Stack<string>();
|
||||
sectionStack.Push("document");
|
||||
|
||||
string NextChunkId() => $"{document.DocumentId}:{++chunkIndex:D4}";
|
||||
|
||||
foreach (var chunk in ExtractDocumentNotes(document, root, NextChunkId))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
|
||||
foreach (var chunk in ExtractVulnerabilities(document, root, NextChunkId))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<AdvisoryChunk> ExtractDocumentNotes(
|
||||
AdvisoryDocument document,
|
||||
JsonElement root,
|
||||
Func<string> chunkIdFactory)
|
||||
{
|
||||
if (!root.TryGetProperty("document", out var documentNode))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!documentNode.TryGetProperty("notes", out var notesNode) || notesNode.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
foreach (var note in notesNode.EnumerateArray())
|
||||
{
|
||||
index++;
|
||||
if (note.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = note.GetPropertyOrDefault("text");
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var category = note.GetPropertyOrDefault("category");
|
||||
if (category.Length > 0 && !SupportedNoteCategories.Contains(category))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["format"] = "csaf",
|
||||
["section"] = "document.notes",
|
||||
["index"] = index.ToString(),
|
||||
};
|
||||
|
||||
if (category.Length > 0)
|
||||
{
|
||||
metadata["category"] = category;
|
||||
}
|
||||
|
||||
yield return AdvisoryChunk.Create(
|
||||
document.DocumentId,
|
||||
chunkIdFactory(),
|
||||
section: "document.notes",
|
||||
paragraphId: $"document.notes[{index}]",
|
||||
text: text.Trim(),
|
||||
metadata: metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<AdvisoryChunk> ExtractVulnerabilities(
|
||||
AdvisoryDocument document,
|
||||
JsonElement root,
|
||||
Func<string> chunkIdFactory)
|
||||
{
|
||||
if (!root.TryGetProperty("vulnerabilities", out var vulnerabilitiesNode) ||
|
||||
vulnerabilitiesNode.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var vulnIndex = 0;
|
||||
foreach (var vulnerability in vulnerabilitiesNode.EnumerateArray())
|
||||
{
|
||||
vulnIndex++;
|
||||
if (vulnerability.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vulnerabilityId = vulnerability.GetPropertyOrDefault("id", fallback: vulnIndex.ToString());
|
||||
|
||||
foreach (var chunk in ExtractVulnerabilityTitle(document, vulnerability, vulnerabilityId, chunkIdFactory))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
|
||||
foreach (var chunk in ExtractVulnerabilityNotes(document, vulnerability, vulnerabilityId, chunkIdFactory))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
|
||||
foreach (var chunk in ExtractRemediations(document, vulnerability, vulnerabilityId, chunkIdFactory))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<AdvisoryChunk> ExtractVulnerabilityTitle(
|
||||
AdvisoryDocument document,
|
||||
JsonElement vulnerability,
|
||||
string vulnerabilityId,
|
||||
Func<string> chunkIdFactory)
|
||||
{
|
||||
var title = vulnerability.GetPropertyOrDefault("title");
|
||||
var description = vulnerability.GetPropertyOrDefault("description");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
yield return CreateChunk(document, chunkIdFactory(), "vulnerabilities.title", vulnerabilityId, title!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
yield return CreateChunk(document, chunkIdFactory(), "vulnerabilities.description", vulnerabilityId, description!);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<AdvisoryChunk> ExtractVulnerabilityNotes(
|
||||
AdvisoryDocument document,
|
||||
JsonElement vulnerability,
|
||||
string vulnerabilityId,
|
||||
Func<string> chunkIdFactory)
|
||||
{
|
||||
if (!vulnerability.TryGetProperty("notes", out var notes) || notes.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var noteIndex = 0;
|
||||
foreach (var note in notes.EnumerateArray())
|
||||
{
|
||||
noteIndex++;
|
||||
if (note.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = note.GetPropertyOrDefault("text");
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return CreateChunk(
|
||||
document,
|
||||
chunkIdFactory(),
|
||||
"vulnerabilities.notes",
|
||||
vulnerabilityId,
|
||||
text!,
|
||||
additionalMetadata: new Dictionary<string, string>
|
||||
{
|
||||
["noteIndex"] = noteIndex.ToString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<AdvisoryChunk> ExtractRemediations(
|
||||
AdvisoryDocument document,
|
||||
JsonElement vulnerability,
|
||||
string vulnerabilityId,
|
||||
Func<string> chunkIdFactory)
|
||||
{
|
||||
if (!vulnerability.TryGetProperty("remediations", out var remediations) || remediations.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var remediationIndex = 0;
|
||||
foreach (var remediation in remediations.EnumerateArray())
|
||||
{
|
||||
remediationIndex++;
|
||||
if (remediation.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var details = remediation.GetPropertyOrDefault("details");
|
||||
if (string.IsNullOrWhiteSpace(details))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["remediationIndex"] = remediationIndex.ToString(),
|
||||
};
|
||||
|
||||
var type = remediation.GetPropertyOrDefault("category");
|
||||
if (!string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
metadata["category"] = type!;
|
||||
}
|
||||
|
||||
var productIds = remediation.GetPropertyOrDefault("product_ids");
|
||||
if (!string.IsNullOrWhiteSpace(productIds))
|
||||
{
|
||||
metadata["product_ids"] = productIds!;
|
||||
}
|
||||
|
||||
yield return CreateChunk(
|
||||
document,
|
||||
chunkIdFactory(),
|
||||
"vulnerabilities.remediations",
|
||||
vulnerabilityId,
|
||||
details!,
|
||||
metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private static AdvisoryChunk CreateChunk(
|
||||
AdvisoryDocument document,
|
||||
string chunkId,
|
||||
string section,
|
||||
string paragraph,
|
||||
string text,
|
||||
IReadOnlyDictionary<string, string>? additionalMetadata = null)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["format"] = "csaf",
|
||||
["section"] = section,
|
||||
["paragraph"] = paragraph,
|
||||
};
|
||||
|
||||
if (additionalMetadata is not null)
|
||||
{
|
||||
foreach (var pair in additionalMetadata)
|
||||
{
|
||||
metadata[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return AdvisoryChunk.Create(
|
||||
document.DocumentId,
|
||||
chunkId,
|
||||
section,
|
||||
paragraph,
|
||||
text.Trim(),
|
||||
metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chunking;
|
||||
|
||||
internal static class JsonElementExtensions
|
||||
{
|
||||
public static string GetPropertyOrDefault(this JsonElement element, string propertyName, string? fallback = "")
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return fallback ?? string.Empty;
|
||||
}
|
||||
|
||||
if (element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.GetString() ?? string.Empty,
|
||||
JsonValueKind.Array => string.Join(",", property.EnumerateArray().Select(ToStringValue).Where(static v => !string.IsNullOrWhiteSpace(v))),
|
||||
JsonValueKind.Object => property.ToString(),
|
||||
JsonValueKind.Number => property.ToString(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
return fallback ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string? ToStringValue(JsonElement element)
|
||||
=> element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.ToString(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chunking;
|
||||
|
||||
internal sealed class MarkdownDocumentChunker : IDocumentChunker
|
||||
{
|
||||
private static readonly Regex HeadingRegex = new("^(#+)\\s+(?<title>.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public bool CanHandle(DocumentFormat format) => format == DocumentFormat.Markdown;
|
||||
|
||||
public IEnumerable<AdvisoryChunk> Chunk(AdvisoryDocument document)
|
||||
{
|
||||
var lines = document.Content.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
|
||||
var section = "body";
|
||||
var paragraphId = 0;
|
||||
var chunkIndex = 0;
|
||||
var buffer = new List<string>();
|
||||
|
||||
IEnumerable<AdvisoryChunk> FlushBuffer()
|
||||
{
|
||||
if (buffer.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var text = string.Join("\n", buffer).Trim();
|
||||
buffer.Clear();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
paragraphId++;
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["format"] = "markdown",
|
||||
["section"] = section,
|
||||
["paragraph"] = paragraphId.ToString(),
|
||||
};
|
||||
|
||||
yield return AdvisoryChunk.Create(
|
||||
document.DocumentId,
|
||||
chunkId: $"{document.DocumentId}:{++chunkIndex:D4}",
|
||||
section,
|
||||
paragraphId: $"{section}#{paragraphId}",
|
||||
text,
|
||||
metadata);
|
||||
}
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.TrimEnd();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
foreach (var chunk in FlushBuffer())
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var headingMatch = HeadingRegex.Match(line);
|
||||
if (headingMatch.Success)
|
||||
{
|
||||
foreach (var chunk in FlushBuffer())
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
|
||||
var level = headingMatch.Groups[1].Value.Length;
|
||||
var title = headingMatch.Groups["title"].Value.Trim();
|
||||
section = level == 1 ? title : $"{section}/{title}";
|
||||
paragraphId = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.Add(line);
|
||||
}
|
||||
|
||||
foreach (var chunk in FlushBuffer())
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chunking;
|
||||
|
||||
internal sealed class OpenVexDocumentChunker : IDocumentChunker
|
||||
{
|
||||
public bool CanHandle(DocumentFormat format) => format == DocumentFormat.OpenVex;
|
||||
|
||||
public IEnumerable<AdvisoryChunk> Chunk(AdvisoryDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
using var jsonDocument = JsonDocument.Parse(document.Content);
|
||||
var root = jsonDocument.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("statements", out var statements) || statements.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
foreach (var statement in statements.EnumerateArray())
|
||||
{
|
||||
index++;
|
||||
if (statement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vulnerabilityId = statement.GetPropertyOrDefault("vulnerability", fallback: string.Empty);
|
||||
var status = statement.GetPropertyOrDefault("status", fallback: string.Empty);
|
||||
var justification = statement.GetPropertyOrDefault("justification", fallback: string.Empty);
|
||||
var impact = statement.GetPropertyOrDefault("impact_statement", fallback: string.Empty);
|
||||
var notes = statement.GetPropertyOrDefault("status_notes", fallback: string.Empty);
|
||||
var timestamp = statement.GetPropertyOrDefault("timestamp", fallback: string.Empty);
|
||||
var lastUpdated = statement.GetPropertyOrDefault("last_updated", fallback: string.Empty);
|
||||
|
||||
var products = ExtractProducts(statement);
|
||||
var section = "vex.statements";
|
||||
var paragraphId = $"statements[{index}]";
|
||||
var chunkId = $"{document.DocumentId}:{index:D4}";
|
||||
|
||||
var text = BuildStatementSummary(vulnerabilityId, status, justification, impact, notes, products);
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["format"] = "openvex",
|
||||
["section"] = section,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
metadata["vulnerability"] = vulnerabilityId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
metadata["status"] = status;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(justification))
|
||||
{
|
||||
metadata["justification"] = justification;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(impact))
|
||||
{
|
||||
metadata["impact_statement"] = impact;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notes))
|
||||
{
|
||||
metadata["status_notes"] = notes;
|
||||
}
|
||||
|
||||
if (products.Count > 0)
|
||||
{
|
||||
metadata["products"] = string.Join(",", products);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(timestamp))
|
||||
{
|
||||
metadata["timestamp"] = timestamp;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(lastUpdated))
|
||||
{
|
||||
metadata["last_updated"] = lastUpdated;
|
||||
}
|
||||
|
||||
yield return AdvisoryChunk.Create(
|
||||
document.DocumentId,
|
||||
chunkId,
|
||||
section,
|
||||
paragraphId,
|
||||
text,
|
||||
metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ExtractProducts(JsonElement statement)
|
||||
{
|
||||
if (!statement.TryGetProperty("products", out var productsElement) ||
|
||||
productsElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var results = new List<string>();
|
||||
foreach (var product in productsElement.EnumerateArray())
|
||||
{
|
||||
switch (product.ValueKind)
|
||||
{
|
||||
case JsonValueKind.String:
|
||||
var value = product.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
results.Add(value.Trim());
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Object:
|
||||
var productId = product.GetPropertyOrDefault("product_id", fallback: string.Empty);
|
||||
if (!string.IsNullOrWhiteSpace(productId))
|
||||
{
|
||||
results.Add(productId);
|
||||
break;
|
||||
}
|
||||
|
||||
var name = product.GetPropertyOrDefault("name", fallback: string.Empty);
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
results.Add(name);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static string BuildStatementSummary(
|
||||
string vulnerabilityId,
|
||||
string status,
|
||||
string justification,
|
||||
string impact,
|
||||
string notes,
|
||||
IReadOnlyList<string> products)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
builder.Append(vulnerabilityId.Trim());
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append("Unknown vulnerability");
|
||||
}
|
||||
|
||||
if (products.Count > 0)
|
||||
{
|
||||
builder.Append(" affects ");
|
||||
builder.Append(string.Join(", ", products));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
builder.Append(" → status: ");
|
||||
builder.Append(status.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(justification))
|
||||
{
|
||||
builder.Append(" (justification: ");
|
||||
builder.Append(justification.Trim());
|
||||
builder.Append(')');
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(impact))
|
||||
{
|
||||
builder.Append(". Impact: ");
|
||||
builder.Append(impact.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notes))
|
||||
{
|
||||
builder.Append(". Notes: ");
|
||||
builder.Append(notes.Trim());
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chunking;
|
||||
|
||||
internal sealed class OsvDocumentChunker : IDocumentChunker
|
||||
{
|
||||
public bool CanHandle(DocumentFormat format) => format == DocumentFormat.Osv;
|
||||
|
||||
public IEnumerable<AdvisoryChunk> Chunk(AdvisoryDocument document)
|
||||
{
|
||||
using var jsonDocument = JsonDocument.Parse(document.Content);
|
||||
var root = jsonDocument.RootElement;
|
||||
|
||||
var chunkIndex = 0;
|
||||
string NextChunkId() => $"{document.DocumentId}:{++chunkIndex:D4}";
|
||||
|
||||
string summary = root.GetPropertyOrDefault("summary");
|
||||
if (!string.IsNullOrWhiteSpace(summary))
|
||||
{
|
||||
yield return CreateChunk(document, NextChunkId(), "summary", "summary", summary!);
|
||||
}
|
||||
|
||||
string details = root.GetPropertyOrDefault("details");
|
||||
if (!string.IsNullOrWhiteSpace(details))
|
||||
{
|
||||
yield return CreateChunk(document, NextChunkId(), "details", "details", details!);
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("affected", out var affectedNode) && affectedNode.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var affectedIndex = 0;
|
||||
foreach (var affected in affectedNode.EnumerateArray())
|
||||
{
|
||||
affectedIndex++;
|
||||
if (affected.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var packageName = string.Empty;
|
||||
var ecosystem = string.Empty;
|
||||
if (affected.TryGetProperty("package", out var package))
|
||||
{
|
||||
packageName = package.GetPropertyOrDefault("name", string.Empty);
|
||||
ecosystem = package.GetPropertyOrDefault("ecosystem", string.Empty);
|
||||
}
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["package"] = packageName,
|
||||
["ecosystem"] = ecosystem,
|
||||
};
|
||||
|
||||
if (affected.TryGetProperty("ranges", out var ranges) && ranges.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var rangeIndex = 0;
|
||||
foreach (var range in ranges.EnumerateArray())
|
||||
{
|
||||
rangeIndex++;
|
||||
var events = range.GetPropertyOrDefault("events");
|
||||
if (string.IsNullOrWhiteSpace(events))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rangeMetadata = new Dictionary<string, string>(metadata)
|
||||
{
|
||||
["range.type"] = range.GetPropertyOrDefault("type", fallback: ""),
|
||||
["range.index"] = rangeIndex.ToString(),
|
||||
};
|
||||
yield return CreateChunk(
|
||||
document,
|
||||
NextChunkId(),
|
||||
"affected.ranges",
|
||||
$"affected[{affectedIndex}]",
|
||||
events!,
|
||||
metadata: rangeMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
var versions = affected.GetPropertyOrDefault("versions");
|
||||
if (!string.IsNullOrWhiteSpace(versions))
|
||||
{
|
||||
var versionMetadata = new Dictionary<string, string>(metadata)
|
||||
{
|
||||
["section"] = "affected.versions",
|
||||
};
|
||||
|
||||
yield return CreateChunk(
|
||||
document,
|
||||
NextChunkId(),
|
||||
"affected.versions",
|
||||
$"affected[{affectedIndex}]",
|
||||
versions!,
|
||||
metadata: versionMetadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var references = root.GetPropertyOrDefault("references");
|
||||
if (!string.IsNullOrWhiteSpace(references))
|
||||
{
|
||||
yield return CreateChunk(document, NextChunkId(), "references", "references", references!);
|
||||
}
|
||||
}
|
||||
|
||||
private static AdvisoryChunk CreateChunk(
|
||||
AdvisoryDocument document,
|
||||
string chunkId,
|
||||
string section,
|
||||
string paragraph,
|
||||
string text,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var meta = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["format"] = "osv",
|
||||
["section"] = section,
|
||||
["paragraph"] = paragraph,
|
||||
};
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
meta[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return AdvisoryChunk.Create(
|
||||
document.DocumentId,
|
||||
chunkId,
|
||||
section,
|
||||
paragraph,
|
||||
text.Trim(),
|
||||
meta);
|
||||
}
|
||||
}
|
||||
189
src/AdvisoryAI/StellaOps.AdvisoryAI/Context/SbomContextResult.cs
Normal file
189
src/AdvisoryAI/StellaOps.AdvisoryAI/Context/SbomContextResult.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Represents SBOM-derived context that Advisory AI can hydrate into prompts.
|
||||
/// </summary>
|
||||
public sealed class SbomContextResult
|
||||
{
|
||||
private SbomContextResult(
|
||||
string artifactId,
|
||||
string? purl,
|
||||
ImmutableArray<SbomVersionTimelineEntry> versionTimeline,
|
||||
ImmutableArray<SbomDependencyPath> dependencyPaths,
|
||||
ImmutableDictionary<string, string> environmentFlags,
|
||||
SbomBlastRadiusSummary? blastRadius,
|
||||
ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
ArtifactId = artifactId;
|
||||
Purl = purl;
|
||||
VersionTimeline = versionTimeline;
|
||||
DependencyPaths = dependencyPaths;
|
||||
EnvironmentFlags = environmentFlags;
|
||||
BlastRadius = blastRadius;
|
||||
Metadata = metadata;
|
||||
}
|
||||
|
||||
public string ArtifactId { get; }
|
||||
|
||||
public string? Purl { get; }
|
||||
|
||||
public ImmutableArray<SbomVersionTimelineEntry> VersionTimeline { get; }
|
||||
|
||||
public ImmutableArray<SbomDependencyPath> DependencyPaths { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> EnvironmentFlags { get; }
|
||||
|
||||
public SbomBlastRadiusSummary? BlastRadius { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public static SbomContextResult Create(
|
||||
string artifactId,
|
||||
string? purl,
|
||||
IEnumerable<SbomVersionTimelineEntry> versionTimeline,
|
||||
IEnumerable<SbomDependencyPath> dependencyPaths,
|
||||
IReadOnlyDictionary<string, string>? environmentFlags = null,
|
||||
SbomBlastRadiusSummary? blastRadius = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
|
||||
ArgumentNullException.ThrowIfNull(versionTimeline);
|
||||
ArgumentNullException.ThrowIfNull(dependencyPaths);
|
||||
|
||||
var timeline = versionTimeline.ToImmutableArray();
|
||||
var paths = dependencyPaths.ToImmutableArray();
|
||||
var flags = environmentFlags is null
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: environmentFlags.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
var meta = metadata is null
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: metadata.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
|
||||
return new SbomContextResult(
|
||||
artifactId.Trim(),
|
||||
string.IsNullOrWhiteSpace(purl) ? null : purl.Trim(),
|
||||
timeline,
|
||||
paths,
|
||||
flags,
|
||||
blastRadius,
|
||||
meta);
|
||||
}
|
||||
|
||||
public static SbomContextResult Empty(string artifactId, string? purl = null)
|
||||
=> Create(artifactId, purl, Array.Empty<SbomVersionTimelineEntry>(), Array.Empty<SbomDependencyPath>());
|
||||
}
|
||||
|
||||
public sealed class SbomVersionTimelineEntry
|
||||
{
|
||||
public SbomVersionTimelineEntry(
|
||||
string version,
|
||||
DateTimeOffset firstObserved,
|
||||
DateTimeOffset? lastObserved,
|
||||
string status,
|
||||
string source)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(status);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(source);
|
||||
|
||||
Version = version.Trim();
|
||||
FirstObserved = firstObserved;
|
||||
LastObserved = lastObserved;
|
||||
Status = status.Trim();
|
||||
Source = source.Trim();
|
||||
}
|
||||
|
||||
public string Version { get; }
|
||||
|
||||
public DateTimeOffset FirstObserved { get; }
|
||||
|
||||
public DateTimeOffset? LastObserved { get; }
|
||||
|
||||
public string Status { get; }
|
||||
|
||||
public string Source { get; }
|
||||
}
|
||||
|
||||
public sealed class SbomDependencyPath
|
||||
{
|
||||
public SbomDependencyPath(
|
||||
IEnumerable<SbomDependencyNode> nodes,
|
||||
bool isRuntime,
|
||||
string? source = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodes);
|
||||
|
||||
var immutableNodes = nodes.ToImmutableArray();
|
||||
if (immutableNodes.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new ArgumentException("At least one node must be supplied.", nameof(nodes));
|
||||
}
|
||||
|
||||
Nodes = immutableNodes;
|
||||
IsRuntime = isRuntime;
|
||||
Source = string.IsNullOrWhiteSpace(source) ? null : source.Trim();
|
||||
Metadata = metadata is null
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: metadata.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public ImmutableArray<SbomDependencyNode> Nodes { get; }
|
||||
|
||||
public bool IsRuntime { get; }
|
||||
|
||||
public string? Source { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
}
|
||||
|
||||
public sealed class SbomDependencyNode
|
||||
{
|
||||
public SbomDependencyNode(string identifier, string? version)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
|
||||
|
||||
Identifier = identifier.Trim();
|
||||
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
|
||||
}
|
||||
|
||||
public string Identifier { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
}
|
||||
|
||||
public sealed class SbomBlastRadiusSummary
|
||||
{
|
||||
public SbomBlastRadiusSummary(
|
||||
int impactedAssets,
|
||||
int impactedWorkloads,
|
||||
int impactedNamespaces,
|
||||
double? impactedPercentage,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
ImpactedAssets = Math.Max(0, impactedAssets);
|
||||
ImpactedWorkloads = Math.Max(0, impactedWorkloads);
|
||||
ImpactedNamespaces = Math.Max(0, impactedNamespaces);
|
||||
ImpactedPercentage = impactedPercentage.HasValue
|
||||
? Math.Max(0, impactedPercentage.Value)
|
||||
: null;
|
||||
Metadata = metadata is null
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: metadata.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public int ImpactedAssets { get; }
|
||||
|
||||
public int ImpactedWorkloads { get; }
|
||||
|
||||
public int ImpactedNamespaces { get; }
|
||||
|
||||
public double? ImpactedPercentage { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
}
|
||||
@@ -6,4 +6,5 @@ public enum DocumentFormat
|
||||
Csaf,
|
||||
Osv,
|
||||
Markdown,
|
||||
OpenVex,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Documents;
|
||||
|
||||
internal static class DocumentFormatMapper
|
||||
{
|
||||
public static DocumentFormat Map(string? format)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(format))
|
||||
{
|
||||
return DocumentFormat.Unknown;
|
||||
}
|
||||
|
||||
var normalized = format.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"csaf" or "csaf-json" or "csaf_json" or "csaf/v2" => DocumentFormat.Csaf,
|
||||
"osv" => DocumentFormat.Osv,
|
||||
"markdown" or "md" => DocumentFormat.Markdown,
|
||||
"openvex" or "open-vex" or "vex" => DocumentFormat.OpenVex,
|
||||
_ => DocumentFormat.Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.AdvisoryAI.Tests")]
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.Concelier.Core.Raw;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Providers;
|
||||
|
||||
public sealed class ConcelierAdvisoryDocumentProviderOptions
|
||||
{
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
|
||||
public ImmutableArray<string> Vendors { get; set; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public int MaxDocuments { get; set; } = 25;
|
||||
}
|
||||
|
||||
internal sealed class ConcelierAdvisoryDocumentProvider : IAdvisoryDocumentProvider
|
||||
{
|
||||
private readonly IAdvisoryRawService _rawService;
|
||||
private readonly ConcelierAdvisoryDocumentProviderOptions _options;
|
||||
private readonly ILogger<ConcelierAdvisoryDocumentProvider>? _logger;
|
||||
|
||||
public ConcelierAdvisoryDocumentProvider(
|
||||
IAdvisoryRawService rawService,
|
||||
IOptions<ConcelierAdvisoryDocumentProviderOptions> options,
|
||||
ILogger<ConcelierAdvisoryDocumentProvider>? logger = null)
|
||||
{
|
||||
_rawService = rawService ?? throw new ArgumentNullException(nameof(rawService));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant must be configured.", nameof(options));
|
||||
}
|
||||
|
||||
if (_options.MaxDocuments <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(options), "MaxDocuments must be positive.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdvisoryDocument>> GetDocumentsAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
|
||||
|
||||
var options = new AdvisoryRawQueryOptions(_options.Tenant)
|
||||
{
|
||||
Aliases = ImmutableArray.Create(advisoryKey),
|
||||
UpstreamIds = ImmutableArray.Create(advisoryKey),
|
||||
Vendors = _options.Vendors,
|
||||
Limit = _options.MaxDocuments
|
||||
};
|
||||
|
||||
var result = await _rawService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Records.Count == 0)
|
||||
{
|
||||
_logger?.LogDebug("No advisory raw records returned for key {AdvisoryKey}", advisoryKey);
|
||||
return Array.Empty<AdvisoryDocument>();
|
||||
}
|
||||
|
||||
var documents = new List<AdvisoryDocument>(result.Records.Count);
|
||||
foreach (var record in result.Records)
|
||||
{
|
||||
var raw = record.Document.Content;
|
||||
var format = DocumentFormatMapper.Map(raw.Format);
|
||||
if (format == DocumentFormat.Unknown)
|
||||
{
|
||||
_logger?.LogWarning("Unsupported advisory content format {Format} for advisory {AdvisoryKey}", raw.Format, advisoryKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
var documentId = DetermineDocumentId(record);
|
||||
var metadata = BuildMetadata(record);
|
||||
var json = record.Document.Content.Raw.GetRawText();
|
||||
|
||||
documents.Add(AdvisoryDocument.Create(documentId, format, record.Document.Source.Vendor, json, metadata));
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static string DetermineDocumentId(AdvisoryRawRecord record)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(record.Document.Upstream.UpstreamId))
|
||||
{
|
||||
return record.Document.Upstream.UpstreamId;
|
||||
}
|
||||
|
||||
var primary = record.Document.Identifiers.PrimaryId;
|
||||
if (!string.IsNullOrWhiteSpace(primary))
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildMetadata(AdvisoryRawRecord record)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["tenant"] = record.Document.Tenant,
|
||||
["vendor"] = record.Document.Source.Vendor,
|
||||
["connector"] = record.Document.Source.Connector,
|
||||
["content_hash"] = record.Document.Upstream.ContentHash,
|
||||
["ingested_at"] = record.IngestedAt.UtcDateTime.ToString("O"),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(record.Document.Source.Stream))
|
||||
{
|
||||
metadata["stream"] = record.Document.Source.Stream!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(record.Document.Upstream.DocumentVersion))
|
||||
{
|
||||
metadata["document_version"] = record.Document.Upstream.DocumentVersion!;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Providers;
|
||||
|
||||
public sealed class ExcititorVexDocumentProviderOptions
|
||||
{
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
|
||||
public ImmutableArray<string> ProviderIds { get; set; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public ImmutableArray<VexClaimStatus> Statuses { get; set; } = ImmutableArray<VexClaimStatus>.Empty;
|
||||
|
||||
public int MaxObservations { get; set; } = 25;
|
||||
}
|
||||
|
||||
internal sealed class ExcititorVexDocumentProvider : IAdvisoryDocumentProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
private readonly IVexObservationQueryService _queryService;
|
||||
private readonly ExcititorVexDocumentProviderOptions _options;
|
||||
private readonly ILogger<ExcititorVexDocumentProvider>? _logger;
|
||||
|
||||
public ExcititorVexDocumentProvider(
|
||||
IVexObservationQueryService queryService,
|
||||
IOptions<ExcititorVexDocumentProviderOptions> options,
|
||||
ILogger<ExcititorVexDocumentProvider>? logger = null)
|
||||
{
|
||||
_queryService = queryService ?? throw new ArgumentNullException(nameof(queryService));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant must be configured.", nameof(options));
|
||||
}
|
||||
|
||||
if (_options.MaxObservations <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(options), "MaxObservations must be positive.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdvisoryDocument>> GetDocumentsAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
|
||||
|
||||
var normalizedKey = advisoryKey.Trim();
|
||||
var lookup = ImmutableArray.Create(normalizedKey);
|
||||
|
||||
var providerIds = _options.ProviderIds.IsDefaultOrEmpty
|
||||
? ImmutableArray<string>.Empty
|
||||
: _options.ProviderIds;
|
||||
var statuses = _options.Statuses.IsDefaultOrEmpty
|
||||
? ImmutableArray<VexClaimStatus>.Empty
|
||||
: _options.Statuses;
|
||||
|
||||
var options = new VexObservationQueryOptions(
|
||||
_options.Tenant,
|
||||
observationIds: lookup,
|
||||
vulnerabilityIds: lookup,
|
||||
productKeys: lookup,
|
||||
purls: ImmutableArray<string>.Empty,
|
||||
cpes: ImmutableArray<string>.Empty,
|
||||
providerIds: providerIds,
|
||||
statuses: statuses,
|
||||
limit: _options.MaxObservations);
|
||||
|
||||
var result = await _queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Observations.IsDefaultOrEmpty)
|
||||
{
|
||||
_logger?.LogDebug("No VEX observations returned for advisory key {AdvisoryKey}", normalizedKey);
|
||||
return Array.Empty<AdvisoryDocument>();
|
||||
}
|
||||
|
||||
var documents = new List<AdvisoryDocument>(result.Observations.Length);
|
||||
foreach (var observation in result.Observations)
|
||||
{
|
||||
var format = DocumentFormatMapper.Map(observation.Content.Format);
|
||||
if (format == DocumentFormat.Unknown)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Unsupported VEX content format {Format} for observation {ObservationId}",
|
||||
observation.Content.Format,
|
||||
observation.ObservationId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = observation.Content.Raw.ToJsonString(JsonOptions);
|
||||
var metadata = BuildMetadata(observation);
|
||||
|
||||
documents.Add(AdvisoryDocument.Create(
|
||||
observation.ObservationId,
|
||||
format,
|
||||
observation.ProviderId,
|
||||
content,
|
||||
metadata));
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildMetadata(VexObservation observation)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["tenant"] = observation.Tenant,
|
||||
["provider"] = observation.ProviderId,
|
||||
["stream"] = observation.StreamId,
|
||||
["created_at"] = observation.CreatedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
["statement_count"] = observation.Statements.Length.ToString(CultureInfo.InvariantCulture),
|
||||
["content_format"] = observation.Content.Format,
|
||||
["content_hash"] = observation.Upstream.ContentHash,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(observation.Content.SpecVersion))
|
||||
{
|
||||
metadata["spec_version"] = observation.Content.SpecVersion!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(observation.Upstream.DocumentVersion))
|
||||
{
|
||||
metadata["document_version"] = observation.Upstream.DocumentVersion!;
|
||||
}
|
||||
|
||||
if (observation.Supersedes.Length > 0)
|
||||
{
|
||||
metadata["supersedes"] = string.Join(",", observation.Supersedes);
|
||||
}
|
||||
|
||||
if (observation.Linkset.Aliases.Length > 0)
|
||||
{
|
||||
metadata["aliases"] = string.Join(",", observation.Linkset.Aliases);
|
||||
}
|
||||
|
||||
if (observation.Linkset.Purls.Length > 0)
|
||||
{
|
||||
metadata["purls"] = string.Join(",", observation.Linkset.Purls);
|
||||
}
|
||||
|
||||
if (observation.Linkset.Cpes.Length > 0)
|
||||
{
|
||||
metadata["cpes"] = string.Join(",", observation.Linkset.Cpes);
|
||||
}
|
||||
|
||||
var statusSummary = BuildStatusSummary(observation.Statements);
|
||||
if (statusSummary.Length > 0)
|
||||
{
|
||||
metadata["status_counts"] = statusSummary;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static string BuildStatusSummary(ImmutableArray<VexObservationStatement> statements)
|
||||
{
|
||||
if (statements.IsDefaultOrEmpty)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var counts = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var statement in statements)
|
||||
{
|
||||
var key = ToStatusKey(statement.Status);
|
||||
counts.TryGetValue(key, out var current);
|
||||
counts[key] = current + 1;
|
||||
}
|
||||
|
||||
if (counts.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join(
|
||||
';',
|
||||
counts.Select(pair => $"{pair.Key}:{pair.Value.ToString(CultureInfo.InvariantCulture)}"));
|
||||
}
|
||||
|
||||
private static string ToStatusKey(VexClaimStatus status)
|
||||
=> status switch
|
||||
{
|
||||
VexClaimStatus.Affected => "affected",
|
||||
VexClaimStatus.NotAffected => "not_affected",
|
||||
VexClaimStatus.Fixed => "fixed",
|
||||
VexClaimStatus.UnderInvestigation => "under_investigation",
|
||||
_ => status.ToString().ToLowerInvariant(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Providers;
|
||||
|
||||
public interface ISbomContextClient
|
||||
{
|
||||
Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class SbomContextQuery
|
||||
{
|
||||
public SbomContextQuery(
|
||||
string artifactId,
|
||||
string? purl,
|
||||
int maxTimelineEntries,
|
||||
int maxDependencyPaths,
|
||||
bool includeEnvironmentFlags,
|
||||
bool includeBlastRadius)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactId))
|
||||
{
|
||||
throw new ArgumentException("ArtifactId must be provided.", nameof(artifactId));
|
||||
}
|
||||
|
||||
ArtifactId = artifactId.Trim();
|
||||
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
|
||||
MaxTimelineEntries = Math.Max(0, maxTimelineEntries);
|
||||
MaxDependencyPaths = Math.Max(0, maxDependencyPaths);
|
||||
IncludeEnvironmentFlags = includeEnvironmentFlags;
|
||||
IncludeBlastRadius = includeBlastRadius;
|
||||
}
|
||||
|
||||
public string ArtifactId { get; }
|
||||
|
||||
public string? Purl { get; }
|
||||
|
||||
public int MaxTimelineEntries { get; }
|
||||
|
||||
public int MaxDependencyPaths { get; }
|
||||
|
||||
public bool IncludeEnvironmentFlags { get; }
|
||||
|
||||
public bool IncludeBlastRadius { get; }
|
||||
}
|
||||
|
||||
public sealed class SbomContextDocument
|
||||
{
|
||||
public SbomContextDocument(
|
||||
string artifactId,
|
||||
string? purl,
|
||||
ImmutableArray<SbomVersionRecord> versions,
|
||||
ImmutableArray<SbomDependencyPathRecord> dependencyPaths,
|
||||
ImmutableDictionary<string, string> environmentFlags,
|
||||
SbomBlastRadiusRecord? blastRadius,
|
||||
ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactId))
|
||||
{
|
||||
throw new ArgumentException("ArtifactId must be provided.", nameof(artifactId));
|
||||
}
|
||||
|
||||
ArtifactId = artifactId.Trim();
|
||||
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
|
||||
Versions = versions.IsDefault ? ImmutableArray<SbomVersionRecord>.Empty : versions;
|
||||
DependencyPaths = dependencyPaths.IsDefault ? ImmutableArray<SbomDependencyPathRecord>.Empty : dependencyPaths;
|
||||
EnvironmentFlags = environmentFlags == default ? ImmutableDictionary<string, string>.Empty : environmentFlags;
|
||||
BlastRadius = blastRadius;
|
||||
Metadata = metadata == default ? ImmutableDictionary<string, string>.Empty : metadata;
|
||||
}
|
||||
|
||||
public string ArtifactId { get; }
|
||||
|
||||
public string? Purl { get; }
|
||||
|
||||
public ImmutableArray<SbomVersionRecord> Versions { get; }
|
||||
|
||||
public ImmutableArray<SbomDependencyPathRecord> DependencyPaths { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> EnvironmentFlags { get; }
|
||||
|
||||
public SbomBlastRadiusRecord? BlastRadius { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
}
|
||||
|
||||
public sealed class SbomVersionRecord
|
||||
{
|
||||
public SbomVersionRecord(
|
||||
string version,
|
||||
DateTimeOffset firstObserved,
|
||||
DateTimeOffset? lastObserved,
|
||||
string status,
|
||||
string source,
|
||||
bool isFixAvailable,
|
||||
ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
throw new ArgumentException("Version must be provided.", nameof(version));
|
||||
}
|
||||
|
||||
Version = version.Trim();
|
||||
FirstObserved = firstObserved;
|
||||
LastObserved = lastObserved;
|
||||
Status = string.IsNullOrWhiteSpace(status) ? "unknown" : status.Trim();
|
||||
Source = string.IsNullOrWhiteSpace(source) ? "unknown" : source.Trim();
|
||||
IsFixAvailable = isFixAvailable;
|
||||
Metadata = metadata == default ? ImmutableDictionary<string, string>.Empty : metadata;
|
||||
}
|
||||
|
||||
public string Version { get; }
|
||||
|
||||
public DateTimeOffset FirstObserved { get; }
|
||||
|
||||
public DateTimeOffset? LastObserved { get; }
|
||||
|
||||
public string Status { get; }
|
||||
|
||||
public string Source { get; }
|
||||
|
||||
public bool IsFixAvailable { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
}
|
||||
|
||||
public sealed class SbomDependencyPathRecord
|
||||
{
|
||||
public SbomDependencyPathRecord(
|
||||
ImmutableArray<SbomDependencyNodeRecord> nodes,
|
||||
bool isRuntime,
|
||||
string? source,
|
||||
ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
Nodes = nodes.IsDefault ? ImmutableArray<SbomDependencyNodeRecord>.Empty : nodes;
|
||||
IsRuntime = isRuntime;
|
||||
Source = string.IsNullOrWhiteSpace(source) ? null : source.Trim();
|
||||
Metadata = metadata == default ? ImmutableDictionary<string, string>.Empty : metadata;
|
||||
}
|
||||
|
||||
public ImmutableArray<SbomDependencyNodeRecord> Nodes { get; }
|
||||
|
||||
public bool IsRuntime { get; }
|
||||
|
||||
public string? Source { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
}
|
||||
|
||||
public sealed class SbomDependencyNodeRecord
|
||||
{
|
||||
public SbomDependencyNodeRecord(string identifier, string? version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
throw new ArgumentException("Identifier must be provided.", nameof(identifier));
|
||||
}
|
||||
|
||||
Identifier = identifier.Trim();
|
||||
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
|
||||
}
|
||||
|
||||
public string Identifier { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
}
|
||||
|
||||
public sealed class SbomBlastRadiusRecord
|
||||
{
|
||||
public SbomBlastRadiusRecord(
|
||||
int impactedAssets,
|
||||
int impactedWorkloads,
|
||||
int impactedNamespaces,
|
||||
double? impactedPercentage,
|
||||
ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
ImpactedAssets = impactedAssets;
|
||||
ImpactedWorkloads = impactedWorkloads;
|
||||
ImpactedNamespaces = impactedNamespaces;
|
||||
ImpactedPercentage = impactedPercentage;
|
||||
Metadata = metadata == default ? ImmutableDictionary<string, string>.Empty : metadata;
|
||||
}
|
||||
|
||||
public int ImpactedAssets { get; }
|
||||
|
||||
public int ImpactedWorkloads { get; }
|
||||
|
||||
public int ImpactedNamespaces { get; }
|
||||
|
||||
public double? ImpactedPercentage { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Chunking;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Retrievers;
|
||||
|
||||
internal sealed class AdvisoryStructuredRetriever : IAdvisoryStructuredRetriever
|
||||
{
|
||||
private readonly IAdvisoryDocumentProvider _documentProvider;
|
||||
private readonly DocumentChunkerFactory _chunkerFactory;
|
||||
private readonly ILogger<AdvisoryStructuredRetriever>? _logger;
|
||||
|
||||
public AdvisoryStructuredRetriever(
|
||||
IAdvisoryDocumentProvider documentProvider,
|
||||
IEnumerable<IDocumentChunker> chunkers,
|
||||
ILogger<AdvisoryStructuredRetriever>? logger = null)
|
||||
{
|
||||
_documentProvider = documentProvider ?? throw new ArgumentNullException(nameof(documentProvider));
|
||||
_chunkerFactory = new DocumentChunkerFactory(chunkers ?? throw new ArgumentNullException(nameof(chunkers)));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AdvisoryRetrievalResult> RetrieveAsync(AdvisoryRetrievalRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var documents = await _documentProvider.GetDocumentsAsync(request.AdvisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
if (documents.Count == 0)
|
||||
{
|
||||
_logger?.LogWarning("No documents returned for advisory {AdvisoryKey}", request.AdvisoryKey);
|
||||
return AdvisoryRetrievalResult.Create(request.AdvisoryKey, Array.Empty<AdvisoryChunk>());
|
||||
}
|
||||
|
||||
var preferredSections = request.PreferredSections is null
|
||||
? null
|
||||
: request.PreferredSections.Select(section => section.Trim()).Where(static s => s.Length > 0).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var chunks = new List<AdvisoryChunk>();
|
||||
foreach (var document in documents)
|
||||
{
|
||||
var chunker = _chunkerFactory.Resolve(document.Format);
|
||||
foreach (var chunk in chunker.Chunk(document))
|
||||
{
|
||||
if (preferredSections is not null && !preferredSections.Contains(chunk.Section))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
chunks.Sort(static (left, right) => string.CompareOrdinal(left.ChunkId, right.ChunkId));
|
||||
|
||||
if (request.MaxChunks is int max && max > 0 && chunks.Count > max)
|
||||
{
|
||||
chunks = chunks.Take(max).ToList();
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["documents"] = string.Join(",", documents.Select(d => d.DocumentId)),
|
||||
["chunk_count"] = chunks.Count.ToString(),
|
||||
};
|
||||
|
||||
return AdvisoryRetrievalResult.Create(request.AdvisoryKey, chunks, metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Retrievers;
|
||||
|
||||
internal sealed class AdvisoryVectorRetriever : IAdvisoryVectorRetriever
|
||||
{
|
||||
private readonly IAdvisoryStructuredRetriever _structuredRetriever;
|
||||
private readonly IVectorEncoder _encoder;
|
||||
|
||||
public AdvisoryVectorRetriever(IAdvisoryStructuredRetriever structuredRetriever, IVectorEncoder encoder)
|
||||
{
|
||||
_structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever));
|
||||
_encoder = encoder ?? throw new ArgumentNullException(nameof(encoder));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VectorRetrievalMatch>> SearchAsync(VectorRetrievalRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.TopK <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request.TopK), "TopK must be a positive integer.");
|
||||
}
|
||||
|
||||
var retrieval = await _structuredRetriever.RetrieveAsync(request.Retrieval, cancellationToken).ConfigureAwait(false);
|
||||
if (retrieval.Chunks.Count == 0)
|
||||
{
|
||||
return Array.Empty<VectorRetrievalMatch>();
|
||||
}
|
||||
|
||||
var queryVector = _encoder.Encode(request.Query);
|
||||
var matches = new List<VectorRetrievalMatch>(retrieval.Chunks.Count);
|
||||
|
||||
foreach (var chunk in retrieval.Chunks)
|
||||
{
|
||||
var vector = chunk.Embedding ?? _encoder.Encode(chunk.Text);
|
||||
var score = CosineSimilarity(queryVector, vector);
|
||||
matches.Add(new VectorRetrievalMatch(chunk.DocumentId, chunk.ChunkId, chunk.Text, score, chunk.Metadata));
|
||||
}
|
||||
|
||||
matches.Sort(static (left, right) => right.Score.CompareTo(left.Score));
|
||||
if (matches.Count > request.TopK)
|
||||
{
|
||||
matches.RemoveRange(request.TopK, matches.Count - request.TopK);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
private static double CosineSimilarity(float[] left, float[] right)
|
||||
{
|
||||
var length = Math.Min(left.Length, right.Length);
|
||||
double dot = 0;
|
||||
double leftNorm = 0;
|
||||
double rightNorm = 0;
|
||||
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
var l = left[i];
|
||||
var r = right[i];
|
||||
dot += l * r;
|
||||
leftNorm += l * l;
|
||||
rightNorm += r * r;
|
||||
}
|
||||
|
||||
if (leftNorm <= 0 || rightNorm <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dot / Math.Sqrt(leftNorm * rightNorm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Retrievers;
|
||||
|
||||
internal sealed class SbomContextRetriever : ISbomContextRetriever
|
||||
{
|
||||
private readonly ISbomContextClient _client;
|
||||
private readonly ILogger<SbomContextRetriever>? _logger;
|
||||
|
||||
public SbomContextRetriever(ISbomContextClient client, ILogger<SbomContextRetriever>? logger = null)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SbomContextResult> RetrieveAsync(SbomContextRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var query = new SbomContextQuery(
|
||||
request.ArtifactId,
|
||||
request.Purl,
|
||||
request.MaxTimelineEntries,
|
||||
request.MaxDependencyPaths,
|
||||
request.IncludeEnvironmentFlags,
|
||||
request.IncludeBlastRadius);
|
||||
|
||||
SbomContextDocument? document;
|
||||
try
|
||||
{
|
||||
document = await _client.GetContextAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to retrieve SBOM context for artifact {ArtifactId}", request.ArtifactId);
|
||||
document = null;
|
||||
}
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
_logger?.LogWarning("No SBOM context returned for artifact {ArtifactId}", request.ArtifactId);
|
||||
return SbomContextResult.Empty(request.ArtifactId, request.Purl);
|
||||
}
|
||||
|
||||
var timeline = ShapeTimeline(document.Versions, request.MaxTimelineEntries);
|
||||
var paths = ShapeDependencyPaths(document.DependencyPaths, request.MaxDependencyPaths);
|
||||
var environmentFlags = request.IncludeEnvironmentFlags
|
||||
? NormalizeEnvironmentFlags(document.EnvironmentFlags)
|
||||
: ImmutableDictionary<string, string>.Empty;
|
||||
var blastRadius = request.IncludeBlastRadius ? ShapeBlastRadius(document.BlastRadius) : null;
|
||||
var metadata = BuildMetadata(document, timeline.Count, paths.Count, environmentFlags.Count, blastRadius is not null);
|
||||
|
||||
return SbomContextResult.Create(
|
||||
document.ArtifactId,
|
||||
document.Purl,
|
||||
timeline,
|
||||
paths,
|
||||
environmentFlags,
|
||||
blastRadius,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SbomVersionTimelineEntry> ShapeTimeline(ImmutableArray<SbomVersionRecord> versions, int max)
|
||||
{
|
||||
if (versions.IsDefaultOrEmpty || max == 0)
|
||||
{
|
||||
return Array.Empty<SbomVersionTimelineEntry>();
|
||||
}
|
||||
|
||||
return versions
|
||||
.OrderBy(static v => v.FirstObserved)
|
||||
.ThenBy(static v => v.Version, StringComparer.Ordinal)
|
||||
.Take(max > 0 ? max : int.MaxValue)
|
||||
.Select(static v => new SbomVersionTimelineEntry(
|
||||
v.Version,
|
||||
v.FirstObserved,
|
||||
v.LastObserved,
|
||||
string.IsNullOrWhiteSpace(v.Status)
|
||||
? (v.IsFixAvailable ? "fixed" : "unknown")
|
||||
: v.Status.Trim(),
|
||||
string.IsNullOrWhiteSpace(v.Source) ? "sbom" : v.Source.Trim()))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SbomDependencyPath> ShapeDependencyPaths(ImmutableArray<SbomDependencyPathRecord> paths, int max)
|
||||
{
|
||||
if (paths.IsDefaultOrEmpty || max == 0)
|
||||
{
|
||||
return Array.Empty<SbomDependencyPath>();
|
||||
}
|
||||
|
||||
var distinct = new SortedDictionary<string, SbomDependencyPath>(StringComparer.Ordinal);
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (path.Nodes.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nodeList = path.Nodes
|
||||
.Select(static node => new SbomDependencyNode(node.Identifier, node.Version))
|
||||
.ToImmutableArray();
|
||||
|
||||
if (nodeList.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = string.Join(
|
||||
"|",
|
||||
nodeList.Select(static n => string.Concat(n.Identifier, "@", n.Version ?? string.Empty)));
|
||||
|
||||
if (distinct.ContainsKey(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var dependencyPath = new SbomDependencyPath(
|
||||
nodeList,
|
||||
path.IsRuntime,
|
||||
string.IsNullOrWhiteSpace(path.Source) ? null : path.Source.Trim());
|
||||
|
||||
distinct[key] = dependencyPath;
|
||||
}
|
||||
|
||||
return distinct.Values
|
||||
.OrderBy(p => p.IsRuntime ? 0 : 1)
|
||||
.ThenBy(p => p.Nodes.Length)
|
||||
.ThenBy(p => string.Join("|", p.Nodes.Select(n => n.Identifier)), StringComparer.Ordinal)
|
||||
.Take(max > 0 ? max : int.MaxValue)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> NormalizeEnvironmentFlags(ImmutableDictionary<string, string> flags)
|
||||
{
|
||||
if (flags == default || flags.IsEmpty)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in flags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[pair.Key.Trim()] = pair.Value.Trim();
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static SbomBlastRadiusSummary? ShapeBlastRadius(SbomBlastRadiusRecord? record)
|
||||
{
|
||||
if (record is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var metadata = record.Metadata == default
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: record.Metadata.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
|
||||
return new SbomBlastRadiusSummary(
|
||||
record.ImpactedAssets,
|
||||
record.ImpactedWorkloads,
|
||||
record.ImpactedNamespaces,
|
||||
record.ImpactedPercentage,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildMetadata(
|
||||
SbomContextDocument document,
|
||||
int timelineCount,
|
||||
int pathCount,
|
||||
int environmentFlagCount,
|
||||
bool hasBlastRadius)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in document.Metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[pair.Key.Trim()] = pair.Value.Trim();
|
||||
}
|
||||
|
||||
builder["version_count"] = timelineCount.ToString();
|
||||
builder["dependency_path_count"] = pathCount.ToString();
|
||||
builder["environment_flag_count"] = environmentFlagCount.ToString();
|
||||
builder["blast_radius_present"] = hasBlastRadius.ToString();
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0-rc.2.25502.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# Advisory AI Task Board — Epic 8
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AIAI-31-001 | DOING (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. | Retrievers return deterministic chunks with source IDs/sections; unit tests cover CSAF/OSV/vendor formats. |
|
||||
| AIAI-31-002 | TODO | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. |
|
||||
| AIAI-31-003 | TODO | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
|
||||
| AIAI-31-004 | TODO | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
|
||||
| AIAI-31-005 | TODO | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
|
||||
| AIAI-31-006 | TODO | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
|
||||
| AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
|
||||
| AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. |
|
||||
| AIAI-31-009 | TODO | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. |
|
||||
# Advisory AI Task Board — Epic 8
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AIAI-31-001 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. | Retrievers return deterministic chunks with source IDs/sections; unit tests cover CSAF/OSV/vendor formats. |
|
||||
| AIAI-31-002 | DOING | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. |
|
||||
| AIAI-31-003 | TODO | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
|
||||
| AIAI-31-004 | TODO | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
|
||||
| AIAI-31-005 | TODO | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
|
||||
| AIAI-31-006 | TODO | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
|
||||
| AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
|
||||
| AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. |
|
||||
| AIAI-31-010 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement Concelier advisory raw document provider mapping CSAF/OSV payloads into structured chunks for retrieval. | Provider resolves content format, preserves metadata, and passes unit tests covering CSAF/OSV cases. |
|
||||
| AIAI-31-011 | DONE (2025-11-02) | Advisory AI Guild | EXCITITOR-LNM-21-201, EXCITITOR-CORE-AOC-19-002 | Implement Excititor VEX document provider to surface structured VEX statements for vector retrieval. | Provider returns conflict-aware VEX chunks with deterministic metadata and tests for representative statements. |
|
||||
| AIAI-31-009 | TODO | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. |
|
||||
|
||||
> 2025-11-02: AIAI-31-002 – SBOM context domain models finalized with limiter guards; retriever tests now cover flag toggles and path dedupe. Service client integration still pending with SBOM guild.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Buffers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Vectorization;
|
||||
|
||||
internal interface IVectorEncoder
|
||||
{
|
||||
float[] Encode(string text);
|
||||
}
|
||||
|
||||
internal sealed class DeterministicHashVectorEncoder : IVectorEncoder, IDisposable
|
||||
{
|
||||
private const int DefaultDimensions = 64;
|
||||
private static readonly Regex TokenRegex = new("[A-Za-z0-9]+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private readonly IncrementalHash _hash;
|
||||
private readonly int _dimensions;
|
||||
|
||||
public DeterministicHashVectorEncoder(int dimensions = DefaultDimensions)
|
||||
{
|
||||
if (dimensions <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(dimensions));
|
||||
}
|
||||
|
||||
_dimensions = dimensions;
|
||||
_hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
}
|
||||
|
||||
public float[] Encode(string text)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
|
||||
var vector = new float[_dimensions];
|
||||
var tokenMatches = TokenRegex.Matches(text);
|
||||
if (tokenMatches.Count == 0)
|
||||
{
|
||||
return vector;
|
||||
}
|
||||
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
|
||||
foreach (Match match in tokenMatches)
|
||||
{
|
||||
var token = match.Value.ToLowerInvariant();
|
||||
var bytes = Encoding.UTF8.GetBytes(token);
|
||||
_hash.AppendData(bytes);
|
||||
_hash.GetHashAndReset(hash);
|
||||
var index = (int)(BitConverter.ToUInt32(hash[..4]) % (uint)_dimensions);
|
||||
vector[index] += 1f;
|
||||
}
|
||||
|
||||
Normalize(vector);
|
||||
return vector;
|
||||
}
|
||||
|
||||
private static void Normalize(float[] vector)
|
||||
{
|
||||
var sumSquares = vector.Sum(v => v * v);
|
||||
if (sumSquares <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var length = MathF.Sqrt(sumSquares);
|
||||
for (var i = 0; i < vector.Length; i++)
|
||||
{
|
||||
vector[i] /= length;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hash.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Chunking;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Retrievers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class AdvisoryStructuredRetrieverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_ReturnsCsafChunksWithMetadata()
|
||||
{
|
||||
var provider = CreateProvider(
|
||||
"test-advisory",
|
||||
AdvisoryDocument.Create(
|
||||
"CSA-2024-0001",
|
||||
DocumentFormat.Csaf,
|
||||
"csaf",
|
||||
await LoadAsync("sample-csaf.json")));
|
||||
|
||||
var retriever = CreateRetriever(provider);
|
||||
var result = await retriever.RetrieveAsync(new AdvisoryRetrievalRequest("test-advisory"), CancellationToken.None);
|
||||
|
||||
result.Chunks.Should().NotBeEmpty();
|
||||
result.Chunks.Should().HaveCountGreaterThan(4);
|
||||
result.Chunks.Select(c => c.ChunkId).Should().BeInAscendingOrder();
|
||||
result.Chunks.All(c => c.Metadata["format"] == "csaf").Should().BeTrue();
|
||||
result.Chunks.Any(c => c.Section == "vulnerabilities.remediations").Should().BeTrue();
|
||||
result.Chunks.Any(c => c.Section == "document.notes").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_ReturnsOsvChunksWithAffectedMetadata()
|
||||
{
|
||||
var provider = CreateProvider(
|
||||
"osv-advisory",
|
||||
AdvisoryDocument.Create(
|
||||
"OSV-2024-0001",
|
||||
DocumentFormat.Osv,
|
||||
"osv",
|
||||
await LoadAsync("sample-osv.json")));
|
||||
|
||||
var retriever = CreateRetriever(provider);
|
||||
var result = await retriever.RetrieveAsync(new AdvisoryRetrievalRequest("osv-advisory"), CancellationToken.None);
|
||||
|
||||
result.Chunks.Should().NotBeEmpty();
|
||||
result.Chunks.Should().ContainSingle(c => c.Section == "summary");
|
||||
result.Chunks.Should().Contain(c => c.Section == "affected.ranges");
|
||||
result.Chunks.First(c => c.Section == "affected.ranges").Metadata.Should().ContainKey("package");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_ReturnsOpenVexChunksWithStatusMetadata()
|
||||
{
|
||||
var provider = CreateProvider(
|
||||
"openvex-advisory",
|
||||
AdvisoryDocument.Create(
|
||||
"OPENVEX-2024-0001",
|
||||
DocumentFormat.OpenVex,
|
||||
"exc-provider",
|
||||
await LoadAsync("sample-openvex.json")));
|
||||
|
||||
var retriever = CreateRetriever(provider);
|
||||
var result = await retriever.RetrieveAsync(new AdvisoryRetrievalRequest("openvex-advisory"), CancellationToken.None);
|
||||
|
||||
result.Chunks.Should().HaveCount(2);
|
||||
result.Chunks.Select(c => c.Metadata["status"]).Should().Contain(new[] { "not_affected", "affected" });
|
||||
result.Chunks.First().Metadata.Should().ContainKey("justification");
|
||||
result.Chunks.Should().AllSatisfy(chunk => chunk.Section.Should().Be("vex.statements"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_FiltersToPreferredSections()
|
||||
{
|
||||
var provider = CreateProvider(
|
||||
"markdown-advisory",
|
||||
AdvisoryDocument.Create(
|
||||
"VENDOR-2024-0001",
|
||||
DocumentFormat.Markdown,
|
||||
"vendor",
|
||||
await LoadAsync("sample-vendor.md")));
|
||||
|
||||
var retriever = CreateRetriever(provider);
|
||||
var request = new AdvisoryRetrievalRequest(
|
||||
"markdown-advisory",
|
||||
PreferredSections: new[] { "Impact" });
|
||||
|
||||
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
|
||||
|
||||
result.Chunks.Should().NotBeEmpty();
|
||||
result.Chunks.Should().OnlyContain(chunk => chunk.Section.StartsWith("Impact", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static AdvisoryStructuredRetriever CreateRetriever(IAdvisoryDocumentProvider provider)
|
||||
{
|
||||
var chunkers = new IDocumentChunker[]
|
||||
{
|
||||
new CsafDocumentChunker(),
|
||||
new OsvDocumentChunker(),
|
||||
new MarkdownDocumentChunker(),
|
||||
new OpenVexDocumentChunker(),
|
||||
};
|
||||
|
||||
return new AdvisoryStructuredRetriever(provider, chunkers, NullLogger<AdvisoryStructuredRetriever>.Instance);
|
||||
}
|
||||
|
||||
private static async Task<string> LoadAsync(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "TestData", fileName);
|
||||
return await File.ReadAllTextAsync(path);
|
||||
}
|
||||
|
||||
private static IAdvisoryDocumentProvider CreateProvider(string key, params AdvisoryDocument[] documents)
|
||||
=> new InMemoryAdvisoryDocumentProvider(new Dictionary<string, IReadOnlyList<AdvisoryDocument>>(StringComparer.Ordinal)
|
||||
{
|
||||
[key] = documents,
|
||||
});
|
||||
|
||||
private sealed class InMemoryAdvisoryDocumentProvider : IAdvisoryDocumentProvider
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<AdvisoryDocument>> _documents;
|
||||
|
||||
public InMemoryAdvisoryDocumentProvider(IReadOnlyDictionary<string, IReadOnlyList<AdvisoryDocument>> documents)
|
||||
{
|
||||
_documents = documents;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisoryDocument>> GetDocumentsAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_documents.TryGetValue(advisoryKey, out var documents))
|
||||
{
|
||||
return Task.FromResult(documents);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdvisoryDocument>>(Array.Empty<AdvisoryDocument>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Chunking;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Retrievers;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class AdvisoryVectorRetrieverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SearchAsync_ReturnsBestMatchingChunk()
|
||||
{
|
||||
var advisoryContent = """
|
||||
# Advisory
|
||||
|
||||
## Impact
|
||||
The vulnerability allows remote attackers to execute arbitrary code.
|
||||
|
||||
## Remediation
|
||||
Update to version 2.1.3 or later and restart the service.
|
||||
""";
|
||||
|
||||
var provider = new InMemoryAdvisoryDocumentProvider(new Dictionary<string, IReadOnlyList<AdvisoryDocument>>(StringComparer.Ordinal)
|
||||
{
|
||||
["adv"] = new[]
|
||||
{
|
||||
AdvisoryDocument.Create("VENDOR-1", DocumentFormat.Markdown, "vendor", advisoryContent)
|
||||
}
|
||||
});
|
||||
|
||||
var structuredRetriever = new AdvisoryStructuredRetriever(
|
||||
provider,
|
||||
new IDocumentChunker[]
|
||||
{
|
||||
new CsafDocumentChunker(),
|
||||
new OsvDocumentChunker(),
|
||||
new MarkdownDocumentChunker(),
|
||||
});
|
||||
|
||||
using var encoder = new DeterministicHashVectorEncoder();
|
||||
var vectorRetriever = new AdvisoryVectorRetriever(structuredRetriever, encoder);
|
||||
|
||||
var matches = await vectorRetriever.SearchAsync(
|
||||
new VectorRetrievalRequest(
|
||||
new AdvisoryRetrievalRequest("adv"),
|
||||
Query: "How do I remediate the vulnerability?",
|
||||
TopK: 1),
|
||||
CancellationToken.None);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].Section().Should().Be("Remediation");
|
||||
}
|
||||
}
|
||||
|
||||
file static class VectorRetrievalMatchExtensions
|
||||
{
|
||||
public static string Section(this VectorRetrievalMatch match)
|
||||
=> match.Metadata.TryGetValue("section", out var value) ? value : string.Empty;
|
||||
}
|
||||
|
||||
file sealed class InMemoryAdvisoryDocumentProvider : IAdvisoryDocumentProvider
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<AdvisoryDocument>> _documents;
|
||||
|
||||
public InMemoryAdvisoryDocumentProvider(IReadOnlyDictionary<string, IReadOnlyList<AdvisoryDocument>> documents)
|
||||
{
|
||||
_documents = documents;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisoryDocument>> GetDocumentsAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_documents.TryGetValue(advisoryKey, out var documents))
|
||||
{
|
||||
return Task.FromResult(documents);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdvisoryDocument>>(Array.Empty<AdvisoryDocument>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using StellaOps.Concelier.Core.Raw;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class ConcelierAdvisoryDocumentProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetDocumentsAsync_ReturnsMappedDocuments()
|
||||
{
|
||||
var rawDocument = RawDocumentFactory.CreateAdvisory(
|
||||
tenant: "tenant-a",
|
||||
source: new RawSourceMetadata("vendor-a", "connector", "1.0"),
|
||||
upstream: new RawUpstreamMetadata(
|
||||
"UP-1",
|
||||
"1",
|
||||
DateTimeOffset.UtcNow,
|
||||
"hash-123",
|
||||
new RawSignatureMetadata(false),
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
content: new RawContent("csaf", "2.0", JsonDocument.Parse("{\"document\": {\"notes\": []}, \"vulnerabilities\": []}").RootElement),
|
||||
identifiers: new RawIdentifiers(ImmutableArray<string>.Empty, "UP-1"),
|
||||
linkset: new RawLinkset());
|
||||
|
||||
var records = new[]
|
||||
{
|
||||
new AdvisoryRawRecord("id-1", rawDocument, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)
|
||||
};
|
||||
|
||||
var service = new FakeAdvisoryRawService(records);
|
||||
var provider = new ConcelierAdvisoryDocumentProvider(
|
||||
service,
|
||||
Options.Create(new ConcelierAdvisoryDocumentProviderOptions
|
||||
{
|
||||
Tenant = "tenant-a",
|
||||
MaxDocuments = 5,
|
||||
}),
|
||||
NullLogger<ConcelierAdvisoryDocumentProvider>.Instance);
|
||||
|
||||
var results = await provider.GetDocumentsAsync("UP-1", CancellationToken.None);
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Format.Should().Be(Documents.DocumentFormat.Csaf);
|
||||
results[0].Source.Should().Be("vendor-a");
|
||||
}
|
||||
|
||||
private sealed class FakeAdvisoryRawService : IAdvisoryRawService
|
||||
{
|
||||
private readonly IReadOnlyList<AdvisoryRawRecord> _records;
|
||||
|
||||
public FakeAdvisoryRawService(IReadOnlyList<AdvisoryRawRecord> records)
|
||||
{
|
||||
_records = records;
|
||||
}
|
||||
|
||||
public Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<AdvisoryRawRecord?>(null);
|
||||
|
||||
public Task<AdvisoryRawUpsertResult> IngestAsync(AdvisoryRawDocument document, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new AdvisoryRawQueryResult(_records, nextCursor: null, hasMore: false));
|
||||
|
||||
public Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class ExcititorVexDocumentProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetDocumentsAsync_ReturnsMappedObservation()
|
||||
{
|
||||
const string vulnerabilityId = "CVE-2024-9999";
|
||||
const string productKey = "product-key";
|
||||
const string packageUrl = "pkg:docker/sample@1.0.0";
|
||||
const string cpe = "cpe:/a:sample:service";
|
||||
const string providerId = "exc-provider";
|
||||
const string tenantId = "tenant-a";
|
||||
|
||||
var observation = CreateObservation(vulnerabilityId, productKey, packageUrl, cpe, providerId, tenantId);
|
||||
var aggregate = new VexObservationAggregate(
|
||||
ImmutableArray.Create(vulnerabilityId),
|
||||
ImmutableArray.Create(productKey),
|
||||
ImmutableArray.Create(packageUrl),
|
||||
ImmutableArray.Create(cpe),
|
||||
ImmutableArray<VexObservationReference>.Empty,
|
||||
ImmutableArray.Create(providerId));
|
||||
|
||||
var queryResult = new VexObservationQueryResult(
|
||||
ImmutableArray.Create(observation),
|
||||
aggregate,
|
||||
NextCursor: null,
|
||||
HasMore: false);
|
||||
|
||||
var service = new FakeVexObservationQueryService(queryResult);
|
||||
var provider = new ExcititorVexDocumentProvider(
|
||||
service,
|
||||
Options.Create(new ExcititorVexDocumentProviderOptions
|
||||
{
|
||||
Tenant = tenantId,
|
||||
MaxObservations = 5,
|
||||
ProviderIds = ImmutableArray.Create(providerId),
|
||||
Statuses = ImmutableArray.Create(VexClaimStatus.NotAffected),
|
||||
}),
|
||||
NullLogger<ExcititorVexDocumentProvider>.Instance);
|
||||
|
||||
var documents = await provider.GetDocumentsAsync(vulnerabilityId, CancellationToken.None);
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
var document = documents[0];
|
||||
document.DocumentId.Should().Be("obs-1");
|
||||
document.Format.Should().Be(DocumentFormat.OpenVex);
|
||||
document.Source.Should().Be(providerId);
|
||||
document.Metadata.Should().ContainKey("status_counts");
|
||||
document.Metadata["status_counts"].Should().Be("not_affected:1");
|
||||
document.Metadata.Should().ContainKey("aliases");
|
||||
|
||||
service.LastOptions.Should().NotBeNull();
|
||||
service.LastOptions!.Tenant.Should().Be(tenantId);
|
||||
service.LastOptions.ProviderIds.Should().ContainSingle().Which.Should().Be(providerId);
|
||||
service.LastOptions.Statuses.Should().ContainSingle(VexClaimStatus.NotAffected);
|
||||
service.LastOptions.VulnerabilityIds.Should().Contain(vulnerabilityId);
|
||||
service.LastOptions.Limit.Should().Be(5);
|
||||
}
|
||||
|
||||
private static VexObservation CreateObservation(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string packageUrl,
|
||||
string cpe,
|
||||
string providerId,
|
||||
string tenantId)
|
||||
{
|
||||
var upstream = new VexObservationUpstream(
|
||||
"VEX-1",
|
||||
1,
|
||||
DateTimeOffset.Parse("2025-10-10T08:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-10-10T08:05:00Z"),
|
||||
"hash-abc123",
|
||||
new VexObservationSignature(true, "dsse", "key-1", "signature"));
|
||||
|
||||
var evidence = ImmutableArray.Create<JsonNode>(JsonNode.Parse("{\"note\":\"deterministic\"}")!);
|
||||
|
||||
var statement = new VexObservationStatement(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
VexClaimStatus.NotAffected,
|
||||
DateTimeOffset.Parse("2025-10-10T08:00:00Z"),
|
||||
locator: "selector",
|
||||
justification: VexJustification.ComponentNotPresent,
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
purl: packageUrl,
|
||||
cpe: cpe,
|
||||
evidence: evidence,
|
||||
metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var content = new VexObservationContent(
|
||||
"openvex",
|
||||
"0.2",
|
||||
JsonNode.Parse("{\"statements\":[{\"status\":\"not_affected\"}]}")!);
|
||||
|
||||
var linkset = new VexObservationLinkset(
|
||||
aliases: new[] { vulnerabilityId },
|
||||
purls: new[] { packageUrl },
|
||||
cpes: new[] { cpe },
|
||||
references: null);
|
||||
|
||||
return new VexObservation(
|
||||
"obs-1",
|
||||
tenantId,
|
||||
providerId,
|
||||
"default",
|
||||
upstream,
|
||||
ImmutableArray.Create(statement),
|
||||
content,
|
||||
linkset,
|
||||
DateTimeOffset.Parse("2025-10-11T09:00:00Z"),
|
||||
supersedes: ImmutableArray<string>.Empty,
|
||||
attributes: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
private sealed class FakeVexObservationQueryService : IVexObservationQueryService
|
||||
{
|
||||
private readonly VexObservationQueryResult _result;
|
||||
|
||||
public FakeVexObservationQueryService(VexObservationQueryResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public VexObservationQueryOptions? LastOptions { get; private set; }
|
||||
|
||||
public ValueTask<VexObservationQueryResult> QueryAsync(
|
||||
VexObservationQueryOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastOptions = options;
|
||||
return ValueTask.FromResult(_result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class SbomContextRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_NormalizesWhitespaceAndLimits()
|
||||
{
|
||||
var request = new SbomContextRequest(
|
||||
artifactId: " scan-42 ",
|
||||
purl: " pkg:docker/sample@1.2.3 ",
|
||||
maxTimelineEntries: 600,
|
||||
maxDependencyPaths: -5,
|
||||
includeEnvironmentFlags: false,
|
||||
includeBlastRadius: false);
|
||||
|
||||
request.ArtifactId.Should().Be("scan-42");
|
||||
request.Purl.Should().Be("pkg:docker/sample@1.2.3");
|
||||
request.MaxTimelineEntries.Should().Be(SbomContextRequest.TimelineLimitCeiling);
|
||||
request.MaxDependencyPaths.Should().Be(0);
|
||||
request.IncludeEnvironmentFlags.Should().BeFalse();
|
||||
request.IncludeBlastRadius.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_AllowsNullPurlAndDefaults()
|
||||
{
|
||||
var request = new SbomContextRequest(artifactId: "scan-123", purl: null);
|
||||
|
||||
request.Purl.Should().BeNull();
|
||||
request.MaxTimelineEntries.Should().BeGreaterThan(0);
|
||||
request.MaxDependencyPaths.Should().BeGreaterThan(0);
|
||||
request.IncludeEnvironmentFlags.Should().BeTrue();
|
||||
request.IncludeBlastRadius.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using StellaOps.AdvisoryAI.Retrievers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class SbomContextRetrieverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_ReturnsDeterministicOrderingAndMetadata()
|
||||
{
|
||||
var document = new SbomContextDocument(
|
||||
"artifact-123",
|
||||
"pkg:docker/sample@1.0.0",
|
||||
ImmutableArray.Create(
|
||||
new SbomVersionRecord(
|
||||
"1.0.1",
|
||||
new DateTimeOffset(2025, 10, 15, 12, 0, 0, TimeSpan.Zero),
|
||||
null,
|
||||
"affected",
|
||||
"scanner",
|
||||
false,
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
new SbomVersionRecord(
|
||||
"1.0.0",
|
||||
new DateTimeOffset(2025, 9, 10, 8, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 10, 15, 11, 0, 0, TimeSpan.Zero),
|
||||
"fixed",
|
||||
"inventory",
|
||||
true,
|
||||
ImmutableDictionary<string, string>.Empty)),
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyPathRecord(
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyNodeRecord("app", "1.0.0"),
|
||||
new SbomDependencyNodeRecord("lib-a", "2.1.3"),
|
||||
new SbomDependencyNodeRecord("lib-b", "3.4.5")),
|
||||
true,
|
||||
"runtime",
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
new SbomDependencyPathRecord(
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyNodeRecord("app", "1.0.0"),
|
||||
new SbomDependencyNodeRecord("test-helper", "0.9.0")),
|
||||
false,
|
||||
"dev",
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
new SbomDependencyPathRecord(
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyNodeRecord("app", "1.0.0"),
|
||||
new SbomDependencyNodeRecord("lib-a", "2.1.3"),
|
||||
new SbomDependencyNodeRecord("lib-b", "3.4.5")),
|
||||
true,
|
||||
"runtime",
|
||||
ImmutableDictionary<string, string>.Empty)),
|
||||
ImmutableDictionary.CreateRange(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("environment/prod", "true"),
|
||||
new KeyValuePair<string, string>("environment/dev", "false"),
|
||||
}),
|
||||
new SbomBlastRadiusRecord(
|
||||
12,
|
||||
8,
|
||||
4,
|
||||
0.25,
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var client = new FakeSbomContextClient(document);
|
||||
var retriever = new SbomContextRetriever(client);
|
||||
|
||||
var request = new SbomContextRequest(
|
||||
artifactId: "artifact-123",
|
||||
purl: "pkg:docker/sample@1.0.0",
|
||||
maxTimelineEntries: 2,
|
||||
maxDependencyPaths: 2);
|
||||
|
||||
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
|
||||
|
||||
result.ArtifactId.Should().Be("artifact-123");
|
||||
result.Purl.Should().Be("pkg:docker/sample@1.0.0");
|
||||
result.VersionTimeline.Select(v => v.Version).Should().ContainInOrder("1.0.0", "1.0.1");
|
||||
result.DependencyPaths.Should().HaveCount(2);
|
||||
result.DependencyPaths.First().IsRuntime.Should().BeTrue();
|
||||
result.DependencyPaths.First().Nodes.Select(n => n.Identifier).Should().Equal("app", "lib-a", "lib-b");
|
||||
result.EnvironmentFlags.Keys.Should().Equal(new[] { "environment/dev", "environment/prod" });
|
||||
result.EnvironmentFlags["environment/prod"].Should().Be("true");
|
||||
result.BlastRadius.Should().NotBeNull();
|
||||
result.BlastRadius!.ImpactedAssets.Should().Be(12);
|
||||
result.Metadata["version_count"].Should().Be("2");
|
||||
result.Metadata["dependency_path_count"].Should().Be("2");
|
||||
result.Metadata["environment_flag_count"].Should().Be("2");
|
||||
result.Metadata["blast_radius_present"].Should().Be(bool.TrueString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_ReturnsEmptyWhenNoDocument()
|
||||
{
|
||||
var client = new FakeSbomContextClient(null);
|
||||
var retriever = new SbomContextRetriever(client);
|
||||
|
||||
var request = new SbomContextRequest("missing-artifact");
|
||||
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
|
||||
|
||||
result.ArtifactId.Should().Be("missing-artifact");
|
||||
result.VersionTimeline.Should().BeEmpty();
|
||||
result.DependencyPaths.Should().BeEmpty();
|
||||
result.EnvironmentFlags.Should().BeEmpty();
|
||||
result.BlastRadius.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_HonorsEnvironmentFlagToggle()
|
||||
{
|
||||
var document = new SbomContextDocument(
|
||||
"artifact-flag",
|
||||
null,
|
||||
ImmutableArray<SbomVersionRecord>.Empty,
|
||||
ImmutableArray<SbomDependencyPathRecord>.Empty,
|
||||
ImmutableDictionary.CreateRange(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("environment/prod", "true"),
|
||||
}),
|
||||
blastRadius: null,
|
||||
metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var client = new FakeSbomContextClient(document);
|
||||
var retriever = new SbomContextRetriever(client);
|
||||
|
||||
var request = new SbomContextRequest(
|
||||
artifactId: "artifact-flag",
|
||||
includeEnvironmentFlags: false,
|
||||
includeBlastRadius: false);
|
||||
|
||||
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
|
||||
|
||||
result.EnvironmentFlags.Should().BeEmpty();
|
||||
result.Metadata["environment_flag_count"].Should().Be("0");
|
||||
|
||||
client.LastQuery.Should().NotBeNull();
|
||||
client.LastQuery!.IncludeEnvironmentFlags.Should().BeFalse();
|
||||
client.LastQuery.IncludeBlastRadius.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_DeduplicatesDependencyPaths()
|
||||
{
|
||||
var duplicatePath = ImmutableArray.Create(
|
||||
new SbomDependencyNodeRecord("app", "1.0.0"),
|
||||
new SbomDependencyNodeRecord("lib-a", "2.0.0"));
|
||||
|
||||
var document = new SbomContextDocument(
|
||||
"artifact-paths",
|
||||
null,
|
||||
ImmutableArray<SbomVersionRecord>.Empty,
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyPathRecord(duplicatePath, true, "runtime", ImmutableDictionary<string, string>.Empty),
|
||||
new SbomDependencyPathRecord(duplicatePath, true, "runtime", ImmutableDictionary<string, string>.Empty),
|
||||
new SbomDependencyPathRecord(
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyNodeRecord("app", "1.0.0"),
|
||||
new SbomDependencyNodeRecord("dev-tool", "0.1.0")),
|
||||
false,
|
||||
"dev",
|
||||
ImmutableDictionary<string, string>.Empty)),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
blastRadius: null,
|
||||
metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var client = new FakeSbomContextClient(document);
|
||||
var retriever = new SbomContextRetriever(client);
|
||||
|
||||
var request = new SbomContextRequest(
|
||||
artifactId: "artifact-paths",
|
||||
maxDependencyPaths: 5);
|
||||
|
||||
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
|
||||
|
||||
result.DependencyPaths.Should().HaveCount(2);
|
||||
result.DependencyPaths.First().IsRuntime.Should().BeTrue();
|
||||
result.DependencyPaths.Last().IsRuntime.Should().BeFalse();
|
||||
result.Metadata["dependency_path_count"].Should().Be("2");
|
||||
}
|
||||
|
||||
private sealed class FakeSbomContextClient : ISbomContextClient
|
||||
{
|
||||
private readonly SbomContextDocument? _document;
|
||||
|
||||
public FakeSbomContextClient(SbomContextDocument? document)
|
||||
{
|
||||
_document = document;
|
||||
}
|
||||
|
||||
public SbomContextQuery? LastQuery { get; private set; }
|
||||
|
||||
public Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
LastQuery = query;
|
||||
return Task.FromResult(_document);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="TestData/*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData/*.md">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"document": {
|
||||
"tracking": {
|
||||
"id": "CSA-2024-0001"
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"category": "summary",
|
||||
"text": "The vendor has published guidance for CVE-2024-1234."
|
||||
},
|
||||
{
|
||||
"category": "description",
|
||||
"text": "Additional context for operators."
|
||||
},
|
||||
{
|
||||
"category": "other",
|
||||
"text": "This note should be ignored."
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "CVE-2024-1234",
|
||||
"title": "Important vulnerability in component",
|
||||
"description": "Remote attackers may execute arbitrary code.",
|
||||
"notes": [
|
||||
{
|
||||
"category": "description",
|
||||
"text": "Applies to installations using default configuration."
|
||||
}
|
||||
],
|
||||
"remediations": [
|
||||
{
|
||||
"category": "mitigation",
|
||||
"details": "Apply patch level QFE-2024-11 or later.",
|
||||
"product_ids": [
|
||||
"pkg:deb/debian/component@1.2.3"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"openvex": "https://openvex.dev/ns/v0.2",
|
||||
"timestamp": "2025-10-15T12:00:00Z",
|
||||
"version": "1",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "CVE-2024-9999",
|
||||
"products": [
|
||||
{
|
||||
"product_id": "pkg:docker/sample@1.0.0"
|
||||
}
|
||||
],
|
||||
"status": "not_affected",
|
||||
"justification": "component_not_present",
|
||||
"impact_statement": "Component not shipped",
|
||||
"status_notes": "Distribution excludes this component",
|
||||
"timestamp": "2025-10-10T08:00:00Z",
|
||||
"last_updated": "2025-10-11T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"vulnerability": "CVE-2024-8888",
|
||||
"products": [
|
||||
"component://sample/service"
|
||||
],
|
||||
"status": "affected",
|
||||
"status_notes": "Patch scheduled",
|
||||
"timestamp": "2025-10-12T13:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "OSV-2024-0001",
|
||||
"summary": "Vulnerability in package affects multiple versions.",
|
||||
"details": "Remote attackers may exploit the issue under specific conditions.",
|
||||
"affected": [
|
||||
{
|
||||
"package": {
|
||||
"name": "example",
|
||||
"ecosystem": "npm"
|
||||
},
|
||||
"ranges": [
|
||||
{
|
||||
"type": "SEMVER",
|
||||
"events": [
|
||||
{
|
||||
"introduced": "0"
|
||||
},
|
||||
{
|
||||
"fixed": "1.2.3"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"versions": ["0.9.0", "1.0.0"]
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"type": "ADVISORY",
|
||||
"url": "https://example.org/advisory"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Vendor Advisory 2024-01
|
||||
|
||||
Initial notice describing the vulnerability and affected platforms.
|
||||
|
||||
## Impact
|
||||
End-users may experience remote compromise when default credentials are unchanged.
|
||||
|
||||
## Remediation
|
||||
Apply hotfix package 2024.11.5 and rotate secrets within 24 hours.
|
||||
|
||||
## References
|
||||
- https://vendor.example.com/advisories/2024-01
|
||||
@@ -24,8 +24,10 @@ public class StellaOpsScopesTests
|
||||
[InlineData(StellaOpsScopes.PolicyAuthor)]
|
||||
[InlineData(StellaOpsScopes.PolicySubmit)]
|
||||
[InlineData(StellaOpsScopes.PolicyApprove)]
|
||||
[InlineData(StellaOpsScopes.PolicyReview)]
|
||||
[InlineData(StellaOpsScopes.PolicyOperate)]
|
||||
[InlineData(StellaOpsScopes.PolicyReview)]
|
||||
[InlineData(StellaOpsScopes.PolicyOperate)]
|
||||
[InlineData(StellaOpsScopes.PolicyPublish)]
|
||||
[InlineData(StellaOpsScopes.PolicyPromote)]
|
||||
[InlineData(StellaOpsScopes.PolicyAudit)]
|
||||
[InlineData(StellaOpsScopes.PolicyRun)]
|
||||
[InlineData(StellaOpsScopes.PolicySimulate)]
|
||||
@@ -72,6 +74,8 @@ public class StellaOpsScopesTests
|
||||
[InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)]
|
||||
[InlineData("AIRGAP:SEAL", StellaOpsScopes.AirgapSeal)]
|
||||
[InlineData("Policy:Author", StellaOpsScopes.PolicyAuthor)]
|
||||
[InlineData("Policy:Publish", StellaOpsScopes.PolicyPublish)]
|
||||
[InlineData("Policy:PROMOTE", StellaOpsScopes.PolicyPromote)]
|
||||
[InlineData("Export.Admin", StellaOpsScopes.ExportAdmin)]
|
||||
[InlineData("Advisory-AI:Operate", StellaOpsScopes.AdvisoryAiOperate)]
|
||||
[InlineData("Notify.Admin", StellaOpsScopes.NotifyAdmin)]
|
||||
|
||||
@@ -85,6 +85,26 @@ public static class StellaOpsClaimTypes
|
||||
/// </summary>
|
||||
public const string BackfillTicket = "stellaops:backfill_ticket";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the policy package being published or promoted.
|
||||
/// </summary>
|
||||
public const string PolicyDigest = "stellaops:policy_digest";
|
||||
|
||||
/// <summary>
|
||||
/// Change management ticket supplied when issuing policy publish/promote tokens.
|
||||
/// </summary>
|
||||
public const string PolicyTicket = "stellaops:policy_ticket";
|
||||
|
||||
/// <summary>
|
||||
/// Operator-provided justification supplied when issuing policy publish/promote tokens.
|
||||
/// </summary>
|
||||
public const string PolicyReason = "stellaops:policy_reason";
|
||||
|
||||
/// <summary>
|
||||
/// Operation discriminator indicating whether the policy token was issued for publish or promote.
|
||||
/// </summary>
|
||||
public const string PolicyOperation = "stellaops:policy_operation";
|
||||
|
||||
/// <summary>
|
||||
/// Incident activation reason recorded when issuing observability incident tokens.
|
||||
/// </summary>
|
||||
|
||||
@@ -154,14 +154,24 @@ public static class StellaOpsScopes
|
||||
public const string PolicyApprove = "policy:approve";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to operate Policy Studio promotions and runs.
|
||||
/// </summary>
|
||||
public const string PolicyOperate = "policy:operate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to audit Policy Studio activity.
|
||||
/// </summary>
|
||||
public const string PolicyAudit = "policy:audit";
|
||||
/// Scope granting permission to operate Policy Studio promotions and runs.
|
||||
/// </summary>
|
||||
public const string PolicyOperate = "policy:operate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to publish approved policy versions with attested artefacts.
|
||||
/// </summary>
|
||||
public const string PolicyPublish = "policy:publish";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to promote policy attestations between environments.
|
||||
/// </summary>
|
||||
public const string PolicyPromote = "policy:promote";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to audit Policy Studio activity.
|
||||
/// </summary>
|
||||
public const string PolicyAudit = "policy:audit";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to trigger policy runs and activation workflows.
|
||||
@@ -377,12 +387,14 @@ public static class StellaOpsScopes
|
||||
PolicyEdit,
|
||||
PolicyRead,
|
||||
PolicyReview,
|
||||
PolicySubmit,
|
||||
PolicyApprove,
|
||||
PolicyOperate,
|
||||
PolicyAudit,
|
||||
PolicyRun,
|
||||
PolicyActivate,
|
||||
PolicySubmit,
|
||||
PolicyApprove,
|
||||
PolicyOperate,
|
||||
PolicyPublish,
|
||||
PolicyPromote,
|
||||
PolicyAudit,
|
||||
PolicyRun,
|
||||
PolicyActivate,
|
||||
PolicySimulate,
|
||||
FindingsRead,
|
||||
EffectiveWrite,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Authority.Storage.Mongo;
|
||||
|
||||
internal static class AuthorityMongoCollectionNames
|
||||
{
|
||||
public const string ServiceAccounts = "authority_service_accounts";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a service account that can receive delegated tokens.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityServiceAccountDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("accountId")]
|
||||
public string AccountId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tenant")]
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("displayName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[BsonElement("description")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[BsonElement("enabled")]
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[BsonElement("allowedScopes")]
|
||||
public List<string> AllowedScopes { get; set; } = new();
|
||||
|
||||
[BsonElement("authorizedClients")]
|
||||
public List<string> AuthorizedClients { get; set; } = new();
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,75 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OAuth token issued by Authority.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityTokenDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("tokenId")]
|
||||
public string TokenId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[BsonElement("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("subjectId")]
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OAuth token issued by Authority.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityTokenDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("tokenId")]
|
||||
public string TokenId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[BsonElement("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
[BsonElement(tokenKind)]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SubjectId { get; set; }
|
||||
|
||||
[BsonElement("clientId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[BsonElement("scope")]
|
||||
public List<string> Scope { get; set; } = new();
|
||||
|
||||
[BsonElement("referenceId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ReferenceId { get; set; }
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "valid";
|
||||
|
||||
[BsonElement("payload")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Payload { get; set; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("expiresAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
[BsonElement("revokedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
[BsonElement("revokedReason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedReason { get; set; }
|
||||
|
||||
[BsonElement("revokedReasonDescription")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedReasonDescription { get; set; }
|
||||
|
||||
[BsonElement("senderConstraint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderConstraint { get; set; }
|
||||
|
||||
[BsonElement("senderKeyThumbprint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderKeyThumbprint { get; set; }
|
||||
public string? TokenKind { get; set; }
|
||||
|
||||
|
||||
[BsonElement("subjectId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SubjectId { get; set; }
|
||||
|
||||
[BsonElement("clientId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[BsonElement("scope")]
|
||||
public List<string> Scope { get; set; } = new();
|
||||
|
||||
[BsonElement("referenceId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ReferenceId { get; set; }
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "valid";
|
||||
|
||||
[BsonElement("payload")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Payload { get; set; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("expiresAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
[BsonElement("revokedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
[BsonElement("revokedReason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedReason { get; set; }
|
||||
|
||||
[BsonElement("revokedReasonDescription")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedReasonDescription { get; set; }
|
||||
|
||||
[BsonElement("senderConstraint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderConstraint { get; set; }
|
||||
|
||||
[BsonElement("senderKeyThumbprint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderKeyThumbprint { get; set; }
|
||||
|
||||
[BsonElement("senderNonce")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderNonce { get; set; }
|
||||
@@ -81,16 +85,24 @@ public sealed class AuthorityTokenDocument
|
||||
[BsonElement("tenant")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
[BsonElement("project")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Project { get; set; }
|
||||
|
||||
[BsonElement("devices")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<BsonDocument>? Devices { get; set; }
|
||||
|
||||
[BsonElement("revokedMetadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? RevokedMetadata { get; set; }
|
||||
|
||||
[BsonElement("project")]
|
||||
[BsonElement(serviceAccountId)]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Project { get; set; }
|
||||
public string? ServiceAccountId { get; set; }
|
||||
|
||||
[BsonElement("devices")]
|
||||
[BsonElement(actors)]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<BsonDocument>? Devices { get; set; }
|
||||
|
||||
[BsonElement("revokedMetadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? RevokedMetadata { get; set; }
|
||||
}
|
||||
public List<string>? ActorChain { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Migrations;
|
||||
using StellaOps.Authority.Storage.Mongo.Options;
|
||||
@@ -112,6 +113,12 @@ public static class ServiceCollectionExtensions
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityAirgapAuditDocument>(AuthorityMongoDefaults.Collections.AirgapAudit);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityServiceAccountDocument>(AuthorityMongoCollectionNames.ServiceAccounts);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
|
||||
@@ -121,6 +128,7 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityAirgapAuditCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityServiceAccountCollectionInitializer>();
|
||||
|
||||
services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
|
||||
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
|
||||
@@ -131,6 +139,7 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
|
||||
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
|
||||
services.TryAddSingleton<IAuthorityAirgapAuditStore, AuthorityAirgapAuditStore>();
|
||||
services.TryAddSingleton<IAuthorityServiceAccountStore, AuthorityServiceAccountStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityServiceAccountCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
var collection = database.GetCollection<AuthorityServiceAccountDocument>(AuthorityMongoCollectionNames.ServiceAccounts);
|
||||
|
||||
var indexModels = new[]
|
||||
{
|
||||
new CreateIndexModel<AuthorityServiceAccountDocument>(
|
||||
Builders<AuthorityServiceAccountDocument>.IndexKeys.Ascending(account => account.AccountId),
|
||||
new CreateIndexOptions { Name = "service_account_id_unique", Unique = true }),
|
||||
new CreateIndexModel<AuthorityServiceAccountDocument>(
|
||||
Builders<AuthorityServiceAccountDocument>.IndexKeys.Ascending(account => account.Tenant).Ascending(account => account.Enabled),
|
||||
new CreateIndexOptions { Name = "service_account_tenant_enabled" }),
|
||||
new CreateIndexModel<AuthorityServiceAccountDocument>(
|
||||
Builders<AuthorityServiceAccountDocument>.IndexKeys.Ascending("authorizedClients"),
|
||||
new CreateIndexOptions { Name = "service_account_authorized_clients" })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Migrations;
|
||||
|
||||
@@ -16,7 +17,8 @@ internal sealed class EnsureAuthorityCollectionsMigration : IAuthorityMongoMigra
|
||||
AuthorityMongoDefaults.Collections.Scopes,
|
||||
AuthorityMongoDefaults.Collections.Tokens,
|
||||
AuthorityMongoDefaults.Collections.LoginAttempts,
|
||||
AuthorityMongoDefaults.Collections.AirgapAudit
|
||||
AuthorityMongoDefaults.Collections.AirgapAudit,
|
||||
AuthorityMongoCollectionNames.ServiceAccounts
|
||||
};
|
||||
|
||||
private readonly ILogger<EnsureAuthorityCollectionsMigration> logger;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityServiceAccountStore : IAuthorityServiceAccountStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityServiceAccountDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityServiceAccountStore> logger;
|
||||
|
||||
public AuthorityServiceAccountStore(
|
||||
IMongoCollection<AuthorityServiceAccountDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityServiceAccountStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(
|
||||
string accountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = accountId.Trim();
|
||||
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.AccountId, normalized);
|
||||
var cursor = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await cursor.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(
|
||||
string tenant,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Array.Empty<AuthorityServiceAccountDocument>();
|
||||
}
|
||||
|
||||
var normalized = tenant.Trim().ToLowerInvariant();
|
||||
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.Tenant, normalized);
|
||||
var cursor = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
var results = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return results;
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(
|
||||
AuthorityServiceAccountDocument document,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
NormalizeDocument(document);
|
||||
var now = clock.GetUtcNow();
|
||||
document.UpdatedAt = now;
|
||||
document.CreatedAt = document.CreatedAt == default ? now : document.CreatedAt;
|
||||
|
||||
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.AccountId, document.AccountId);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
ReplaceOneResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority service account {AccountId}.", document.AccountId);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Updated Authority service account {AccountId}.", document.AccountId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
string accountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = accountId.Trim();
|
||||
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.AccountId, normalized);
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteOneAsync(session, filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.DeletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Deleted Authority service account {AccountId}.", normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void NormalizeDocument(AuthorityServiceAccountDocument document)
|
||||
{
|
||||
document.AccountId = string.IsNullOrWhiteSpace(document.AccountId)
|
||||
? string.Empty
|
||||
: document.AccountId.Trim().ToLowerInvariant();
|
||||
|
||||
document.Tenant = string.IsNullOrWhiteSpace(document.Tenant)
|
||||
? string.Empty
|
||||
: document.Tenant.Trim().ToLowerInvariant();
|
||||
|
||||
NormalizeList(document.AllowedScopes, static scope => scope.Trim().ToLowerInvariant(), StringComparer.Ordinal);
|
||||
NormalizeList(document.AuthorizedClients, static client => client.Trim().ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, Func<string, string> normalizer, IEqualityComparer<string> comparer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
ArgumentNullException.ThrowIfNull(normalizer);
|
||||
comparer ??= StringComparer.Ordinal;
|
||||
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(comparer);
|
||||
for (var index = values.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var current = values[index];
|
||||
if (string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = normalizer(current);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[index] = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal interface IAuthorityServiceAccountStore
|
||||
{
|
||||
ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(string tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -68,12 +68,13 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<Authorit
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.AddAuthentication(options =>
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,12 +144,13 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<Authorit
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.AddAuthentication(options =>
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -386,6 +386,74 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal(new[] { "policy:author" }, grantedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsPolicyPublishForClientCredentials()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "policy:publish",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:publish");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
|
||||
Assert.Equal("Scope 'policy:publish' requires interactive authentication.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsPolicyPromoteForClientCredentials()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "policy:promote",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:promote");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
|
||||
Assert.Equal("Scope 'policy:promote' requires interactive authentication.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.PolicyPromote, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsAdvisoryReadWithAocVerify()
|
||||
{
|
||||
@@ -3163,6 +3231,185 @@ public class ObservabilityIncidentTokenHandlerTests
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationMissingClaims()
|
||||
{
|
||||
var clientDocument = CreateClient(tenant: "tenant-alpha");
|
||||
var tokenStore = new TestTokenStore
|
||||
{
|
||||
Inserted = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = "token-policy",
|
||||
Status = "valid",
|
||||
ClientId = clientDocument.ClientId,
|
||||
Tenant = "tenant-alpha",
|
||||
Scope = new List<string> { StellaOpsScopes.PolicyPublish },
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
new TestClientStore(clientDocument),
|
||||
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
|
||||
metadataAccessor,
|
||||
auditSink,
|
||||
TimeProvider.System,
|
||||
ActivitySource,
|
||||
NullLogger<ValidateAccessTokenHandler>.Instance);
|
||||
|
||||
var transaction = new OpenIddictServerTransaction
|
||||
{
|
||||
Options = new OpenIddictServerOptions(),
|
||||
EndpointType = OpenIddictServerEndpointType.Token,
|
||||
Request = new OpenIddictRequest()
|
||||
};
|
||||
|
||||
var principal = CreatePrincipal(clientDocument.ClientId, "token-policy", clientDocument.Plugin);
|
||||
principal.SetScopes(StellaOpsScopes.PolicyPublish);
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue);
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy");
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2000");
|
||||
principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
|
||||
{
|
||||
Principal = principal,
|
||||
TokenId = "token-policy"
|
||||
};
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenHandler_RejectsPolicyAttestationNotFresh()
|
||||
{
|
||||
var clientDocument = CreateClient(tenant: "tenant-alpha");
|
||||
var tokenStore = new TestTokenStore
|
||||
{
|
||||
Inserted = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = "token-policy-stale",
|
||||
Status = "valid",
|
||||
ClientId = clientDocument.ClientId,
|
||||
Tenant = "tenant-alpha",
|
||||
Scope = new List<string> { StellaOpsScopes.PolicyPublish },
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20)
|
||||
}
|
||||
};
|
||||
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
new TestClientStore(clientDocument),
|
||||
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
|
||||
metadataAccessor,
|
||||
auditSink,
|
||||
TimeProvider.System,
|
||||
ActivitySource,
|
||||
NullLogger<ValidateAccessTokenHandler>.Instance);
|
||||
|
||||
var transaction = new OpenIddictServerTransaction
|
||||
{
|
||||
Options = new OpenIddictServerOptions(),
|
||||
EndpointType = OpenIddictServerEndpointType.Token,
|
||||
Request = new OpenIddictRequest()
|
||||
};
|
||||
|
||||
var principal = CreatePrincipal(clientDocument.ClientId, "token-policy-stale", clientDocument.Plugin);
|
||||
principal.SetScopes(StellaOpsScopes.PolicyPublish);
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue);
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyDigest, new string('a', 64));
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Publish approved policy");
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2001");
|
||||
var staleAuth = DateTimeOffset.UtcNow.AddMinutes(-10);
|
||||
principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, staleAuth.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
|
||||
{
|
||||
Principal = principal,
|
||||
TokenId = "token-policy-stale"
|
||||
};
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StellaOpsScopes.PolicyPublish, AuthorityOpenIddictConstants.PolicyOperationPublishValue)]
|
||||
[InlineData(StellaOpsScopes.PolicyPromote, AuthorityOpenIddictConstants.PolicyOperationPromoteValue)]
|
||||
public async Task ValidateAccessTokenHandler_AllowsPolicyAttestationWithMetadata(string scope, string expectedOperation)
|
||||
{
|
||||
var clientDocument = CreateClient(tenant: "tenant-alpha");
|
||||
var tokenStore = new TestTokenStore
|
||||
{
|
||||
Inserted = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = $"token-{expectedOperation}",
|
||||
Status = "valid",
|
||||
ClientId = clientDocument.ClientId,
|
||||
Tenant = "tenant-alpha",
|
||||
Scope = new List<string> { scope },
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
new TestClientStore(clientDocument),
|
||||
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
|
||||
metadataAccessor,
|
||||
auditSink,
|
||||
TimeProvider.System,
|
||||
ActivitySource,
|
||||
NullLogger<ValidateAccessTokenHandler>.Instance);
|
||||
|
||||
var transaction = new OpenIddictServerTransaction
|
||||
{
|
||||
Options = new OpenIddictServerOptions(),
|
||||
EndpointType = OpenIddictServerEndpointType.Token,
|
||||
Request = new OpenIddictRequest()
|
||||
};
|
||||
|
||||
var principal = CreatePrincipal(clientDocument.ClientId, $"token-{expectedOperation}", clientDocument.Plugin);
|
||||
principal.SetScopes(scope);
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyOperation, expectedOperation);
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyDigest, new string('b', 64));
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyReason, "Promotion approved");
|
||||
principal.SetClaim(StellaOpsClaimTypes.PolicyTicket, "CR-2002");
|
||||
principal.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
|
||||
{
|
||||
Principal = principal,
|
||||
TokenId = $"token-{expectedOperation}"
|
||||
};
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected);
|
||||
var metadata = metadataAccessor.GetMetadata();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.True(metadata!.Tags.TryGetValue("authority.policy_attestation_validated", out var tagValue));
|
||||
Assert.Equal(expectedOperation.ToLowerInvariant(), tagValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope()
|
||||
{
|
||||
|
||||
@@ -203,6 +203,137 @@ public class PasswordGrantHandlersTests
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutReason()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument("policy:publish"));
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish");
|
||||
transaction.Request.SetParameter("policy_ticket", "CR-1001");
|
||||
transaction.Request.SetParameter("policy_digest", new string('a', 64));
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await validate.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Policy attestation actions require 'policy_reason'.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
Assert.Contains(sink.Events, record =>
|
||||
record.EventType == "authority.password.grant" &&
|
||||
record.Outcome == AuthEventOutcome.Failure &&
|
||||
record.Properties.Any(property => property.Name == "policy.action"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutTicket()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument("policy:publish"));
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish");
|
||||
transaction.Request.SetParameter("policy_reason", "Publish approved policy");
|
||||
transaction.Request.SetParameter("policy_digest", new string('b', 64));
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await validate.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Policy attestation actions require 'policy_ticket'.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_RejectsPolicyPublishWithoutDigest()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument("policy:publish"));
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish");
|
||||
transaction.Request.SetParameter("policy_reason", "Publish approved policy");
|
||||
transaction.Request.SetParameter("policy_ticket", "CR-1002");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await validate.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Policy attestation actions require 'policy_digest'.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_RejectsPolicyPublishWithInvalidDigest()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientStore = new StubClientStore(CreateClientDocument("policy:publish"));
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish");
|
||||
transaction.Request.SetParameter("policy_reason", "Publish approved policy");
|
||||
transaction.Request.SetParameter("policy_ticket", "CR-1003");
|
||||
transaction.Request.SetParameter("policy_digest", "not-hex");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await validate.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("policy_digest must be a hexadecimal string between 32 and 128 characters.", context.ErrorDescription);
|
||||
Assert.Equal(StellaOpsScopes.PolicyPublish, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("policy:publish", AuthorityOpenIddictConstants.PolicyOperationPublishValue)]
|
||||
[InlineData("policy:promote", AuthorityOpenIddictConstants.PolicyOperationPromoteValue)]
|
||||
public async Task HandlePasswordGrant_AddsPolicyAttestationClaims(string scope, string expectedOperation)
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var clientDocument = CreateClientDocument(scope);
|
||||
var clientStore = new StubClientStore(clientDocument);
|
||||
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!", scope);
|
||||
transaction.Request.SetParameter("policy_reason", "Promote approved policy");
|
||||
transaction.Request.SetParameter("policy_ticket", "CR-1004");
|
||||
transaction.Request.SetParameter("policy_digest", new string('c', 64));
|
||||
|
||||
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await validate.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
|
||||
await handle.HandleAsync(handleContext);
|
||||
|
||||
Assert.False(handleContext.IsRejected);
|
||||
var principal = Assert.IsType<ClaimsPrincipal>(handleContext.Principal);
|
||||
Assert.Equal(expectedOperation, principal.GetClaim(StellaOpsClaimTypes.PolicyOperation));
|
||||
Assert.Equal(new string('c', 64), principal.GetClaim(StellaOpsClaimTypes.PolicyDigest));
|
||||
Assert.Equal("Promote approved policy", principal.GetClaim(StellaOpsClaimTypes.PolicyReason));
|
||||
Assert.Equal("CR-1004", principal.GetClaim(StellaOpsClaimTypes.PolicyTicket));
|
||||
Assert.Contains(sink.Events, record =>
|
||||
record.EventType == "authority.password.grant" &&
|
||||
record.Outcome == AuthEventOutcome.Success &&
|
||||
record.Properties.Any(property => property.Name == "policy.action"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_RejectsPolicyAuthorWithoutTenant()
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="../../../tests/shared/OpenSslLegacyShim.cs" Link="Infrastructure/OpenSslLegacyShim.cs" />
|
||||
<None Include="../../../tests/native/openssl-1.1/linux-x64/*" Link="native/linux-x64/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Compile Include="../../../../tests/shared/OpenSslLegacyShim.cs" Link="Infrastructure/OpenSslLegacyShim.cs" />
|
||||
<None Include="../../../../tests/native/openssl-1.1/linux-x64/*" Link="native/linux-x64/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -46,4 +46,14 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string BackfillTicketProperty = "authority:backfill_ticket";
|
||||
internal const string BackfillReasonParameterName = "backfill_reason";
|
||||
internal const string BackfillTicketParameterName = "backfill_ticket";
|
||||
internal const string PolicyReasonProperty = "authority:policy_reason";
|
||||
internal const string PolicyTicketProperty = "authority:policy_ticket";
|
||||
internal const string PolicyDigestProperty = "authority:policy_digest";
|
||||
internal const string PolicyOperationProperty = "authority:policy_operation";
|
||||
internal const string PolicyAuditPropertiesProperty = "authority:policy_audit_properties";
|
||||
internal const string PolicyReasonParameterName = "policy_reason";
|
||||
internal const string PolicyTicketParameterName = "policy_ticket";
|
||||
internal const string PolicyDigestParameterName = "policy_digest";
|
||||
internal const string PolicyOperationPublishValue = "publish";
|
||||
internal const string PolicyOperationPromoteValue = "promote";
|
||||
}
|
||||
|
||||
@@ -314,6 +314,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
var hasPolicyAuthor = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyAuthor) >= 0;
|
||||
var hasPolicyReview = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyReview) >= 0;
|
||||
var hasPolicyOperate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyOperate) >= 0;
|
||||
var hasPolicyPublish = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyPublish) >= 0;
|
||||
var hasPolicyPromote = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyPromote) >= 0;
|
||||
var hasPolicyAudit = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyAudit) >= 0;
|
||||
var hasPolicyApprove = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyApprove) >= 0;
|
||||
var hasPolicyRun = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyRun) >= 0;
|
||||
@@ -327,6 +329,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
var policyStudioScopesRequested = hasPolicyAuthor
|
||||
|| hasPolicyReview
|
||||
|| hasPolicyOperate
|
||||
|| hasPolicyPublish
|
||||
|| hasPolicyPromote
|
||||
|| hasPolicyAudit
|
||||
|| hasPolicyApprove
|
||||
|| hasPolicyRun
|
||||
@@ -662,6 +666,20 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPolicyPublish || hasPolicyPromote)
|
||||
{
|
||||
var restrictedScope = hasPolicyPublish ? StellaOpsScopes.PolicyPublish : StellaOpsScopes.PolicyPromote;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = restrictedScope;
|
||||
activity?.SetTag("authority.policy_attestation_denied", restrictedScope);
|
||||
var message = $"Scope '{restrictedScope}' requires interactive authentication.";
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidScope, message);
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: {Scope} is restricted to interactive authentication.",
|
||||
document.ClientId,
|
||||
restrictedScope);
|
||||
return;
|
||||
}
|
||||
|
||||
if (policyStudioScopesRequested && !EnsureTenantAssigned())
|
||||
{
|
||||
var policyScopeForAudit =
|
||||
|
||||
@@ -202,7 +202,29 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
|
||||
=> scopes.Length > 0 && Array.IndexOf(scopes, scope) >= 0;
|
||||
static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
static bool IsHexString(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (!System.Uri.IsHexDigit(ch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const int IncidentReasonMaxLength = 512;
|
||||
const int PolicyReasonMaxLength = 512;
|
||||
const int PolicyTicketMaxLength = 128;
|
||||
const int PolicyDigestMinLength = 32;
|
||||
const int PolicyDigestMaxLength = 128;
|
||||
|
||||
var hasAdvisoryIngest = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryIngest);
|
||||
var hasAdvisoryRead = ContainsScope(grantedScopesArray, StellaOpsScopes.AdvisoryRead);
|
||||
@@ -221,6 +243,8 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
|
||||
var hasPolicyAuthor = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyAuthor);
|
||||
var hasPolicyReview = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyReview);
|
||||
var hasPolicyOperate = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyOperate);
|
||||
var hasPolicyPublish = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyPublish);
|
||||
var hasPolicyPromote = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyPromote);
|
||||
var hasPolicyAudit = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyAudit);
|
||||
var hasPolicyApprove = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyApprove);
|
||||
var hasPolicyRun = ContainsScope(grantedScopesArray, StellaOpsScopes.PolicyRun);
|
||||
@@ -230,6 +254,8 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
|
||||
var policyStudioScopesRequested = hasPolicyAuthor
|
||||
|| hasPolicyReview
|
||||
|| hasPolicyOperate
|
||||
|| hasPolicyPublish
|
||||
|| hasPolicyPromote
|
||||
|| hasPolicyAudit
|
||||
|| hasPolicyApprove
|
||||
|| hasPolicyRun
|
||||
@@ -501,6 +527,126 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPolicyPublish || hasPolicyPromote)
|
||||
{
|
||||
var restrictedScope = hasPolicyPublish ? StellaOpsScopes.PolicyPublish : StellaOpsScopes.PolicyPromote;
|
||||
var policyOperation = hasPolicyPublish
|
||||
? AuthorityOpenIddictConstants.PolicyOperationPublishValue
|
||||
: AuthorityOpenIddictConstants.PolicyOperationPromoteValue;
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.PolicyOperationProperty] = policyOperation;
|
||||
activity?.SetTag("authority.policy_action", policyOperation);
|
||||
|
||||
var digestRaw = Normalize(context.Request.GetParameter(AuthorityOpenIddictConstants.PolicyDigestParameterName)?.Value?.ToString());
|
||||
var reasonRaw = Normalize(context.Request.GetParameter(AuthorityOpenIddictConstants.PolicyReasonParameterName)?.Value?.ToString());
|
||||
var ticketRaw = Normalize(context.Request.GetParameter(AuthorityOpenIddictConstants.PolicyTicketParameterName)?.Value?.ToString());
|
||||
|
||||
var policyAuditProperties = new List<AuthEventProperty>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "policy.action",
|
||||
Value = ClassifiedString.Public(policyOperation)
|
||||
}
|
||||
};
|
||||
|
||||
async ValueTask RejectPolicyAsync(string message)
|
||||
{
|
||||
activity?.SetTag("authority.policy_attestation_denied", message);
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = restrictedScope;
|
||||
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
AuthEventOutcome.Failure,
|
||||
message,
|
||||
clientId,
|
||||
providerName: null,
|
||||
tenant,
|
||||
user: null,
|
||||
username: context.Request.Username,
|
||||
scopes: grantedScopesArray,
|
||||
retryAfter: null,
|
||||
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
extraProperties: policyAuditProperties);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, message);
|
||||
logger.LogWarning(
|
||||
"Password grant validation failed for {Username}: {Message}.",
|
||||
context.Request.Username,
|
||||
message);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reasonRaw))
|
||||
{
|
||||
await RejectPolicyAsync("Policy attestation actions require 'policy_reason'.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reasonRaw.Length > PolicyReasonMaxLength)
|
||||
{
|
||||
await RejectPolicyAsync($"policy_reason must not exceed {PolicyReasonMaxLength} characters.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
policyAuditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.reason",
|
||||
Value = ClassifiedString.Sensitive(reasonRaw)
|
||||
});
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ticketRaw))
|
||||
{
|
||||
await RejectPolicyAsync("Policy attestation actions require 'policy_ticket'.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticketRaw.Length > PolicyTicketMaxLength)
|
||||
{
|
||||
await RejectPolicyAsync($"policy_ticket must not exceed {PolicyTicketMaxLength} characters.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
policyAuditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.ticket",
|
||||
Value = ClassifiedString.Sensitive(ticketRaw)
|
||||
});
|
||||
|
||||
if (string.IsNullOrWhiteSpace(digestRaw))
|
||||
{
|
||||
await RejectPolicyAsync("Policy attestation actions require 'policy_digest'.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var digestNormalized = digestRaw.ToLowerInvariant();
|
||||
if (digestNormalized.Length < PolicyDigestMinLength ||
|
||||
digestNormalized.Length > PolicyDigestMaxLength ||
|
||||
!IsHexString(digestNormalized))
|
||||
{
|
||||
await RejectPolicyAsync(
|
||||
$"policy_digest must be a hexadecimal string between {PolicyDigestMinLength} and {PolicyDigestMaxLength} characters.")
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
policyAuditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.digest",
|
||||
Value = ClassifiedString.Sensitive(digestNormalized)
|
||||
});
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.PolicyReasonProperty] = reasonRaw;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.PolicyTicketProperty] = ticketRaw;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.PolicyDigestProperty] = digestNormalized;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.PolicyAuditPropertiesProperty] = policyAuditProperties;
|
||||
activity?.SetTag("authority.policy_reason_present", true);
|
||||
activity?.SetTag("authority.policy_ticket_present", true);
|
||||
activity?.SetTag("authority.policy_digest_present", true);
|
||||
}
|
||||
|
||||
var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedPasswordGrantParameters(context.Request);
|
||||
if (unexpectedParameters.Count > 0)
|
||||
{
|
||||
@@ -926,6 +1072,34 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
activity?.SetTag("authority.incident_reason_present", true);
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyOperationProperty, out var policyOperationObj) &&
|
||||
policyOperationObj is string policyOperationValue &&
|
||||
!string.IsNullOrWhiteSpace(policyOperationValue))
|
||||
{
|
||||
identity.SetClaim(StellaOpsClaimTypes.PolicyOperation, policyOperationValue);
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyDigestProperty, out var policyDigestObj) &&
|
||||
policyDigestObj is string policyDigestValue &&
|
||||
!string.IsNullOrWhiteSpace(policyDigestValue))
|
||||
{
|
||||
identity.SetClaim(StellaOpsClaimTypes.PolicyDigest, policyDigestValue);
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyReasonProperty, out var policyReasonObj) &&
|
||||
policyReasonObj is string policyReasonValue &&
|
||||
!string.IsNullOrWhiteSpace(policyReasonValue))
|
||||
{
|
||||
identity.SetClaim(StellaOpsClaimTypes.PolicyReason, policyReasonValue);
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyTicketProperty, out var policyTicketObj) &&
|
||||
policyTicketObj is string policyTicketValue &&
|
||||
!string.IsNullOrWhiteSpace(policyTicketValue))
|
||||
{
|
||||
identity.SetClaim(StellaOpsClaimTypes.PolicyTicket, policyTicketValue);
|
||||
}
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
identity.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
@@ -968,6 +1142,69 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyAuditPropertiesProperty, out var successPolicyAuditObj) &&
|
||||
successPolicyAuditObj is List<AuthEventProperty> policyAuditProps &&
|
||||
policyAuditProps.Count > 0)
|
||||
{
|
||||
successProperties ??= new List<AuthEventProperty>();
|
||||
|
||||
foreach (var property in policyAuditProps)
|
||||
{
|
||||
if (property is null || string.IsNullOrWhiteSpace(property.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
successProperties.Add(property);
|
||||
}
|
||||
|
||||
context.Transaction.Properties.Remove(AuthorityOpenIddictConstants.PolicyAuditPropertiesProperty);
|
||||
}
|
||||
|
||||
var principalPolicyOperation = principal.GetClaim(StellaOpsClaimTypes.PolicyOperation);
|
||||
if (!string.IsNullOrWhiteSpace(principalPolicyOperation))
|
||||
{
|
||||
successProperties ??= new List<AuthEventProperty>();
|
||||
successProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.action",
|
||||
Value = ClassifiedString.Public(principalPolicyOperation)
|
||||
});
|
||||
}
|
||||
|
||||
var principalPolicyDigest = principal.GetClaim(StellaOpsClaimTypes.PolicyDigest);
|
||||
if (!string.IsNullOrWhiteSpace(principalPolicyDigest))
|
||||
{
|
||||
successProperties ??= new List<AuthEventProperty>();
|
||||
successProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.digest",
|
||||
Value = ClassifiedString.Sensitive(principalPolicyDigest)
|
||||
});
|
||||
}
|
||||
|
||||
var principalPolicyReason = principal.GetClaim(StellaOpsClaimTypes.PolicyReason);
|
||||
if (!string.IsNullOrWhiteSpace(principalPolicyReason))
|
||||
{
|
||||
successProperties ??= new List<AuthEventProperty>();
|
||||
successProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.reason",
|
||||
Value = ClassifiedString.Sensitive(principalPolicyReason)
|
||||
});
|
||||
}
|
||||
|
||||
var principalPolicyTicket = principal.GetClaim(StellaOpsClaimTypes.PolicyTicket);
|
||||
if (!string.IsNullOrWhiteSpace(principalPolicyTicket))
|
||||
{
|
||||
successProperties ??= new List<AuthEventProperty>();
|
||||
successProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.ticket",
|
||||
Value = ClassifiedString.Sensitive(principalPolicyTicket)
|
||||
});
|
||||
}
|
||||
|
||||
var successRecord = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
@@ -1039,23 +1276,27 @@ internal static class PasswordGrantAuditHelper
|
||||
var subject = BuildSubject(user, username, providerName);
|
||||
var client = BuildClient(clientId, providerName);
|
||||
var network = BuildNetwork(metadata);
|
||||
var properties = BuildProperties(user, retryAfter, failureCode, extraProperties);
|
||||
|
||||
return new AuthEventRecord
|
||||
{
|
||||
EventType = string.IsNullOrWhiteSpace(eventType) ? "authority.password.grant" : eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
var properties = BuildProperties(user, retryAfter, failureCode, extraProperties);
|
||||
var mutableProperties = properties.Count == 0
|
||||
? new List<AuthEventProperty>()
|
||||
: new List<AuthEventProperty>(properties);
|
||||
AppendPolicyMetadata(transaction, mutableProperties);
|
||||
|
||||
return new AuthEventRecord
|
||||
{
|
||||
EventType = string.IsNullOrWhiteSpace(eventType) ? "authority.password.grant" : eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId,
|
||||
Outcome = outcome,
|
||||
Reason = Normalize(reason),
|
||||
Subject = subject,
|
||||
Client = client,
|
||||
Scopes = normalizedScopes,
|
||||
Network = network,
|
||||
Tenant = ClassifiedString.Public(normalizedTenant),
|
||||
Properties = properties
|
||||
};
|
||||
}
|
||||
Subject = subject,
|
||||
Client = client,
|
||||
Scopes = normalizedScopes,
|
||||
Network = network,
|
||||
Tenant = ClassifiedString.Public(normalizedTenant),
|
||||
Properties = mutableProperties.Count == 0 ? Array.Empty<AuthEventProperty>() : mutableProperties
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthEventSubject? BuildSubject(AuthorityUserDescriptor? user, string? username, string? providerName)
|
||||
{
|
||||
@@ -1193,16 +1434,63 @@ internal static class PasswordGrantAuditHelper
|
||||
|
||||
properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeScopes(IEnumerable<string>? scopes)
|
||||
{
|
||||
if (scopes is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties;
|
||||
}
|
||||
|
||||
private static void AppendPolicyMetadata(OpenIddictServerTransaction transaction, List<AuthEventProperty> properties)
|
||||
{
|
||||
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyOperationProperty, out var operationObj) &&
|
||||
operationObj is string operationValue &&
|
||||
!string.IsNullOrWhiteSpace(operationValue))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.action",
|
||||
Value = ClassifiedString.Public(operationValue)
|
||||
});
|
||||
}
|
||||
|
||||
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyDigestProperty, out var digestObj) &&
|
||||
digestObj is string digestValue &&
|
||||
!string.IsNullOrWhiteSpace(digestValue))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.digest",
|
||||
Value = ClassifiedString.Sensitive(digestValue)
|
||||
});
|
||||
}
|
||||
|
||||
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyReasonProperty, out var reasonObj) &&
|
||||
reasonObj is string reasonValue &&
|
||||
!string.IsNullOrWhiteSpace(reasonValue))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.reason",
|
||||
Value = ClassifiedString.Sensitive(reasonValue)
|
||||
});
|
||||
}
|
||||
|
||||
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.PolicyTicketProperty, out var ticketObj) &&
|
||||
ticketObj is string ticketValue &&
|
||||
!string.IsNullOrWhiteSpace(ticketValue))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.ticket",
|
||||
Value = ClassifiedString.Sensitive(ticketValue)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeScopes(IEnumerable<string>? scopes)
|
||||
{
|
||||
if (scopes is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = scopes
|
||||
@@ -1216,11 +1504,11 @@ internal static class PasswordGrantAuditHelper
|
||||
return normalized.Length == 0 ? Array.Empty<string>() : normalized;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
internal static string? NormalizeTenant(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
internal static string? NormalizeTenant(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
|
||||
internal static AuthEventRecord CreateTamperRecord(
|
||||
TimeProvider timeProvider,
|
||||
|
||||
@@ -33,6 +33,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly ILogger<ValidateAccessTokenHandler> logger;
|
||||
private static readonly TimeSpan IncidentFreshAuthWindow = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan PolicyAttestationFreshAuthWindow = TimeSpan.FromMinutes(5);
|
||||
|
||||
public ValidateAccessTokenHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
@@ -363,6 +364,69 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
metadataAccessor.SetTag("authority.incident_scope_validated", "true");
|
||||
}
|
||||
|
||||
if (context.Principal.HasScope(StellaOpsScopes.PolicyPublish) ||
|
||||
context.Principal.HasScope(StellaOpsScopes.PolicyPromote))
|
||||
{
|
||||
var policyOperation = identity.GetClaim(StellaOpsClaimTypes.PolicyOperation);
|
||||
if (string.IsNullOrWhiteSpace(policyOperation) ||
|
||||
(!string.Equals(policyOperation, AuthorityOpenIddictConstants.PolicyOperationPublishValue, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(policyOperation, AuthorityOpenIddictConstants.PolicyOperationPromoteValue, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require a valid policy_operation claim.");
|
||||
logger.LogWarning("Access token validation failed: policy attestation token missing/invalid operation. ClientId={ClientId}", clientId ?? "<unknown>");
|
||||
return;
|
||||
}
|
||||
|
||||
var policyDigest = identity.GetClaim(StellaOpsClaimTypes.PolicyDigest);
|
||||
if (string.IsNullOrWhiteSpace(policyDigest))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require policy_digest claim.");
|
||||
logger.LogWarning("Access token validation failed: policy attestation token missing digest. ClientId={ClientId}", clientId ?? "<unknown>");
|
||||
return;
|
||||
}
|
||||
|
||||
var policyReason = identity.GetClaim(StellaOpsClaimTypes.PolicyReason);
|
||||
if (string.IsNullOrWhiteSpace(policyReason))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require policy_reason claim.");
|
||||
logger.LogWarning("Access token validation failed: policy attestation token missing reason. ClientId={ClientId}", clientId ?? "<unknown>");
|
||||
return;
|
||||
}
|
||||
|
||||
var policyTicket = identity.GetClaim(StellaOpsClaimTypes.PolicyTicket);
|
||||
if (string.IsNullOrWhiteSpace(policyTicket))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require policy_ticket claim.");
|
||||
logger.LogWarning("Access token validation failed: policy attestation token missing ticket. ClientId={ClientId}", clientId ?? "<unknown>");
|
||||
return;
|
||||
}
|
||||
|
||||
var authTimeClaim = context.Principal.GetClaim(OpenIddictConstants.Claims.AuthenticationTime);
|
||||
if (string.IsNullOrWhiteSpace(authTimeClaim) ||
|
||||
!long.TryParse(authTimeClaim, NumberStyles.Integer, CultureInfo.InvariantCulture, out var attestationAuthTimeSeconds))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require authentication_time claim.");
|
||||
logger.LogWarning("Access token validation failed: policy attestation token missing auth_time. ClientId={ClientId}", clientId ?? "<unknown>");
|
||||
return;
|
||||
}
|
||||
|
||||
var attestationAuthTime = DateTimeOffset.FromUnixTimeSeconds(attestationAuthTimeSeconds);
|
||||
var now = clock.GetUtcNow();
|
||||
if (now - attestationAuthTime > PolicyAttestationFreshAuthWindow)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidToken, "policy attestation tokens require fresh authentication.");
|
||||
logger.LogWarning(
|
||||
"Access token validation failed: policy attestation token stale. ClientId={ClientId}; AuthTime={AuthTime:o}; Now={Now:o}; Window={Window}",
|
||||
clientId ?? "<unknown>",
|
||||
attestationAuthTime,
|
||||
now,
|
||||
PolicyAttestationFreshAuthWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
metadataAccessor.SetTag("authority.policy_attestation_validated", policyOperation.ToLowerInvariant());
|
||||
}
|
||||
|
||||
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user, client);
|
||||
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Access token validated for subject {Subject} and client {ClientId}.",
|
||||
|
||||
@@ -36,12 +36,15 @@ internal static class TokenRequestTamperInspector
|
||||
OpenIddictConstants.Parameters.IdToken
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> PasswordGrantParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
OpenIddictConstants.Parameters.Username,
|
||||
OpenIddictConstants.Parameters.Password,
|
||||
AuthorityOpenIddictConstants.ProviderParameterName
|
||||
};
|
||||
private static readonly HashSet<string> PasswordGrantParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
OpenIddictConstants.Parameters.Username,
|
||||
OpenIddictConstants.Parameters.Password,
|
||||
AuthorityOpenIddictConstants.ProviderParameterName,
|
||||
AuthorityOpenIddictConstants.PolicyReasonParameterName,
|
||||
AuthorityOpenIddictConstants.PolicyTicketParameterName,
|
||||
AuthorityOpenIddictConstants.PolicyDigestParameterName
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ClientCredentialsParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
|
||||
@@ -50,7 +50,8 @@ using System.Text;
|
||||
using StellaOps.Authority.Signing;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Authority.OpenApi;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
@@ -383,9 +384,29 @@ builder.Services.Configure<OpenIddictServerOptions>(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var mongoInitializer = app.Services.GetRequiredService<AuthorityMongoInitializer>();
|
||||
var mongoDatabase = app.Services.GetRequiredService<IMongoDatabase>();
|
||||
await mongoInitializer.InitialiseAsync(mongoDatabase, CancellationToken.None);
|
||||
var mongoInitializer = app.Services.GetRequiredService<AuthorityMongoInitializer>();
|
||||
var mongoDatabase = app.Services.GetRequiredService<IMongoDatabase>();
|
||||
await mongoInitializer.InitialiseAsync(mongoDatabase, CancellationToken.None);
|
||||
|
||||
var serviceAccountStore = app.Services.GetRequiredService<IAuthorityServiceAccountStore>();
|
||||
if (authorityOptions.Delegation.ServiceAccounts.Count > 0)
|
||||
{
|
||||
foreach (var seed in authorityOptions.Delegation.ServiceAccounts)
|
||||
{
|
||||
var document = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = seed.AccountId,
|
||||
Tenant = seed.Tenant,
|
||||
DisplayName = string.IsNullOrWhiteSpace(seed.DisplayName) ? seed.AccountId : seed.DisplayName,
|
||||
Description = seed.Description,
|
||||
Enabled = seed.Enabled,
|
||||
AllowedScopes = seed.AllowedScopes.ToList(),
|
||||
AuthorizedClients = seed.AuthorizedClients.ToList()
|
||||
};
|
||||
|
||||
await serviceAccountStore.UpsertAsync(document, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var registrationSummary = app.Services.GetRequiredService<AuthorityPluginRegistrationSummary>();
|
||||
if (registrationSummary.RegisteredPlugins.Count > 0)
|
||||
@@ -1252,36 +1273,68 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
}
|
||||
});
|
||||
|
||||
bootstrapGroup.MapPost("/notifications/ack-tokens/rotate", (
|
||||
SigningRotationRequest? request,
|
||||
AuthorityAckTokenKeyManager ackManager,
|
||||
ILogger<AuthorityAckTokenKeyManager> ackLogger) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
ackLogger.LogWarning("Ack token rotation request payload missing.");
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
request.KeyId = trimmedKeyId;
|
||||
request.Location = trimmedLocation;
|
||||
|
||||
logger.LogDebug(
|
||||
"Attempting ack token rotation with keyId='{KeyId}', location='{Location}', provider='{Provider}', source='{Source}', algorithm='{Algorithm}'",
|
||||
trimmedKeyId,
|
||||
trimmedLocation,
|
||||
request.Provider ?? ackOptions.Provider,
|
||||
request.Source ?? ackOptions.KeySource,
|
||||
request.Algorithm ?? ackOptions.Algorithm);
|
||||
|
||||
var result = ackManager.Rotate(request);
|
||||
ackLogger.LogInformation("Ack token key rotation completed. Active key {KeyId}.", result.ActiveKeyId);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
activeKeyId = result.ActiveKeyId,
|
||||
bootstrapGroup.MapPost("/notifications/ack-tokens/rotate", (
|
||||
SigningRotationRequest? request,
|
||||
AuthorityAckTokenKeyManager ackManager,
|
||||
IOptions<StellaOpsAuthorityOptions> optionsAccessor,
|
||||
ILogger<AuthorityAckTokenKeyManager> ackLogger) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
ackLogger.LogWarning("Ack token rotation request payload missing.");
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var notifications = optionsAccessor.Value.Notifications ?? throw new InvalidOperationException("Authority notifications configuration is missing.");
|
||||
var ackOptions = notifications.AckTokens ?? throw new InvalidOperationException("Ack token configuration is missing.");
|
||||
|
||||
var trimmedKeyId = request.KeyId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedKeyId))
|
||||
{
|
||||
ackLogger.LogWarning("Ack token rotation rejected: missing keyId.");
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Ack token key rotation requires a keyId." });
|
||||
}
|
||||
|
||||
var trimmedLocation = request.Location?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedLocation))
|
||||
{
|
||||
ackLogger.LogWarning("Ack token rotation rejected: missing key path/location.");
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Ack token key rotation requires a key path/location." });
|
||||
}
|
||||
|
||||
if (!ackOptions.Enabled)
|
||||
{
|
||||
ackLogger.LogWarning("Ack token rotation attempted while ack tokens are disabled.");
|
||||
return Results.BadRequest(new { error = "ack_tokens_disabled", message = "Ack tokens are disabled. Enable notifications.ackTokens before rotating keys." });
|
||||
}
|
||||
|
||||
request.KeyId = trimmedKeyId;
|
||||
request.Location = trimmedLocation;
|
||||
|
||||
var provider = request.Provider ?? ackOptions.Provider;
|
||||
var source = request.Source ?? ackOptions.KeySource;
|
||||
var algorithm = request.Algorithm ?? ackOptions.Algorithm;
|
||||
|
||||
ackLogger.LogDebug(
|
||||
"Attempting ack token rotation with keyId='{KeyId}', location='{Location}', provider='{Provider}', source='{Source}', algorithm='{Algorithm}'",
|
||||
trimmedKeyId,
|
||||
trimmedLocation,
|
||||
provider,
|
||||
source,
|
||||
algorithm);
|
||||
|
||||
request.Provider = provider;
|
||||
request.Source = source;
|
||||
request.Algorithm = algorithm;
|
||||
|
||||
var result = ackManager.Rotate(request);
|
||||
ackLogger.LogInformation("Ack token key rotation completed. Active key {KeyId}.", result.ActiveKeyId);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
activeKeyId = result.ActiveKeyId,
|
||||
provider = result.ActiveProvider,
|
||||
source = result.ActiveSource,
|
||||
location = result.ActiveLocation,
|
||||
|
||||
@@ -69,9 +69,10 @@
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue.
|
||||
| AUTH-POLICY-27-002 | TODO | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
|
||||
| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
|
||||
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
|
||||
| AUTH-POLICY-27-003 | TODO | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
|
||||
> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references.
|
||||
| AUTH-POLICY-27-003 | DOING (2025-11-02) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
|
||||
|
||||
## Exceptions v1
|
||||
|
||||
@@ -111,6 +112,9 @@
|
||||
| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
|
||||
| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. |
|
||||
> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement.
|
||||
| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. |
|
||||
> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`.
|
||||
|
||||
|
||||
## CLI Parity & Task Packs
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
@@ -125,7 +129,7 @@
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
|
||||
| AUTH-TEN-49-001 | TODO | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
|
||||
| AUTH-TEN-49-001 | DOING (2025-11-02) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.RateLimiting;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
@@ -113,6 +114,11 @@ public sealed class StellaOpsAuthorityOptions
|
||||
/// </summary>
|
||||
public AuthoritySigningOptions Signing { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Delegation and service account configuration.
|
||||
/// </summary>
|
||||
public AuthorityDelegationOptions Delegation { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Validates configured values and normalises collections.
|
||||
/// </summary>
|
||||
@@ -154,6 +160,7 @@ public sealed class StellaOpsAuthorityOptions
|
||||
Notifications.Validate();
|
||||
ApiLifecycle.Validate();
|
||||
Signing.Validate();
|
||||
Delegation.NormalizeAndValidate(tenants);
|
||||
Plugins.NormalizeAndValidate();
|
||||
Storage.Validate();
|
||||
Exceptions.Validate();
|
||||
@@ -164,8 +171,8 @@ public sealed class StellaOpsAuthorityOptions
|
||||
var identifiers = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
tenant.Normalize(AdvisoryAi);
|
||||
tenant.Validate(AdvisoryAi);
|
||||
tenant.Normalize(AdvisoryAi, Delegation);
|
||||
tenant.Validate(AdvisoryAi, Delegation);
|
||||
if (!identifiers.Add(tenant.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Authority configuration contains duplicate tenant identifier '{tenant.Id}'.");
|
||||
@@ -767,7 +774,9 @@ public sealed class AuthorityTenantOptions
|
||||
|
||||
public AuthorityTenantAdvisoryAiOptions AdvisoryAi { get; } = new();
|
||||
|
||||
internal void Normalize(AuthorityAdvisoryAiOptions? advisoryAiOptions)
|
||||
public AuthorityTenantDelegationOptions Delegation { get; } = new();
|
||||
|
||||
internal void Normalize(AuthorityAdvisoryAiOptions? advisoryAiOptions, AuthorityDelegationOptions delegationOptions)
|
||||
{
|
||||
Id = (Id ?? string.Empty).Trim();
|
||||
DisplayName = (DisplayName ?? string.Empty).Trim();
|
||||
@@ -810,9 +819,10 @@ public sealed class AuthorityTenantOptions
|
||||
}
|
||||
|
||||
AdvisoryAi.Normalize(advisoryAiOptions);
|
||||
Delegation.Normalize(delegationOptions);
|
||||
}
|
||||
|
||||
internal void Validate(AuthorityAdvisoryAiOptions? advisoryAiOptions)
|
||||
internal void Validate(AuthorityAdvisoryAiOptions? advisoryAiOptions, AuthorityDelegationOptions delegationOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Id))
|
||||
{
|
||||
@@ -841,12 +851,186 @@ public sealed class AuthorityTenantOptions
|
||||
}
|
||||
|
||||
AdvisoryAi.Validate(advisoryAiOptions);
|
||||
Delegation.Validate(delegationOptions, Id);
|
||||
}
|
||||
|
||||
private static readonly Regex TenantSlugRegex = new("^[a-z0-9-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex ProjectSlugRegex = new("^[a-z0-9-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
public sealed class AuthorityDelegationOptions
|
||||
{
|
||||
private readonly IList<AuthorityServiceAccountSeedOptions> serviceAccounts = new List<AuthorityServiceAccountSeedOptions>();
|
||||
|
||||
public AuthorityDelegationQuotaOptions Quotas { get; } = new();
|
||||
|
||||
public IList<AuthorityServiceAccountSeedOptions> ServiceAccounts => (IList<AuthorityServiceAccountSeedOptions>)serviceAccounts;
|
||||
|
||||
internal void NormalizeAndValidate(IList<AuthorityTenantOptions> tenants)
|
||||
{
|
||||
Quotas.Validate(nameof(Quotas));
|
||||
|
||||
var tenantIds = tenants is { Count: > 0 }
|
||||
? tenants
|
||||
.Where(static tenant => !string.IsNullOrWhiteSpace(tenant.Id))
|
||||
.Select(static tenant => tenant.Id.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var seenAccounts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var account in serviceAccounts)
|
||||
{
|
||||
account.Normalize();
|
||||
account.Validate(tenantIds);
|
||||
|
||||
if (!seenAccounts.Add(account.AccountId))
|
||||
{
|
||||
throw new InvalidOperationException($"Delegation configuration contains duplicate service account id '{account.AccountId}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthorityDelegationQuotaOptions
|
||||
{
|
||||
public int MaxActiveTokens { get; set; } = 50;
|
||||
|
||||
internal void Validate(string propertyName)
|
||||
{
|
||||
if (MaxActiveTokens <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Authority delegation configuration requires {propertyName}.{nameof(MaxActiveTokens)} to be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthorityTenantDelegationOptions
|
||||
{
|
||||
public int? MaxActiveTokens { get; set; }
|
||||
|
||||
internal void Normalize(AuthorityDelegationOptions defaults)
|
||||
{
|
||||
_ = defaults ?? throw new ArgumentNullException(nameof(defaults));
|
||||
}
|
||||
|
||||
internal void Validate(AuthorityDelegationOptions defaults, string tenantId)
|
||||
{
|
||||
_ = defaults ?? throw new ArgumentNullException(nameof(defaults));
|
||||
|
||||
if (MaxActiveTokens is { } value && value <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Tenant '{tenantId}' delegation maxActiveTokens must be greater than zero when specified.");
|
||||
}
|
||||
}
|
||||
|
||||
public int ResolveMaxActiveTokens(AuthorityDelegationOptions defaults)
|
||||
{
|
||||
_ = defaults ?? throw new ArgumentNullException(nameof(defaults));
|
||||
return MaxActiveTokens ?? defaults.Quotas.MaxActiveTokens;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthorityServiceAccountSeedOptions
|
||||
{
|
||||
private static readonly Regex AccountIdRegex = new("^[a-z0-9][a-z0-9:_-]{2,63}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public string AccountId { get; set; } = string.Empty;
|
||||
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public IList<string> AuthorizedClients { get; } = new List<string>();
|
||||
|
||||
public IList<string> AllowedScopes { get; } = new List<string>();
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
AccountId = (AccountId ?? string.Empty).Trim();
|
||||
Tenant = string.IsNullOrWhiteSpace(Tenant) ? string.Empty : Tenant.Trim().ToLowerInvariant();
|
||||
DisplayName = (DisplayName ?? string.Empty).Trim();
|
||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim();
|
||||
|
||||
NormalizeList(AuthorizedClients, static client => client.Trim().ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
|
||||
NormalizeList(AllowedScopes, scope =>
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(scope);
|
||||
return normalized ?? scope.Trim().ToLowerInvariant();
|
||||
}, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal void Validate(ISet<string> tenantIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AccountId))
|
||||
{
|
||||
throw new InvalidOperationException("Delegation service account seeds require an accountId.");
|
||||
}
|
||||
|
||||
if (!AccountIdRegex.IsMatch(AccountId))
|
||||
{
|
||||
throw new InvalidOperationException($"Service account id '{AccountId}' must contain lowercase letters, digits, colon, underscore, or hyphen.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Tenant))
|
||||
{
|
||||
throw new InvalidOperationException($"Service account '{AccountId}' requires a tenant assignment.");
|
||||
}
|
||||
|
||||
if (tenantIds.Count > 0 && !tenantIds.Contains(Tenant))
|
||||
{
|
||||
throw new InvalidOperationException($"Service account '{AccountId}' references unknown tenant '{Tenant}'.");
|
||||
}
|
||||
|
||||
if (AllowedScopes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Service account '{AccountId}' must specify at least one allowed scope.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, Func<string, string> normalize, IEqualityComparer<string> comparer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
ArgumentNullException.ThrowIfNull(normalize);
|
||||
comparer ??= StringComparer.Ordinal;
|
||||
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(comparer);
|
||||
for (var index = values.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var current = values[index];
|
||||
if (string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = normalize(current);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[index] = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public sealed class AuthorityPluginSettings
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user