up
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / build-test (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / authority-container (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / docs (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / deploy (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			Build Test Deploy / build-test (push) Has been cancelled
				
			Build Test Deploy / authority-container (push) Has been cancelled
				
			Build Test Deploy / docs (push) Has been cancelled
				
			Build Test Deploy / deploy (push) Has been cancelled
				
			This commit is contained in:
		| @@ -7,6 +7,8 @@ | ||||
|     <IsConcelierPlugin Condition="'$(IsConcelierPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Concelier.Connector.'))">true</IsConcelierPlugin> | ||||
|     <IsConcelierPlugin Condition="'$(IsConcelierPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Concelier.Exporter.'))">true</IsConcelierPlugin> | ||||
|     <IsAuthorityPlugin Condition="'$(IsAuthorityPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Authority.Plugin.'))">true</IsAuthorityPlugin> | ||||
|     <ScannerBuildxPluginOutputRoot Condition="'$(ScannerBuildxPluginOutputRoot)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\plugins\scanner\buildx\'))</ScannerBuildxPluginOutputRoot> | ||||
|     <IsScannerBuildxPlugin Condition="'$(IsScannerBuildxPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)')) == 'StellaOps.Scanner.Sbomer.BuildXPlugin'">true</IsScannerBuildxPlugin> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|   | ||||
| @@ -30,4 +30,21 @@ | ||||
|  | ||||
|     <Copy SourceFiles="@(AuthorityPluginArtifacts)" DestinationFolder="$(AuthorityPluginOutputDirectory)" SkipUnchangedFiles="true" /> | ||||
|   </Target> | ||||
|  | ||||
|   <Target Name="ScannerCopyBuildxPluginArtifacts" AfterTargets="Build" Condition="'$(IsScannerBuildxPlugin)' == 'true'"> | ||||
|     <PropertyGroup> | ||||
|       <ScannerBuildxPluginOutputDirectory>$(ScannerBuildxPluginOutputRoot)\$(MSBuildProjectName)</ScannerBuildxPluginOutputDirectory> | ||||
|     </PropertyGroup> | ||||
|  | ||||
|     <MakeDir Directories="$(ScannerBuildxPluginOutputDirectory)" /> | ||||
|  | ||||
|     <ItemGroup> | ||||
|       <ScannerBuildxPluginArtifacts Include="$(TargetPath)" /> | ||||
|       <ScannerBuildxPluginArtifacts Include="$(TargetPath).deps.json" Condition="Exists('$(TargetPath).deps.json')" /> | ||||
|       <ScannerBuildxPluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" /> | ||||
|       <ScannerBuildxPluginArtifacts Include="$(ProjectDir)stellaops.sbom-indexer.manifest.json" Condition="Exists('$(ProjectDir)stellaops.sbom-indexer.manifest.json')" /> | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <Copy SourceFiles="@(ScannerBuildxPluginArtifacts)" DestinationFolder="$(ScannerBuildxPluginOutputDirectory)" SkipUnchangedFiles="true" /> | ||||
|   </Target> | ||||
| </Project> | ||||
|   | ||||
							
								
								
									
										86
									
								
								src/StellaOps.Policy.Tests/PolicyBinderTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/StellaOps.Policy.Tests/PolicyBinderTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Policy.Tests; | ||||
|  | ||||
| public sealed class PolicyBinderTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Bind_ValidYaml_ReturnsSuccess() | ||||
|     { | ||||
|         const string yaml = """ | ||||
|         version: "1.0" | ||||
|         rules: | ||||
|           - name: Block Critical | ||||
|             severity: [Critical] | ||||
|             sources: [NVD] | ||||
|             action: block | ||||
|         """; | ||||
|  | ||||
|         var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml); | ||||
|  | ||||
|         Assert.True(result.Success); | ||||
|         Assert.Equal("1.0", result.Document.Version); | ||||
|         Assert.Single(result.Document.Rules); | ||||
|         Assert.Empty(result.Issues); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Bind_InvalidSeverity_ReturnsError() | ||||
|     { | ||||
|         const string yaml = """ | ||||
|         version: "1.0" | ||||
|         rules: | ||||
|           - name: Invalid Severity | ||||
|             severity: [Nope] | ||||
|             action: block | ||||
|         """; | ||||
|  | ||||
|         var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml); | ||||
|  | ||||
|         Assert.False(result.Success); | ||||
|         Assert.Contains(result.Issues, issue => issue.Code == "policy.severity.invalid"); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task Cli_StrictMode_FailsOnWarnings() | ||||
|     { | ||||
|         const string yaml = """ | ||||
|         version: "1.0" | ||||
|         rules: | ||||
|           - name: Quiet Warning | ||||
|             sources: ["", "NVD"] | ||||
|             action: ignore | ||||
|         """; | ||||
|  | ||||
|         var path = Path.Combine(Path.GetTempPath(), $"policy-{Guid.NewGuid():N}.yaml"); | ||||
|         await File.WriteAllTextAsync(path, yaml); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             using var output = new StringWriter(); | ||||
|             using var error = new StringWriter(); | ||||
|             var cli = new PolicyValidationCli(output, error); | ||||
|             var options = new PolicyValidationCliOptions | ||||
|             { | ||||
|                 Inputs = new[] { path }, | ||||
|                 Strict = true, | ||||
|             }; | ||||
|  | ||||
|             var exitCode = await cli.RunAsync(options, CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(2, exitCode); | ||||
|             Assert.Contains("WARNING", output.ToString()); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             if (File.Exists(path)) | ||||
|             { | ||||
|                 File.Delete(path); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										166
									
								
								src/StellaOps.Policy.Tests/PolicyPreviewServiceTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								src/StellaOps.Policy.Tests/PolicyPreviewServiceTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,166 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Time.Testing; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Policy.Tests; | ||||
|  | ||||
| public sealed class PolicyPreviewServiceTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task PreviewAsync_ComputesDiffs_ForBlockingRule() | ||||
|     { | ||||
|         const string yaml = """ | ||||
| version: "1.0" | ||||
| rules: | ||||
|   - name: Block Critical | ||||
|     severity: [Critical] | ||||
|     action: block | ||||
| """; | ||||
|  | ||||
|         var snapshotRepo = new InMemoryPolicySnapshotRepository(); | ||||
|         var auditRepo = new InMemoryPolicyAuditRepository(); | ||||
|         var timeProvider = new FakeTimeProvider(); | ||||
|         var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance); | ||||
|  | ||||
|         await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None); | ||||
|  | ||||
|         var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance); | ||||
|  | ||||
|         var findings = ImmutableArray.Create( | ||||
|             PolicyFinding.Create("finding-1", PolicySeverity.Critical, environment: "prod", source: "NVD"), | ||||
|             PolicyFinding.Create("finding-2", PolicySeverity.Low)); | ||||
|  | ||||
|         var baseline = ImmutableArray.Create( | ||||
|             new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass), | ||||
|             new PolicyVerdict("finding-2", PolicyVerdictStatus.Pass)); | ||||
|  | ||||
|         var response = await service.PreviewAsync(new PolicyPreviewRequest( | ||||
|             "sha256:abc", | ||||
|             findings, | ||||
|             baseline), | ||||
|             CancellationToken.None); | ||||
|  | ||||
|         Assert.True(response.Success); | ||||
|         Assert.Equal(1, response.ChangedCount); | ||||
|         var diff1 = Assert.Single(response.Diffs.Where(diff => diff.Projected.FindingId == "finding-1")); | ||||
|         Assert.Equal(PolicyVerdictStatus.Pass, diff1.Baseline.Status); | ||||
|         Assert.Equal(PolicyVerdictStatus.Blocked, diff1.Projected.Status); | ||||
|         Assert.Equal("Block Critical", diff1.Projected.RuleName); | ||||
|         Assert.True(diff1.Projected.Score > 0); | ||||
|         Assert.Equal(PolicyScoringConfig.Default.Version, diff1.Projected.ConfigVersion); | ||||
|         Assert.Equal(PolicyVerdictStatus.Pass, response.Diffs.First(diff => diff.Projected.FindingId == "finding-2").Projected.Status); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task PreviewAsync_UsesProposedPolicy_WhenProvided() | ||||
|     { | ||||
|         const string yaml = """ | ||||
| version: "1.0" | ||||
| rules: | ||||
|   - name: Ignore Dev | ||||
|     environments: [dev] | ||||
|     action: | ||||
|       type: ignore | ||||
|       justification: dev waiver | ||||
| """; | ||||
|  | ||||
|         var snapshotRepo = new InMemoryPolicySnapshotRepository(); | ||||
|         var auditRepo = new InMemoryPolicyAuditRepository(); | ||||
|         var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance); | ||||
|         var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance); | ||||
|  | ||||
|         var findings = ImmutableArray.Create( | ||||
|             PolicyFinding.Create("finding-1", PolicySeverity.Medium, environment: "dev")); | ||||
|  | ||||
|         var baseline = ImmutableArray.Create(new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked)); | ||||
|  | ||||
|         var response = await service.PreviewAsync(new PolicyPreviewRequest( | ||||
|             "sha256:def", | ||||
|             findings, | ||||
|             baseline, | ||||
|             SnapshotOverride: null, | ||||
|             ProposedPolicy: new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "dev override")), | ||||
|             CancellationToken.None); | ||||
|  | ||||
|         Assert.True(response.Success); | ||||
|         var diff = Assert.Single(response.Diffs); | ||||
|         Assert.Equal(PolicyVerdictStatus.Blocked, diff.Baseline.Status); | ||||
|         Assert.Equal(PolicyVerdictStatus.Ignored, diff.Projected.Status); | ||||
|         Assert.Equal("Ignore Dev", diff.Projected.RuleName); | ||||
|         Assert.True(diff.Projected.Score >= 0); | ||||
|         Assert.Equal(1, response.ChangedCount); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task PreviewAsync_ReturnsIssues_WhenPolicyInvalid() | ||||
|     { | ||||
|         var snapshotRepo = new InMemoryPolicySnapshotRepository(); | ||||
|         var auditRepo = new InMemoryPolicyAuditRepository(); | ||||
|         var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance); | ||||
|         var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance); | ||||
|  | ||||
|         const string invalid = "version: 1.0"; | ||||
|         var request = new PolicyPreviewRequest( | ||||
|             "sha256:ghi", | ||||
|             ImmutableArray<PolicyFinding>.Empty, | ||||
|             ImmutableArray<PolicyVerdict>.Empty, | ||||
|             SnapshotOverride: null, | ||||
|             ProposedPolicy: new PolicySnapshotContent(invalid, PolicyDocumentFormat.Yaml, null, null, null)); | ||||
|  | ||||
|         var response = await service.PreviewAsync(request, CancellationToken.None); | ||||
|  | ||||
|         Assert.False(response.Success); | ||||
|         Assert.NotEmpty(response.Issues); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task PreviewAsync_QuietWithoutVexDowngradesToWarn() | ||||
|     { | ||||
|         const string yaml = """ | ||||
| version: "1.0" | ||||
| rules: | ||||
|   - name: Quiet Without VEX | ||||
|     severity: [Low] | ||||
|     quiet: true | ||||
|     action: | ||||
|       type: ignore | ||||
| """; | ||||
|  | ||||
|         var binding = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml); | ||||
|         Assert.True(binding.Success); | ||||
|         Assert.Empty(binding.Issues); | ||||
|         Assert.False(binding.Document.Rules[0].Metadata.ContainsKey("quiet")); | ||||
|         Assert.True(binding.Document.Rules[0].Action.Quiet); | ||||
|  | ||||
|         var store = new PolicySnapshotStore(new InMemoryPolicySnapshotRepository(), new InMemoryPolicyAuditRepository(), TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance); | ||||
|         await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "quiet test"), CancellationToken.None); | ||||
|         var snapshot = await store.GetLatestAsync(); | ||||
|         Assert.NotNull(snapshot); | ||||
|         Assert.True(snapshot!.Document.Rules[0].Action.Quiet); | ||||
|         Assert.Null(snapshot.Document.Rules[0].Action.RequireVex); | ||||
|         Assert.Equal(PolicyActionType.Ignore, snapshot.Document.Rules[0].Action.Type); | ||||
|         var manualVerdict = PolicyEvaluation.EvaluateFinding(snapshot.Document, snapshot.ScoringConfig, PolicyFinding.Create("finding-quiet", PolicySeverity.Low)); | ||||
|         Assert.Equal(PolicyVerdictStatus.Warned, manualVerdict.Status); | ||||
|  | ||||
|         var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance); | ||||
|  | ||||
|         var findings = ImmutableArray.Create(PolicyFinding.Create("finding-quiet", PolicySeverity.Low)); | ||||
|         var baseline = ImmutableArray<PolicyVerdict>.Empty; | ||||
|  | ||||
|         var response = await service.PreviewAsync(new PolicyPreviewRequest( | ||||
|             "sha256:quiet", | ||||
|             findings, | ||||
|             baseline), | ||||
|             CancellationToken.None); | ||||
|  | ||||
|         Assert.True(response.Success); | ||||
|         var verdict = Assert.Single(response.Diffs).Projected; | ||||
|         Assert.Equal(PolicyVerdictStatus.Warned, verdict.Status); | ||||
|         Assert.Contains("requireVex", verdict.Notes, System.StringComparison.OrdinalIgnoreCase); | ||||
|         Assert.True(verdict.Score >= 0); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										26
									
								
								src/StellaOps.Policy.Tests/PolicyScoringConfigTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/StellaOps.Policy.Tests/PolicyScoringConfigTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| using System.Threading.Tasks; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Policy.Tests; | ||||
|  | ||||
| public sealed class PolicyScoringConfigTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void LoadDefaultReturnsConfig() | ||||
|     { | ||||
|         var config = PolicyScoringConfigBinder.LoadDefault(); | ||||
|         Assert.NotNull(config); | ||||
|         Assert.Equal("1.0", config.Version); | ||||
|         Assert.NotEmpty(config.SeverityWeights); | ||||
|         Assert.True(config.SeverityWeights.ContainsKey(PolicySeverity.Critical)); | ||||
|         Assert.True(config.QuietPenalty > 0); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void BindRejectsEmptyContent() | ||||
|     { | ||||
|         var result = PolicyScoringConfigBinder.Bind(string.Empty, PolicyDocumentFormat.Json); | ||||
|         Assert.False(result.Success); | ||||
|         Assert.NotEmpty(result.Issues); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										94
									
								
								src/StellaOps.Policy.Tests/PolicySnapshotStoreTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/StellaOps.Policy.Tests/PolicySnapshotStoreTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Time.Testing; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Policy.Tests; | ||||
|  | ||||
| public sealed class PolicySnapshotStoreTests | ||||
| { | ||||
|     private const string BasePolicyYaml = """ | ||||
| version: "1.0" | ||||
| rules: | ||||
|   - name: Block Critical | ||||
|     severity: [Critical] | ||||
|     action: block | ||||
| """; | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task SaveAsync_CreatesNewSnapshotAndAuditEntry() | ||||
|     { | ||||
|         var snapshotRepo = new InMemoryPolicySnapshotRepository(); | ||||
|         var auditRepo = new InMemoryPolicyAuditRepository(); | ||||
|         var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero)); | ||||
|         var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance); | ||||
|  | ||||
|         var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null); | ||||
|  | ||||
|         var result = await store.SaveAsync(content, CancellationToken.None); | ||||
|  | ||||
|         Assert.True(result.Success); | ||||
|         Assert.True(result.Created); | ||||
|         Assert.NotNull(result.Snapshot); | ||||
|         Assert.Equal("rev-1", result.Snapshot!.RevisionId); | ||||
|         Assert.Equal(result.Digest, result.Snapshot.Digest); | ||||
|         Assert.Equal(timeProvider.GetUtcNow(), result.Snapshot.CreatedAt); | ||||
|         Assert.Equal(PolicyScoringConfig.Default.Version, result.Snapshot.ScoringConfig.Version); | ||||
|  | ||||
|         var latest = await store.GetLatestAsync(); | ||||
|         Assert.Equal(result.Snapshot, latest); | ||||
|  | ||||
|         var audits = await auditRepo.ListAsync(10); | ||||
|         Assert.Single(audits); | ||||
|         Assert.Equal(result.Digest, audits[0].Digest); | ||||
|         Assert.Equal("snapshot.created", audits[0].Action); | ||||
|         Assert.Equal("rev-1", audits[0].RevisionId); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task SaveAsync_DoesNotCreateNewRevisionWhenDigestUnchanged() | ||||
|     { | ||||
|         var snapshotRepo = new InMemoryPolicySnapshotRepository(); | ||||
|         var auditRepo = new InMemoryPolicyAuditRepository(); | ||||
|         var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero)); | ||||
|         var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance); | ||||
|  | ||||
|         var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null); | ||||
|         var first = await store.SaveAsync(content, CancellationToken.None); | ||||
|         Assert.True(first.Created); | ||||
|  | ||||
|         timeProvider.Advance(TimeSpan.FromHours(1)); | ||||
|         var second = await store.SaveAsync(content, CancellationToken.None); | ||||
|  | ||||
|         Assert.True(second.Success); | ||||
|         Assert.False(second.Created); | ||||
|         Assert.Equal(first.Digest, second.Digest); | ||||
|         Assert.Equal("rev-1", second.Snapshot!.RevisionId); | ||||
|         Assert.Equal(PolicyScoringConfig.Default.Version, second.Snapshot.ScoringConfig.Version); | ||||
|  | ||||
|         var audits = await auditRepo.ListAsync(10); | ||||
|         Assert.Single(audits); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task SaveAsync_ReturnsFailureWhenValidationFails() | ||||
|     { | ||||
|         var snapshotRepo = new InMemoryPolicySnapshotRepository(); | ||||
|         var auditRepo = new InMemoryPolicyAuditRepository(); | ||||
|         var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance); | ||||
|  | ||||
|         const string invalidYaml = "version: '1.0'\nrules: []"; | ||||
|         var content = new PolicySnapshotContent(invalidYaml, PolicyDocumentFormat.Yaml, null, null, null); | ||||
|  | ||||
|         var result = await store.SaveAsync(content, CancellationToken.None); | ||||
|  | ||||
|         Assert.False(result.Success); | ||||
|         Assert.False(result.Created); | ||||
|         Assert.Null(result.Snapshot); | ||||
|  | ||||
|         var audits = await auditRepo.ListAsync(5); | ||||
|         Assert.Empty(audits); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										13
									
								
								src/StellaOps.Policy.Tests/StellaOps.Policy.Tests.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/StellaOps.Policy.Tests/StellaOps.Policy.Tests.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Policy\StellaOps.Policy.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
							
								
								
									
										12
									
								
								src/StellaOps.Policy/Audit/IPolicyAuditRepository.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/StellaOps.Policy/Audit/IPolicyAuditRepository.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public interface IPolicyAuditRepository | ||||
| { | ||||
|     Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default); | ||||
|  | ||||
|     Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default); | ||||
| } | ||||
							
								
								
									
										52
									
								
								src/StellaOps.Policy/Audit/InMemoryPolicyAuditRepository.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/StellaOps.Policy/Audit/InMemoryPolicyAuditRepository.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed class InMemoryPolicyAuditRepository : IPolicyAuditRepository | ||||
| { | ||||
|     private readonly List<PolicyAuditEntry> _entries = new(); | ||||
|     private readonly SemaphoreSlim _mutex = new(1, 1); | ||||
|  | ||||
|     public async Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         if (entry is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(entry)); | ||||
|         } | ||||
|  | ||||
|         await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             _entries.Add(entry); | ||||
|             _entries.Sort(static (left, right) => left.CreatedAt.CompareTo(right.CreatedAt)); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _mutex.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             IEnumerable<PolicyAuditEntry> query = _entries; | ||||
|             if (limit > 0) | ||||
|             { | ||||
|                 query = query.TakeLast(limit); | ||||
|             } | ||||
|  | ||||
|             return query.ToImmutableArray(); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _mutex.Release(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/StellaOps.Policy/PolicyAuditEntry.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/StellaOps.Policy/PolicyAuditEntry.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed record PolicyAuditEntry( | ||||
|     Guid Id, | ||||
|     DateTimeOffset CreatedAt, | ||||
|     string Action, | ||||
|     string RevisionId, | ||||
|     string Digest, | ||||
|     string? Actor, | ||||
|     string Message); | ||||
							
								
								
									
										913
									
								
								src/StellaOps.Policy/PolicyBinder.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										913
									
								
								src/StellaOps.Policy/PolicyBinder.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,913 @@ | ||||
| using System; | ||||
| using System.Collections; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Nodes; | ||||
| using System.Text.Json.Serialization; | ||||
| using YamlDotNet.Serialization; | ||||
| using YamlDotNet.Serialization.NamingConventions; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public enum PolicyDocumentFormat | ||||
| { | ||||
|     Json, | ||||
|     Yaml, | ||||
| } | ||||
|  | ||||
| public sealed record PolicyBindingResult( | ||||
|     bool Success, | ||||
|     PolicyDocument Document, | ||||
|     ImmutableArray<PolicyIssue> Issues, | ||||
|     PolicyDocumentFormat Format); | ||||
|  | ||||
| public static class PolicyBinder | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new() | ||||
|     { | ||||
|         PropertyNameCaseInsensitive = true, | ||||
|         ReadCommentHandling = JsonCommentHandling.Skip, | ||||
|         AllowTrailingCommas = true, | ||||
|         NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString, | ||||
|         Converters = | ||||
|         { | ||||
|             new JsonStringEnumConverter() | ||||
|         }, | ||||
|     }; | ||||
|  | ||||
|     private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() | ||||
|         .WithNamingConvention(CamelCaseNamingConvention.Instance) | ||||
|         .IgnoreUnmatchedProperties() | ||||
|         .Build(); | ||||
|  | ||||
|     public static PolicyBindingResult Bind(string content, PolicyDocumentFormat format) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(content)) | ||||
|         { | ||||
|             var issues = ImmutableArray.Create( | ||||
|                 PolicyIssue.Error("policy.empty", "Policy document is empty.", "$")); | ||||
|             return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format); | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var node = ParseToNode(content, format); | ||||
|             if (node is not JsonObject obj) | ||||
|             { | ||||
|                 var issues = ImmutableArray.Create( | ||||
|                     PolicyIssue.Error("policy.document.invalid", "Policy document must be an object.", "$")); | ||||
|                 return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format); | ||||
|             } | ||||
|  | ||||
|             var model = obj.Deserialize<PolicyDocumentModel>(SerializerOptions) ?? new PolicyDocumentModel(); | ||||
|             var normalization = PolicyNormalizer.Normalize(model); | ||||
|             var success = normalization.Issues.All(static issue => issue.Severity != PolicyIssueSeverity.Error); | ||||
|             return new PolicyBindingResult(success, normalization.Document, normalization.Issues, format); | ||||
|         } | ||||
|         catch (JsonException ex) | ||||
|         { | ||||
|             var issues = ImmutableArray.Create( | ||||
|                 PolicyIssue.Error("policy.parse.json", $"Failed to parse policy JSON: {ex.Message}", "$")); | ||||
|             return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format); | ||||
|         } | ||||
|         catch (YamlDotNet.Core.YamlException ex) | ||||
|         { | ||||
|             var issues = ImmutableArray.Create( | ||||
|                 PolicyIssue.Error("policy.parse.yaml", $"Failed to parse policy YAML: {ex.Message}", "$")); | ||||
|             return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static PolicyBindingResult Bind(Stream stream, PolicyDocumentFormat format, Encoding? encoding = null) | ||||
|     { | ||||
|         if (stream is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(stream)); | ||||
|         } | ||||
|  | ||||
|         encoding ??= Encoding.UTF8; | ||||
|         using var reader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: true, leaveOpen: true); | ||||
|         var content = reader.ReadToEnd(); | ||||
|         return Bind(content, format); | ||||
|     } | ||||
|  | ||||
|     private static JsonNode? ParseToNode(string content, PolicyDocumentFormat format) | ||||
|     { | ||||
|         return format switch | ||||
|         { | ||||
|             PolicyDocumentFormat.Json => JsonNode.Parse(content, documentOptions: new JsonDocumentOptions | ||||
|             { | ||||
|                 AllowTrailingCommas = true, | ||||
|                 CommentHandling = JsonCommentHandling.Skip, | ||||
|             }), | ||||
|             PolicyDocumentFormat.Yaml => ConvertYamlToJsonNode(content), | ||||
|             _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported policy document format."), | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static JsonNode? ConvertYamlToJsonNode(string content) | ||||
|     { | ||||
|         var yamlObject = YamlDeserializer.Deserialize<object?>(content); | ||||
|         return ConvertYamlObject(yamlObject); | ||||
|     } | ||||
|  | ||||
|     private static JsonNode? ConvertYamlObject(object? value) | ||||
|     { | ||||
|         switch (value) | ||||
|         { | ||||
|             case null: | ||||
|                 return null; | ||||
|             case string s: | ||||
|                 return JsonValue.Create(s); | ||||
|             case bool b: | ||||
|                 return JsonValue.Create(b); | ||||
|             case sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal: | ||||
|                 return JsonValue.Create(Convert.ToDecimal(value, CultureInfo.InvariantCulture)); | ||||
|             case DateTime dt: | ||||
|                 return JsonValue.Create(dt.ToString("O", CultureInfo.InvariantCulture)); | ||||
|             case DateTimeOffset dto: | ||||
|                 return JsonValue.Create(dto.ToString("O", CultureInfo.InvariantCulture)); | ||||
|             case Enum e: | ||||
|                 return JsonValue.Create(e.ToString()); | ||||
|             case IDictionary dictionary: | ||||
|             { | ||||
|                 var obj = new JsonObject(); | ||||
|                 foreach (DictionaryEntry entry in dictionary) | ||||
|                 { | ||||
|                     if (entry.Key is null) | ||||
|                     { | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     var key = Convert.ToString(entry.Key, CultureInfo.InvariantCulture); | ||||
|                     if (string.IsNullOrWhiteSpace(key)) | ||||
|                     { | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     obj[key!] = ConvertYamlObject(entry.Value); | ||||
|                 } | ||||
|  | ||||
|                 return obj; | ||||
|             } | ||||
|             case IEnumerable enumerable: | ||||
|             { | ||||
|                 var array = new JsonArray(); | ||||
|                 foreach (var item in enumerable) | ||||
|                 { | ||||
|                     array.Add(ConvertYamlObject(item)); | ||||
|                 } | ||||
|  | ||||
|                 return array; | ||||
|             } | ||||
|             default: | ||||
|                 return JsonValue.Create(value.ToString()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed record PolicyDocumentModel | ||||
|     { | ||||
|         [JsonPropertyName("version")] | ||||
|         public JsonNode? Version { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("description")] | ||||
|         public string? Description { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("metadata")] | ||||
|         public Dictionary<string, JsonNode?>? Metadata { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("rules")] | ||||
|         public List<PolicyRuleModel>? Rules { get; init; } | ||||
|  | ||||
|         [JsonExtensionData] | ||||
|         public Dictionary<string, JsonElement>? Extensions { get; init; } | ||||
|     } | ||||
|  | ||||
|     private sealed record PolicyRuleModel | ||||
|     { | ||||
|         [JsonPropertyName("id")] | ||||
|         public string? Identifier { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("name")] | ||||
|         public string? Name { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("description")] | ||||
|         public string? Description { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("severity")] | ||||
|         public List<string>? Severity { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("sources")] | ||||
|         public List<string>? Sources { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("vendors")] | ||||
|         public List<string>? Vendors { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("licenses")] | ||||
|         public List<string>? Licenses { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("tags")] | ||||
|         public List<string>? Tags { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("environments")] | ||||
|         public List<string>? Environments { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("images")] | ||||
|         public List<string>? Images { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("repositories")] | ||||
|         public List<string>? Repositories { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("packages")] | ||||
|         public List<string>? Packages { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("purls")] | ||||
|         public List<string>? Purls { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("cves")] | ||||
|         public List<string>? Cves { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("paths")] | ||||
|         public List<string>? Paths { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("layerDigests")] | ||||
|         public List<string>? LayerDigests { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("usedByEntrypoint")] | ||||
|         public List<string>? UsedByEntrypoint { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("action")] | ||||
|         public JsonNode? Action { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("expires")] | ||||
|         public JsonNode? Expires { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("until")] | ||||
|         public JsonNode? Until { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("justification")] | ||||
|         public string? Justification { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("quiet")] | ||||
|         public bool? Quiet { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("metadata")] | ||||
|         public Dictionary<string, JsonNode?>? Metadata { get; init; } | ||||
|  | ||||
|         [JsonExtensionData] | ||||
|         public Dictionary<string, JsonElement>? Extensions { get; init; } | ||||
|     } | ||||
|  | ||||
|     private sealed class PolicyNormalizer | ||||
|     { | ||||
|         private static readonly ImmutableDictionary<string, PolicySeverity> SeverityMap = | ||||
|             new Dictionary<string, PolicySeverity>(StringComparer.OrdinalIgnoreCase) | ||||
|             { | ||||
|                 ["critical"] = PolicySeverity.Critical, | ||||
|                 ["high"] = PolicySeverity.High, | ||||
|                 ["medium"] = PolicySeverity.Medium, | ||||
|                 ["moderate"] = PolicySeverity.Medium, | ||||
|                 ["low"] = PolicySeverity.Low, | ||||
|                 ["informational"] = PolicySeverity.Informational, | ||||
|                 ["info"] = PolicySeverity.Informational, | ||||
|                 ["none"] = PolicySeverity.None, | ||||
|                 ["unknown"] = PolicySeverity.Unknown, | ||||
|             }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         public static (PolicyDocument Document, ImmutableArray<PolicyIssue> Issues) Normalize(PolicyDocumentModel model) | ||||
|         { | ||||
|             var issues = ImmutableArray.CreateBuilder<PolicyIssue>(); | ||||
|  | ||||
|             var version = NormalizeVersion(model.Version, issues); | ||||
|             var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues); | ||||
|             var rules = NormalizeRules(model.Rules, issues); | ||||
|  | ||||
|             if (model.Extensions is { Count: > 0 }) | ||||
|             { | ||||
|                 foreach (var pair in model.Extensions) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Warning( | ||||
|                         "policy.document.extension", | ||||
|                         $"Unrecognized document property '{pair.Key}' has been ignored.", | ||||
|                         $"$.{pair.Key}")); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             var document = new PolicyDocument( | ||||
|                 version ?? PolicySchema.CurrentVersion, | ||||
|                 rules, | ||||
|                 metadata); | ||||
|  | ||||
|             var orderedIssues = SortIssues(issues); | ||||
|             return (document, orderedIssues); | ||||
|         } | ||||
|  | ||||
|         private static string? NormalizeVersion(JsonNode? versionNode, ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (versionNode is null) | ||||
|             { | ||||
|                 issues.Add(PolicyIssue.Warning("policy.version.missing", "Policy version not specified; defaulting to 1.0.", "$.version")); | ||||
|                 return PolicySchema.CurrentVersion; | ||||
|             } | ||||
|  | ||||
|             if (versionNode is JsonValue value) | ||||
|             { | ||||
|                 if (value.TryGetValue(out string? versionText)) | ||||
|                 { | ||||
|                     versionText = versionText?.Trim(); | ||||
|                     if (string.IsNullOrEmpty(versionText)) | ||||
|                     { | ||||
|                         issues.Add(PolicyIssue.Error("policy.version.empty", "Policy version is empty.", "$.version")); | ||||
|                         return null; | ||||
|                     } | ||||
|  | ||||
|                     if (IsSupportedVersion(versionText)) | ||||
|                     { | ||||
|                         return CanonicalizeVersion(versionText); | ||||
|                     } | ||||
|  | ||||
|                     issues.Add(PolicyIssue.Error("policy.version.unsupported", $"Unsupported policy version '{versionText}'. Expected '{PolicySchema.CurrentVersion}'.", "$.version")); | ||||
|                     return null; | ||||
|                 } | ||||
|  | ||||
|                 if (value.TryGetValue(out double numericVersion)) | ||||
|                 { | ||||
|                     var numericText = numericVersion.ToString("0.0###", CultureInfo.InvariantCulture); | ||||
|                     if (IsSupportedVersion(numericText)) | ||||
|                     { | ||||
|                         return CanonicalizeVersion(numericText); | ||||
|                     } | ||||
|  | ||||
|                     issues.Add(PolicyIssue.Error("policy.version.unsupported", $"Unsupported policy version '{numericText}'.", "$.version")); | ||||
|                     return null; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             var raw = versionNode.ToJsonString(); | ||||
|             issues.Add(PolicyIssue.Error("policy.version.invalid", $"Policy version must be a string. Received: {raw}", "$.version")); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         private static bool IsSupportedVersion(string versionText) | ||||
|             => string.Equals(versionText, "1", StringComparison.OrdinalIgnoreCase) | ||||
|                || string.Equals(versionText, "1.0", StringComparison.OrdinalIgnoreCase) | ||||
|                || string.Equals(versionText, PolicySchema.CurrentVersion, StringComparison.OrdinalIgnoreCase); | ||||
|  | ||||
|         private static string CanonicalizeVersion(string versionText) | ||||
|             => string.Equals(versionText, "1", StringComparison.OrdinalIgnoreCase) | ||||
|                 ? "1.0" | ||||
|                 : versionText; | ||||
|  | ||||
|         private static ImmutableDictionary<string, string> NormalizeMetadata( | ||||
|             Dictionary<string, JsonNode?>? metadata, | ||||
|             string path, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (metadata is null || metadata.Count == 0) | ||||
|             { | ||||
|                 return ImmutableDictionary<string, string>.Empty; | ||||
|             } | ||||
|  | ||||
|             var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal); | ||||
|             foreach (var pair in metadata) | ||||
|             { | ||||
|                 var key = pair.Key?.Trim(); | ||||
|                 if (string.IsNullOrEmpty(key)) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Warning("policy.metadata.key.empty", "Metadata keys must be non-empty strings.", path)); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var value = ConvertNodeToString(pair.Value); | ||||
|                 builder[key] = value; | ||||
|             } | ||||
|  | ||||
|             return builder.ToImmutable(); | ||||
|         } | ||||
|  | ||||
|         private static ImmutableArray<PolicyRule> NormalizeRules( | ||||
|             List<PolicyRuleModel>? rules, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (rules is null || rules.Count == 0) | ||||
|             { | ||||
|                 issues.Add(PolicyIssue.Error("policy.rules.empty", "At least one rule must be defined.", "$.rules")); | ||||
|                 return ImmutableArray<PolicyRule>.Empty; | ||||
|             } | ||||
|  | ||||
|             var normalized = new List<(PolicyRule Rule, int Index)>(rules.Count); | ||||
|             var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|             for (var index = 0; index < rules.Count; index++) | ||||
|             { | ||||
|                 var model = rules[index]; | ||||
|                 var normalizedRule = NormalizeRule(model, index, issues); | ||||
|                 if (normalizedRule is null) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!seenNames.Add(normalizedRule.Name)) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Warning( | ||||
|                         "policy.rules.duplicateName", | ||||
|                         $"Duplicate rule name '{normalizedRule.Name}' detected; evaluation order may be ambiguous.", | ||||
|                         $"$.rules[{index}].name")); | ||||
|                 } | ||||
|  | ||||
|                 normalized.Add((normalizedRule, index)); | ||||
|             } | ||||
|  | ||||
|             return normalized | ||||
|                 .OrderBy(static tuple => tuple.Rule.Name, StringComparer.OrdinalIgnoreCase) | ||||
|                 .ThenBy(static tuple => tuple.Rule.Identifier ?? string.Empty, StringComparer.OrdinalIgnoreCase) | ||||
|                 .ThenBy(static tuple => tuple.Index) | ||||
|                 .Select(static tuple => tuple.Rule) | ||||
|                 .ToImmutableArray(); | ||||
|         } | ||||
|  | ||||
|         private static PolicyRule? NormalizeRule( | ||||
|             PolicyRuleModel model, | ||||
|             int index, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             var basePath = $"$.rules[{index}]"; | ||||
|  | ||||
|             var name = NormalizeRequiredString(model.Name, $"{basePath}.name", "Rule name", issues); | ||||
|             if (name is null) | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             var identifier = NormalizeOptionalString(model.Identifier); | ||||
|             var description = NormalizeOptionalString(model.Description); | ||||
|             var metadata = NormalizeMetadata(model.Metadata, $"{basePath}.metadata", issues); | ||||
|  | ||||
|             var severities = NormalizeSeverityList(model.Severity, $"{basePath}.severity", issues); | ||||
|             var environments = NormalizeStringList(model.Environments, $"{basePath}.environments", issues); | ||||
|             var sources = NormalizeStringList(model.Sources, $"{basePath}.sources", issues); | ||||
|             var vendors = NormalizeStringList(model.Vendors, $"{basePath}.vendors", issues); | ||||
|             var licenses = NormalizeStringList(model.Licenses, $"{basePath}.licenses", issues); | ||||
|             var tags = NormalizeStringList(model.Tags, $"{basePath}.tags", issues); | ||||
|  | ||||
|             var match = new PolicyRuleMatchCriteria( | ||||
|                 NormalizeStringList(model.Images, $"{basePath}.images", issues), | ||||
|                 NormalizeStringList(model.Repositories, $"{basePath}.repositories", issues), | ||||
|                 NormalizeStringList(model.Packages, $"{basePath}.packages", issues), | ||||
|                 NormalizeStringList(model.Purls, $"{basePath}.purls", issues), | ||||
|                 NormalizeStringList(model.Cves, $"{basePath}.cves", issues), | ||||
|                 NormalizeStringList(model.Paths, $"{basePath}.paths", issues), | ||||
|                 NormalizeStringList(model.LayerDigests, $"{basePath}.layerDigests", issues), | ||||
|                 NormalizeStringList(model.UsedByEntrypoint, $"{basePath}.usedByEntrypoint", issues)); | ||||
|  | ||||
|             var action = NormalizeAction(model, basePath, issues); | ||||
|             var justification = NormalizeOptionalString(model.Justification); | ||||
|             var expires = NormalizeTemporal(model.Expires ?? model.Until, $"{basePath}.expires", issues); | ||||
|  | ||||
|             if (model.Extensions is { Count: > 0 }) | ||||
|             { | ||||
|                 foreach (var pair in model.Extensions) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Warning( | ||||
|                         "policy.rule.extension", | ||||
|                         $"Unrecognized rule property '{pair.Key}' has been ignored.", | ||||
|                         $"{basePath}.{pair.Key}")); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return PolicyRule.Create( | ||||
|                 name, | ||||
|                 action, | ||||
|                 severities, | ||||
|                 environments, | ||||
|                 sources, | ||||
|                 vendors, | ||||
|                 licenses, | ||||
|                 tags, | ||||
|                 match, | ||||
|                 expires, | ||||
|                 justification, | ||||
|                 identifier, | ||||
|                 description, | ||||
|                 metadata); | ||||
|         } | ||||
|  | ||||
|         private static PolicyAction NormalizeAction( | ||||
|             PolicyRuleModel model, | ||||
|             string basePath, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             var actionNode = model.Action; | ||||
|             var quiet = model.Quiet ?? false; | ||||
|             if (!quiet && model.Extensions is not null && model.Extensions.TryGetValue("quiet", out var quietExtension) && quietExtension.ValueKind == JsonValueKind.True) | ||||
|             { | ||||
|                 quiet = true; | ||||
|             } | ||||
|             string? justification = NormalizeOptionalString(model.Justification); | ||||
|             DateTimeOffset? until = NormalizeTemporal(model.Until, $"{basePath}.until", issues); | ||||
|             DateTimeOffset? expires = NormalizeTemporal(model.Expires, $"{basePath}.expires", issues); | ||||
|  | ||||
|             var effectiveUntil = until ?? expires; | ||||
|  | ||||
|             if (actionNode is null) | ||||
|             { | ||||
|                 issues.Add(PolicyIssue.Error("policy.action.missing", "Rule action is required.", $"{basePath}.action")); | ||||
|                 return new PolicyAction(PolicyActionType.Block, null, null, null, Quiet: false); | ||||
|             } | ||||
|  | ||||
|             string? actionType = null; | ||||
|             JsonObject? actionObject = null; | ||||
|  | ||||
|             switch (actionNode) | ||||
|             { | ||||
|                 case JsonValue value when value.TryGetValue(out string? text): | ||||
|                     actionType = text; | ||||
|                     break; | ||||
|                 case JsonValue value when value.TryGetValue(out bool booleanValue): | ||||
|                     actionType = booleanValue ? "block" : "ignore"; | ||||
|                     break; | ||||
|                 case JsonObject obj: | ||||
|                     actionObject = obj; | ||||
|                     if (obj.TryGetPropertyValue("type", out var typeNode) && typeNode is JsonValue typeValue && typeValue.TryGetValue(out string? typeText)) | ||||
|                     { | ||||
|                         actionType = typeText; | ||||
|                     } | ||||
|                     else | ||||
|                     { | ||||
|                         issues.Add(PolicyIssue.Error("policy.action.type", "Action object must contain a 'type' property.", $"{basePath}.action.type")); | ||||
|                     } | ||||
|  | ||||
|                     if (obj.TryGetPropertyValue("quiet", out var quietNode) && quietNode is JsonValue quietValue && quietValue.TryGetValue(out bool quietFlag)) | ||||
|                     { | ||||
|                         quiet = quietFlag; | ||||
|                     } | ||||
|  | ||||
|                     if (obj.TryGetPropertyValue("until", out var untilNode)) | ||||
|                     { | ||||
|                         effectiveUntil ??= NormalizeTemporal(untilNode, $"{basePath}.action.until", issues); | ||||
|                     } | ||||
|  | ||||
|                     if (obj.TryGetPropertyValue("justification", out var justificationNode) && justificationNode is JsonValue justificationValue && justificationValue.TryGetValue(out string? justificationText)) | ||||
|                     { | ||||
|                         justification = NormalizeOptionalString(justificationText); | ||||
|                     } | ||||
|  | ||||
|                     break; | ||||
|                 default: | ||||
|                     actionType = actionNode.ToString(); | ||||
|                     break; | ||||
|             } | ||||
|  | ||||
|             if (string.IsNullOrWhiteSpace(actionType)) | ||||
|             { | ||||
|                 issues.Add(PolicyIssue.Error("policy.action.type", "Action type is required.", $"{basePath}.action")); | ||||
|                 return new PolicyAction(PolicyActionType.Block, null, null, null, Quiet: quiet); | ||||
|             } | ||||
|  | ||||
|             actionType = actionType.Trim(); | ||||
|             var (type, typeIssues) = MapActionType(actionType, $"{basePath}.action"); | ||||
|             foreach (var issue in typeIssues) | ||||
|             { | ||||
|                 issues.Add(issue); | ||||
|             } | ||||
|  | ||||
|             PolicyIgnoreOptions? ignoreOptions = null; | ||||
|             PolicyEscalateOptions? escalateOptions = null; | ||||
|             PolicyRequireVexOptions? requireVexOptions = null; | ||||
|  | ||||
|             if (type == PolicyActionType.Ignore) | ||||
|             { | ||||
|                 ignoreOptions = new PolicyIgnoreOptions(effectiveUntil, justification); | ||||
|             } | ||||
|             else if (type == PolicyActionType.Escalate) | ||||
|             { | ||||
|                 escalateOptions = NormalizeEscalateOptions(actionObject, $"{basePath}.action", issues); | ||||
|             } | ||||
|             else if (type == PolicyActionType.RequireVex) | ||||
|             { | ||||
|                 requireVexOptions = NormalizeRequireVexOptions(actionObject, $"{basePath}.action", issues); | ||||
|             } | ||||
|  | ||||
|             return new PolicyAction(type, ignoreOptions, escalateOptions, requireVexOptions, quiet); | ||||
|         } | ||||
|  | ||||
|         private static (PolicyActionType Type, ImmutableArray<PolicyIssue> Issues) MapActionType(string value, string path) | ||||
|         { | ||||
|             var issues = ImmutableArray<PolicyIssue>.Empty; | ||||
|             var lower = value.ToLowerInvariant(); | ||||
|             return lower switch | ||||
|             { | ||||
|                 "block" or "fail" or "deny" => (PolicyActionType.Block, issues), | ||||
|                 "ignore" or "mute" => (PolicyActionType.Ignore, issues), | ||||
|                 "warn" or "warning" => (PolicyActionType.Warn, issues), | ||||
|                 "defer" => (PolicyActionType.Defer, issues), | ||||
|                 "escalate" => (PolicyActionType.Escalate, issues), | ||||
|                 "requirevex" or "require_vex" or "require-vex" => (PolicyActionType.RequireVex, issues), | ||||
|                 _ => (PolicyActionType.Block, ImmutableArray.Create(PolicyIssue.Warning( | ||||
|                     "policy.action.unknown", | ||||
|                     $"Unknown action '{value}' encountered. Defaulting to 'block'.", | ||||
|                     path))), | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         private static PolicyEscalateOptions? NormalizeEscalateOptions( | ||||
|             JsonObject? actionObject, | ||||
|             string path, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (actionObject is null) | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             PolicySeverity? minSeverity = null; | ||||
|             bool requireKev = false; | ||||
|             double? minEpss = null; | ||||
|  | ||||
|             if (actionObject.TryGetPropertyValue("severity", out var severityNode) && severityNode is JsonValue severityValue && severityValue.TryGetValue(out string? severityText)) | ||||
|             { | ||||
|                 if (SeverityMap.TryGetValue(severityText ?? string.Empty, out var mapped)) | ||||
|                 { | ||||
|                     minSeverity = mapped; | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Warning("policy.action.escalate.severity", $"Unknown escalate severity '{severityText}'.", $"{path}.severity")); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (actionObject.TryGetPropertyValue("kev", out var kevNode) && kevNode is JsonValue kevValue && kevValue.TryGetValue(out bool kevFlag)) | ||||
|             { | ||||
|                 requireKev = kevFlag; | ||||
|             } | ||||
|  | ||||
|             if (actionObject.TryGetPropertyValue("epss", out var epssNode)) | ||||
|             { | ||||
|                 var parsed = ParseDouble(epssNode, $"{path}.epss", issues); | ||||
|                 if (parsed is { } epssValue) | ||||
|                 { | ||||
|                     if (epssValue < 0 || epssValue > 1) | ||||
|                     { | ||||
|                         issues.Add(PolicyIssue.Warning("policy.action.escalate.epssRange", "EPS score must be between 0 and 1.", $"{path}.epss")); | ||||
|                     } | ||||
|                     else | ||||
|                     { | ||||
|                         minEpss = epssValue; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return new PolicyEscalateOptions(minSeverity, requireKev, minEpss); | ||||
|         } | ||||
|  | ||||
|         private static PolicyRequireVexOptions? NormalizeRequireVexOptions( | ||||
|             JsonObject? actionObject, | ||||
|             string path, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (actionObject is null) | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             var vendors = ImmutableArray<string>.Empty; | ||||
|             var justifications = ImmutableArray<string>.Empty; | ||||
|  | ||||
|             if (actionObject.TryGetPropertyValue("vendors", out var vendorsNode)) | ||||
|             { | ||||
|                 vendors = NormalizeJsonStringArray(vendorsNode, $"{path}.vendors", issues); | ||||
|             } | ||||
|  | ||||
|             if (actionObject.TryGetPropertyValue("justifications", out var justificationsNode)) | ||||
|             { | ||||
|                 justifications = NormalizeJsonStringArray(justificationsNode, $"{path}.justifications", issues); | ||||
|             } | ||||
|  | ||||
|             return new PolicyRequireVexOptions(vendors, justifications); | ||||
|         } | ||||
|  | ||||
|         private static ImmutableArray<string> NormalizeStringList( | ||||
|             List<string>? values, | ||||
|             string path, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (values is null || values.Count == 0) | ||||
|             { | ||||
|                 return ImmutableArray<string>.Empty; | ||||
|             } | ||||
|  | ||||
|             var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase); | ||||
|             foreach (var value in values) | ||||
|             { | ||||
|                 var normalized = NormalizeOptionalString(value); | ||||
|                 if (string.IsNullOrEmpty(normalized)) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Warning("policy.list.blank", $"Blank entry detected; ignoring value at {path}.", path)); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 builder.Add(normalized); | ||||
|             } | ||||
|  | ||||
|             return builder.ToImmutable() | ||||
|                 .OrderBy(static item => item, StringComparer.OrdinalIgnoreCase) | ||||
|                 .ToImmutableArray(); | ||||
|         } | ||||
|  | ||||
|         private static ImmutableArray<PolicySeverity> NormalizeSeverityList( | ||||
|             List<string>? values, | ||||
|             string path, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (values is null || values.Count == 0) | ||||
|             { | ||||
|                 return ImmutableArray<PolicySeverity>.Empty; | ||||
|             } | ||||
|  | ||||
|             var builder = ImmutableArray.CreateBuilder<PolicySeverity>(); | ||||
|             foreach (var value in values) | ||||
|             { | ||||
|                 var normalized = NormalizeOptionalString(value); | ||||
|                 if (string.IsNullOrEmpty(normalized)) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Warning("policy.severity.blank", "Blank severity was ignored.", path)); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (SeverityMap.TryGetValue(normalized, out var severity)) | ||||
|                 { | ||||
|                     builder.Add(severity); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Error("policy.severity.invalid", $"Unknown severity '{value}'.", path)); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return builder.Distinct().OrderBy(static sev => sev).ToImmutableArray(); | ||||
|         } | ||||
|  | ||||
|         private static ImmutableArray<string> NormalizeJsonStringArray( | ||||
|             JsonNode? node, | ||||
|             string path, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (node is null) | ||||
|             { | ||||
|                 return ImmutableArray<string>.Empty; | ||||
|             } | ||||
|  | ||||
|             if (node is JsonArray array) | ||||
|             { | ||||
|                 var values = new List<string>(array.Count); | ||||
|                 foreach (var element in array) | ||||
|                 { | ||||
|                     var text = ConvertNodeToString(element); | ||||
|                     if (string.IsNullOrWhiteSpace(text)) | ||||
|                     { | ||||
|                         issues.Add(PolicyIssue.Warning("policy.list.blank", $"Blank entry detected; ignoring value at {path}.", path)); | ||||
|                     } | ||||
|                     else | ||||
|                     { | ||||
|                         values.Add(text); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 return values | ||||
|                     .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|                     .OrderBy(static entry => entry, StringComparer.OrdinalIgnoreCase) | ||||
|                     .ToImmutableArray(); | ||||
|             } | ||||
|  | ||||
|             var single = ConvertNodeToString(node); | ||||
|             return ImmutableArray.Create(single); | ||||
|         } | ||||
|  | ||||
|         private static double? ParseDouble(JsonNode? node, string path, ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (node is null) | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             if (node is JsonValue value) | ||||
|             { | ||||
|                 if (value.TryGetValue(out double numeric)) | ||||
|                 { | ||||
|                     return numeric; | ||||
|                 } | ||||
|  | ||||
|                 if (value.TryGetValue(out string? text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out numeric)) | ||||
|                 { | ||||
|                     return numeric; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             issues.Add(PolicyIssue.Warning("policy.number.invalid", $"Value '{node.ToJsonString()}' is not a valid number.", path)); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         private static DateTimeOffset? NormalizeTemporal(JsonNode? node, string path, ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (node is null) | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             if (node is JsonValue value) | ||||
|             { | ||||
|                 if (value.TryGetValue(out DateTimeOffset dto)) | ||||
|                 { | ||||
|                     return dto; | ||||
|                 } | ||||
|  | ||||
|                 if (value.TryGetValue(out DateTime dt)) | ||||
|                 { | ||||
|                     return new DateTimeOffset(DateTime.SpecifyKind(dt, DateTimeKind.Utc)); | ||||
|                 } | ||||
|  | ||||
|                 if (value.TryGetValue(out string? text)) | ||||
|                 { | ||||
|                     if (DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) | ||||
|                     { | ||||
|                         return parsed; | ||||
|                     } | ||||
|  | ||||
|                     if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsedDate)) | ||||
|                     { | ||||
|                         return new DateTimeOffset(parsedDate); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             issues.Add(PolicyIssue.Warning("policy.date.invalid", $"Value '{node.ToJsonString()}' is not a valid ISO-8601 timestamp.", path)); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         private static string? NormalizeRequiredString( | ||||
|             string? value, | ||||
|             string path, | ||||
|             string fieldDescription, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             var normalized = NormalizeOptionalString(value); | ||||
|             if (!string.IsNullOrEmpty(normalized)) | ||||
|             { | ||||
|                 return normalized; | ||||
|             } | ||||
|  | ||||
|             issues.Add(PolicyIssue.Error( | ||||
|                 "policy.required", | ||||
|                 $"{fieldDescription} is required.", | ||||
|                 path)); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         private static string? NormalizeOptionalString(string? value) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(value)) | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             return value.Trim(); | ||||
|         } | ||||
|  | ||||
|         private static string ConvertNodeToString(JsonNode? node) | ||||
|         { | ||||
|             if (node is null) | ||||
|             { | ||||
|                 return string.Empty; | ||||
|             } | ||||
|  | ||||
|             return node switch | ||||
|             { | ||||
|                 JsonValue value when value.TryGetValue(out string? text) => text ?? string.Empty, | ||||
|                 JsonValue value when value.TryGetValue(out bool boolean) => boolean ? "true" : "false", | ||||
|                 JsonValue value when value.TryGetValue(out double numeric) => numeric.ToString(CultureInfo.InvariantCulture), | ||||
|                 JsonObject obj => obj.ToJsonString(), | ||||
|                 JsonArray array => array.ToJsonString(), | ||||
|                 _ => node.ToJsonString(), | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         private static ImmutableArray<PolicyIssue> SortIssues(ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             return issues.ToImmutable() | ||||
|                 .OrderBy(static issue => issue.Severity switch | ||||
|                 { | ||||
|                     PolicyIssueSeverity.Error => 0, | ||||
|                     PolicyIssueSeverity.Warning => 1, | ||||
|                     _ => 2, | ||||
|                 }) | ||||
|                 .ThenBy(static issue => issue.Path, StringComparer.Ordinal) | ||||
|                 .ThenBy(static issue => issue.Code, StringComparer.Ordinal) | ||||
|                 .ToImmutableArray(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										77
									
								
								src/StellaOps.Policy/PolicyDiagnostics.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								src/StellaOps.Policy/PolicyDiagnostics.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed record PolicyDiagnosticsReport( | ||||
|     string Version, | ||||
|     int RuleCount, | ||||
|     int ErrorCount, | ||||
|     int WarningCount, | ||||
|     DateTimeOffset GeneratedAt, | ||||
|     ImmutableArray<PolicyIssue> Issues, | ||||
|     ImmutableArray<string> Recommendations); | ||||
|  | ||||
| public static class PolicyDiagnostics | ||||
| { | ||||
|     public static PolicyDiagnosticsReport Create(PolicyBindingResult bindingResult, TimeProvider? timeProvider = null) | ||||
|     { | ||||
|         if (bindingResult is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(bindingResult)); | ||||
|         } | ||||
|  | ||||
|         var time = (timeProvider ?? TimeProvider.System).GetUtcNow(); | ||||
|         var errorCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Error); | ||||
|         var warningCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Warning); | ||||
|  | ||||
|         var recommendations = BuildRecommendations(bindingResult.Document, errorCount, warningCount); | ||||
|  | ||||
|         return new PolicyDiagnosticsReport( | ||||
|             bindingResult.Document.Version, | ||||
|             bindingResult.Document.Rules.Length, | ||||
|             errorCount, | ||||
|             warningCount, | ||||
|             time, | ||||
|             bindingResult.Issues, | ||||
|             recommendations); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<string> BuildRecommendations(PolicyDocument document, int errorCount, int warningCount) | ||||
|     { | ||||
|         var messages = ImmutableArray.CreateBuilder<string>(); | ||||
|  | ||||
|         if (errorCount > 0) | ||||
|         { | ||||
|             messages.Add("Resolve policy errors before promoting the revision; fallback rules may be applied while errors remain."); | ||||
|         } | ||||
|  | ||||
|         if (warningCount > 0) | ||||
|         { | ||||
|             messages.Add("Review policy warnings and ensure intentional overrides are documented."); | ||||
|         } | ||||
|  | ||||
|         if (document.Rules.Length == 0) | ||||
|         { | ||||
|             messages.Add("Add at least one policy rule to enforce gating logic."); | ||||
|         } | ||||
|  | ||||
|         var quietRules = document.Rules | ||||
|             .Where(static rule => rule.Action.Quiet) | ||||
|             .Select(static rule => rule.Name) | ||||
|             .ToArray(); | ||||
|  | ||||
|         if (quietRules.Length > 0) | ||||
|         { | ||||
|             messages.Add($"Quiet rules detected ({string.Join(", ", quietRules)}); verify scoring behaviour aligns with expectations."); | ||||
|         } | ||||
|  | ||||
|         if (messages.Count == 0) | ||||
|         { | ||||
|             messages.Add("Policy validated successfully; no additional action required."); | ||||
|         } | ||||
|  | ||||
|         return messages.ToImmutable(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										211
									
								
								src/StellaOps.Policy/PolicyDigest.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										211
									
								
								src/StellaOps.Policy/PolicyDigest.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,211 @@ | ||||
| using System; | ||||
| using System.Buffers; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public static class PolicyDigest | ||||
| { | ||||
|     public static string Compute(PolicyDocument document) | ||||
|     { | ||||
|         if (document is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(document)); | ||||
|         } | ||||
|  | ||||
|         var buffer = new ArrayBufferWriter<byte>(); | ||||
|         using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions | ||||
|         { | ||||
|             SkipValidation = true, | ||||
|         })) | ||||
|         { | ||||
|             WriteDocument(writer, document); | ||||
|         } | ||||
|  | ||||
|         var hash = SHA256.HashData(buffer.WrittenSpan); | ||||
|         return Convert.ToHexString(hash).ToLowerInvariant(); | ||||
|     } | ||||
|  | ||||
|     private static void WriteDocument(Utf8JsonWriter writer, PolicyDocument document) | ||||
|     { | ||||
|         writer.WriteStartObject(); | ||||
|         writer.WriteString("version", document.Version); | ||||
|  | ||||
|         if (!document.Metadata.IsEmpty) | ||||
|         { | ||||
|             writer.WritePropertyName("metadata"); | ||||
|             writer.WriteStartObject(); | ||||
|             foreach (var pair in document.Metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)) | ||||
|             { | ||||
|                 writer.WriteString(pair.Key, pair.Value); | ||||
|             } | ||||
|             writer.WriteEndObject(); | ||||
|         } | ||||
|  | ||||
|         writer.WritePropertyName("rules"); | ||||
|         writer.WriteStartArray(); | ||||
|         foreach (var rule in document.Rules) | ||||
|         { | ||||
|             WriteRule(writer, rule); | ||||
|         } | ||||
|         writer.WriteEndArray(); | ||||
|  | ||||
|         writer.WriteEndObject(); | ||||
|         writer.Flush(); | ||||
|     } | ||||
|  | ||||
|     private static void WriteRule(Utf8JsonWriter writer, PolicyRule rule) | ||||
|     { | ||||
|         writer.WriteStartObject(); | ||||
|         writer.WriteString("name", rule.Name); | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(rule.Identifier)) | ||||
|         { | ||||
|             writer.WriteString("id", rule.Identifier); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(rule.Description)) | ||||
|         { | ||||
|             writer.WriteString("description", rule.Description); | ||||
|         } | ||||
|  | ||||
|         WriteMetadata(writer, rule.Metadata); | ||||
|         WriteSeverities(writer, rule.Severities); | ||||
|         WriteStringArray(writer, "environments", rule.Environments); | ||||
|         WriteStringArray(writer, "sources", rule.Sources); | ||||
|         WriteStringArray(writer, "vendors", rule.Vendors); | ||||
|         WriteStringArray(writer, "licenses", rule.Licenses); | ||||
|         WriteStringArray(writer, "tags", rule.Tags); | ||||
|  | ||||
|         if (!rule.Match.IsEmpty) | ||||
|         { | ||||
|             writer.WritePropertyName("match"); | ||||
|             writer.WriteStartObject(); | ||||
|             WriteStringArray(writer, "images", rule.Match.Images); | ||||
|             WriteStringArray(writer, "repositories", rule.Match.Repositories); | ||||
|             WriteStringArray(writer, "packages", rule.Match.Packages); | ||||
|             WriteStringArray(writer, "purls", rule.Match.Purls); | ||||
|             WriteStringArray(writer, "cves", rule.Match.Cves); | ||||
|             WriteStringArray(writer, "paths", rule.Match.Paths); | ||||
|             WriteStringArray(writer, "layerDigests", rule.Match.LayerDigests); | ||||
|             WriteStringArray(writer, "usedByEntrypoint", rule.Match.UsedByEntrypoint); | ||||
|             writer.WriteEndObject(); | ||||
|         } | ||||
|  | ||||
|         WriteAction(writer, rule.Action); | ||||
|  | ||||
|         if (rule.Expires is DateTimeOffset expires) | ||||
|         { | ||||
|             writer.WriteString("expires", expires.ToUniversalTime().ToString("O")); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(rule.Justification)) | ||||
|         { | ||||
|             writer.WriteString("justification", rule.Justification); | ||||
|         } | ||||
|  | ||||
|         writer.WriteEndObject(); | ||||
|     } | ||||
|  | ||||
|     private static void WriteAction(Utf8JsonWriter writer, PolicyAction action) | ||||
|     { | ||||
|         writer.WritePropertyName("action"); | ||||
|         writer.WriteStartObject(); | ||||
|         writer.WriteString("type", action.Type.ToString().ToLowerInvariant()); | ||||
|  | ||||
|         if (action.Quiet) | ||||
|         { | ||||
|             writer.WriteBoolean("quiet", true); | ||||
|         } | ||||
|  | ||||
|         if (action.Ignore is { } ignore) | ||||
|         { | ||||
|             if (ignore.Until is DateTimeOffset until) | ||||
|             { | ||||
|                 writer.WriteString("until", until.ToUniversalTime().ToString("O")); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(ignore.Justification)) | ||||
|             { | ||||
|                 writer.WriteString("justification", ignore.Justification); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (action.Escalate is { } escalate) | ||||
|         { | ||||
|             if (escalate.MinimumSeverity is { } severity) | ||||
|             { | ||||
|                 writer.WriteString("severity", severity.ToString()); | ||||
|             } | ||||
|  | ||||
|             if (escalate.RequireKev) | ||||
|             { | ||||
|                 writer.WriteBoolean("kev", true); | ||||
|             } | ||||
|  | ||||
|             if (escalate.MinimumEpss is double epss) | ||||
|             { | ||||
|                 writer.WriteNumber("epss", epss); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (action.RequireVex is { } requireVex) | ||||
|         { | ||||
|             WriteStringArray(writer, "vendors", requireVex.Vendors); | ||||
|             WriteStringArray(writer, "justifications", requireVex.Justifications); | ||||
|         } | ||||
|  | ||||
|         writer.WriteEndObject(); | ||||
|     } | ||||
|  | ||||
|     private static void WriteMetadata(Utf8JsonWriter writer, ImmutableDictionary<string, string> metadata) | ||||
|     { | ||||
|         if (metadata.IsEmpty) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         writer.WritePropertyName("metadata"); | ||||
|         writer.WriteStartObject(); | ||||
|         foreach (var pair in metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)) | ||||
|         { | ||||
|             writer.WriteString(pair.Key, pair.Value); | ||||
|         } | ||||
|         writer.WriteEndObject(); | ||||
|     } | ||||
|  | ||||
|     private static void WriteSeverities(Utf8JsonWriter writer, ImmutableArray<PolicySeverity> severities) | ||||
|     { | ||||
|         if (severities.IsDefaultOrEmpty) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         writer.WritePropertyName("severity"); | ||||
|         writer.WriteStartArray(); | ||||
|         foreach (var severity in severities) | ||||
|         { | ||||
|             writer.WriteStringValue(severity.ToString()); | ||||
|         } | ||||
|         writer.WriteEndArray(); | ||||
|     } | ||||
|  | ||||
|     private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values) | ||||
|     { | ||||
|         if (values.IsDefaultOrEmpty) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         writer.WritePropertyName(propertyName); | ||||
|         writer.WriteStartArray(); | ||||
|         foreach (var value in values) | ||||
|         { | ||||
|             writer.WriteStringValue(value); | ||||
|         } | ||||
|         writer.WriteEndArray(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										192
									
								
								src/StellaOps.Policy/PolicyDocument.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										192
									
								
								src/StellaOps.Policy/PolicyDocument.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,192 @@ | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| /// <summary> | ||||
| /// Canonical representation of a StellaOps policy document. | ||||
| /// </summary> | ||||
| public sealed record PolicyDocument( | ||||
|     string Version, | ||||
|     ImmutableArray<PolicyRule> Rules, | ||||
|     ImmutableDictionary<string, string> Metadata) | ||||
| { | ||||
|     public static PolicyDocument Empty { get; } = new( | ||||
|         PolicySchema.CurrentVersion, | ||||
|         ImmutableArray<PolicyRule>.Empty, | ||||
|         ImmutableDictionary<string, string>.Empty); | ||||
| } | ||||
|  | ||||
| public static class PolicySchema | ||||
| { | ||||
|     public const string SchemaId = "https://schemas.stella-ops.org/policy/policy-schema@1.json"; | ||||
|     public const string CurrentVersion = "1.0"; | ||||
|  | ||||
|     public static PolicyDocumentFormat DetectFormat(string fileName) | ||||
|     { | ||||
|         if (fileName is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(fileName)); | ||||
|         } | ||||
|  | ||||
|         var lower = fileName.Trim().ToLowerInvariant(); | ||||
|         if (lower.EndsWith(".yaml", StringComparison.Ordinal) || lower.EndsWith(".yml", StringComparison.Ordinal)) | ||||
|         { | ||||
|             return PolicyDocumentFormat.Yaml; | ||||
|         } | ||||
|  | ||||
|         return PolicyDocumentFormat.Json; | ||||
|     } | ||||
| } | ||||
|  | ||||
| public sealed record PolicyRule( | ||||
|     string Name, | ||||
|     string? Identifier, | ||||
|     string? Description, | ||||
|     PolicyAction Action, | ||||
|     ImmutableArray<PolicySeverity> Severities, | ||||
|     ImmutableArray<string> Environments, | ||||
|     ImmutableArray<string> Sources, | ||||
|     ImmutableArray<string> Vendors, | ||||
|     ImmutableArray<string> Licenses, | ||||
|     ImmutableArray<string> Tags, | ||||
|     PolicyRuleMatchCriteria Match, | ||||
|     DateTimeOffset? Expires, | ||||
|     string? Justification, | ||||
|     ImmutableDictionary<string, string> Metadata) | ||||
| { | ||||
|     public static PolicyRuleMatchCriteria EmptyMatch { get; } = new( | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty); | ||||
|  | ||||
|     public static PolicyRule Create( | ||||
|         string name, | ||||
|         PolicyAction action, | ||||
|         ImmutableArray<PolicySeverity> severities, | ||||
|         ImmutableArray<string> environments, | ||||
|         ImmutableArray<string> sources, | ||||
|         ImmutableArray<string> vendors, | ||||
|         ImmutableArray<string> licenses, | ||||
|         ImmutableArray<string> tags, | ||||
|         PolicyRuleMatchCriteria match, | ||||
|         DateTimeOffset? expires, | ||||
|         string? justification, | ||||
|         string? identifier = null, | ||||
|         string? description = null, | ||||
|         ImmutableDictionary<string, string>? metadata = null) | ||||
|     { | ||||
|         metadata ??= ImmutableDictionary<string, string>.Empty; | ||||
|         return new PolicyRule( | ||||
|             name, | ||||
|             identifier, | ||||
|             description, | ||||
|             action, | ||||
|             severities, | ||||
|             environments, | ||||
|             sources, | ||||
|             vendors, | ||||
|             licenses, | ||||
|             tags, | ||||
|             match, | ||||
|             expires, | ||||
|             justification, | ||||
|             metadata); | ||||
|     } | ||||
|  | ||||
|     public bool MatchesAnyEnvironment => Environments.IsDefaultOrEmpty; | ||||
| } | ||||
|  | ||||
| public sealed record PolicyRuleMatchCriteria( | ||||
|     ImmutableArray<string> Images, | ||||
|     ImmutableArray<string> Repositories, | ||||
|     ImmutableArray<string> Packages, | ||||
|     ImmutableArray<string> Purls, | ||||
|     ImmutableArray<string> Cves, | ||||
|     ImmutableArray<string> Paths, | ||||
|     ImmutableArray<string> LayerDigests, | ||||
|     ImmutableArray<string> UsedByEntrypoint) | ||||
| { | ||||
|     public static PolicyRuleMatchCriteria Create( | ||||
|         ImmutableArray<string> images, | ||||
|         ImmutableArray<string> repositories, | ||||
|         ImmutableArray<string> packages, | ||||
|         ImmutableArray<string> purls, | ||||
|         ImmutableArray<string> cves, | ||||
|         ImmutableArray<string> paths, | ||||
|         ImmutableArray<string> layerDigests, | ||||
|         ImmutableArray<string> usedByEntrypoint) | ||||
|         => new( | ||||
|             images, | ||||
|             repositories, | ||||
|             packages, | ||||
|             purls, | ||||
|             cves, | ||||
|             paths, | ||||
|             layerDigests, | ||||
|             usedByEntrypoint); | ||||
|  | ||||
|     public static PolicyRuleMatchCriteria Empty { get; } = new( | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty, | ||||
|         ImmutableArray<string>.Empty); | ||||
|  | ||||
|     public bool IsEmpty => | ||||
|         Images.IsDefaultOrEmpty && | ||||
|         Repositories.IsDefaultOrEmpty && | ||||
|         Packages.IsDefaultOrEmpty && | ||||
|         Purls.IsDefaultOrEmpty && | ||||
|         Cves.IsDefaultOrEmpty && | ||||
|         Paths.IsDefaultOrEmpty && | ||||
|         LayerDigests.IsDefaultOrEmpty && | ||||
|         UsedByEntrypoint.IsDefaultOrEmpty; | ||||
| } | ||||
|  | ||||
| public sealed record PolicyAction( | ||||
|     PolicyActionType Type, | ||||
|     PolicyIgnoreOptions? Ignore, | ||||
|     PolicyEscalateOptions? Escalate, | ||||
|     PolicyRequireVexOptions? RequireVex, | ||||
|     bool Quiet); | ||||
|  | ||||
| public enum PolicyActionType | ||||
| { | ||||
|     Block, | ||||
|     Ignore, | ||||
|     Warn, | ||||
|     Defer, | ||||
|     Escalate, | ||||
|     RequireVex, | ||||
| } | ||||
|  | ||||
| public sealed record PolicyIgnoreOptions(DateTimeOffset? Until, string? Justification); | ||||
|  | ||||
| public sealed record PolicyEscalateOptions( | ||||
|     PolicySeverity? MinimumSeverity, | ||||
|     bool RequireKev, | ||||
|     double? MinimumEpss); | ||||
|  | ||||
| public sealed record PolicyRequireVexOptions( | ||||
|     ImmutableArray<string> Vendors, | ||||
|     ImmutableArray<string> Justifications); | ||||
|  | ||||
| public enum PolicySeverity | ||||
| { | ||||
|     Critical, | ||||
|     High, | ||||
|     Medium, | ||||
|     Low, | ||||
|     Informational, | ||||
|     None, | ||||
|     Unknown, | ||||
| } | ||||
							
								
								
									
										270
									
								
								src/StellaOps.Policy/PolicyEvaluation.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										270
									
								
								src/StellaOps.Policy/PolicyEvaluation.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,270 @@ | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public static class PolicyEvaluation | ||||
| { | ||||
|     public static PolicyVerdict EvaluateFinding(PolicyDocument document, PolicyScoringConfig scoringConfig, PolicyFinding finding) | ||||
|     { | ||||
|         if (document is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(document)); | ||||
|         } | ||||
|  | ||||
|         if (scoringConfig is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(scoringConfig)); | ||||
|         } | ||||
|  | ||||
|         if (finding is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(finding)); | ||||
|         } | ||||
|  | ||||
|         var severityWeight = scoringConfig.SeverityWeights.TryGetValue(finding.Severity, out var weight) | ||||
|             ? weight | ||||
|             : scoringConfig.SeverityWeights.GetValueOrDefault(PolicySeverity.Unknown, 0); | ||||
|  | ||||
|         foreach (var rule in document.Rules) | ||||
|         { | ||||
|             if (!RuleMatches(rule, finding)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             return BuildVerdict(rule, finding, scoringConfig, severityWeight); | ||||
|         } | ||||
|  | ||||
|         return PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig); | ||||
|     } | ||||
|  | ||||
|     private static PolicyVerdict BuildVerdict( | ||||
|         PolicyRule rule, | ||||
|         PolicyFinding finding, | ||||
|         PolicyScoringConfig config, | ||||
|         double severityWeight) | ||||
|     { | ||||
|         var action = rule.Action; | ||||
|         var status = MapAction(action); | ||||
|         var notes = BuildNotes(action); | ||||
|         var inputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase); | ||||
|         inputs["severityWeight"] = severityWeight; | ||||
|  | ||||
|         double score = severityWeight; | ||||
|         string? quietedBy = null; | ||||
|         var quiet = false; | ||||
|  | ||||
|         switch (status) | ||||
|         { | ||||
|             case PolicyVerdictStatus.Ignored: | ||||
|                 score = Math.Max(0, severityWeight - config.IgnorePenalty); | ||||
|                 inputs["ignorePenalty"] = config.IgnorePenalty; | ||||
|                 break; | ||||
|             case PolicyVerdictStatus.Warned: | ||||
|                 score = Math.Max(0, severityWeight - config.WarnPenalty); | ||||
|                 inputs["warnPenalty"] = config.WarnPenalty; | ||||
|                 break; | ||||
|             case PolicyVerdictStatus.Deferred: | ||||
|                 score = Math.Max(0, severityWeight - (config.WarnPenalty / 2)); | ||||
|                 inputs["deferPenalty"] = config.WarnPenalty / 2; | ||||
|                 break; | ||||
|         } | ||||
|  | ||||
|         if (action.Quiet) | ||||
|         { | ||||
|             var quietAllowed = action.RequireVex is not null || action.Type == PolicyActionType.RequireVex; | ||||
|             if (quietAllowed) | ||||
|             { | ||||
|                 score = Math.Max(0, score - config.QuietPenalty); | ||||
|                 inputs["quietPenalty"] = config.QuietPenalty; | ||||
|                 quietedBy = rule.Name; | ||||
|                 quiet = true; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 inputs.Remove("ignorePenalty"); | ||||
|                 var warnScore = Math.Max(0, severityWeight - config.WarnPenalty); | ||||
|                 inputs["warnPenalty"] = config.WarnPenalty; | ||||
|                 var warnNotes = AppendNote(notes, "Quiet flag ignored: rule must specify requireVex justifications."); | ||||
|  | ||||
|                 return new PolicyVerdict( | ||||
|                     finding.FindingId, | ||||
|                     PolicyVerdictStatus.Warned, | ||||
|                     rule.Name, | ||||
|                     action.Type.ToString(), | ||||
|                     warnNotes, | ||||
|                     warnScore, | ||||
|                     config.Version, | ||||
|                     inputs.ToImmutable(), | ||||
|                     QuietedBy: null, | ||||
|                     Quiet: false); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return new PolicyVerdict( | ||||
|             finding.FindingId, | ||||
|             status, | ||||
|             rule.Name, | ||||
|             action.Type.ToString(), | ||||
|             notes, | ||||
|             score, | ||||
|             config.Version, | ||||
|             inputs.ToImmutable(), | ||||
|             quietedBy, | ||||
|             quiet); | ||||
|     } | ||||
|  | ||||
|     private static bool RuleMatches(PolicyRule rule, PolicyFinding finding) | ||||
|     { | ||||
|         if (!rule.Severities.IsDefaultOrEmpty && !rule.Severities.Contains(finding.Severity)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!Matches(rule.Environments, finding.Environment)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!Matches(rule.Sources, finding.Source)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!Matches(rule.Vendors, finding.Vendor)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!Matches(rule.Licenses, finding.License)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!RuleMatchCriteria(rule.Match, finding)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static bool Matches(ImmutableArray<string> ruleValues, string? candidate) | ||||
|     { | ||||
|         if (ruleValues.IsDefaultOrEmpty) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(candidate)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return ruleValues.Contains(candidate, StringComparer.OrdinalIgnoreCase); | ||||
|     } | ||||
|  | ||||
|     private static bool RuleMatchCriteria(PolicyRuleMatchCriteria criteria, PolicyFinding finding) | ||||
|     { | ||||
|         if (!criteria.Images.IsDefaultOrEmpty && !ContainsValue(criteria.Images, finding.Image, StringComparer.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!criteria.Repositories.IsDefaultOrEmpty && !ContainsValue(criteria.Repositories, finding.Repository, StringComparer.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!criteria.Packages.IsDefaultOrEmpty && !ContainsValue(criteria.Packages, finding.Package, StringComparer.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!criteria.Purls.IsDefaultOrEmpty && !ContainsValue(criteria.Purls, finding.Purl, StringComparer.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!criteria.Cves.IsDefaultOrEmpty && !ContainsValue(criteria.Cves, finding.Cve, StringComparer.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!criteria.Paths.IsDefaultOrEmpty && !ContainsValue(criteria.Paths, finding.Path, StringComparer.Ordinal)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!criteria.LayerDigests.IsDefaultOrEmpty && !ContainsValue(criteria.LayerDigests, finding.LayerDigest, StringComparer.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!criteria.UsedByEntrypoint.IsDefaultOrEmpty) | ||||
|         { | ||||
|             var match = false; | ||||
|             foreach (var tag in criteria.UsedByEntrypoint) | ||||
|             { | ||||
|                 if (finding.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) | ||||
|                 { | ||||
|                     match = true; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (!match) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static bool ContainsValue(ImmutableArray<string> values, string? candidate, StringComparer comparer) | ||||
|     { | ||||
|         if (values.IsDefaultOrEmpty) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(candidate)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return values.Contains(candidate, comparer); | ||||
|     } | ||||
|  | ||||
|     private static PolicyVerdictStatus MapAction(PolicyAction action) | ||||
|         => action.Type switch | ||||
|         { | ||||
|             PolicyActionType.Block => PolicyVerdictStatus.Blocked, | ||||
|             PolicyActionType.Ignore => PolicyVerdictStatus.Ignored, | ||||
|             PolicyActionType.Warn => PolicyVerdictStatus.Warned, | ||||
|             PolicyActionType.Defer => PolicyVerdictStatus.Deferred, | ||||
|             PolicyActionType.Escalate => PolicyVerdictStatus.Escalated, | ||||
|             PolicyActionType.RequireVex => PolicyVerdictStatus.RequiresVex, | ||||
|             _ => PolicyVerdictStatus.Pass, | ||||
|         }; | ||||
|  | ||||
|     private static string? BuildNotes(PolicyAction action) | ||||
|     { | ||||
|         if (action.Ignore is { } ignore && !string.IsNullOrWhiteSpace(ignore.Justification)) | ||||
|         { | ||||
|             return ignore.Justification; | ||||
|         } | ||||
|  | ||||
|         if (action.Escalate is { } escalate && escalate.MinimumSeverity is { } severity) | ||||
|         { | ||||
|             return $"Escalate >= {severity}"; | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static string? AppendNote(string? existing, string addition) | ||||
|         => string.IsNullOrWhiteSpace(existing) ? addition : string.Concat(existing, " | ", addition); | ||||
| } | ||||
							
								
								
									
										51
									
								
								src/StellaOps.Policy/PolicyFinding.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/StellaOps.Policy/PolicyFinding.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed record PolicyFinding( | ||||
|     string FindingId, | ||||
|     PolicySeverity Severity, | ||||
|     string? Environment, | ||||
|     string? Source, | ||||
|     string? Vendor, | ||||
|     string? License, | ||||
|     string? Image, | ||||
|     string? Repository, | ||||
|     string? Package, | ||||
|     string? Purl, | ||||
|     string? Cve, | ||||
|     string? Path, | ||||
|     string? LayerDigest, | ||||
|     ImmutableArray<string> Tags) | ||||
| { | ||||
|     public static PolicyFinding Create( | ||||
|         string findingId, | ||||
|         PolicySeverity severity, | ||||
|         string? environment = null, | ||||
|         string? source = null, | ||||
|         string? vendor = null, | ||||
|         string? license = null, | ||||
|         string? image = null, | ||||
|         string? repository = null, | ||||
|         string? package = null, | ||||
|         string? purl = null, | ||||
|         string? cve = null, | ||||
|         string? path = null, | ||||
|         string? layerDigest = null, | ||||
|         ImmutableArray<string>? tags = null) | ||||
|         => new( | ||||
|             findingId, | ||||
|             severity, | ||||
|             environment, | ||||
|             source, | ||||
|             vendor, | ||||
|             license, | ||||
|             image, | ||||
|             repository, | ||||
|             package, | ||||
|             purl, | ||||
|             cve, | ||||
|             path, | ||||
|             layerDigest, | ||||
|             tags ?? ImmutableArray<string>.Empty); | ||||
| } | ||||
							
								
								
									
										28
									
								
								src/StellaOps.Policy/PolicyIssue.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/StellaOps.Policy/PolicyIssue.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a validation or normalization issue discovered while processing a policy document. | ||||
| /// </summary> | ||||
| public sealed record PolicyIssue(string Code, string Message, PolicyIssueSeverity Severity, string Path) | ||||
| { | ||||
|     public static PolicyIssue Error(string code, string message, string path) | ||||
|         => new(code, message, PolicyIssueSeverity.Error, path); | ||||
|  | ||||
|     public static PolicyIssue Warning(string code, string message, string path) | ||||
|         => new(code, message, PolicyIssueSeverity.Warning, path); | ||||
|  | ||||
|     public static PolicyIssue Info(string code, string message, string path) | ||||
|         => new(code, message, PolicyIssueSeverity.Info, path); | ||||
|  | ||||
|     public PolicyIssue EnsurePath(string fallbackPath) | ||||
|         => string.IsNullOrWhiteSpace(Path) ? this with { Path = fallbackPath } : this; | ||||
| } | ||||
|  | ||||
| public enum PolicyIssueSeverity | ||||
| { | ||||
|     Error, | ||||
|     Warning, | ||||
|     Info, | ||||
| } | ||||
							
								
								
									
										18
									
								
								src/StellaOps.Policy/PolicyPreviewModels.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/StellaOps.Policy/PolicyPreviewModels.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed record PolicyPreviewRequest( | ||||
|     string ImageDigest, | ||||
|     ImmutableArray<PolicyFinding> Findings, | ||||
|     ImmutableArray<PolicyVerdict> BaselineVerdicts, | ||||
|     PolicySnapshot? SnapshotOverride = null, | ||||
|     PolicySnapshotContent? ProposedPolicy = null); | ||||
|  | ||||
| public sealed record PolicyPreviewResponse( | ||||
|     bool Success, | ||||
|     string PolicyDigest, | ||||
|     string? RevisionId, | ||||
|     ImmutableArray<PolicyIssue> Issues, | ||||
|     ImmutableArray<PolicyVerdictDiff> Diffs, | ||||
|     int ChangedCount); | ||||
							
								
								
									
										142
									
								
								src/StellaOps.Policy/PolicyPreviewService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								src/StellaOps.Policy/PolicyPreviewService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed class PolicyPreviewService | ||||
| { | ||||
|     private readonly PolicySnapshotStore _snapshotStore; | ||||
|     private readonly ILogger<PolicyPreviewService> _logger; | ||||
|  | ||||
|     public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger<PolicyPreviewService> logger) | ||||
|     { | ||||
|         _snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<PolicyPreviewResponse> PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         if (request is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(request)); | ||||
|         } | ||||
|  | ||||
|         var (snapshot, bindingIssues) = await ResolveSnapshotAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|         if (snapshot is null) | ||||
|         { | ||||
|             _logger.LogWarning("Policy preview failed: snapshot unavailable or validation errors. Issues={Count}", bindingIssues.Length); | ||||
|             return new PolicyPreviewResponse(false, string.Empty, null, bindingIssues, ImmutableArray<PolicyVerdictDiff>.Empty, 0); | ||||
|         } | ||||
|  | ||||
|         var projected = Evaluate(snapshot.Document, snapshot.ScoringConfig, request.Findings); | ||||
|         var baseline = BuildBaseline(request.BaselineVerdicts, projected, snapshot.ScoringConfig); | ||||
|         var diffs = BuildDiffs(baseline, projected); | ||||
|         var changed = diffs.Count(static diff => diff.Changed); | ||||
|  | ||||
|         _logger.LogDebug("Policy preview computed for {ImageDigest}. Changed={Changed}", request.ImageDigest, changed); | ||||
|  | ||||
|         return new PolicyPreviewResponse(true, snapshot.Digest, snapshot.RevisionId, bindingIssues, diffs, changed); | ||||
|     } | ||||
|  | ||||
|     private async Task<(PolicySnapshot? Snapshot, ImmutableArray<PolicyIssue> Issues)> ResolveSnapshotAsync(PolicyPreviewRequest request, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (request.ProposedPolicy is not null) | ||||
|         { | ||||
|             var binding = PolicyBinder.Bind(request.ProposedPolicy.Content, request.ProposedPolicy.Format); | ||||
|             if (!binding.Success) | ||||
|             { | ||||
|                 return (null, binding.Issues); | ||||
|             } | ||||
|  | ||||
|             var digest = PolicyDigest.Compute(binding.Document); | ||||
|             var snapshot = new PolicySnapshot( | ||||
|                 request.SnapshotOverride?.RevisionNumber + 1 ?? 0, | ||||
|                 request.SnapshotOverride?.RevisionId ?? "preview", | ||||
|                 digest, | ||||
|                 DateTimeOffset.UtcNow, | ||||
|                 request.ProposedPolicy.Actor, | ||||
|                 request.ProposedPolicy.Format, | ||||
|                 binding.Document, | ||||
|                 binding.Issues, | ||||
|                 PolicyScoringConfig.Default); | ||||
|  | ||||
|             return (snapshot, binding.Issues); | ||||
|         } | ||||
|  | ||||
|         if (request.SnapshotOverride is not null) | ||||
|         { | ||||
|             return (request.SnapshotOverride, ImmutableArray<PolicyIssue>.Empty); | ||||
|         } | ||||
|  | ||||
|         var latest = await _snapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false); | ||||
|         if (latest is not null) | ||||
|         { | ||||
|             return (latest, ImmutableArray<PolicyIssue>.Empty); | ||||
|         } | ||||
|  | ||||
|         return (null, ImmutableArray.Create(PolicyIssue.Error("policy.preview.snapshot_missing", "No policy snapshot is available for preview.", "$"))); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<PolicyVerdict> Evaluate(PolicyDocument document, PolicyScoringConfig scoringConfig, ImmutableArray<PolicyFinding> findings) | ||||
|     { | ||||
|         if (findings.IsDefaultOrEmpty) | ||||
|         { | ||||
|             return ImmutableArray<PolicyVerdict>.Empty; | ||||
|         } | ||||
|  | ||||
|         var results = ImmutableArray.CreateBuilder<PolicyVerdict>(findings.Length); | ||||
|         foreach (var finding in findings) | ||||
|         { | ||||
|             var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding); | ||||
|             results.Add(verdict); | ||||
|         } | ||||
|  | ||||
|         return results.ToImmutable(); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableDictionary<string, PolicyVerdict> BuildBaseline(ImmutableArray<PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected, PolicyScoringConfig scoringConfig) | ||||
|     { | ||||
|         var builder = ImmutableDictionary.CreateBuilder<string, PolicyVerdict>(StringComparer.Ordinal); | ||||
|         if (!baseline.IsDefaultOrEmpty) | ||||
|         { | ||||
|             foreach (var verdict in baseline) | ||||
|             { | ||||
|                 if (!string.IsNullOrEmpty(verdict.FindingId) && !builder.ContainsKey(verdict.FindingId)) | ||||
|                 { | ||||
|                     builder.Add(verdict.FindingId, verdict); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         foreach (var verdict in projected) | ||||
|         { | ||||
|             if (!builder.ContainsKey(verdict.FindingId)) | ||||
|             { | ||||
|                 builder.Add(verdict.FindingId, PolicyVerdict.CreateBaseline(verdict.FindingId, scoringConfig)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return builder.ToImmutable(); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<PolicyVerdictDiff> BuildDiffs(ImmutableDictionary<string, PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected) | ||||
|     { | ||||
|         var diffs = ImmutableArray.CreateBuilder<PolicyVerdictDiff>(projected.Length); | ||||
|         foreach (var verdict in projected.OrderBy(static v => v.FindingId, StringComparer.Ordinal)) | ||||
|         { | ||||
|             var baseVerdict = baseline.TryGetValue(verdict.FindingId, out var existing) | ||||
|                 ? existing | ||||
|                 : new PolicyVerdict(verdict.FindingId, PolicyVerdictStatus.Pass); | ||||
|  | ||||
|             diffs.Add(new PolicyVerdictDiff(baseVerdict, verdict)); | ||||
|         } | ||||
|  | ||||
|         return diffs.ToImmutable(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										30
									
								
								src/StellaOps.Policy/PolicySchemaResource.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/StellaOps.Policy/PolicySchemaResource.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Reflection; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public static class PolicySchemaResource | ||||
| { | ||||
|     private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-schema@1.json"; | ||||
|  | ||||
|     public static Stream OpenSchemaStream() | ||||
|     { | ||||
|         var assembly = Assembly.GetExecutingAssembly(); | ||||
|         var stream = assembly.GetManifestResourceStream(SchemaResourceName); | ||||
|         if (stream is null) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Unable to locate embedded schema resource '{SchemaResourceName}'."); | ||||
|         } | ||||
|  | ||||
|         return stream; | ||||
|     } | ||||
|  | ||||
|     public static string ReadSchemaJson() | ||||
|     { | ||||
|         using var stream = OpenSchemaStream(); | ||||
|         using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); | ||||
|         return reader.ReadToEnd(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								src/StellaOps.Policy/PolicyScoringConfig.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/StellaOps.Policy/PolicyScoringConfig.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed record PolicyScoringConfig( | ||||
|     string Version, | ||||
|     ImmutableDictionary<PolicySeverity, double> SeverityWeights, | ||||
|     double QuietPenalty, | ||||
|     double WarnPenalty, | ||||
|     double IgnorePenalty, | ||||
|     ImmutableDictionary<string, double> TrustOverrides) | ||||
| { | ||||
|     public static string BaselineVersion => "1.0"; | ||||
|  | ||||
|     public static PolicyScoringConfig Default { get; } = PolicyScoringConfigBinder.LoadDefault(); | ||||
| } | ||||
							
								
								
									
										266
									
								
								src/StellaOps.Policy/PolicyScoringConfigBinder.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										266
									
								
								src/StellaOps.Policy/PolicyScoringConfigBinder.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,266 @@ | ||||
| using System; | ||||
| using System.Collections; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Reflection; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Nodes; | ||||
| using YamlDotNet.Serialization; | ||||
| using YamlDotNet.Serialization.NamingConventions; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed record PolicyScoringBindingResult( | ||||
|     bool Success, | ||||
|     PolicyScoringConfig? Config, | ||||
|     ImmutableArray<PolicyIssue> Issues); | ||||
|  | ||||
| public static class PolicyScoringConfigBinder | ||||
| { | ||||
|     private const string DefaultResourceName = "StellaOps.Policy.Schemas.policy-scoring-default.json"; | ||||
|  | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new() | ||||
|     { | ||||
|         PropertyNameCaseInsensitive = true, | ||||
|         ReadCommentHandling = JsonCommentHandling.Skip, | ||||
|         AllowTrailingCommas = true, | ||||
|     }; | ||||
|  | ||||
|     private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() | ||||
|         .WithNamingConvention(CamelCaseNamingConvention.Instance) | ||||
|         .IgnoreUnmatchedProperties() | ||||
|         .Build(); | ||||
|  | ||||
|     public static PolicyScoringConfig LoadDefault() | ||||
|     { | ||||
|         var assembly = Assembly.GetExecutingAssembly(); | ||||
|         using var stream = assembly.GetManifestResourceStream(DefaultResourceName) | ||||
|             ?? throw new InvalidOperationException($"Embedded resource '{DefaultResourceName}' not found."); | ||||
|         using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); | ||||
|         var json = reader.ReadToEnd(); | ||||
|         var binding = Bind(json, PolicyDocumentFormat.Json); | ||||
|         if (!binding.Success || binding.Config is null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Failed to load default policy scoring configuration."); | ||||
|         } | ||||
|  | ||||
|         return binding.Config; | ||||
|     } | ||||
|  | ||||
|     public static PolicyScoringBindingResult Bind(string content, PolicyDocumentFormat format) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(content)) | ||||
|         { | ||||
|             var issue = PolicyIssue.Error("scoring.empty", "Scoring configuration content is empty.", "$"); | ||||
|             return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue)); | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var root = Parse(content, format); | ||||
|             if (root is not JsonObject obj) | ||||
|             { | ||||
|                 var issue = PolicyIssue.Error("scoring.invalid", "Scoring configuration must be a JSON object.", "$"); | ||||
|                 return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue)); | ||||
|             } | ||||
|  | ||||
|             var issues = ImmutableArray.CreateBuilder<PolicyIssue>(); | ||||
|             var config = BuildConfig(obj, issues); | ||||
|             var hasErrors = issues.Any(issue => issue.Severity == PolicyIssueSeverity.Error); | ||||
|             return new PolicyScoringBindingResult(!hasErrors, config, issues.ToImmutable()); | ||||
|         } | ||||
|         catch (JsonException ex) | ||||
|         { | ||||
|             var issue = PolicyIssue.Error("scoring.parse.json", $"Failed to parse scoring JSON: {ex.Message}", "$"); | ||||
|             return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue)); | ||||
|         } | ||||
|         catch (YamlDotNet.Core.YamlException ex) | ||||
|         { | ||||
|             var issue = PolicyIssue.Error("scoring.parse.yaml", $"Failed to parse scoring YAML: {ex.Message}", "$"); | ||||
|             return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static JsonNode? Parse(string content, PolicyDocumentFormat format) | ||||
|     { | ||||
|         return format switch | ||||
|         { | ||||
|             PolicyDocumentFormat.Json => JsonNode.Parse(content, new JsonNodeOptions { PropertyNameCaseInsensitive = true }), | ||||
|             PolicyDocumentFormat.Yaml => ConvertYamlToJsonNode(content), | ||||
|             _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported scoring configuration format."), | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static JsonNode? ConvertYamlToJsonNode(string content) | ||||
|     { | ||||
|         var yamlObject = YamlDeserializer.Deserialize<object?>(content); | ||||
|         return PolicyBinderUtilities.ConvertYamlObject(yamlObject); | ||||
|     } | ||||
|  | ||||
|     private static PolicyScoringConfig BuildConfig(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues) | ||||
|     { | ||||
|         var version = ReadString(obj, "version", issues, required: true) ?? PolicyScoringConfig.BaselineVersion; | ||||
|  | ||||
|         var severityWeights = ReadSeverityWeights(obj, issues); | ||||
|         var quietPenalty = ReadDouble(obj, "quietPenalty", issues, defaultValue: 45); | ||||
|         var warnPenalty = ReadDouble(obj, "warnPenalty", issues, defaultValue: 15); | ||||
|         var ignorePenalty = ReadDouble(obj, "ignorePenalty", issues, defaultValue: 35); | ||||
|         var trustOverrides = ReadTrustOverrides(obj, issues); | ||||
|  | ||||
|         return new PolicyScoringConfig( | ||||
|             version, | ||||
|             severityWeights, | ||||
|             quietPenalty, | ||||
|             warnPenalty, | ||||
|             ignorePenalty, | ||||
|             trustOverrides); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableDictionary<PolicySeverity, double> ReadSeverityWeights(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues) | ||||
|     { | ||||
|         if (!obj.TryGetPropertyValue("severityWeights", out var node) || node is not JsonObject severityObj) | ||||
|         { | ||||
|             issues.Add(PolicyIssue.Error("scoring.severityWeights.missing", "severityWeights section is required.", "$.severityWeights")); | ||||
|             return ImmutableDictionary<PolicySeverity, double>.Empty; | ||||
|         } | ||||
|  | ||||
|         var builder = ImmutableDictionary.CreateBuilder<PolicySeverity, double>(); | ||||
|         foreach (var severity in Enum.GetValues<PolicySeverity>()) | ||||
|         { | ||||
|             var key = severity.ToString(); | ||||
|             if (!severityObj.TryGetPropertyValue(key, out var valueNode)) | ||||
|             { | ||||
|                 issues.Add(PolicyIssue.Warning("scoring.severityWeights.default", $"Severity '{key}' not specified; defaulting to 0.", $"$.severityWeights.{key}")); | ||||
|                 builder[severity] = 0; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var value = ExtractDouble(valueNode, issues, $"$.severityWeights.{key}"); | ||||
|             builder[severity] = value; | ||||
|         } | ||||
|  | ||||
|         return builder.ToImmutable(); | ||||
|     } | ||||
|  | ||||
|     private static double ReadDouble(JsonObject obj, string property, ImmutableArray<PolicyIssue>.Builder issues, double defaultValue) | ||||
|     { | ||||
|         if (!obj.TryGetPropertyValue(property, out var node)) | ||||
|         { | ||||
|             issues.Add(PolicyIssue.Warning("scoring.numeric.default", $"{property} not specified; defaulting to {defaultValue:0.##}.", $"$.{property}")); | ||||
|             return defaultValue; | ||||
|         } | ||||
|  | ||||
|         return ExtractDouble(node, issues, $"$.{property}"); | ||||
|     } | ||||
|  | ||||
|     private static double ExtractDouble(JsonNode? node, ImmutableArray<PolicyIssue>.Builder issues, string path) | ||||
|     { | ||||
|         if (node is null) | ||||
|         { | ||||
|             issues.Add(PolicyIssue.Warning("scoring.numeric.null", $"Value at {path} missing; defaulting to 0.", path)); | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         if (node is JsonValue value) | ||||
|         { | ||||
|             if (value.TryGetValue(out double number)) | ||||
|             { | ||||
|                 return number; | ||||
|             } | ||||
|  | ||||
|             if (value.TryGetValue(out string? text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out number)) | ||||
|             { | ||||
|                 return number; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         issues.Add(PolicyIssue.Error("scoring.numeric.invalid", $"Value at {path} is not numeric.", path)); | ||||
|         return 0; | ||||
|     } | ||||
|  | ||||
|     private static ImmutableDictionary<string, double> ReadTrustOverrides(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues) | ||||
|     { | ||||
|         if (!obj.TryGetPropertyValue("trustOverrides", out var node) || node is not JsonObject trustObj) | ||||
|         { | ||||
|             return ImmutableDictionary<string, double>.Empty; | ||||
|         } | ||||
|  | ||||
|         var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase); | ||||
|         foreach (var pair in trustObj) | ||||
|         { | ||||
|             var value = ExtractDouble(pair.Value, issues, $"$.trustOverrides.{pair.Key}"); | ||||
|             builder[pair.Key] = value; | ||||
|         } | ||||
|  | ||||
|         return builder.ToImmutable(); | ||||
|     } | ||||
|  | ||||
|     private static string? ReadString(JsonObject obj, string property, ImmutableArray<PolicyIssue>.Builder issues, bool required) | ||||
|     { | ||||
|         if (!obj.TryGetPropertyValue(property, out var node) || node is null) | ||||
|         { | ||||
|             if (required) | ||||
|             { | ||||
|                 issues.Add(PolicyIssue.Error("scoring.string.missing", $"{property} is required.", $"$.{property}")); | ||||
|             } | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (node is JsonValue value && value.TryGetValue(out string? text)) | ||||
|         { | ||||
|             return text?.Trim(); | ||||
|         } | ||||
|  | ||||
|         issues.Add(PolicyIssue.Error("scoring.string.invalid", $"{property} must be a string.", $"$.{property}")); | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal static class PolicyBinderUtilities | ||||
| { | ||||
|     public static JsonNode? ConvertYamlObject(object? value) | ||||
|     { | ||||
|         switch (value) | ||||
|         { | ||||
|             case null: | ||||
|                 return null; | ||||
|             case string s: | ||||
|                 return JsonValue.Create(s); | ||||
|             case bool b: | ||||
|                 return JsonValue.Create(b); | ||||
|             case sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal: | ||||
|                 return JsonValue.Create(Convert.ToDouble(value, CultureInfo.InvariantCulture)); | ||||
|             case IDictionary dictionary: | ||||
|             { | ||||
|                 var obj = new JsonObject(); | ||||
|                 foreach (DictionaryEntry entry in dictionary) | ||||
|                 { | ||||
|                     if (entry.Key is null) | ||||
|                     { | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     obj[entry.Key.ToString()!] = ConvertYamlObject(entry.Value); | ||||
|                 } | ||||
|  | ||||
|                 return obj; | ||||
|             } | ||||
|             case IEnumerable enumerable: | ||||
|             { | ||||
|                 var array = new JsonArray(); | ||||
|                 foreach (var item in enumerable) | ||||
|                 { | ||||
|                     array.Add(ConvertYamlObject(item)); | ||||
|                 } | ||||
|  | ||||
|                 return array; | ||||
|             } | ||||
|             default: | ||||
|                 return JsonValue.Create(value.ToString()); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								src/StellaOps.Policy/PolicySnapshot.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/StellaOps.Policy/PolicySnapshot.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed record PolicySnapshot( | ||||
|     long RevisionNumber, | ||||
|     string RevisionId, | ||||
|     string Digest, | ||||
|     DateTimeOffset CreatedAt, | ||||
|     string? CreatedBy, | ||||
|     PolicyDocumentFormat Format, | ||||
|     PolicyDocument Document, | ||||
|     ImmutableArray<PolicyIssue> Issues, | ||||
|     PolicyScoringConfig ScoringConfig); | ||||
|  | ||||
| public sealed record PolicySnapshotContent( | ||||
|     string Content, | ||||
|     PolicyDocumentFormat Format, | ||||
|     string? Actor, | ||||
|     string? Source, | ||||
|     string? Description); | ||||
|  | ||||
| public sealed record PolicySnapshotSaveResult( | ||||
|     bool Success, | ||||
|     bool Created, | ||||
|     string Digest, | ||||
|     PolicySnapshot? Snapshot, | ||||
|     PolicyBindingResult BindingResult); | ||||
							
								
								
									
										101
									
								
								src/StellaOps.Policy/PolicySnapshotStore.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/StellaOps.Policy/PolicySnapshotStore.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,101 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed class PolicySnapshotStore | ||||
| { | ||||
|     private readonly IPolicySnapshotRepository _snapshotRepository; | ||||
|     private readonly IPolicyAuditRepository _auditRepository; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|     private readonly ILogger<PolicySnapshotStore> _logger; | ||||
|     private readonly SemaphoreSlim _mutex = new(1, 1); | ||||
|  | ||||
|     public PolicySnapshotStore( | ||||
|         IPolicySnapshotRepository snapshotRepository, | ||||
|         IPolicyAuditRepository auditRepository, | ||||
|         TimeProvider? timeProvider, | ||||
|         ILogger<PolicySnapshotStore> logger) | ||||
|     { | ||||
|         _snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository)); | ||||
|         _auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository)); | ||||
|         _timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<PolicySnapshotSaveResult> SaveAsync(PolicySnapshotContent content, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         if (content is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(content)); | ||||
|         } | ||||
|  | ||||
|         var bindingResult = PolicyBinder.Bind(content.Content, content.Format); | ||||
|         if (!bindingResult.Success) | ||||
|         { | ||||
|             _logger.LogWarning("Policy snapshot rejected due to validation errors (Format: {Format})", content.Format); | ||||
|             return new PolicySnapshotSaveResult(false, false, string.Empty, null, bindingResult); | ||||
|         } | ||||
|  | ||||
|         var digest = PolicyDigest.Compute(bindingResult.Document); | ||||
|  | ||||
|         await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             var latest = await _snapshotRepository.GetLatestAsync(cancellationToken).ConfigureAwait(false); | ||||
|             if (latest is not null && string.Equals(latest.Digest, digest, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 _logger.LogInformation("Policy snapshot unchanged; digest {Digest} matches revision {RevisionId}", digest, latest.RevisionId); | ||||
|                 return new PolicySnapshotSaveResult(true, false, digest, latest, bindingResult); | ||||
|             } | ||||
|  | ||||
|             var revisionNumber = (latest?.RevisionNumber ?? 0) + 1; | ||||
|             var revisionId = $"rev-{revisionNumber}"; | ||||
|             var createdAt = _timeProvider.GetUtcNow(); | ||||
|  | ||||
|             var scoringConfig = PolicyScoringConfig.Default; | ||||
|  | ||||
|             var snapshot = new PolicySnapshot( | ||||
|                 revisionNumber, | ||||
|                 revisionId, | ||||
|                 digest, | ||||
|                 createdAt, | ||||
|                 content.Actor, | ||||
|                 content.Format, | ||||
|                 bindingResult.Document, | ||||
|                 bindingResult.Issues, | ||||
|                 scoringConfig); | ||||
|  | ||||
|             await _snapshotRepository.AddAsync(snapshot, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             var auditMessage = content.Description ?? "Policy snapshot created"; | ||||
|             var auditEntry = new PolicyAuditEntry( | ||||
|                 Guid.NewGuid(), | ||||
|                 createdAt, | ||||
|                 "snapshot.created", | ||||
|                 revisionId, | ||||
|                 digest, | ||||
|                 content.Actor, | ||||
|                 auditMessage); | ||||
|  | ||||
|             await _auditRepository.AddAsync(auditEntry, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             _logger.LogInformation( | ||||
|                 "Policy snapshot saved. Revision {RevisionId}, digest {Digest}, issues {IssueCount}", | ||||
|                 revisionId, | ||||
|                 digest, | ||||
|                 bindingResult.Issues.Length); | ||||
|  | ||||
|             return new PolicySnapshotSaveResult(true, true, digest, snapshot, bindingResult); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _mutex.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default) | ||||
|         => _snapshotRepository.GetLatestAsync(cancellationToken); | ||||
| } | ||||
							
								
								
									
										241
									
								
								src/StellaOps.Policy/PolicyValidationCli.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								src/StellaOps.Policy/PolicyValidationCli.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,241 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed record PolicyValidationCliOptions | ||||
| { | ||||
|     public IReadOnlyList<string> Inputs { get; init; } = Array.Empty<string>(); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Writes machine-readable JSON instead of human-formatted text. | ||||
|     /// </summary> | ||||
|     public bool OutputJson { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// When enabled, warnings cause a non-zero exit code. | ||||
|     /// </summary> | ||||
|     public bool Strict { get; init; } | ||||
| } | ||||
|  | ||||
| public sealed record PolicyValidationFileResult( | ||||
|     string Path, | ||||
|     PolicyBindingResult BindingResult, | ||||
|     PolicyDiagnosticsReport Diagnostics); | ||||
|  | ||||
| public sealed class PolicyValidationCli | ||||
| { | ||||
|     private readonly TextWriter _output; | ||||
|     private readonly TextWriter _error; | ||||
|  | ||||
|     public PolicyValidationCli(TextWriter? output = null, TextWriter? error = null) | ||||
|     { | ||||
|         _output = output ?? Console.Out; | ||||
|         _error = error ?? Console.Error; | ||||
|     } | ||||
|  | ||||
|     public async Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         if (options is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(options)); | ||||
|         } | ||||
|  | ||||
|         if (options.Inputs.Count == 0) | ||||
|         { | ||||
|             await _error.WriteLineAsync("No input files provided. Supply one or more policy file paths."); | ||||
|             return 64; // EX_USAGE | ||||
|         } | ||||
|  | ||||
|         var results = new List<PolicyValidationFileResult>(); | ||||
|         foreach (var input in options.Inputs) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var resolvedPaths = ResolveInput(input); | ||||
|             if (resolvedPaths.Count == 0) | ||||
|             { | ||||
|                 await _error.WriteLineAsync($"No files matched '{input}'."); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             foreach (var path in resolvedPaths) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|                 var format = PolicySchema.DetectFormat(path); | ||||
|                 var content = await File.ReadAllTextAsync(path, cancellationToken); | ||||
|                 var bindingResult = PolicyBinder.Bind(content, format); | ||||
|                 var diagnostics = PolicyDiagnostics.Create(bindingResult); | ||||
|  | ||||
|                 results.Add(new PolicyValidationFileResult(path, bindingResult, diagnostics)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (results.Count == 0) | ||||
|         { | ||||
|             await _error.WriteLineAsync("No files were processed."); | ||||
|             return 65; // EX_DATAERR | ||||
|         } | ||||
|  | ||||
|         if (options.OutputJson) | ||||
|         { | ||||
|             WriteJson(results); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             await WriteTextAsync(results, cancellationToken); | ||||
|         } | ||||
|  | ||||
|         var hasErrors = results.Any(static result => !result.BindingResult.Success); | ||||
|         var hasWarnings = results.Any(static result => result.BindingResult.Issues.Any(static issue => issue.Severity == PolicyIssueSeverity.Warning)); | ||||
|  | ||||
|         if (hasErrors) | ||||
|         { | ||||
|             return 1; | ||||
|         } | ||||
|  | ||||
|         if (options.Strict && hasWarnings) | ||||
|         { | ||||
|             return 2; | ||||
|         } | ||||
|  | ||||
|         return 0; | ||||
|     } | ||||
|  | ||||
|     private async Task WriteTextAsync(IReadOnlyList<PolicyValidationFileResult> results, CancellationToken cancellationToken) | ||||
|     { | ||||
|         foreach (var result in results) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var relativePath = MakeRelative(result.Path); | ||||
|             await _output.WriteLineAsync($"{relativePath} [{result.BindingResult.Format}]"); | ||||
|  | ||||
|             if (result.BindingResult.Issues.Length == 0) | ||||
|             { | ||||
|                 await _output.WriteLineAsync("  OK"); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             foreach (var issue in result.BindingResult.Issues) | ||||
|             { | ||||
|                 var severity = issue.Severity.ToString().ToUpperInvariant().PadRight(7); | ||||
|                 await _output.WriteLineAsync($"  {severity} {issue.Path} :: {issue.Message} ({issue.Code})"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void WriteJson(IReadOnlyList<PolicyValidationFileResult> results) | ||||
|     { | ||||
|         var payload = results.Select(static result => new | ||||
|             { | ||||
|                 path = result.Path, | ||||
|                 format = result.BindingResult.Format.ToString().ToLowerInvariant(), | ||||
|                 success = result.BindingResult.Success, | ||||
|                 issues = result.BindingResult.Issues.Select(static issue => new | ||||
|                 { | ||||
|                     code = issue.Code, | ||||
|                     message = issue.Message, | ||||
|                     severity = issue.Severity.ToString().ToLowerInvariant(), | ||||
|                     path = issue.Path, | ||||
|                 }), | ||||
|                 diagnostics = new | ||||
|                 { | ||||
|                     version = result.Diagnostics.Version, | ||||
|                     ruleCount = result.Diagnostics.RuleCount, | ||||
|                     errorCount = result.Diagnostics.ErrorCount, | ||||
|                     warningCount = result.Diagnostics.WarningCount, | ||||
|                     generatedAt = result.Diagnostics.GeneratedAt, | ||||
|                     recommendations = result.Diagnostics.Recommendations, | ||||
|                 }, | ||||
|             }) | ||||
|             .ToArray(); | ||||
|  | ||||
|         var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions | ||||
|         { | ||||
|             WriteIndented = true, | ||||
|         }); | ||||
|         _output.WriteLine(json); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> ResolveInput(string input) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(input)) | ||||
|         { | ||||
|             return Array.Empty<string>(); | ||||
|         } | ||||
|  | ||||
|         var expanded = Environment.ExpandEnvironmentVariables(input.Trim()); | ||||
|         if (File.Exists(expanded)) | ||||
|         { | ||||
|             return new[] { Path.GetFullPath(expanded) }; | ||||
|         } | ||||
|  | ||||
|         if (Directory.Exists(expanded)) | ||||
|         { | ||||
|             return Directory.EnumerateFiles(expanded, "*.*", SearchOption.TopDirectoryOnly) | ||||
|                 .Where(static path => MatchesPolicyExtension(path)) | ||||
|                 .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) | ||||
|                 .Select(Path.GetFullPath) | ||||
|                 .ToArray(); | ||||
|         } | ||||
|  | ||||
|         var directory = Path.GetDirectoryName(expanded); | ||||
|         var searchPattern = Path.GetFileName(expanded); | ||||
|  | ||||
|         if (string.IsNullOrEmpty(searchPattern)) | ||||
|         { | ||||
|             return Array.Empty<string>(); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             directory = "."; | ||||
|         } | ||||
|  | ||||
|         if (!Directory.Exists(directory)) | ||||
|         { | ||||
|             return Array.Empty<string>(); | ||||
|         } | ||||
|  | ||||
|         return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly) | ||||
|             .Where(static path => MatchesPolicyExtension(path)) | ||||
|             .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) | ||||
|             .Select(Path.GetFullPath) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static bool MatchesPolicyExtension(string path) | ||||
|     { | ||||
|         var extension = Path.GetExtension(path); | ||||
|         return extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) | ||||
|                || extension.Equals(".yml", StringComparison.OrdinalIgnoreCase) | ||||
|                || extension.Equals(".json", StringComparison.OrdinalIgnoreCase); | ||||
|     } | ||||
|  | ||||
|     private static string MakeRelative(string path) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             var fullPath = Path.GetFullPath(path); | ||||
|             var current = Directory.GetCurrentDirectory(); | ||||
|             if (fullPath.StartsWith(current, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 return fullPath[current.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); | ||||
|             } | ||||
|  | ||||
|             return fullPath; | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             return path; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										80
									
								
								src/StellaOps.Policy/PolicyVerdict.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/StellaOps.Policy/PolicyVerdict.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public enum PolicyVerdictStatus | ||||
| { | ||||
|     Pass, | ||||
|     Blocked, | ||||
|     Ignored, | ||||
|     Warned, | ||||
|     Deferred, | ||||
|     Escalated, | ||||
|     RequiresVex, | ||||
| } | ||||
|  | ||||
| public sealed record PolicyVerdict( | ||||
|     string FindingId, | ||||
|     PolicyVerdictStatus Status, | ||||
|     string? RuleName = null, | ||||
|     string? RuleAction = null, | ||||
|     string? Notes = null, | ||||
|     double Score = 0, | ||||
|     string ConfigVersion = "1.0", | ||||
|     ImmutableDictionary<string, double>? Inputs = null, | ||||
|     string? QuietedBy = null, | ||||
|     bool Quiet = false) | ||||
| { | ||||
|     public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig) | ||||
|     { | ||||
|         var inputs = ImmutableDictionary<string, double>.Empty; | ||||
|         return new PolicyVerdict( | ||||
|             findingId, | ||||
|             PolicyVerdictStatus.Pass, | ||||
|             RuleName: null, | ||||
|             RuleAction: null, | ||||
|             Notes: null, | ||||
|             Score: 0, | ||||
|             ConfigVersion: scoringConfig.Version, | ||||
|             Inputs: inputs, | ||||
|             QuietedBy: null, | ||||
|             Quiet: false); | ||||
|     } | ||||
|  | ||||
|     public ImmutableDictionary<string, double> GetInputs() | ||||
|         => Inputs ?? ImmutableDictionary<string, double>.Empty; | ||||
| } | ||||
|  | ||||
| public sealed record PolicyVerdictDiff( | ||||
|     PolicyVerdict Baseline, | ||||
|     PolicyVerdict Projected) | ||||
| { | ||||
|     public bool Changed | ||||
|     { | ||||
|         get | ||||
|         { | ||||
|             if (Baseline.Status != Projected.Status) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             if (!string.Equals(Baseline.RuleName, Projected.RuleName, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             if (Math.Abs(Baseline.Score - Projected.Score) > 0.0001) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             if (!string.Equals(Baseline.QuietedBy, Projected.QuietedBy, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										176
									
								
								src/StellaOps.Policy/Schemas/policy-schema@1.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								src/StellaOps.Policy/Schemas/policy-schema@1.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | ||||
| { | ||||
|   "$schema": "https://json-schema.org/draft/2020-12/schema", | ||||
|   "$id": "https://schemas.stella-ops.org/policy/policy-schema@1.json", | ||||
|   "title": "StellaOps Policy Schema v1", | ||||
|   "type": "object", | ||||
|   "required": ["version", "rules"], | ||||
|   "properties": { | ||||
|     "version": { | ||||
|       "type": ["string", "number"], | ||||
|       "enum": ["1", "1.0", 1, 1.0] | ||||
|     }, | ||||
|     "description": { | ||||
|       "type": "string" | ||||
|     }, | ||||
|     "metadata": { | ||||
|       "type": "object", | ||||
|       "additionalProperties": { | ||||
|         "type": ["string", "number", "boolean"] | ||||
|       } | ||||
|     }, | ||||
|     "rules": { | ||||
|       "type": "array", | ||||
|       "minItems": 1, | ||||
|       "items": { | ||||
|         "$ref": "#/$defs/rule" | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "additionalProperties": true, | ||||
|   "$defs": { | ||||
|     "identifier": { | ||||
|       "type": "string", | ||||
|       "minLength": 1 | ||||
|     }, | ||||
|     "severity": { | ||||
|       "type": "string", | ||||
|       "enum": ["Critical", "High", "Medium", "Low", "Informational", "None", "Unknown"] | ||||
|     }, | ||||
|     "stringArray": { | ||||
|       "type": "array", | ||||
|       "items": { | ||||
|         "type": "string", | ||||
|         "minLength": 1 | ||||
|       }, | ||||
|       "uniqueItems": true | ||||
|     }, | ||||
|     "rule": { | ||||
|       "type": "object", | ||||
|       "required": ["name", "action"], | ||||
|       "properties": { | ||||
|         "id": { | ||||
|           "$ref": "#/$defs/identifier" | ||||
|         }, | ||||
|         "name": { | ||||
|           "type": "string", | ||||
|           "minLength": 1 | ||||
|         }, | ||||
|         "description": { | ||||
|           "type": "string" | ||||
|         }, | ||||
|         "severity": { | ||||
|           "type": "array", | ||||
|           "items": { | ||||
|             "$ref": "#/$defs/severity" | ||||
|           }, | ||||
|           "uniqueItems": true | ||||
|         }, | ||||
|         "sources": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "vendors": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "licenses": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "tags": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "environments": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "images": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "repositories": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "packages": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "purls": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "cves": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "paths": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "layerDigests": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "usedByEntrypoint": { | ||||
|           "$ref": "#/$defs/stringArray" | ||||
|         }, | ||||
|         "justification": { | ||||
|           "type": "string" | ||||
|         }, | ||||
|         "quiet": { | ||||
|           "type": "boolean" | ||||
|         }, | ||||
|         "action": { | ||||
|           "oneOf": [ | ||||
|             { | ||||
|               "type": "string", | ||||
|               "enum": ["block", "fail", "deny", "ignore", "warn", "defer", "escalate", "requireVex"] | ||||
|             }, | ||||
|             { | ||||
|               "type": "object", | ||||
|               "required": ["type"], | ||||
|               "properties": { | ||||
|                 "type": { | ||||
|                   "type": "string" | ||||
|                 }, | ||||
|                 "quiet": { | ||||
|                   "type": "boolean" | ||||
|                 }, | ||||
|                 "until": { | ||||
|                   "type": "string", | ||||
|                   "format": "date-time" | ||||
|                 }, | ||||
|                 "justification": { | ||||
|                   "type": "string" | ||||
|                 }, | ||||
|                 "severity": { | ||||
|                   "$ref": "#/$defs/severity" | ||||
|                 }, | ||||
|                 "vendors": { | ||||
|                   "$ref": "#/$defs/stringArray" | ||||
|                 }, | ||||
|                 "justifications": { | ||||
|                   "$ref": "#/$defs/stringArray" | ||||
|                 }, | ||||
|                 "epss": { | ||||
|                   "type": "number", | ||||
|                   "minimum": 0, | ||||
|                   "maximum": 1 | ||||
|                 }, | ||||
|                 "kev": { | ||||
|                   "type": "boolean" | ||||
|                 } | ||||
|               }, | ||||
|               "additionalProperties": true | ||||
|             } | ||||
|           ] | ||||
|         }, | ||||
|         "expires": { | ||||
|           "type": "string", | ||||
|           "format": "date-time" | ||||
|         }, | ||||
|         "until": { | ||||
|           "type": "string", | ||||
|           "format": "date-time" | ||||
|         }, | ||||
|         "metadata": { | ||||
|           "type": "object", | ||||
|           "additionalProperties": { | ||||
|             "type": ["string", "number", "boolean"] | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       "additionalProperties": true | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										21
									
								
								src/StellaOps.Policy/Schemas/policy-scoring-default.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/StellaOps.Policy/Schemas/policy-scoring-default.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "version": "1.0", | ||||
|   "severityWeights": { | ||||
|     "Critical": 90.0, | ||||
|     "High": 75.0, | ||||
|     "Medium": 50.0, | ||||
|     "Low": 25.0, | ||||
|     "Informational": 10.0, | ||||
|     "None": 0.0, | ||||
|     "Unknown": 60.0 | ||||
|   }, | ||||
|   "quietPenalty": 45.0, | ||||
|   "warnPenalty": 15.0, | ||||
|   "ignorePenalty": 35.0, | ||||
|   "trustOverrides": { | ||||
|     "vendor": 1.0, | ||||
|     "distro": 0.85, | ||||
|     "platform": 0.75, | ||||
|     "community": 0.65 | ||||
|   } | ||||
| } | ||||
| @@ -3,5 +3,18 @@ | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" /> | ||||
|     <PackageReference Include="YamlDotNet" Version="13.7.1" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <EmbeddedResource Include="Schemas\policy-schema@1.json" /> | ||||
|     <EmbeddedResource Include="Schemas\policy-scoring-default.json" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
							
								
								
									
										14
									
								
								src/StellaOps.Policy/Storage/IPolicySnapshotRepository.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/StellaOps.Policy/Storage/IPolicySnapshotRepository.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public interface IPolicySnapshotRepository | ||||
| { | ||||
|     Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default); | ||||
|  | ||||
|     Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default); | ||||
|  | ||||
|     Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default); | ||||
| } | ||||
| @@ -0,0 +1,65 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Policy; | ||||
|  | ||||
| public sealed class InMemoryPolicySnapshotRepository : IPolicySnapshotRepository | ||||
| { | ||||
|     private readonly List<PolicySnapshot> _snapshots = new(); | ||||
|     private readonly SemaphoreSlim _mutex = new(1, 1); | ||||
|  | ||||
|     public async Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         if (snapshot is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(snapshot)); | ||||
|         } | ||||
|  | ||||
|         await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             _snapshots.Add(snapshot); | ||||
|             _snapshots.Sort(static (left, right) => left.RevisionNumber.CompareTo(right.RevisionNumber)); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _mutex.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             return _snapshots.Count == 0 ? null : _snapshots[^1]; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _mutex.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             IEnumerable<PolicySnapshot> query = _snapshots; | ||||
|             if (limit > 0) | ||||
|             { | ||||
|                 query = query.TakeLast(limit); | ||||
|             } | ||||
|  | ||||
|             return query.ToImmutableArray(); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _mutex.Release(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -2,12 +2,17 @@ | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | POLICY-CORE-09-001 | TODO | Policy Guild | SCANNER-WEB-09-101 | Define YAML schema/binder, diagnostics, CLI validation for policy files. | Schema doc published; binder loads sample policy; validation errors actionable. | | ||||
| | POLICY-CORE-09-002 | TODO | Policy Guild | POLICY-CORE-09-001 | Implement policy snapshot store + revision digests + audit logging. | Snapshots persisted with digest; tests compare revisions; audit entries created. | | ||||
| | POLICY-CORE-09-003 | TODO | Policy Guild | POLICY-CORE-09-002 | `/policy/preview` API (image digest → projected verdict delta). | Preview returns diff JSON; integration tests with mocked report; docs updated. | | ||||
| | POLICY-CORE-09-001 | DONE | Policy Guild | SCANNER-WEB-09-101 | Define YAML schema/binder, diagnostics, CLI validation for policy files. | Schema doc published; binder loads sample policy; validation errors actionable. | | ||||
| | POLICY-CORE-09-002 | DONE | Policy Guild | POLICY-CORE-09-001 | Implement policy snapshot store + revision digests + audit logging. | Snapshots persisted with digest; tests compare revisions; audit entries created. | | ||||
| | POLICY-CORE-09-003 | DONE | Policy Guild | POLICY-CORE-09-002 | `/policy/preview` API (image digest → projected verdict delta). | Preview returns diff JSON; integration tests with mocked report; docs updated. | | ||||
| | POLICY-CORE-09-004 | TODO | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config with schema validation, trust table, and golden fixtures. | Scoring config documented; fixtures stored; validation CLI passes. | | ||||
| | POLICY-CORE-09-005 | TODO | Policy Guild | POLICY-CORE-09-004 | Scoring/quiet engine – compute score, enforce VEX-only quiet rules, emit inputs and provenance. | Engine unit tests cover severity weighting; outputs include provenance data. | | ||||
| | POLICY-CORE-09-006 | TODO | Policy Guild | POLICY-CORE-09-005 | Unknown state & confidence decay – deterministic bands surfaced in policy outputs. | Confidence decay tests pass; docs updated; preview endpoint displays banding. | | ||||
| | POLICY-CORE-09-004 | TODO | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config (weights, trust table, reachability buckets) with schema validation, binder, and golden fixtures. | Config serialized with semantic version, binder loads defaults, fixtures assert deterministic hash. | | ||||
| | POLICY-CORE-09-005 | TODO | Policy Guild | POLICY-CORE-09-004, POLICY-CORE-09-002 | Implement scoring/quiet engine: compute score from config, enforce VEX-only quiet rules, emit inputs + `quietedBy` metadata in policy verdicts. | `/reports` policy result includes score, inputs, configVersion, quiet provenance; unit/integration tests prove reproducibility. | | ||||
| | POLICY-CORE-09-006 | TODO | Policy Guild | POLICY-CORE-09-005, FEEDCORE-ENGINE-07-003 | Track unknown states with deterministic confidence bands that decay over time; expose state in policy outputs and docs. | Unknown flags + confidence band persisted, decay job deterministic, preview/report APIs show state with tests covering decay math. | | ||||
|  | ||||
| ## Notes | ||||
| - 2025-10-18: POLICY-CORE-09-001 completed. Binder + diagnostics + CLI scaffolding landed with tests; schema embedded at `src/StellaOps.Policy/Schemas/policy-schema@1.json` and referenced by docs/11_DATA_SCHEMAS.md. | ||||
| - 2025-10-18: POLICY-CORE-09-002 completed. Snapshot store + audit trail implemented with deterministic digest hashing and tests covering revision increments and dedupe. | ||||
| - 2025-10-18: POLICY-CORE-09-003 delivered. Preview service evaluates policy projections vs. baseline, returns verdict diffs, and ships with unit coverage. | ||||
|   | ||||
							
								
								
									
										81
									
								
								src/StellaOps.Scanner.Core.Tests/Contracts/ScanJobTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/StellaOps.Scanner.Core.Tests/Contracts/ScanJobTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| using System.Text.Json; | ||||
| using StellaOps.Scanner.Core.Contracts; | ||||
| using StellaOps.Scanner.Core.Serialization; | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Tests.Contracts; | ||||
|  | ||||
| public sealed class ScanJobTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void SerializeAndDeserialize_RoundTripsDeterministically() | ||||
|     { | ||||
|         var createdAt = new DateTimeOffset(2025, 10, 18, 14, 30, 15, TimeSpan.Zero); | ||||
|         var jobId = ScannerIdentifiers.CreateJobId("registry.example.com/stellaops/scanner:1.2.3", "sha256:ABCDEF", "tenant-a", "request-1"); | ||||
|         var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, "enqueue"); | ||||
|         var error = new ScannerError( | ||||
|             ScannerErrorCode.AnalyzerFailure, | ||||
|             ScannerErrorSeverity.Error, | ||||
|             "Analyzer crashed for layer sha256:abc", | ||||
|             createdAt, | ||||
|             retryable: false, | ||||
|             details: new Dictionary<string, string> | ||||
|             { | ||||
|                 ["stage"] = "analyze-os", | ||||
|                 ["layer"] = "sha256:abc" | ||||
|             }); | ||||
|  | ||||
|         var job = new ScanJob( | ||||
|             jobId, | ||||
|             ScanJobStatus.Running, | ||||
|             "registry.example.com/stellaops/scanner:1.2.3", | ||||
|             "SHA256:ABCDEF", | ||||
|             createdAt, | ||||
|             createdAt, | ||||
|             correlationId, | ||||
|             "tenant-a", | ||||
|             new Dictionary<string, string> | ||||
|             { | ||||
|                 ["requestId"] = "request-1" | ||||
|             }, | ||||
|             error); | ||||
|  | ||||
|         var json = JsonSerializer.Serialize(job, ScannerJsonOptions.CreateDefault()); | ||||
|         var deserialized = JsonSerializer.Deserialize<ScanJob>(json, ScannerJsonOptions.CreateDefault()); | ||||
|  | ||||
|         Assert.NotNull(deserialized); | ||||
|         Assert.Equal(job.Id, deserialized!.Id); | ||||
|         Assert.Equal(job.ImageDigest, deserialized.ImageDigest); | ||||
|         Assert.Equal(job.CorrelationId, deserialized.CorrelationId); | ||||
|         Assert.Equal(job.Metadata["requestId"], deserialized.Metadata["requestId"]); | ||||
|  | ||||
|         var secondJson = JsonSerializer.Serialize(deserialized, ScannerJsonOptions.CreateDefault()); | ||||
|         Assert.Equal(json, secondJson); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void WithStatus_UpdatesTimestampDeterministically() | ||||
|     { | ||||
|         var createdAt = new DateTimeOffset(2025, 10, 18, 14, 30, 15, 123, TimeSpan.Zero); | ||||
|         var jobId = ScannerIdentifiers.CreateJobId("example/scanner:latest", "sha256:def", null, null); | ||||
|         var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, "enqueue"); | ||||
|  | ||||
|         var job = new ScanJob( | ||||
|             jobId, | ||||
|             ScanJobStatus.Pending, | ||||
|             "example/scanner:latest", | ||||
|             "sha256:def", | ||||
|             createdAt, | ||||
|             null, | ||||
|             correlationId, | ||||
|             null, | ||||
|             null, | ||||
|             null); | ||||
|  | ||||
|         var updated = job.WithStatus(ScanJobStatus.Running, createdAt.AddSeconds(5)); | ||||
|  | ||||
|         Assert.Equal(ScanJobStatus.Running, updated.Status); | ||||
|         Assert.Equal(ScannerTimestamps.Normalize(createdAt.AddSeconds(5)), updated.UpdatedAt); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,39 @@ | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Scanner.Core.Contracts; | ||||
| using StellaOps.Scanner.Core.Observability; | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Tests.Observability; | ||||
|  | ||||
| public sealed class ScannerLogExtensionsTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void BeginScanScope_PopulatesCorrelationContext() | ||||
|     { | ||||
|         using var factory = LoggerFactory.Create(builder => builder.AddFilter(_ => true)); | ||||
|         var logger = factory.CreateLogger("test"); | ||||
|  | ||||
|         var jobId = ScannerIdentifiers.CreateJobId("example/scanner:1.0", "sha256:abc", null, null); | ||||
|         var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, "enqueue"); | ||||
|         var job = new ScanJob( | ||||
|             jobId, | ||||
|             ScanJobStatus.Pending, | ||||
|             "example/scanner:1.0", | ||||
|             "sha256:abc", | ||||
|             DateTimeOffset.UtcNow, | ||||
|             null, | ||||
|             correlationId, | ||||
|             null, | ||||
|             null, | ||||
|             null); | ||||
|  | ||||
|         using (logger.BeginScanScope(job, "enqueue")) | ||||
|         { | ||||
|             Assert.True(ScannerCorrelationContextAccessor.TryGetCorrelationId(out var current)); | ||||
|             Assert.Equal(correlationId, current); | ||||
|         } | ||||
|  | ||||
|         Assert.False(ScannerCorrelationContextAccessor.TryGetCorrelationId(out _)); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,89 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Time.Testing; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using StellaOps.Auth.Client; | ||||
| using StellaOps.Scanner.Core.Security; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Tests.Security; | ||||
|  | ||||
| public sealed class AuthorityTokenSourceTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task GetAsync_ReusesCachedTokenUntilRefreshSkew() | ||||
|     { | ||||
|         var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); | ||||
|         var client = new FakeTokenClient(timeProvider); | ||||
|         var source = new AuthorityTokenSource(client, TimeSpan.FromSeconds(30), timeProvider, NullLogger<AuthorityTokenSource>.Instance); | ||||
|  | ||||
|         var token1 = await source.GetAsync("scanner", new[] { "scanner.read" }); | ||||
|         Assert.Equal(1, client.RequestCount); | ||||
|  | ||||
|         var token2 = await source.GetAsync("scanner", new[] { "scanner.read" }); | ||||
|         Assert.Equal(1, client.RequestCount); | ||||
|         Assert.Equal(token1.AccessToken, token2.AccessToken); | ||||
|  | ||||
|         timeProvider.Advance(TimeSpan.FromMinutes(3)); | ||||
|         var token3 = await source.GetAsync("scanner", new[] { "scanner.read" }); | ||||
|         Assert.Equal(2, client.RequestCount); | ||||
|         Assert.NotEqual(token1.AccessToken, token3.AccessToken); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task InvalidateAsync_RemovesCachedToken() | ||||
|     { | ||||
|         var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); | ||||
|         var client = new FakeTokenClient(timeProvider); | ||||
|         var source = new AuthorityTokenSource(client, TimeSpan.FromSeconds(30), timeProvider, NullLogger<AuthorityTokenSource>.Instance); | ||||
|  | ||||
|         _ = await source.GetAsync("scanner", new[] { "scanner.read" }); | ||||
|         Assert.Equal(1, client.RequestCount); | ||||
|  | ||||
|         await source.InvalidateAsync("scanner", new[] { "scanner.read" }); | ||||
|         _ = await source.GetAsync("scanner", new[] { "scanner.read" }); | ||||
|  | ||||
|         Assert.Equal(2, client.RequestCount); | ||||
|     } | ||||
|  | ||||
|     private sealed class FakeTokenClient : IStellaOpsTokenClient | ||||
|     { | ||||
|         private readonly FakeTimeProvider timeProvider; | ||||
|         private int counter; | ||||
|  | ||||
|         public FakeTokenClient(FakeTimeProvider timeProvider) | ||||
|         { | ||||
|             this.timeProvider = timeProvider; | ||||
|         } | ||||
|  | ||||
|         public int RequestCount => counter; | ||||
|  | ||||
|         public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             var access = $"token-{Interlocked.Increment(ref counter)}"; | ||||
|             var expires = timeProvider.GetUtcNow().AddMinutes(2); | ||||
|             var scopes = scope is null | ||||
|                 ? Array.Empty<string>() | ||||
|                 : scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); | ||||
|  | ||||
|             return Task.FromResult(new StellaOpsTokenResult(access, "Bearer", expires, scopes)); | ||||
|         } | ||||
|  | ||||
|         public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default) | ||||
|             => throw new NotSupportedException(); | ||||
|  | ||||
|         public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default) | ||||
|             => throw new NotSupportedException(); | ||||
|  | ||||
|         public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null); | ||||
|  | ||||
|         public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.CompletedTask; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,117 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
| using System.Security.Cryptography; | ||||
| using Microsoft.Extensions.Time.Testing; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using StellaOps.Scanner.Core.Security; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Tests.Security; | ||||
|  | ||||
| public sealed class DpopProofValidatorTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task ValidateAsync_ReturnsSuccess_ForValidProof() | ||||
|     { | ||||
|         var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); | ||||
|         var validator = new DpopProofValidator(Options.Create(new DpopValidationOptions()), new InMemoryDpopReplayCache(timeProvider), timeProvider); | ||||
|         using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); | ||||
|         var securityKey = new ECDsaSecurityKey(key) { KeyId = Guid.NewGuid().ToString("N") }; | ||||
|  | ||||
|         var proof = CreateProof(timeProvider, securityKey, "GET", new Uri("https://scanner.example.com/api/v1/scans")); | ||||
|         var result = await validator.ValidateAsync(proof, "GET", new Uri("https://scanner.example.com/api/v1/scans")); | ||||
|  | ||||
|         Assert.True(result.IsValid); | ||||
|         Assert.NotNull(result.PublicKey); | ||||
|         Assert.NotNull(result.JwtId); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateAsync_Fails_OnNonceMismatch() | ||||
|     { | ||||
|         var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); | ||||
|         var validator = new DpopProofValidator(Options.Create(new DpopValidationOptions()), new InMemoryDpopReplayCache(timeProvider), timeProvider); | ||||
|         using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); | ||||
|         var securityKey = new ECDsaSecurityKey(key) { KeyId = Guid.NewGuid().ToString("N") }; | ||||
|  | ||||
|         var proof = CreateProof(timeProvider, securityKey, "POST", new Uri("https://scanner.example.com/api/v1/scans"), nonce: "expected"); | ||||
|         var result = await validator.ValidateAsync(proof, "POST", new Uri("https://scanner.example.com/api/v1/scans"), nonce: "different"); | ||||
|  | ||||
|         Assert.False(result.IsValid); | ||||
|         Assert.Equal("invalid_token", result.ErrorCode); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateAsync_Fails_OnReplay() | ||||
|     { | ||||
|         var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); | ||||
|         var cache = new InMemoryDpopReplayCache(timeProvider); | ||||
|         var validator = new DpopProofValidator(Options.Create(new DpopValidationOptions()), cache, timeProvider); | ||||
|         using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); | ||||
|         var securityKey = new ECDsaSecurityKey(key) { KeyId = Guid.NewGuid().ToString("N") }; | ||||
|         var jti = Guid.NewGuid().ToString(); | ||||
|  | ||||
|         var proof = CreateProof(timeProvider, securityKey, "GET", new Uri("https://scanner.example.com/api/v1/scans"), jti: jti); | ||||
|  | ||||
|         var first = await validator.ValidateAsync(proof, "GET", new Uri("https://scanner.example.com/api/v1/scans")); | ||||
|         Assert.True(first.IsValid); | ||||
|  | ||||
|         var second = await validator.ValidateAsync(proof, "GET", new Uri("https://scanner.example.com/api/v1/scans")); | ||||
|         Assert.False(second.IsValid); | ||||
|         Assert.Equal("replay", second.ErrorCode); | ||||
|     } | ||||
|  | ||||
|     private static string CreateProof(FakeTimeProvider timeProvider, ECDsaSecurityKey key, string method, Uri uri, string? nonce = null, string? jti = null) | ||||
|     { | ||||
|         var handler = new JwtSecurityTokenHandler(); | ||||
|         var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256); | ||||
|         var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key); | ||||
|  | ||||
|         var header = new JwtHeader(signingCredentials) | ||||
|         { | ||||
|             ["typ"] = "dpop+jwt", | ||||
|             ["jwk"] = new Dictionary<string, object?> | ||||
|             { | ||||
|                 ["kty"] = jwk.Kty, | ||||
|                 ["crv"] = jwk.Crv, | ||||
|                 ["x"] = jwk.X, | ||||
|                 ["y"] = jwk.Y | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         var payload = new JwtPayload | ||||
|         { | ||||
|             ["htm"] = method.ToUpperInvariant(), | ||||
|             ["htu"] = Normalize(uri), | ||||
|             ["iat"] = timeProvider.GetUtcNow().ToUnixTimeSeconds(), | ||||
|             ["jti"] = jti ?? Guid.NewGuid().ToString() | ||||
|         }; | ||||
|  | ||||
|         if (nonce is not null) | ||||
|         { | ||||
|             payload["nonce"] = nonce; | ||||
|         } | ||||
|  | ||||
|         var token = new JwtSecurityToken(header, payload); | ||||
|         return handler.WriteToken(token); | ||||
|     } | ||||
|  | ||||
|     private static string Normalize(Uri uri) | ||||
|     { | ||||
|         var builder = new UriBuilder(uri) | ||||
|         { | ||||
|             Fragment = string.Empty | ||||
|         }; | ||||
|  | ||||
|         builder.Host = builder.Host.ToLowerInvariant(); | ||||
|         builder.Scheme = builder.Scheme.ToLowerInvariant(); | ||||
|  | ||||
|         if ((builder.Scheme == "http" && builder.Port == 80) || (builder.Scheme == "https" && builder.Port == 443)) | ||||
|         { | ||||
|             builder.Port = -1; | ||||
|         } | ||||
|  | ||||
|         return builder.Uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| using System; | ||||
| using StellaOps.Scanner.Core.Security; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Tests.Security; | ||||
|  | ||||
| public sealed class RestartOnlyPluginGuardTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void EnsureRegistrationAllowed_AllowsNewPluginsBeforeSeal() | ||||
|     { | ||||
|         var guard = new RestartOnlyPluginGuard(); | ||||
|         guard.EnsureRegistrationAllowed("./plugins/analyzer.dll"); | ||||
|  | ||||
|         Assert.Contains(guard.KnownPlugins, path => path.EndsWith("analyzer.dll", StringComparison.OrdinalIgnoreCase)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void EnsureRegistrationAllowed_ThrowsAfterSeal() | ||||
|     { | ||||
|         var guard = new RestartOnlyPluginGuard(new[] { "./plugins/a.dll" }); | ||||
|         guard.Seal(); | ||||
|  | ||||
|         Assert.Throws<InvalidOperationException>(() => guard.EnsureRegistrationAllowed("./plugins/new.dll")); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="../StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" /> | ||||
|     <ProjectReference Include="../StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="../StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,33 @@ | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Tests.Utility; | ||||
|  | ||||
| public sealed class ScannerIdentifiersTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void CreateJobId_IsDeterministicAndCaseInsensitive() | ||||
|     { | ||||
|         var first = ScannerIdentifiers.CreateJobId("registry.example.com/repo:latest", "SHA256:ABC", "Tenant-A", "salt"); | ||||
|         var second = ScannerIdentifiers.CreateJobId("REGISTRY.EXAMPLE.COM/REPO:latest", "sha256:abc", "tenant-a", "salt"); | ||||
|  | ||||
|         Assert.Equal(first, second); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void CreateDeterministicHash_ProducesLowercaseHex() | ||||
|     { | ||||
|         var hash = ScannerIdentifiers.CreateDeterministicHash("scan", "abc", "123"); | ||||
|  | ||||
|         Assert.Matches("^[0-9a-f]{64}$", hash); | ||||
|         Assert.Equal(hash, hash.ToLowerInvariant()); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void NormalizeImageReference_LowercasesRegistryAndRepository() | ||||
|     { | ||||
|         var normalized = ScannerIdentifiers.NormalizeImageReference("Registry.Example.com/StellaOps/Scanner:1.0"); | ||||
|  | ||||
|         Assert.Equal("registry.example.com/stellaops/scanner:1.0", normalized); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Tests.Utility; | ||||
|  | ||||
| public sealed class ScannerTimestampsTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Normalize_TrimsToMicroseconds() | ||||
|     { | ||||
|         var value = new DateTimeOffset(2025, 10, 18, 14, 30, 15, TimeSpan.Zero).AddTicks(7); | ||||
|         var normalized = ScannerTimestamps.Normalize(value); | ||||
|  | ||||
|         var expectedTicks = value.UtcTicks - (value.UtcTicks % 10); | ||||
|         Assert.Equal(expectedTicks, normalized.UtcTicks); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void ToIso8601_ProducesUtcString() | ||||
|     { | ||||
|         var value = new DateTimeOffset(2025, 10, 18, 14, 30, 15, TimeSpan.FromHours(-4)); | ||||
|         var iso = ScannerTimestamps.ToIso8601(value); | ||||
|  | ||||
|         Assert.Equal("2025-10-18T18:30:15.000000Z", iso); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								src/StellaOps.Scanner.Core/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/StellaOps.Scanner.Core/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| # AGENTS | ||||
| ## Role | ||||
| Provide shared scanner contracts, observability primitives, and security utilities consumed by the WebService, Worker, analyzers, and downstream tooling. | ||||
| ## Scope | ||||
| - Canonical DTOs for scan jobs, progress, outcomes, and error taxonomy shared across scanner services. | ||||
| - Deterministic ID and timestamp helpers to guarantee reproducible job identifiers and ISO-8601 rendering. | ||||
| - Observability helpers (logging scopes, correlation IDs, metric naming, activity sources) with negligible overhead. | ||||
| - Authority/OpTok integrations, DPoP validation helpers, and restart-time plug-in guardrails for scanner components. | ||||
| ## Participants | ||||
| - Scanner.WebService and Scanner.Worker depend on these primitives for request handling, queue interactions, and diagnostics. | ||||
| - Policy/Signer integrations rely on deterministic identifiers and timestamps emitted here. | ||||
| - DevOps/Offline kits bundle plug-in manifests validated via the guardrails defined in this module. | ||||
| ## Interfaces & contracts | ||||
| - DTOs must round-trip via System.Text.Json with `JsonSerializerDefaults.Web` and preserve ordering. | ||||
| - Deterministic helpers must not depend on ambient time/randomness; they derive IDs from explicit inputs and normalize timestamps to microsecond precision in UTC. | ||||
| - Observability scopes expose `scanId`, `jobId`, `correlationId`, and `imageDigest` fields with `stellaops scanner` metric prefixing. | ||||
| - Security helpers expose `IAuthorityTokenSource`, `IDPoPProofValidator`, and `IPluginCatalogGuard` abstractions with DI-friendly implementations. | ||||
| ## In/Out of scope | ||||
| In: shared contracts, telemetry primitives, security utilities, plug-in manifest checks. | ||||
| Out: queue implementations, analyzer logic, storage adapters, HTTP endpoints, UI wiring. | ||||
| ## Observability & security expectations | ||||
| - No network calls except via registered Authority clients. | ||||
| - Avoid allocations in hot paths; prefer struct enumerables/`ValueTask`. | ||||
| - All logs structured, correlation IDs propagated, no secrets persisted. | ||||
| - DPoP validation enforces algorithm allowlist (ES256/ES384) and ensures replay cache hooks. | ||||
| ## Tests | ||||
| - `../StellaOps.Scanner.Core.Tests` owns unit coverage with deterministic fixtures. | ||||
| - Golden JSON for DTO round-trips stored under `Fixtures/`. | ||||
| - Security and observability helpers must include tests proving deterministic outputs and rejecting malformed proofs. | ||||
							
								
								
									
										173
									
								
								src/StellaOps.Scanner.Core/Contracts/ScanJob.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								src/StellaOps.Scanner.Core/Contracts/ScanJob.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,173 @@ | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Globalization; | ||||
| using System.Text.Json.Serialization; | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Contracts; | ||||
|  | ||||
| [JsonConverter(typeof(ScanJobIdJsonConverter))] | ||||
| public readonly record struct ScanJobId(Guid Value) | ||||
| { | ||||
|     public static readonly ScanJobId Empty = new(Guid.Empty); | ||||
|  | ||||
|     public override string ToString() | ||||
|         => Value.ToString("n", CultureInfo.InvariantCulture); | ||||
|  | ||||
|     public static ScanJobId From(Guid value) | ||||
|         => new(value); | ||||
|  | ||||
|     public static bool TryParse(string? text, out ScanJobId id) | ||||
|     { | ||||
|         if (Guid.TryParse(text, out var guid)) | ||||
|         { | ||||
|             id = new ScanJobId(guid); | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         id = Empty; | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
|  | ||||
| [JsonConverter(typeof(JsonStringEnumConverter<ScanJobStatus>))] | ||||
| public enum ScanJobStatus | ||||
| { | ||||
|     Unknown = 0, | ||||
|     Pending, | ||||
|     Queued, | ||||
|     Running, | ||||
|     Succeeded, | ||||
|     Failed, | ||||
|     Cancelled | ||||
| } | ||||
|  | ||||
| public sealed class ScanJob | ||||
| { | ||||
|     private static readonly IReadOnlyDictionary<string, string> EmptyMetadata = | ||||
|         new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.Ordinal)); | ||||
|  | ||||
|     [JsonConstructor] | ||||
|     public ScanJob( | ||||
|         ScanJobId id, | ||||
|         ScanJobStatus status, | ||||
|         string imageReference, | ||||
|         string? imageDigest, | ||||
|         DateTimeOffset createdAt, | ||||
|         DateTimeOffset? updatedAt, | ||||
|         string correlationId, | ||||
|         string? tenantId, | ||||
|         IReadOnlyDictionary<string, string>? metadata = null, | ||||
|         ScannerError? failure = null) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(imageReference)) | ||||
|         { | ||||
|             throw new ArgumentException("Image reference cannot be null or whitespace.", nameof(imageReference)); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(correlationId)) | ||||
|         { | ||||
|             throw new ArgumentException("Correlation identifier cannot be null or whitespace.", nameof(correlationId)); | ||||
|         } | ||||
|  | ||||
|         Id = id; | ||||
|         Status = status; | ||||
|         ImageReference = imageReference.Trim(); | ||||
|         ImageDigest = NormalizeDigest(imageDigest); | ||||
|         CreatedAt = ScannerTimestamps.Normalize(createdAt); | ||||
|         UpdatedAt = updatedAt is null ? null : ScannerTimestamps.Normalize(updatedAt.Value); | ||||
|         CorrelationId = correlationId; | ||||
|         TenantId = string.IsNullOrWhiteSpace(tenantId) ? null : tenantId.Trim(); | ||||
|         Metadata = metadata is null or { Count: 0 } | ||||
|             ? EmptyMetadata | ||||
|             : new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal)); | ||||
|         Failure = failure; | ||||
|     } | ||||
|  | ||||
|     [JsonPropertyName("id")] | ||||
|     [JsonPropertyOrder(0)] | ||||
|     public ScanJobId Id { get; } | ||||
|  | ||||
|     [JsonPropertyName("status")] | ||||
|     [JsonPropertyOrder(1)] | ||||
|     public ScanJobStatus Status { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("imageReference")] | ||||
|     [JsonPropertyOrder(2)] | ||||
|     public string ImageReference { get; } | ||||
|  | ||||
|     [JsonPropertyName("imageDigest")] | ||||
|     [JsonPropertyOrder(3)] | ||||
|     public string? ImageDigest { get; } | ||||
|  | ||||
|     [JsonPropertyName("createdAt")] | ||||
|     [JsonPropertyOrder(4)] | ||||
|     public DateTimeOffset CreatedAt { get; } | ||||
|  | ||||
|     [JsonPropertyName("updatedAt")] | ||||
|     [JsonPropertyOrder(5)] | ||||
|     public DateTimeOffset? UpdatedAt { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("correlationId")] | ||||
|     [JsonPropertyOrder(6)] | ||||
|     public string CorrelationId { get; } | ||||
|  | ||||
|     [JsonPropertyName("tenantId")] | ||||
|     [JsonPropertyOrder(7)] | ||||
|     public string? TenantId { get; } | ||||
|  | ||||
|     [JsonPropertyName("metadata")] | ||||
|     [JsonPropertyOrder(8)] | ||||
|     public IReadOnlyDictionary<string, string> Metadata { get; } | ||||
|  | ||||
|     [JsonPropertyName("failure")] | ||||
|     [JsonPropertyOrder(9)] | ||||
|     public ScannerError? Failure { get; init; } | ||||
|  | ||||
|     public ScanJob WithStatus(ScanJobStatus status, DateTimeOffset? updatedAt = null) | ||||
|         => new( | ||||
|             Id, | ||||
|             status, | ||||
|             ImageReference, | ||||
|             ImageDigest, | ||||
|             CreatedAt, | ||||
|             updatedAt ?? UpdatedAt ?? CreatedAt, | ||||
|             CorrelationId, | ||||
|             TenantId, | ||||
|             Metadata, | ||||
|             Failure); | ||||
|  | ||||
|     public ScanJob WithFailure(ScannerError failure, DateTimeOffset? updatedAt = null, TimeProvider? timeProvider = null) | ||||
|         => new( | ||||
|             Id, | ||||
|             ScanJobStatus.Failed, | ||||
|             ImageReference, | ||||
|             ImageDigest, | ||||
|             CreatedAt, | ||||
|             updatedAt ?? ScannerTimestamps.UtcNow(timeProvider), | ||||
|             CorrelationId, | ||||
|             TenantId, | ||||
|             Metadata, | ||||
|             failure); | ||||
|  | ||||
|     private static string? NormalizeDigest(string? digest) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(digest)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmed = digest.Trim(); | ||||
|         if (!trimmed.StartsWith("sha", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return trimmed; | ||||
|         } | ||||
|  | ||||
|         var parts = trimmed.Split(':', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); | ||||
|         if (parts.Length != 2) | ||||
|         { | ||||
|             return trimmed.ToLowerInvariant(); | ||||
|         } | ||||
|  | ||||
|         return $"{parts[0].ToLowerInvariant()}:{parts[1].ToLowerInvariant()}"; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Contracts; | ||||
|  | ||||
| internal sealed class ScanJobIdJsonConverter : JsonConverter<ScanJobId> | ||||
| { | ||||
|     public override ScanJobId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | ||||
|     { | ||||
|         if (reader.TokenType != JsonTokenType.String) | ||||
|         { | ||||
|             throw new JsonException("Expected scan job identifier to be a string."); | ||||
|         } | ||||
|  | ||||
|         var value = reader.GetString(); | ||||
|         if (!ScanJobId.TryParse(value, out var id)) | ||||
|         { | ||||
|             throw new JsonException("Invalid scan job identifier."); | ||||
|         } | ||||
|  | ||||
|         return id; | ||||
|     } | ||||
|  | ||||
|     public override void Write(Utf8JsonWriter writer, ScanJobId value, JsonSerializerOptions options) | ||||
|         => writer.WriteStringValue(value.ToString()); | ||||
| } | ||||
							
								
								
									
										121
									
								
								src/StellaOps.Scanner.Core/Contracts/ScanProgressEvent.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								src/StellaOps.Scanner.Core/Contracts/ScanProgressEvent.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Text.Json.Serialization; | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Contracts; | ||||
|  | ||||
| [JsonConverter(typeof(JsonStringEnumConverter<ScanStage>))] | ||||
| public enum ScanStage | ||||
| { | ||||
|     Unknown = 0, | ||||
|     ResolveImage, | ||||
|     FetchLayers, | ||||
|     MountLayers, | ||||
|     AnalyzeOperatingSystem, | ||||
|     AnalyzeLanguageEcosystems, | ||||
|     AnalyzeNativeArtifacts, | ||||
|     ComposeSbom, | ||||
|     BuildDiffs, | ||||
|     EmitArtifacts, | ||||
|     SignArtifacts, | ||||
|     Complete | ||||
| } | ||||
|  | ||||
| [JsonConverter(typeof(JsonStringEnumConverter<ScanProgressEventKind>))] | ||||
| public enum ScanProgressEventKind | ||||
| { | ||||
|     Progress = 0, | ||||
|     StageStarted, | ||||
|     StageCompleted, | ||||
|     Warning, | ||||
|     Error | ||||
| } | ||||
|  | ||||
| public sealed class ScanProgressEvent | ||||
| { | ||||
|     private static readonly IReadOnlyDictionary<string, string> EmptyAttributes = | ||||
|         new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.Ordinal)); | ||||
|  | ||||
|     [JsonConstructor] | ||||
|     public ScanProgressEvent( | ||||
|         ScanJobId jobId, | ||||
|         ScanStage stage, | ||||
|         ScanProgressEventKind kind, | ||||
|         int sequence, | ||||
|         DateTimeOffset timestamp, | ||||
|         double? percentComplete = null, | ||||
|         string? message = null, | ||||
|         IReadOnlyDictionary<string, string>? attributes = null, | ||||
|         ScannerError? error = null) | ||||
|     { | ||||
|         if (sequence < 0) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence cannot be negative."); | ||||
|         } | ||||
|  | ||||
|         JobId = jobId; | ||||
|         Stage = stage; | ||||
|         Kind = kind; | ||||
|         Sequence = sequence; | ||||
|         Timestamp = ScannerTimestamps.Normalize(timestamp); | ||||
|         PercentComplete = percentComplete is < 0 or > 100 ? null : percentComplete; | ||||
|         Message = message is { Length: > 0 } ? message.Trim() : null; | ||||
|         Attributes = attributes is null or { Count: 0 } | ||||
|             ? EmptyAttributes | ||||
|             : new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal)); | ||||
|         Error = error; | ||||
|     } | ||||
|  | ||||
|     [JsonPropertyName("jobId")] | ||||
|     [JsonPropertyOrder(0)] | ||||
|     public ScanJobId JobId { get; } | ||||
|  | ||||
|     [JsonPropertyName("stage")] | ||||
|     [JsonPropertyOrder(1)] | ||||
|     public ScanStage Stage { get; } | ||||
|  | ||||
|     [JsonPropertyName("kind")] | ||||
|     [JsonPropertyOrder(2)] | ||||
|     public ScanProgressEventKind Kind { get; } | ||||
|  | ||||
|     [JsonPropertyName("sequence")] | ||||
|     [JsonPropertyOrder(3)] | ||||
|     public int Sequence { get; } | ||||
|  | ||||
|     [JsonPropertyName("timestamp")] | ||||
|     [JsonPropertyOrder(4)] | ||||
|     public DateTimeOffset Timestamp { get; } | ||||
|  | ||||
|     [JsonPropertyName("percentComplete")] | ||||
|     [JsonPropertyOrder(5)] | ||||
|     public double? PercentComplete { get; } | ||||
|  | ||||
|     [JsonPropertyName("message")] | ||||
|     [JsonPropertyOrder(6)] | ||||
|     public string? Message { get; } | ||||
|  | ||||
|     [JsonPropertyName("attributes")] | ||||
|     [JsonPropertyOrder(7)] | ||||
|     public IReadOnlyDictionary<string, string> Attributes { get; } | ||||
|  | ||||
|     [JsonPropertyName("error")] | ||||
|     [JsonPropertyOrder(8)] | ||||
|     public ScannerError? Error { get; } | ||||
|  | ||||
|     public ScanProgressEvent With( | ||||
|         ScanProgressEventKind? kind = null, | ||||
|         double? percentComplete = null, | ||||
|         string? message = null, | ||||
|         IReadOnlyDictionary<string, string>? attributes = null, | ||||
|         ScannerError? error = null) | ||||
|         => new( | ||||
|             JobId, | ||||
|             Stage, | ||||
|             kind ?? Kind, | ||||
|             Sequence, | ||||
|             Timestamp, | ||||
|             percentComplete ?? PercentComplete, | ||||
|             message ?? Message, | ||||
|             attributes ?? Attributes, | ||||
|             error ?? Error); | ||||
| } | ||||
							
								
								
									
										110
									
								
								src/StellaOps.Scanner.Core/Contracts/ScannerError.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								src/StellaOps.Scanner.Core/Contracts/ScannerError.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Text.Json.Serialization; | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Contracts; | ||||
|  | ||||
| [JsonConverter(typeof(JsonStringEnumConverter<ScannerErrorCode>))] | ||||
| public enum ScannerErrorCode | ||||
| { | ||||
|     Unknown = 0, | ||||
|     InvalidImageReference, | ||||
|     ImageNotFound, | ||||
|     AuthorizationFailed, | ||||
|     QueueUnavailable, | ||||
|     StorageUnavailable, | ||||
|     AnalyzerFailure, | ||||
|     ExportFailure, | ||||
|     SigningFailure, | ||||
|     RuntimeFailure, | ||||
|     Timeout, | ||||
|     Cancelled, | ||||
|     PluginViolation | ||||
| } | ||||
|  | ||||
| [JsonConverter(typeof(JsonStringEnumConverter<ScannerErrorSeverity>))] | ||||
| public enum ScannerErrorSeverity | ||||
| { | ||||
|     Warning = 0, | ||||
|     Error, | ||||
|     Fatal | ||||
| } | ||||
|  | ||||
| public sealed class ScannerError | ||||
| { | ||||
|     private static readonly IReadOnlyDictionary<string, string> EmptyDetails = | ||||
|         new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.Ordinal)); | ||||
|  | ||||
|     [JsonConstructor] | ||||
|     public ScannerError( | ||||
|         ScannerErrorCode code, | ||||
|         ScannerErrorSeverity severity, | ||||
|         string message, | ||||
|         DateTimeOffset timestamp, | ||||
|         bool retryable, | ||||
|         IReadOnlyDictionary<string, string>? details = null, | ||||
|         string? stage = null, | ||||
|         string? component = null) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(message)) | ||||
|         { | ||||
|             throw new ArgumentException("Error message cannot be null or whitespace.", nameof(message)); | ||||
|         } | ||||
|  | ||||
|         Code = code; | ||||
|         Severity = severity; | ||||
|         Message = message.Trim(); | ||||
|         Timestamp = ScannerTimestamps.Normalize(timestamp); | ||||
|         Retryable = retryable; | ||||
|         Stage = stage; | ||||
|         Component = component; | ||||
|         Details = details is null or { Count: 0 } | ||||
|             ? EmptyDetails | ||||
|             : new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(details, StringComparer.Ordinal)); | ||||
|     } | ||||
|  | ||||
|     [JsonPropertyName("code")] | ||||
|     [JsonPropertyOrder(0)] | ||||
|     public ScannerErrorCode Code { get; } | ||||
|  | ||||
|     [JsonPropertyName("severity")] | ||||
|     [JsonPropertyOrder(1)] | ||||
|     public ScannerErrorSeverity Severity { get; } | ||||
|  | ||||
|     [JsonPropertyName("message")] | ||||
|     [JsonPropertyOrder(2)] | ||||
|     public string Message { get; } | ||||
|  | ||||
|     [JsonPropertyName("timestamp")] | ||||
|     [JsonPropertyOrder(3)] | ||||
|     public DateTimeOffset Timestamp { get; } | ||||
|  | ||||
|     [JsonPropertyName("retryable")] | ||||
|     [JsonPropertyOrder(4)] | ||||
|     public bool Retryable { get; } | ||||
|  | ||||
|     [JsonPropertyName("stage")] | ||||
|     [JsonPropertyOrder(5)] | ||||
|     public string? Stage { get; } | ||||
|  | ||||
|     [JsonPropertyName("component")] | ||||
|     [JsonPropertyOrder(6)] | ||||
|     public string? Component { get; } | ||||
|  | ||||
|     [JsonPropertyName("details")] | ||||
|     [JsonPropertyOrder(7)] | ||||
|     public IReadOnlyDictionary<string, string> Details { get; } | ||||
|  | ||||
|     public ScannerError WithDetail(string key, string value) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(key); | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(value); | ||||
|  | ||||
|         var mutable = new Dictionary<string, string>(Details, StringComparer.Ordinal) | ||||
|         { | ||||
|             [key] = value | ||||
|         }; | ||||
|  | ||||
|         return new ScannerError(Code, Severity, Message, Timestamp, Retryable, mutable, Stage, Component); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,80 @@ | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Threading; | ||||
| using StellaOps.Scanner.Core.Contracts; | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Observability; | ||||
|  | ||||
| public readonly record struct ScannerCorrelationContext( | ||||
|     ScanJobId JobId, | ||||
|     string CorrelationId, | ||||
|     string? Stage, | ||||
|     string? Component, | ||||
|     string? Audience = null) | ||||
| { | ||||
|     public static ScannerCorrelationContext Create( | ||||
|         ScanJobId jobId, | ||||
|         string? stage = null, | ||||
|         string? component = null, | ||||
|         string? audience = null) | ||||
|     { | ||||
|         var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, stage, component); | ||||
|         return new ScannerCorrelationContext(jobId, correlationId, stage, component, audience); | ||||
|     } | ||||
|  | ||||
|     public string DeterministicHash() | ||||
|         => ScannerIdentifiers.CreateDeterministicHash( | ||||
|             JobId.ToString(), | ||||
|             Stage ?? string.Empty, | ||||
|             Component ?? string.Empty, | ||||
|             Audience ?? string.Empty); | ||||
| } | ||||
|  | ||||
| public static class ScannerCorrelationContextAccessor | ||||
| { | ||||
|     private static readonly AsyncLocal<ScannerCorrelationContext?> CurrentContext = new(); | ||||
|  | ||||
|     public static ScannerCorrelationContext? Current => CurrentContext.Value; | ||||
|  | ||||
|     public static IDisposable Push(in ScannerCorrelationContext context) | ||||
|     { | ||||
|         var previous = CurrentContext.Value; | ||||
|         CurrentContext.Value = context; | ||||
|         return new DisposableScope(() => CurrentContext.Value = previous); | ||||
|     } | ||||
|  | ||||
|     public static bool TryGetCorrelationId([NotNullWhen(true)] out string? correlationId) | ||||
|     { | ||||
|         var context = CurrentContext.Value; | ||||
|         if (context.HasValue) | ||||
|         { | ||||
|             correlationId = context.Value.CorrelationId; | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         correlationId = null; | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private sealed class DisposableScope : IDisposable | ||||
|     { | ||||
|         private readonly Action release; | ||||
|         private bool disposed; | ||||
|  | ||||
|         public DisposableScope(Action release) | ||||
|         { | ||||
|             this.release = release ?? throw new ArgumentNullException(nameof(release)); | ||||
|         } | ||||
|  | ||||
|         public void Dispose() | ||||
|         { | ||||
|             if (disposed) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             disposed = true; | ||||
|             release(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,55 @@ | ||||
| using System.Diagnostics; | ||||
| using System.Diagnostics.Metrics; | ||||
| using StellaOps.Scanner.Core.Contracts; | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Observability; | ||||
|  | ||||
| public static class ScannerDiagnostics | ||||
| { | ||||
|     public const string ActivitySourceName = "StellaOps.Scanner"; | ||||
|     public const string ActivityVersion = "1.0.0"; | ||||
|     public const string MeterName = "stellaops.scanner"; | ||||
|     public const string MeterVersion = "1.0.0"; | ||||
|  | ||||
|     public static ActivitySource ActivitySource { get; } = new(ActivitySourceName, ActivityVersion); | ||||
|     public static Meter Meter { get; } = new(MeterName, MeterVersion); | ||||
|  | ||||
|     public static Activity? StartActivity( | ||||
|         string name, | ||||
|         ScanJobId jobId, | ||||
|         string? stage = null, | ||||
|         string? component = null, | ||||
|         ActivityKind kind = ActivityKind.Internal, | ||||
|         IEnumerable<KeyValuePair<string, object?>>? tags = null) | ||||
|     { | ||||
|         var activity = ActivitySource.StartActivity(name, kind); | ||||
|         if (activity is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         activity.SetTag("stellaops.scanner.job_id", jobId.ToString()); | ||||
|         activity.SetTag("stellaops.scanner.correlation_id", ScannerIdentifiers.CreateCorrelationId(jobId, stage, component)); | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(stage)) | ||||
|         { | ||||
|             activity.SetTag("stellaops.scanner.stage", stage); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(component)) | ||||
|         { | ||||
|             activity.SetTag("stellaops.scanner.component", component); | ||||
|         } | ||||
|  | ||||
|         if (tags is not null) | ||||
|         { | ||||
|             foreach (var tag in tags) | ||||
|             { | ||||
|                 activity?.SetTag(tag.Key, tag.Value); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return activity; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										115
									
								
								src/StellaOps.Scanner.Core/Observability/ScannerLogExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/StellaOps.Scanner.Core/Observability/ScannerLogExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Scanner.Core.Contracts; | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Observability; | ||||
|  | ||||
| public static class ScannerLogExtensions | ||||
| { | ||||
|     private sealed class NoopScope : IDisposable | ||||
|     { | ||||
|         public static NoopScope Instance { get; } = new(); | ||||
|  | ||||
|         public void Dispose() | ||||
|         { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class CompositeScope : IDisposable | ||||
|     { | ||||
|         private readonly IDisposable first; | ||||
|         private readonly IDisposable second; | ||||
|         private bool disposed; | ||||
|  | ||||
|         public CompositeScope(IDisposable first, IDisposable second) | ||||
|         { | ||||
|             this.first = first; | ||||
|             this.second = second; | ||||
|         } | ||||
|  | ||||
|         public void Dispose() | ||||
|         { | ||||
|             if (disposed) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             disposed = true; | ||||
|             second.Dispose(); | ||||
|             first.Dispose(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static IDisposable BeginScanScope(this ILogger? logger, ScanJob job, string? stage = null, string? component = null) | ||||
|     { | ||||
|         var correlation = ScannerCorrelationContext.Create(job.Id, stage, component); | ||||
|         var logScope = logger is null | ||||
|             ? NoopScope.Instance | ||||
|             : logger.BeginScope(CreateScopeState( | ||||
|                 job.Id, | ||||
|                 job.CorrelationId, | ||||
|                 stage, | ||||
|                 component, | ||||
|                 job.TenantId, | ||||
|                 job.ImageDigest)) ?? NoopScope.Instance; | ||||
|  | ||||
|         var correlationScope = ScannerCorrelationContextAccessor.Push(correlation); | ||||
|         return new CompositeScope(logScope, correlationScope); | ||||
|     } | ||||
|  | ||||
|     public static IDisposable BeginProgressScope(this ILogger? logger, ScanProgressEvent progress, string? component = null) | ||||
|     { | ||||
|         var correlationId = ScannerIdentifiers.CreateCorrelationId(progress.JobId, progress.Stage.ToString(), component); | ||||
|         var correlation = new ScannerCorrelationContext(progress.JobId, correlationId, progress.Stage.ToString(), component); | ||||
|  | ||||
|         var logScope = logger is null | ||||
|             ? NoopScope.Instance | ||||
|             : logger.BeginScope(new Dictionary<string, object?>(6, StringComparer.Ordinal) | ||||
|             { | ||||
|                 ["scanId"] = progress.JobId.ToString(), | ||||
|                 ["stage"] = progress.Stage.ToString(), | ||||
|                 ["sequence"] = progress.Sequence, | ||||
|                 ["kind"] = progress.Kind.ToString(), | ||||
|                 ["correlationId"] = correlationId, | ||||
|                 ["component"] = component ?? string.Empty | ||||
|             }) ?? NoopScope.Instance; | ||||
|  | ||||
|         var correlationScope = ScannerCorrelationContextAccessor.Push(correlation); | ||||
|         return new CompositeScope(logScope, correlationScope); | ||||
|     } | ||||
|  | ||||
|     public static IDisposable BeginCorrelationScope(this ILogger? logger, ScannerCorrelationContext context) | ||||
|     { | ||||
|         var scope = logger is null | ||||
|             ? NoopScope.Instance | ||||
|             : logger.BeginScope(CreateScopeState(context.JobId, context.CorrelationId, context.Stage, context.Component, null, null)) ?? NoopScope.Instance; | ||||
|  | ||||
|         var correlationScope = ScannerCorrelationContextAccessor.Push(context); | ||||
|         return new CompositeScope(scope, correlationScope); | ||||
|     } | ||||
|  | ||||
|     private static Dictionary<string, object?> CreateScopeState( | ||||
|         ScanJobId jobId, | ||||
|         string correlationId, | ||||
|         string? stage, | ||||
|         string? component, | ||||
|         string? tenantId, | ||||
|         string? imageDigest) | ||||
|     { | ||||
|         var state = new Dictionary<string, object?>(6, StringComparer.Ordinal) | ||||
|         { | ||||
|             ["scanId"] = jobId.ToString(), | ||||
|             ["correlationId"] = correlationId, | ||||
|             ["stage"] = stage ?? string.Empty, | ||||
|             ["component"] = component ?? string.Empty, | ||||
|             ["tenantId"] = tenantId ?? string.Empty | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(imageDigest)) | ||||
|         { | ||||
|             state["imageDigest"] = imageDigest; | ||||
|         } | ||||
|  | ||||
|         return state; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,55 @@ | ||||
| using System.Collections.Frozen; | ||||
| using StellaOps.Scanner.Core.Contracts; | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Observability; | ||||
|  | ||||
| public static class ScannerMetricNames | ||||
| { | ||||
|     public const string Prefix = "stellaops.scanner"; | ||||
|     public const string QueueLatency = $"{Prefix}.queue.latency"; | ||||
|     public const string QueueDepth = $"{Prefix}.queue.depth"; | ||||
|     public const string StageDuration = $"{Prefix}.stage.duration"; | ||||
|     public const string StageProgress = $"{Prefix}.stage.progress"; | ||||
|     public const string JobCount = $"{Prefix}.jobs.count"; | ||||
|     public const string JobFailures = $"{Prefix}.jobs.failures"; | ||||
|     public const string ArtifactBytes = $"{Prefix}.artifacts.bytes"; | ||||
|  | ||||
|     public static FrozenDictionary<string, object?> BuildJobTags(ScanJob job, string? stage = null, string? component = null) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(job); | ||||
|  | ||||
|         var builder = new Dictionary<string, object?>(6, StringComparer.Ordinal) | ||||
|         { | ||||
|             ["jobId"] = job.Id.ToString(), | ||||
|             ["stage"] = stage ?? string.Empty, | ||||
|             ["component"] = component ?? string.Empty, | ||||
|             ["tenantId"] = job.TenantId ?? string.Empty, | ||||
|             ["correlationId"] = job.CorrelationId, | ||||
|             ["status"] = job.Status.ToString() | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(job.ImageDigest)) | ||||
|         { | ||||
|             builder["imageDigest"] = job.ImageDigest; | ||||
|         } | ||||
|  | ||||
|         return builder.ToFrozenDictionary(StringComparer.Ordinal); | ||||
|     } | ||||
|  | ||||
|     public static FrozenDictionary<string, object?> BuildEventTags(ScanProgressEvent progress) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(progress); | ||||
|  | ||||
|         var builder = new Dictionary<string, object?>(5, StringComparer.Ordinal) | ||||
|         { | ||||
|             ["jobId"] = progress.JobId.ToString(), | ||||
|             ["stage"] = progress.Stage.ToString(), | ||||
|             ["kind"] = progress.Kind.ToString(), | ||||
|             ["sequence"] = progress.Sequence, | ||||
|             ["correlationId"] = ScannerIdentifiers.CreateCorrelationId(progress.JobId, progress.Stage.ToString()) | ||||
|         }; | ||||
|  | ||||
|         return builder.ToFrozenDictionary(StringComparer.Ordinal); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										128
									
								
								src/StellaOps.Scanner.Core/Security/AuthorityTokenSource.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								src/StellaOps.Scanner.Core/Security/AuthorityTokenSource.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,128 @@ | ||||
| using System.Collections.Concurrent; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Auth.Client; | ||||
| using StellaOps.Scanner.Core.Utility; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public sealed class AuthorityTokenSource : IAuthorityTokenSource | ||||
| { | ||||
|     private readonly IStellaOpsTokenClient tokenClient; | ||||
|     private readonly TimeProvider timeProvider; | ||||
|     private readonly TimeSpan refreshSkew; | ||||
|     private readonly ILogger<AuthorityTokenSource>? logger; | ||||
|     private readonly ConcurrentDictionary<string, CacheEntry> cache = new(StringComparer.Ordinal); | ||||
|     private readonly ConcurrentDictionary<string, SemaphoreSlim> locks = new(StringComparer.Ordinal); | ||||
|  | ||||
|     public AuthorityTokenSource( | ||||
|         IStellaOpsTokenClient tokenClient, | ||||
|         TimeSpan? refreshSkew = null, | ||||
|         TimeProvider? timeProvider = null, | ||||
|         ILogger<AuthorityTokenSource>? logger = null) | ||||
|     { | ||||
|         this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient)); | ||||
|         this.timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         this.logger = logger; | ||||
|         this.refreshSkew = refreshSkew is { } value && value > TimeSpan.Zero ? value : TimeSpan.FromSeconds(30); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<ScannerOperationalToken> GetAsync(string audience, IEnumerable<string> scopes, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(audience); | ||||
|  | ||||
|         var normalizedAudience = NormalizeAudience(audience); | ||||
|         var normalizedScopes = NormalizeScopes(scopes, normalizedAudience); | ||||
|         var cacheKey = BuildCacheKey(normalizedAudience, normalizedScopes); | ||||
|  | ||||
|         if (cache.TryGetValue(cacheKey, out var cached) && !cached.Token.IsExpired(timeProvider, refreshSkew)) | ||||
|         { | ||||
|             return cached.Token; | ||||
|         } | ||||
|  | ||||
|         var mutex = locks.GetOrAdd(cacheKey, static _ => new SemaphoreSlim(1, 1)); | ||||
|         await mutex.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             if (cache.TryGetValue(cacheKey, out cached) && !cached.Token.IsExpired(timeProvider, refreshSkew)) | ||||
|             { | ||||
|                 return cached.Token; | ||||
|             } | ||||
|  | ||||
|             var scopeString = string.Join(' ', normalizedScopes); | ||||
|             var tokenResult = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             var token = ScannerOperationalToken.FromResult( | ||||
|                 tokenResult.AccessToken, | ||||
|                 tokenResult.TokenType, | ||||
|                 tokenResult.ExpiresAtUtc, | ||||
|                 tokenResult.Scopes); | ||||
|  | ||||
|             cache[cacheKey] = new CacheEntry(token); | ||||
|             logger?.LogDebug( | ||||
|                 "Issued new scanner OpTok for audience {Audience} with scopes {Scopes}; expires at {ExpiresAt}.", | ||||
|                 normalizedAudience, | ||||
|                 scopeString, | ||||
|                 token.ExpiresAt); | ||||
|  | ||||
|             return token; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             mutex.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public ValueTask InvalidateAsync(string audience, IEnumerable<string> scopes, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(audience); | ||||
|  | ||||
|         var normalizedAudience = NormalizeAudience(audience); | ||||
|         var normalizedScopes = NormalizeScopes(scopes, normalizedAudience); | ||||
|         var cacheKey = BuildCacheKey(normalizedAudience, normalizedScopes); | ||||
|  | ||||
|         cache.TryRemove(cacheKey, out _); | ||||
|         if (locks.TryRemove(cacheKey, out var mutex)) | ||||
|         { | ||||
|             mutex.Dispose(); | ||||
|         } | ||||
|  | ||||
|         logger?.LogDebug("Invalidated cached OpTok for {Audience} ({CacheKey}).", normalizedAudience, cacheKey); | ||||
|         return ValueTask.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeAudience(string audience) | ||||
|         => audience.Trim().ToLowerInvariant(); | ||||
|  | ||||
|     private static IReadOnlyList<string> NormalizeScopes(IEnumerable<string> scopes, string audience) | ||||
|     { | ||||
|         var set = new SortedSet<string>(StringComparer.Ordinal) | ||||
|         { | ||||
|             $"aud:{audience}" | ||||
|         }; | ||||
|  | ||||
|         if (scopes is not null) | ||||
|         { | ||||
|             foreach (var scope in scopes) | ||||
|             { | ||||
|                 if (string.IsNullOrWhiteSpace(scope)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 set.Add(scope.Trim()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return set.ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static string BuildCacheKey(string audience, IReadOnlyList<string> scopes) | ||||
|         => ScannerIdentifiers.CreateDeterministicHash(audience, string.Join(' ', scopes)); | ||||
|  | ||||
|     private readonly record struct CacheEntry(ScannerOperationalToken Token); | ||||
| } | ||||
							
								
								
									
										248
									
								
								src/StellaOps.Scanner.Core/Security/DpopProofValidator.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								src/StellaOps.Scanner.Core/Security/DpopProofValidator.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,248 @@ | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public sealed class DpopProofValidator : IDpopProofValidator | ||||
| { | ||||
|     private static readonly string ProofType = "dpop+jwt"; | ||||
|     private readonly DpopValidationOptions options; | ||||
|     private readonly IDpopReplayCache replayCache; | ||||
|     private readonly TimeProvider timeProvider; | ||||
|     private readonly ILogger<DpopProofValidator>? logger; | ||||
|     private readonly JwtSecurityTokenHandler tokenHandler = new(); | ||||
|  | ||||
|     public DpopProofValidator( | ||||
|         IOptions<DpopValidationOptions> options, | ||||
|         IDpopReplayCache? replayCache = null, | ||||
|         TimeProvider? timeProvider = null, | ||||
|         ILogger<DpopProofValidator>? logger = null) | ||||
|     { | ||||
|         if (options is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(options)); | ||||
|         } | ||||
|  | ||||
|         var cloned = options.Value ?? throw new InvalidOperationException("DPoP options must be provided."); | ||||
|         cloned.Validate(); | ||||
|  | ||||
|         this.options = cloned; | ||||
|         this.replayCache = replayCache ?? NullReplayCache.Instance; | ||||
|         this.timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         this.logger = logger; | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(proof); | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod); | ||||
|         ArgumentNullException.ThrowIfNull(httpUri); | ||||
|  | ||||
|         var now = timeProvider.GetUtcNow(); | ||||
|  | ||||
|         if (!TryDecodeSegment(proof, segmentIndex: 0, out var headerElement, out var headerError)) | ||||
|         { | ||||
|             logger?.LogWarning("DPoP header decode failure: {Error}", headerError); | ||||
|             return DpopValidationResult.Failure("invalid_header", headerError ?? "Unable to decode header."); | ||||
|         } | ||||
|  | ||||
|         if (!headerElement.TryGetProperty("typ", out var typElement) || !string.Equals(typElement.GetString(), ProofType, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_header", "DPoP proof missing typ=dpop+jwt header."); | ||||
|         } | ||||
|  | ||||
|         if (!headerElement.TryGetProperty("alg", out var algElement)) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_header", "DPoP proof missing alg header."); | ||||
|         } | ||||
|  | ||||
|         var algorithm = algElement.GetString()?.Trim().ToUpperInvariant(); | ||||
|         if (string.IsNullOrEmpty(algorithm) || !options.NormalizedAlgorithms.Contains(algorithm)) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_header", "Unsupported DPoP algorithm."); | ||||
|         } | ||||
|  | ||||
|         if (!headerElement.TryGetProperty("jwk", out var jwkElement)) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_header", "DPoP proof missing jwk header."); | ||||
|         } | ||||
|  | ||||
|         JsonWebKey jwk; | ||||
|         try | ||||
|         { | ||||
|             jwk = new JsonWebKey(jwkElement.GetRawText()); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger?.LogWarning(ex, "Failed to parse DPoP jwk header."); | ||||
|             return DpopValidationResult.Failure("invalid_header", "DPoP proof jwk header is invalid."); | ||||
|         } | ||||
|  | ||||
|         if (!TryDecodeSegment(proof, segmentIndex: 1, out var payloadElement, out var payloadError)) | ||||
|         { | ||||
|             logger?.LogWarning("DPoP payload decode failure: {Error}", payloadError); | ||||
|             return DpopValidationResult.Failure("invalid_payload", payloadError ?? "Unable to decode payload."); | ||||
|         } | ||||
|  | ||||
|         if (!payloadElement.TryGetProperty("htm", out var htmElement)) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htm claim."); | ||||
|         } | ||||
|  | ||||
|         var method = httpMethod.Trim().ToUpperInvariant(); | ||||
|         if (!string.Equals(htmElement.GetString(), method, StringComparison.Ordinal)) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_payload", "DPoP htm does not match request method."); | ||||
|         } | ||||
|  | ||||
|         if (!payloadElement.TryGetProperty("htu", out var htuElement)) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htu claim."); | ||||
|         } | ||||
|  | ||||
|         var normalizedHtu = NormalizeHtu(httpUri); | ||||
|         if (!string.Equals(htuElement.GetString(), normalizedHtu, StringComparison.Ordinal)) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_payload", "DPoP htu does not match request URI."); | ||||
|         } | ||||
|  | ||||
|         if (!payloadElement.TryGetProperty("iat", out var iatElement) || iatElement.ValueKind is not JsonValueKind.Number) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing iat claim."); | ||||
|         } | ||||
|  | ||||
|         if (!payloadElement.TryGetProperty("jti", out var jtiElement) || jtiElement.ValueKind != JsonValueKind.String) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing jti claim."); | ||||
|         } | ||||
|  | ||||
|         long iatSeconds; | ||||
|         try | ||||
|         { | ||||
|             iatSeconds = iatElement.GetInt64(); | ||||
|         } | ||||
|         catch (Exception) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_payload", "DPoP proof iat claim is not a valid number."); | ||||
|         } | ||||
|  | ||||
|         var issuedAt = DateTimeOffset.FromUnixTimeSeconds(iatSeconds).ToUniversalTime(); | ||||
|         if (issuedAt - options.AllowedClockSkew > now) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_token", "DPoP proof issued in the future."); | ||||
|         } | ||||
|  | ||||
|         if (now - issuedAt > options.ProofLifetime + options.AllowedClockSkew) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("invalid_token", "DPoP proof expired."); | ||||
|         } | ||||
|  | ||||
|         if (nonce is not null) | ||||
|         { | ||||
|             if (!payloadElement.TryGetProperty("nonce", out var nonceElement) || nonceElement.ValueKind != JsonValueKind.String) | ||||
|             { | ||||
|                 return DpopValidationResult.Failure("invalid_token", "DPoP proof missing nonce claim."); | ||||
|             } | ||||
|  | ||||
|             if (!string.Equals(nonceElement.GetString(), nonce, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return DpopValidationResult.Failure("invalid_token", "DPoP nonce mismatch."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var jwtId = jtiElement.GetString()!; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var parameters = new TokenValidationParameters | ||||
|             { | ||||
|                 ValidateAudience = false, | ||||
|                 ValidateIssuer = false, | ||||
|                 ValidateLifetime = false, | ||||
|                 ValidateTokenReplay = false, | ||||
|                 RequireSignedTokens = true, | ||||
|                 ValidateIssuerSigningKey = true, | ||||
|                 IssuerSigningKey = jwk, | ||||
|                 ValidAlgorithms = options.NormalizedAlgorithms.ToArray() | ||||
|             }; | ||||
|  | ||||
|             tokenHandler.ValidateToken(proof, parameters, out _); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger?.LogWarning(ex, "DPoP proof signature validation failed."); | ||||
|             return DpopValidationResult.Failure("invalid_signature", "DPoP proof signature validation failed."); | ||||
|         } | ||||
|  | ||||
|         if (!await replayCache.TryStoreAsync(jwtId, issuedAt + options.ReplayWindow, cancellationToken).ConfigureAwait(false)) | ||||
|         { | ||||
|             return DpopValidationResult.Failure("replay", "DPoP proof already used."); | ||||
|         } | ||||
|  | ||||
|         return DpopValidationResult.Success(jwk, jwtId, issuedAt); | ||||
|     } | ||||
|  | ||||
|     private static bool TryDecodeSegment(string token, int segmentIndex, out JsonElement element, out string? error) | ||||
|     { | ||||
|         element = default; | ||||
|         error = null; | ||||
|  | ||||
|         var segments = token.Split('.'); | ||||
|         if (segments.Length != 3) | ||||
|         { | ||||
|             error = "Token must contain three segments."; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (segmentIndex < 0 || segmentIndex > 1) | ||||
|         { | ||||
|             error = "Segment index must be 0 or 1."; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var jsonBytes = Base64UrlEncoder.DecodeBytes(segments[segmentIndex]); | ||||
|             using var document = JsonDocument.Parse(jsonBytes); | ||||
|             element = document.RootElement.Clone(); | ||||
|             return true; | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             error = ex.Message; | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeHtu(Uri uri) | ||||
|     { | ||||
|         var builder = new UriBuilder(uri) | ||||
|         { | ||||
|             Fragment = string.Empty | ||||
|         }; | ||||
|  | ||||
|         builder.Host = builder.Host.ToLowerInvariant(); | ||||
|         builder.Scheme = builder.Scheme.ToLowerInvariant(); | ||||
|  | ||||
|         if ((builder.Scheme == "http" && builder.Port == 80) || (builder.Scheme == "https" && builder.Port == 443)) | ||||
|         { | ||||
|             builder.Port = -1; | ||||
|         } | ||||
|  | ||||
|         return builder.Uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped); | ||||
|     } | ||||
|  | ||||
|     private sealed class NullReplayCache : IDpopReplayCache | ||||
|     { | ||||
|         public static NullReplayCache Instance { get; } = new(); | ||||
|  | ||||
|         public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.FromResult(true); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										58
									
								
								src/StellaOps.Scanner.Core/Security/DpopValidationOptions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/StellaOps.Scanner.Core/Security/DpopValidationOptions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public sealed class DpopValidationOptions | ||||
| { | ||||
|     private readonly HashSet<string> allowedAlgorithms = new(StringComparer.Ordinal); | ||||
|  | ||||
|     public DpopValidationOptions() | ||||
|     { | ||||
|         allowedAlgorithms.Add("ES256"); | ||||
|         allowedAlgorithms.Add("ES384"); | ||||
|     } | ||||
|  | ||||
|     public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2); | ||||
|  | ||||
|     public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromSeconds(30); | ||||
|  | ||||
|     public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(5); | ||||
|  | ||||
|     public ISet<string> AllowedAlgorithms => allowedAlgorithms; | ||||
|  | ||||
|     public IReadOnlySet<string> NormalizedAlgorithms { get; private set; } = ImmutableHashSet<string>.Empty; | ||||
|  | ||||
|     public void Validate() | ||||
|     { | ||||
|         if (ProofLifetime <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException("DPoP proof lifetime must be greater than zero."); | ||||
|         } | ||||
|  | ||||
|         if (AllowedClockSkew < TimeSpan.Zero || AllowedClockSkew > TimeSpan.FromMinutes(5)) | ||||
|         { | ||||
|             throw new InvalidOperationException("DPoP allowed clock skew must be between 0 seconds and 5 minutes."); | ||||
|         } | ||||
|  | ||||
|         if (ReplayWindow < TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException("DPoP replay window must be greater than or equal to zero."); | ||||
|         } | ||||
|  | ||||
|         if (allowedAlgorithms.Count == 0) | ||||
|         { | ||||
|             throw new InvalidOperationException("At least one allowed DPoP algorithm must be configured."); | ||||
|         } | ||||
|  | ||||
|         NormalizedAlgorithms = allowedAlgorithms | ||||
|             .Select(static algorithm => algorithm.Trim().ToUpperInvariant()) | ||||
|             .Where(static algorithm => algorithm.Length > 0) | ||||
|             .ToImmutableHashSet(StringComparer.Ordinal); | ||||
|  | ||||
|         if (NormalizedAlgorithms.Count == 0) | ||||
|         { | ||||
|             throw new InvalidOperationException("Allowed DPoP algorithms cannot be empty after normalization."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										34
									
								
								src/StellaOps.Scanner.Core/Security/DpopValidationResult.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/StellaOps.Scanner.Core/Security/DpopValidationResult.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public sealed class DpopValidationResult | ||||
| { | ||||
|     private DpopValidationResult(bool success, string? errorCode, string? errorDescription, SecurityKey? key, string? jwtId, DateTimeOffset? issuedAt) | ||||
|     { | ||||
|         IsValid = success; | ||||
|         ErrorCode = errorCode; | ||||
|         ErrorDescription = errorDescription; | ||||
|         PublicKey = key; | ||||
|         JwtId = jwtId; | ||||
|         IssuedAt = issuedAt; | ||||
|     } | ||||
|  | ||||
|     public bool IsValid { get; } | ||||
|  | ||||
|     public string? ErrorCode { get; } | ||||
|  | ||||
|     public string? ErrorDescription { get; } | ||||
|  | ||||
|     public SecurityKey? PublicKey { get; } | ||||
|  | ||||
|     public string? JwtId { get; } | ||||
|  | ||||
|     public DateTimeOffset? IssuedAt { get; } | ||||
|  | ||||
|     public static DpopValidationResult Success(SecurityKey key, string jwtId, DateTimeOffset issuedAt) | ||||
|         => new(true, null, null, key, jwtId, issuedAt); | ||||
|  | ||||
|     public static DpopValidationResult Failure(string code, string description) | ||||
|         => new(false, code, description, null, null, null); | ||||
| } | ||||
							
								
								
									
										11
									
								
								src/StellaOps.Scanner.Core/Security/IAuthorityTokenSource.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/StellaOps.Scanner.Core/Security/IAuthorityTokenSource.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public interface IAuthorityTokenSource | ||||
| { | ||||
|     ValueTask<ScannerOperationalToken> GetAsync(string audience, IEnumerable<string> scopes, CancellationToken cancellationToken = default); | ||||
|  | ||||
|     ValueTask InvalidateAsync(string audience, IEnumerable<string> scopes, CancellationToken cancellationToken = default); | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public interface IDpopProofValidator | ||||
| { | ||||
|     ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default); | ||||
| } | ||||
							
								
								
									
										9
									
								
								src/StellaOps.Scanner.Core/Security/IDpopReplayCache.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/StellaOps.Scanner.Core/Security/IDpopReplayCache.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public interface IDpopReplayCache | ||||
| { | ||||
|     ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default); | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/StellaOps.Scanner.Core/Security/IPluginCatalogGuard.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/StellaOps.Scanner.Core/Security/IPluginCatalogGuard.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public interface IPluginCatalogGuard | ||||
| { | ||||
|     IReadOnlyCollection<string> KnownPlugins { get; } | ||||
|  | ||||
|     bool IsSealed { get; } | ||||
|  | ||||
|     void EnsureRegistrationAllowed(string pluginPath); | ||||
|  | ||||
|     void Seal(); | ||||
| } | ||||
| @@ -0,0 +1,65 @@ | ||||
| using System.Collections.Concurrent; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public sealed class InMemoryDpopReplayCache : IDpopReplayCache | ||||
| { | ||||
|     private readonly ConcurrentDictionary<string, DateTimeOffset> entries = new(StringComparer.Ordinal); | ||||
|     private readonly TimeProvider timeProvider; | ||||
|  | ||||
|     public InMemoryDpopReplayCache(TimeProvider? timeProvider = null) | ||||
|     { | ||||
|         this.timeProvider = timeProvider ?? TimeProvider.System; | ||||
|     } | ||||
|  | ||||
|     public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(jwtId); | ||||
|  | ||||
|         var now = timeProvider.GetUtcNow(); | ||||
|         RemoveExpired(now); | ||||
|  | ||||
|         if (entries.TryAdd(jwtId, expiresAt)) | ||||
|         { | ||||
|             return ValueTask.FromResult(true); | ||||
|         } | ||||
|  | ||||
|         while (!cancellationToken.IsCancellationRequested) | ||||
|         { | ||||
|             if (!entries.TryGetValue(jwtId, out var existing)) | ||||
|             { | ||||
|                 if (entries.TryAdd(jwtId, expiresAt)) | ||||
|                 { | ||||
|                     return ValueTask.FromResult(true); | ||||
|                 } | ||||
|  | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (existing > now) | ||||
|             { | ||||
|                 return ValueTask.FromResult(false); | ||||
|             } | ||||
|  | ||||
|             if (entries.TryUpdate(jwtId, expiresAt, existing)) | ||||
|             { | ||||
|                 return ValueTask.FromResult(true); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return ValueTask.FromResult(false); | ||||
|     } | ||||
|  | ||||
|     private void RemoveExpired(DateTimeOffset now) | ||||
|     { | ||||
|         foreach (var entry in entries) | ||||
|         { | ||||
|             if (entry.Value <= now) | ||||
|             { | ||||
|                 entries.TryRemove(entry.Key, out _); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,53 @@ | ||||
| using System.Collections.Concurrent; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public sealed class RestartOnlyPluginGuard : IPluginCatalogGuard | ||||
| { | ||||
|     private readonly ConcurrentDictionary<string, byte> plugins = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private bool sealedState; | ||||
|  | ||||
|     public RestartOnlyPluginGuard(IEnumerable<string>? initialPlugins = null) | ||||
|     { | ||||
|         if (initialPlugins is not null) | ||||
|         { | ||||
|             foreach (var plugin in initialPlugins) | ||||
|             { | ||||
|                 var normalized = Normalize(plugin); | ||||
|                 plugins.TryAdd(normalized, 0); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyCollection<string> KnownPlugins => plugins.Keys.ToArray(); | ||||
|  | ||||
|     public bool IsSealed => Volatile.Read(ref sealedState); | ||||
|  | ||||
|     public void EnsureRegistrationAllowed(string pluginPath) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(pluginPath); | ||||
|  | ||||
|         var normalized = Normalize(pluginPath); | ||||
|         if (IsSealed && !plugins.ContainsKey(normalized)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Plug-in '{pluginPath}' cannot be registered after startup. Restart required."); | ||||
|         } | ||||
|  | ||||
|         plugins.TryAdd(normalized, 0); | ||||
|     } | ||||
|  | ||||
|     public void Seal() | ||||
|     { | ||||
|         Volatile.Write(ref sealedState, true); | ||||
|     } | ||||
|  | ||||
|     private static string Normalize(string path) | ||||
|     { | ||||
|         var full = Path.GetFullPath(path); | ||||
|         return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,66 @@ | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public readonly record struct ScannerOperationalToken( | ||||
|     string AccessToken, | ||||
|     string TokenType, | ||||
|     DateTimeOffset ExpiresAt, | ||||
|     IReadOnlyList<string> Scopes) | ||||
| { | ||||
|     public bool IsExpired(TimeProvider timeProvider, TimeSpan refreshSkew) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(timeProvider); | ||||
|  | ||||
|         var now = timeProvider.GetUtcNow(); | ||||
|         return now >= ExpiresAt - refreshSkew; | ||||
|     } | ||||
|  | ||||
|     public static ScannerOperationalToken FromResult( | ||||
|         string accessToken, | ||||
|         string tokenType, | ||||
|         DateTimeOffset expiresAt, | ||||
|         IEnumerable<string> scopes) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(accessToken); | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(tokenType); | ||||
|  | ||||
|         IReadOnlyList<string> normalized = scopes switch | ||||
|         { | ||||
|             null => Array.Empty<string>(), | ||||
|             IReadOnlyList<string> readOnly => readOnly.Count == 0 ? Array.Empty<string>() : readOnly, | ||||
|             ICollection<string> collection => NormalizeCollection(collection), | ||||
|             _ => NormalizeEnumerable(scopes) | ||||
|         }; | ||||
|  | ||||
|         return new ScannerOperationalToken( | ||||
|             accessToken, | ||||
|             tokenType, | ||||
|             expiresAt, | ||||
|             normalized); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> NormalizeCollection(ICollection<string> collection) | ||||
|     { | ||||
|         if (collection.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<string>(); | ||||
|         } | ||||
|  | ||||
|         if (collection is IReadOnlyList<string> readOnly) | ||||
|         { | ||||
|             return readOnly; | ||||
|         } | ||||
|  | ||||
|         var buffer = new string[collection.Count]; | ||||
|         collection.CopyTo(buffer, 0); | ||||
|         return new ReadOnlyCollection<string>(buffer); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> NormalizeEnumerable(IEnumerable<string> scopes) | ||||
|     { | ||||
|         var buffer = scopes.ToArray(); | ||||
|         return buffer.Length == 0 ? Array.Empty<string>() : new ReadOnlyCollection<string>(buffer); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,36 @@ | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Auth.Client; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Security; | ||||
|  | ||||
| public static class ServiceCollectionExtensions | ||||
| { | ||||
|     public static IServiceCollection AddScannerAuthorityCore( | ||||
|         this IServiceCollection services, | ||||
|         Action<StellaOpsAuthClientOptions> configureAuthority, | ||||
|         Action<DpopValidationOptions>? configureDpop = null) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|         ArgumentNullException.ThrowIfNull(configureAuthority); | ||||
|  | ||||
|         services.AddStellaOpsAuthClient(configureAuthority); | ||||
|  | ||||
|         if (configureDpop is not null) | ||||
|         { | ||||
|             services.AddOptions<DpopValidationOptions>().Configure(configureDpop).PostConfigure(static options => options.Validate()); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             services.AddOptions<DpopValidationOptions>().PostConfigure(static options => options.Validate()); | ||||
|         } | ||||
|  | ||||
|         services.TryAddSingleton<IDpopReplayCache>(provider => new InMemoryDpopReplayCache(provider.GetService<TimeProvider>())); | ||||
|         services.TryAddSingleton<IDpopProofValidator, DpopProofValidator>(); | ||||
|         services.TryAddSingleton<IAuthorityTokenSource, AuthorityTokenSource>(); | ||||
|         services.TryAddSingleton<IPluginCatalogGuard, RestartOnlyPluginGuard>(); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,21 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Serialization; | ||||
|  | ||||
| public static class ScannerJsonOptions | ||||
| { | ||||
|     public static JsonSerializerOptions Default { get; } = CreateDefault(); | ||||
|  | ||||
|     public static JsonSerializerOptions CreateDefault(bool indent = false) | ||||
|     { | ||||
|         var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) | ||||
|         { | ||||
|             WriteIndented = indent | ||||
|         }; | ||||
|  | ||||
|         options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); | ||||
|  | ||||
|         return options; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										18
									
								
								src/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" /> | ||||
|     <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Scanner.Core/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Scanner.Core/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Scanner Core Task Board | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCANNER-CORE-09-501 | DONE (2025-10-18) | Scanner Core Guild | — | Define shared DTOs (ScanJob, ProgressEvent), error taxonomy, and deterministic ID/timestamp helpers aligning with `ARCHITECTURE_SCANNER.md` §3–§4. | DTOs serialize deterministically, helpers produce reproducible IDs/timestamps, tests cover round-trips and hash derivation. | | ||||
| | SCANNER-CORE-09-502 | DONE (2025-10-18) | Scanner Core Guild | SCANNER-CORE-09-501 | Observability helpers (correlation IDs, logging scopes, metric namespacing, deterministic hashes) consumed by WebService/Worker. | Logging/metrics helpers allocate minimally, correlation IDs stable, ActivitySource emitted; tests assert determinism. | | ||||
| | SCANNER-CORE-09-503 | DONE (2025-10-18) | Scanner Core Guild | SCANNER-CORE-09-501, SCANNER-CORE-09-502 | Security utilities: Authority client factory, OpTok caching, DPoP verifier, restart-time plug-in guardrails for scanner components. | Authority helpers cache tokens, DPoP validator rejects invalid proofs, plug-in guard prevents runtime additions; tests cover happy/error paths. | | ||||
							
								
								
									
										136
									
								
								src/StellaOps.Scanner.Core/Utility/ScannerIdentifiers.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/StellaOps.Scanner.Core/Utility/ScannerIdentifiers.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using StellaOps.Scanner.Core.Contracts; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Utility; | ||||
|  | ||||
| public static class ScannerIdentifiers | ||||
| { | ||||
|     private static readonly Guid ScanJobNamespace = new("d985aa76-8c2b-4cba-bac0-c98c90674f04"); | ||||
|     private static readonly Guid CorrelationNamespace = new("7cde18f5-729e-4ea1-be3d-46fda4c55e38"); | ||||
|  | ||||
|     public static ScanJobId CreateJobId( | ||||
|         string imageReference, | ||||
|         string? imageDigest = null, | ||||
|         string? tenantId = null, | ||||
|         string? salt = null) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(imageReference); | ||||
|  | ||||
|         var normalizedReference = NormalizeImageReference(imageReference); | ||||
|         var normalizedDigest = NormalizeDigest(imageDigest) ?? "none"; | ||||
|         var normalizedTenant = string.IsNullOrWhiteSpace(tenantId) ? "global" : tenantId.Trim().ToLowerInvariant(); | ||||
|         var normalizedSalt = (salt?.Trim() ?? string.Empty).ToLowerInvariant(); | ||||
|  | ||||
|         using var sha256 = SHA256.Create(); | ||||
|         var payload = $"{normalizedReference}|{normalizedDigest}|{normalizedTenant}|{normalizedSalt}"; | ||||
|         var hashed = sha256.ComputeHash(Encoding.UTF8.GetBytes(payload)); | ||||
|         return new ScanJobId(CreateGuidFromHash(ScanJobNamespace, hashed)); | ||||
|     } | ||||
|  | ||||
|     public static string CreateCorrelationId(ScanJobId jobId, string? stage = null, string? suffix = null) | ||||
|     { | ||||
|         var normalizedStage = string.IsNullOrWhiteSpace(stage) | ||||
|             ? "scan" | ||||
|             : stage.Trim().ToLowerInvariant().Replace(' ', '-'); | ||||
|  | ||||
|         var normalizedSuffix = string.IsNullOrWhiteSpace(suffix) | ||||
|             ? string.Empty | ||||
|             : "-" + suffix.Trim().ToLowerInvariant().Replace(' ', '-'); | ||||
|  | ||||
|         return $"scan-{normalizedStage}-{jobId}{normalizedSuffix}"; | ||||
|     } | ||||
|  | ||||
|     public static string CreateDeterministicHash(params string[] segments) | ||||
|     { | ||||
|         if (segments is null || segments.Length == 0) | ||||
|         { | ||||
|             throw new ArgumentException("At least one segment must be provided.", nameof(segments)); | ||||
|         } | ||||
|  | ||||
|         using var sha256 = SHA256.Create(); | ||||
|         var joined = string.Join('|', segments.Select(static s => s?.Trim() ?? string.Empty)); | ||||
|         var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(joined)); | ||||
|         return Convert.ToHexString(hash).ToLowerInvariant(); | ||||
|     } | ||||
|  | ||||
|     public static Guid CreateDeterministicGuid(Guid namespaceId, ReadOnlySpan<byte> nameBytes) | ||||
|     { | ||||
|         Span<byte> namespaceBytes = stackalloc byte[16]; | ||||
|         namespaceId.TryWriteBytes(namespaceBytes); | ||||
|  | ||||
|         Span<byte> buffer = stackalloc byte[namespaceBytes.Length + nameBytes.Length]; | ||||
|         namespaceBytes.CopyTo(buffer); | ||||
|         nameBytes.CopyTo(buffer[namespaceBytes.Length..]); | ||||
|  | ||||
|         Span<byte> hash = stackalloc byte[32]; | ||||
|         SHA256.TryHashData(buffer, hash, out _); | ||||
|  | ||||
|         Span<byte> guidBytes = stackalloc byte[16]; | ||||
|         hash[..16].CopyTo(guidBytes); | ||||
|  | ||||
|         guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50); | ||||
|         guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); | ||||
|  | ||||
|         return new Guid(guidBytes); | ||||
|     } | ||||
|  | ||||
|     public static string NormalizeImageReference(string reference) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(reference); | ||||
|         var trimmed = reference.Trim(); | ||||
|         var atIndex = trimmed.IndexOf('@'); | ||||
|         if (atIndex > 0) | ||||
|         { | ||||
|             var prefix = trimmed[..atIndex].ToLowerInvariant(); | ||||
|             return $"{prefix}{trimmed[atIndex..]}"; | ||||
|         } | ||||
|  | ||||
|         var colonIndex = trimmed.IndexOf(':'); | ||||
|         if (colonIndex > 0) | ||||
|         { | ||||
|             var name = trimmed[..colonIndex].ToLowerInvariant(); | ||||
|             var tag = trimmed[(colonIndex + 1)..]; | ||||
|             return $"{name}:{tag}"; | ||||
|         } | ||||
|  | ||||
|         return trimmed.ToLowerInvariant(); | ||||
|     } | ||||
|  | ||||
|     public static string? NormalizeDigest(string? digest) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(digest)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmed = digest.Trim(); | ||||
|         var parts = trimmed.Split(':', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); | ||||
|         if (parts.Length != 2) | ||||
|         { | ||||
|             return trimmed.ToLowerInvariant(); | ||||
|         } | ||||
|  | ||||
|         return $"{parts[0].ToLowerInvariant()}:{parts[1].ToLowerInvariant()}"; | ||||
|     } | ||||
|  | ||||
|     public static string CreateDeterministicCorrelation(string audience, ScanJobId jobId, string? component = null) | ||||
|     { | ||||
|         using var sha256 = SHA256.Create(); | ||||
|         var payload = $"{audience.Trim().ToLowerInvariant()}|{jobId}|{component?.Trim().ToLowerInvariant() ?? string.Empty}"; | ||||
|         var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(payload)); | ||||
|         var guid = CreateGuidFromHash(CorrelationNamespace, hash); | ||||
|         return $"corr-{guid.ToString("n", CultureInfo.InvariantCulture)}"; | ||||
|     } | ||||
|  | ||||
|     private static Guid CreateGuidFromHash(Guid namespaceId, ReadOnlySpan<byte> hash) | ||||
|     { | ||||
|         Span<byte> guidBytes = stackalloc byte[16]; | ||||
|         hash[..16].CopyTo(guidBytes); | ||||
|         guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50); | ||||
|         guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); | ||||
|         return new Guid(guidBytes); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										43
									
								
								src/StellaOps.Scanner.Core/Utility/ScannerTimestamps.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/StellaOps.Scanner.Core/Utility/ScannerTimestamps.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| using System.Globalization; | ||||
|  | ||||
| namespace StellaOps.Scanner.Core.Utility; | ||||
|  | ||||
| public static class ScannerTimestamps | ||||
| { | ||||
|     private const long TicksPerMicrosecond = TimeSpan.TicksPerMillisecond / 1000; | ||||
|  | ||||
|     public static DateTimeOffset Normalize(DateTimeOffset value) | ||||
|     { | ||||
|         var utc = value.ToUniversalTime(); | ||||
|         var ticks = utc.Ticks - (utc.Ticks % TicksPerMicrosecond); | ||||
|         return new DateTimeOffset(ticks, TimeSpan.Zero); | ||||
|     } | ||||
|  | ||||
|     public static DateTimeOffset UtcNow(TimeProvider? provider = null) | ||||
|         => Normalize((provider ?? TimeProvider.System).GetUtcNow()); | ||||
|  | ||||
|     public static string ToIso8601(DateTimeOffset value) | ||||
|         => Normalize(value).ToString("yyyy-MM-dd'T'HH:mm:ss.ffffff'Z'", CultureInfo.InvariantCulture); | ||||
|  | ||||
|     public static bool TryParseIso8601(string? value, out DateTimeOffset timestamp) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             timestamp = default; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (DateTimeOffset.TryParse( | ||||
|                 value, | ||||
|                 CultureInfo.InvariantCulture, | ||||
|                 DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, | ||||
|                 out var parsed)) | ||||
|         { | ||||
|             timestamp = Normalize(parsed); | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         timestamp = default; | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										353
									
								
								src/StellaOps.Scanner.Queue.Tests/QueueLeaseIntegrationTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								src/StellaOps.Scanner.Queue.Tests/QueueLeaseIntegrationTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,353 @@ | ||||
| using System; | ||||
| using System.Collections.Concurrent; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using FluentAssertions; | ||||
| using Microsoft.Extensions.Time.Testing; | ||||
| using StellaOps.Scanner.Queue; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue.Tests; | ||||
|  | ||||
| public sealed class QueueLeaseIntegrationTests | ||||
| { | ||||
|     private readonly ScannerQueueOptions _options = new() | ||||
|     { | ||||
|         MaxDeliveryAttempts = 3, | ||||
|         RetryInitialBackoff = TimeSpan.FromMilliseconds(1), | ||||
|         RetryMaxBackoff = TimeSpan.FromMilliseconds(5), | ||||
|         DefaultLeaseDuration = TimeSpan.FromSeconds(5) | ||||
|     }; | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task Enqueue_ShouldDeduplicate_ByIdempotencyKey() | ||||
|     { | ||||
|         var clock = new FakeTimeProvider(); | ||||
|         var queue = new InMemoryScanQueue(_options, clock); | ||||
|  | ||||
|         var payload = new byte[] { 1, 2, 3 }; | ||||
|         var message = new ScanQueueMessage("job-1", payload) | ||||
|         { | ||||
|             IdempotencyKey = "idem-1" | ||||
|         }; | ||||
|  | ||||
|         var first = await queue.EnqueueAsync(message); | ||||
|         first.Deduplicated.Should().BeFalse(); | ||||
|  | ||||
|         var second = await queue.EnqueueAsync(message); | ||||
|         second.Deduplicated.Should().BeTrue(); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task Lease_Acknowledge_ShouldRemoveFromQueue() | ||||
|     { | ||||
|         var clock = new FakeTimeProvider(); | ||||
|         var queue = new InMemoryScanQueue(_options, clock); | ||||
|  | ||||
|         var message = new ScanQueueMessage("job-ack", new byte[] { 42 }); | ||||
|         await queue.EnqueueAsync(message); | ||||
|  | ||||
|         var lease = await LeaseSingleAsync(queue, consumer: "worker-1"); | ||||
|         lease.Should().NotBeNull(); | ||||
|  | ||||
|         await lease!.AcknowledgeAsync(); | ||||
|  | ||||
|         var afterAck = await queue.LeaseAsync(new QueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1))); | ||||
|         afterAck.Should().BeEmpty(); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task Release_WithRetry_ShouldDeadLetterAfterMaxAttempts() | ||||
|     { | ||||
|         var clock = new FakeTimeProvider(); | ||||
|         var queue = new InMemoryScanQueue(_options, clock); | ||||
|  | ||||
|         var message = new ScanQueueMessage("job-retry", new byte[] { 5 }); | ||||
|         await queue.EnqueueAsync(message); | ||||
|  | ||||
|         for (var attempt = 1; attempt <= _options.MaxDeliveryAttempts; attempt++) | ||||
|         { | ||||
|             var lease = await LeaseSingleAsync(queue, consumer: $"worker-{attempt}"); | ||||
|             lease.Should().NotBeNull(); | ||||
|  | ||||
|             await lease!.ReleaseAsync(QueueReleaseDisposition.Retry); | ||||
|         } | ||||
|  | ||||
|         queue.DeadLetters.Should().ContainSingle(dead => dead.JobId == "job-retry"); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task Retry_ShouldIncreaseAttemptOnNextLease() | ||||
|     { | ||||
|         var clock = new FakeTimeProvider(); | ||||
|         var queue = new InMemoryScanQueue(_options, clock); | ||||
|  | ||||
|         await queue.EnqueueAsync(new ScanQueueMessage("job-retry-attempt", new byte[] { 77 })); | ||||
|  | ||||
|         var firstLease = await LeaseSingleAsync(queue, "worker-retry"); | ||||
|         firstLease.Should().NotBeNull(); | ||||
|         firstLease!.Attempt.Should().Be(1); | ||||
|  | ||||
|         await firstLease.ReleaseAsync(QueueReleaseDisposition.Retry); | ||||
|  | ||||
|         var secondLease = await LeaseSingleAsync(queue, "worker-retry"); | ||||
|         secondLease.Should().NotBeNull(); | ||||
|         secondLease!.Attempt.Should().Be(2); | ||||
|     } | ||||
|  | ||||
|     private static async Task<IScanQueueLease?> LeaseSingleAsync(InMemoryScanQueue queue, string consumer) | ||||
|     { | ||||
|         var leases = await queue.LeaseAsync(new QueueLeaseRequest(consumer, 1, TimeSpan.FromSeconds(1))); | ||||
|         return leases.FirstOrDefault(); | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryScanQueue : IScanQueue | ||||
|     { | ||||
|         private readonly ScannerQueueOptions _options; | ||||
|         private readonly TimeProvider _timeProvider; | ||||
|         private readonly ConcurrentQueue<QueueEntry> _ready = new(); | ||||
|         private readonly ConcurrentDictionary<string, QueueEntry> _idempotency = new(StringComparer.Ordinal); | ||||
|         private readonly ConcurrentDictionary<string, QueueEntry> _inFlight = new(StringComparer.Ordinal); | ||||
|         private readonly List<QueueEntry> _deadLetters = new(); | ||||
|         private long _sequence; | ||||
|  | ||||
|         public InMemoryScanQueue(ScannerQueueOptions options, TimeProvider timeProvider) | ||||
|         { | ||||
|             _options = options; | ||||
|             _timeProvider = timeProvider; | ||||
|         } | ||||
|  | ||||
|         public IReadOnlyList<QueueEntry> DeadLetters => _deadLetters; | ||||
|  | ||||
|         public ValueTask<QueueEnqueueResult> EnqueueAsync(ScanQueueMessage message, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             var token = message.IdempotencyKey ?? message.JobId; | ||||
|             if (_idempotency.TryGetValue(token, out var existing)) | ||||
|             { | ||||
|                 return ValueTask.FromResult(new QueueEnqueueResult(existing.SequenceId, true)); | ||||
|             } | ||||
|  | ||||
|             var entry = new QueueEntry( | ||||
|                 sequenceId: Interlocked.Increment(ref _sequence).ToString(), | ||||
|                 jobId: message.JobId, | ||||
|                 payload: message.Payload.ToArray(), | ||||
|                 idempotencyKey: token, | ||||
|                 attempt: 1, | ||||
|                 enqueuedAt: _timeProvider.GetUtcNow()); | ||||
|  | ||||
|             _idempotency[token] = entry; | ||||
|             _ready.Enqueue(entry); | ||||
|             return ValueTask.FromResult(new QueueEnqueueResult(entry.SequenceId, false)); | ||||
|         } | ||||
|  | ||||
|         public ValueTask<IReadOnlyList<IScanQueueLease>> LeaseAsync(QueueLeaseRequest request, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             var now = _timeProvider.GetUtcNow(); | ||||
|             var leases = new List<IScanQueueLease>(request.BatchSize); | ||||
|  | ||||
|             while (leases.Count < request.BatchSize && _ready.TryDequeue(out var entry)) | ||||
|             { | ||||
|                 entry.Attempt = Math.Max(entry.Attempt, entry.Deliveries + 1); | ||||
|                 entry.Deliveries = entry.Attempt; | ||||
|                 entry.LastLeaseAt = now; | ||||
|                 _inFlight[entry.SequenceId] = entry; | ||||
|  | ||||
|                 var lease = new InMemoryLease( | ||||
|                     this, | ||||
|                     entry, | ||||
|                     request.Consumer, | ||||
|                     now, | ||||
|                     request.LeaseDuration); | ||||
|                 leases.Add(lease); | ||||
|             } | ||||
|  | ||||
|             return ValueTask.FromResult<IReadOnlyList<IScanQueueLease>>(leases); | ||||
|         } | ||||
|  | ||||
|         public ValueTask<IReadOnlyList<IScanQueueLease>> ClaimExpiredLeasesAsync(QueueClaimOptions options, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             var now = _timeProvider.GetUtcNow(); | ||||
|             var leases = _inFlight.Values | ||||
|                 .Where(entry => now - entry.LastLeaseAt >= options.MinIdleTime) | ||||
|                 .Take(options.BatchSize) | ||||
|                 .Select(entry => new InMemoryLease(this, entry, options.ClaimantConsumer, now, _options.DefaultLeaseDuration)) | ||||
|                 .Cast<IScanQueueLease>() | ||||
|                 .ToList(); | ||||
|  | ||||
|             return ValueTask.FromResult<IReadOnlyList<IScanQueueLease>>(leases); | ||||
|         } | ||||
|  | ||||
|         internal Task AcknowledgeAsync(QueueEntry entry) | ||||
|         { | ||||
|             _inFlight.TryRemove(entry.SequenceId, out _); | ||||
|             _idempotency.TryRemove(entry.IdempotencyKey, out _); | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         internal Task<DateTimeOffset> RenewAsync(QueueEntry entry, TimeSpan leaseDuration) | ||||
|         { | ||||
|             var expires = _timeProvider.GetUtcNow().Add(leaseDuration); | ||||
|             entry.LeaseExpiresAt = expires; | ||||
|             return Task.FromResult(expires); | ||||
|         } | ||||
|  | ||||
|         internal Task ReleaseAsync(QueueEntry entry, QueueReleaseDisposition disposition) | ||||
|         { | ||||
|             if (disposition == QueueReleaseDisposition.Retry && entry.Attempt >= _options.MaxDeliveryAttempts) | ||||
|             { | ||||
|                 return DeadLetterAsync(entry, $"max-delivery-attempts:{entry.Attempt}"); | ||||
|             } | ||||
|  | ||||
|             if (disposition == QueueReleaseDisposition.Retry) | ||||
|             { | ||||
|                 entry.Attempt++; | ||||
|                 _ready.Enqueue(entry); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 _idempotency.TryRemove(entry.IdempotencyKey, out _); | ||||
|             } | ||||
|  | ||||
|             _inFlight.TryRemove(entry.SequenceId, out _); | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         internal Task DeadLetterAsync(QueueEntry entry, string reason) | ||||
|         { | ||||
|             entry.DeadLetterReason = reason; | ||||
|             _inFlight.TryRemove(entry.SequenceId, out _); | ||||
|             _idempotency.TryRemove(entry.IdempotencyKey, out _); | ||||
|             _deadLetters.Add(entry); | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         private sealed class InMemoryLease : IScanQueueLease | ||||
|         { | ||||
|             private readonly InMemoryScanQueue _owner; | ||||
|             private readonly QueueEntry _entry; | ||||
|             private int _completed; | ||||
|  | ||||
|             public InMemoryLease( | ||||
|                 InMemoryScanQueue owner, | ||||
|                 QueueEntry entry, | ||||
|                 string consumer, | ||||
|                 DateTimeOffset now, | ||||
|                 TimeSpan leaseDuration) | ||||
|             { | ||||
|                 _owner = owner; | ||||
|                 _entry = entry; | ||||
|                 Consumer = consumer; | ||||
|                 MessageId = entry.SequenceId; | ||||
|                 JobId = entry.JobId; | ||||
|                 Payload = entry.Payload; | ||||
|                 Attempt = entry.Attempt; | ||||
|                 EnqueuedAt = entry.EnqueuedAt; | ||||
|                 LeaseExpiresAt = now.Add(leaseDuration); | ||||
|                 IdempotencyKey = entry.IdempotencyKey; | ||||
|                 Attributes = entry.Attributes; | ||||
|             } | ||||
|  | ||||
|             public string MessageId { get; } | ||||
|  | ||||
|             public string JobId { get; } | ||||
|  | ||||
|             public ReadOnlyMemory<byte> Payload { get; } | ||||
|  | ||||
|             public int Attempt { get; } | ||||
|  | ||||
|             public DateTimeOffset EnqueuedAt { get; } | ||||
|  | ||||
|             public DateTimeOffset LeaseExpiresAt { get; private set; } | ||||
|  | ||||
|             public string Consumer { get; } | ||||
|  | ||||
|             public string? IdempotencyKey { get; } | ||||
|  | ||||
|             public IReadOnlyDictionary<string, string> Attributes { get; } | ||||
|  | ||||
|             public Task AcknowledgeAsync(CancellationToken cancellationToken = default) | ||||
|             { | ||||
|                 if (TryComplete()) | ||||
|                 { | ||||
|                     return _owner.AcknowledgeAsync(_entry); | ||||
|                 } | ||||
|  | ||||
|                 return Task.CompletedTask; | ||||
|             } | ||||
|  | ||||
|         public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             return RenewInternalAsync(leaseDuration); | ||||
|         } | ||||
|  | ||||
|             public Task ReleaseAsync(QueueReleaseDisposition disposition, CancellationToken cancellationToken = default) | ||||
|             { | ||||
|                 if (TryComplete()) | ||||
|                 { | ||||
|                     return _owner.ReleaseAsync(_entry, disposition); | ||||
|                 } | ||||
|  | ||||
|                 return Task.CompletedTask; | ||||
|             } | ||||
|  | ||||
|         public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             if (TryComplete()) | ||||
|             { | ||||
|                 return _owner.DeadLetterAsync(_entry, reason); | ||||
|             } | ||||
|  | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         private async Task RenewInternalAsync(TimeSpan leaseDuration) | ||||
|         { | ||||
|             var expires = await _owner.RenewAsync(_entry, leaseDuration).ConfigureAwait(false); | ||||
|             LeaseExpiresAt = expires; | ||||
|         } | ||||
|  | ||||
|             private bool TryComplete() | ||||
|                 => Interlocked.CompareExchange(ref _completed, 1, 0) == 0; | ||||
|         } | ||||
|  | ||||
|         internal sealed class QueueEntry | ||||
|         { | ||||
|             public QueueEntry(string sequenceId, string jobId, byte[] payload, string idempotencyKey, int attempt, DateTimeOffset enqueuedAt) | ||||
|             { | ||||
|                 SequenceId = sequenceId; | ||||
|                 JobId = jobId; | ||||
|                 Payload = payload; | ||||
|                 IdempotencyKey = idempotencyKey; | ||||
|                 Attempt = attempt; | ||||
|                 EnqueuedAt = enqueuedAt; | ||||
|                 LastLeaseAt = enqueuedAt; | ||||
|                 Attributes = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal)); | ||||
|             } | ||||
|  | ||||
|             public string SequenceId { get; } | ||||
|  | ||||
|             public string JobId { get; } | ||||
|  | ||||
|             public byte[] Payload { get; } | ||||
|  | ||||
|             public string IdempotencyKey { get; } | ||||
|  | ||||
|             public int Attempt { get; set; } | ||||
|  | ||||
|             public int Deliveries { get; set; } | ||||
|  | ||||
|             public DateTimeOffset EnqueuedAt { get; } | ||||
|  | ||||
|             public DateTimeOffset LeaseExpiresAt { get; set; } | ||||
|  | ||||
|             public DateTimeOffset LastLeaseAt { get; set; } | ||||
|  | ||||
|             public IReadOnlyDictionary<string, string> Attributes { get; } | ||||
|  | ||||
|             public string? DeadLetterReason { get; set; } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <IsPackable>false</IsPackable> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="FluentAssertions" Version="6.12.0" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="../StellaOps.Scanner.Queue/StellaOps.Scanner.Queue.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
							
								
								
									
										15
									
								
								src/StellaOps.Scanner.Queue/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/StellaOps.Scanner.Queue/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| # StellaOps.Scanner.Queue — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Deliver the scanner job queue backbone defined in `docs/ARCHITECTURE_SCANNER.md`, providing deterministic, offline-friendly leasing semantics for WebService producers and Worker consumers. | ||||
|  | ||||
| ## Responsibilities | ||||
| - Define queue abstractions with idempotent enqueue tokens, acknowledgement, lease renewal, and claim support. | ||||
| - Ship first-party adapters for Redis Streams and NATS JetStream, respecting offline deployments and allow-listed hosts. | ||||
| - Surface health probes, structured diagnostics, and metrics needed by Scanner WebService/Worker. | ||||
| - Document operational expectations and configuration binding hooks. | ||||
|  | ||||
| ## Interfaces & Dependencies | ||||
| - Consumes shared configuration primitives from `StellaOps.Configuration`. | ||||
| - Exposes dependency injection extensions for `StellaOps.DependencyInjection`. | ||||
| - Targets `net10.0` (preview) and aligns with scanner DTOs once `StellaOps.Scanner.Core` lands. | ||||
							
								
								
									
										20
									
								
								src/StellaOps.Scanner.Queue/IScanQueue.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/StellaOps.Scanner.Queue/IScanQueue.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue; | ||||
|  | ||||
| public interface IScanQueue | ||||
| { | ||||
|     ValueTask<QueueEnqueueResult> EnqueueAsync( | ||||
|         ScanQueueMessage message, | ||||
|         CancellationToken cancellationToken = default); | ||||
|  | ||||
|     ValueTask<IReadOnlyList<IScanQueueLease>> LeaseAsync( | ||||
|         QueueLeaseRequest request, | ||||
|         CancellationToken cancellationToken = default); | ||||
|  | ||||
|     ValueTask<IReadOnlyList<IScanQueueLease>> ClaimExpiredLeasesAsync( | ||||
|         QueueClaimOptions options, | ||||
|         CancellationToken cancellationToken = default); | ||||
| } | ||||
							
								
								
									
										35
									
								
								src/StellaOps.Scanner.Queue/IScanQueueLease.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/StellaOps.Scanner.Queue/IScanQueueLease.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue; | ||||
|  | ||||
| public interface IScanQueueLease | ||||
| { | ||||
|     string MessageId { get; } | ||||
|  | ||||
|     string JobId { get; } | ||||
|  | ||||
|     ReadOnlyMemory<byte> Payload { get; } | ||||
|  | ||||
|     int Attempt { get; } | ||||
|  | ||||
|     DateTimeOffset EnqueuedAt { get; } | ||||
|  | ||||
|     DateTimeOffset LeaseExpiresAt { get; } | ||||
|  | ||||
|     string Consumer { get; } | ||||
|  | ||||
|     string? IdempotencyKey { get; } | ||||
|  | ||||
|     IReadOnlyDictionary<string, string> Attributes { get; } | ||||
|  | ||||
|     Task AcknowledgeAsync(CancellationToken cancellationToken = default); | ||||
|  | ||||
|     Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default); | ||||
|  | ||||
|     Task ReleaseAsync(QueueReleaseDisposition disposition, CancellationToken cancellationToken = default); | ||||
|  | ||||
|     Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default); | ||||
| } | ||||
							
								
								
									
										644
									
								
								src/StellaOps.Scanner.Queue/Nats/NatsScanQueue.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										644
									
								
								src/StellaOps.Scanner.Queue/Nats/NatsScanQueue.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,644 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using NATS.Client.Core; | ||||
| using NATS.Client.JetStream; | ||||
| using NATS.Client.JetStream.Models; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue.Nats; | ||||
|  | ||||
| internal sealed class NatsScanQueue : IScanQueue, IAsyncDisposable | ||||
| { | ||||
|     private const string TransportName = "nats"; | ||||
|  | ||||
|     private static readonly INatsSerializer<byte[]> PayloadSerializer = NatsRawSerializer<byte[]>.Default; | ||||
|  | ||||
|     private readonly ScannerQueueOptions _queueOptions; | ||||
|     private readonly NatsQueueOptions _options; | ||||
|     private readonly ILogger<NatsScanQueue> _logger; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|     private readonly SemaphoreSlim _connectionGate = new(1, 1); | ||||
|     private readonly Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>> _connectionFactory; | ||||
|  | ||||
|     private NatsConnection? _connection; | ||||
|     private NatsJSContext? _jsContext; | ||||
|     private INatsJSConsumer? _consumer; | ||||
|     private bool _disposed; | ||||
|  | ||||
|     public NatsScanQueue( | ||||
|         ScannerQueueOptions queueOptions, | ||||
|         NatsQueueOptions options, | ||||
|         ILogger<NatsScanQueue> logger, | ||||
|         TimeProvider timeProvider, | ||||
|         Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null) | ||||
|     { | ||||
|         _queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions)); | ||||
|         _options = options ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         _timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         _connectionFactory = connectionFactory ?? ((opts, cancellationToken) => new ValueTask<NatsConnection>(new NatsConnection(opts))); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(_options.Url)) | ||||
|         { | ||||
|             throw new InvalidOperationException("NATS connection URL must be configured for the scanner queue."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<QueueEnqueueResult> EnqueueAsync( | ||||
|         ScanQueueMessage message, | ||||
|         CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(message); | ||||
|  | ||||
|         var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false); | ||||
|         await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var idempotencyKey = message.IdempotencyKey ?? message.JobId; | ||||
|         var headers = BuildHeaders(message, idempotencyKey); | ||||
|         var publishOpts = new NatsJSPubOpts | ||||
|         { | ||||
|             MsgId = idempotencyKey, | ||||
|             RetryAttempts = 0 | ||||
|         }; | ||||
|  | ||||
|         var ack = await js.PublishAsync( | ||||
|                 _options.Subject, | ||||
|                 message.Payload.ToArray(), | ||||
|                 PayloadSerializer, | ||||
|                 publishOpts, | ||||
|                 headers, | ||||
|                 cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         if (ack.Duplicate) | ||||
|         { | ||||
|             _logger.LogDebug( | ||||
|                 "Duplicate NATS enqueue detected for job {JobId} (token {Token}).", | ||||
|                 message.JobId, | ||||
|                 idempotencyKey); | ||||
|  | ||||
|             QueueMetrics.RecordDeduplicated(TransportName); | ||||
|             return new QueueEnqueueResult(ack.Seq.ToString(), true); | ||||
|         } | ||||
|  | ||||
|         QueueMetrics.RecordEnqueued(TransportName); | ||||
|         _logger.LogDebug( | ||||
|             "Enqueued job {JobId} into NATS stream {Stream} with sequence {Sequence}.", | ||||
|             message.JobId, | ||||
|             ack.Stream, | ||||
|             ack.Seq); | ||||
|  | ||||
|         return new QueueEnqueueResult(ack.Seq.ToString(), false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<IReadOnlyList<IScanQueueLease>> LeaseAsync( | ||||
|         QueueLeaseRequest request, | ||||
|         CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(request); | ||||
|  | ||||
|         var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var fetchOpts = new NatsJSFetchOpts | ||||
|         { | ||||
|             MaxMsgs = request.BatchSize, | ||||
|             Expires = request.LeaseDuration, | ||||
|             IdleHeartbeat = _options.IdleHeartbeat | ||||
|         }; | ||||
|  | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|         var leases = new List<IScanQueueLease>(capacity: request.BatchSize); | ||||
|  | ||||
|         await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false)) | ||||
|         { | ||||
|             var lease = CreateLease(msg, request.Consumer, now, request.LeaseDuration); | ||||
|             if (lease is not null) | ||||
|             { | ||||
|                 leases.Add(lease); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return leases; | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<IReadOnlyList<IScanQueueLease>> ClaimExpiredLeasesAsync( | ||||
|         QueueClaimOptions options, | ||||
|         CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var fetchOpts = new NatsJSFetchOpts | ||||
|         { | ||||
|             MaxMsgs = options.BatchSize, | ||||
|             Expires = options.MinIdleTime, | ||||
|             IdleHeartbeat = _options.IdleHeartbeat | ||||
|         }; | ||||
|  | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|         var leases = new List<IScanQueueLease>(options.BatchSize); | ||||
|  | ||||
|         await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false)) | ||||
|         { | ||||
|             var deliveries = (int)(msg.Metadata?.NumDelivered ?? 1); | ||||
|             if (deliveries <= 1) | ||||
|             { | ||||
|                 // Fresh message; surface back to queue and continue. | ||||
|                 await msg.NakAsync(new AckOpts(), TimeSpan.Zero, cancellationToken).ConfigureAwait(false); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var lease = CreateLease(msg, options.ClaimantConsumer, now, _queueOptions.DefaultLeaseDuration); | ||||
|             if (lease is not null) | ||||
|             { | ||||
|                 leases.Add(lease); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return leases; | ||||
|     } | ||||
|  | ||||
|     public async ValueTask DisposeAsync() | ||||
|     { | ||||
|         if (_disposed) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         _disposed = true; | ||||
|  | ||||
|         if (_connection is not null) | ||||
|         { | ||||
|             await _connection.DisposeAsync().ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         _connectionGate.Dispose(); | ||||
|         GC.SuppressFinalize(this); | ||||
|     } | ||||
|  | ||||
|     internal async Task AcknowledgeAsync( | ||||
|         NatsScanQueueLease lease, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!lease.TryBeginCompletion()) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await lease.Message.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         QueueMetrics.RecordAck(TransportName); | ||||
|         _logger.LogDebug( | ||||
|             "Acknowledged job {JobId} (seq {Seq}).", | ||||
|             lease.JobId, | ||||
|             lease.MessageId); | ||||
|     } | ||||
|  | ||||
|     internal async Task RenewLeaseAsync( | ||||
|         NatsScanQueueLease lease, | ||||
|         TimeSpan leaseDuration, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         await lease.Message.AckProgressAsync(new AckOpts(), cancellationToken).ConfigureAwait(false); | ||||
|         var expires = _timeProvider.GetUtcNow().Add(leaseDuration); | ||||
|         lease.RefreshLease(expires); | ||||
|  | ||||
|         _logger.LogDebug( | ||||
|             "Renewed NATS lease for job {JobId} until {Expires:u}.", | ||||
|             lease.JobId, | ||||
|             expires); | ||||
|     } | ||||
|  | ||||
|     internal async Task ReleaseAsync( | ||||
|         NatsScanQueueLease lease, | ||||
|         QueueReleaseDisposition disposition, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (disposition == QueueReleaseDisposition.Retry | ||||
|             && lease.Attempt >= _queueOptions.MaxDeliveryAttempts) | ||||
|         { | ||||
|             _logger.LogWarning( | ||||
|                 "Job {JobId} reached max delivery attempts ({Attempts}); shipping to dead-letter stream.", | ||||
|                 lease.JobId, | ||||
|                 lease.Attempt); | ||||
|  | ||||
|             await DeadLetterAsync( | ||||
|                 lease, | ||||
|                 $"max-delivery-attempts:{lease.Attempt}", | ||||
|                 cancellationToken).ConfigureAwait(false); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!lease.TryBeginCompletion()) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (disposition == QueueReleaseDisposition.Retry) | ||||
|         { | ||||
|             QueueMetrics.RecordRetry(TransportName); | ||||
|  | ||||
|             var delay = CalculateBackoff(lease.Attempt); | ||||
|             await lease.Message.NakAsync(new AckOpts(), delay, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             _logger.LogWarning( | ||||
|                 "Rescheduled job {JobId} via NATS NAK with delay {Delay} (attempt {Attempt}).", | ||||
|                 lease.JobId, | ||||
|                 delay, | ||||
|                 lease.Attempt); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             await lease.Message.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false); | ||||
|             QueueMetrics.RecordAck(TransportName); | ||||
|  | ||||
|             _logger.LogInformation( | ||||
|                 "Abandoned job {JobId} after {Attempt} attempt(s).", | ||||
|                 lease.JobId, | ||||
|                 lease.Attempt); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     internal async Task DeadLetterAsync( | ||||
|         NatsScanQueueLease lease, | ||||
|         string reason, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!lease.TryBeginCompletion()) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await lease.Message.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false); | ||||
|         await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var headers = BuildDeadLetterHeaders(lease, reason); | ||||
|         await js.PublishAsync( | ||||
|                 _options.DeadLetterSubject, | ||||
|                 lease.Payload.ToArray(), | ||||
|                 PayloadSerializer, | ||||
|                 new NatsJSPubOpts(), | ||||
|                 headers, | ||||
|                 cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         QueueMetrics.RecordDeadLetter(TransportName); | ||||
|         _logger.LogError( | ||||
|             "Dead-lettered job {JobId} (attempt {Attempt}): {Reason}", | ||||
|             lease.JobId, | ||||
|             lease.Attempt, | ||||
|             reason); | ||||
|     } | ||||
|  | ||||
|     private async Task<NatsJSContext> GetJetStreamAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (_jsContext is not null) | ||||
|         { | ||||
|             return _jsContext; | ||||
|         } | ||||
|  | ||||
|         var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             _jsContext ??= new NatsJSContext(connection); | ||||
|             return _jsContext; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _connectionGate.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async ValueTask<INatsJSConsumer> EnsureStreamAndConsumerAsync( | ||||
|         NatsJSContext js, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (_consumer is not null) | ||||
|         { | ||||
|             return _consumer; | ||||
|         } | ||||
|  | ||||
|         await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             if (_consumer is not null) | ||||
|             { | ||||
|                 return _consumer; | ||||
|             } | ||||
|  | ||||
|             await EnsureStreamAsync(js, cancellationToken).ConfigureAwait(false); | ||||
|             await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             var consumerConfig = new ConsumerConfig | ||||
|             { | ||||
|                 DurableName = _options.DurableConsumer, | ||||
|                 AckPolicy = ConsumerConfigAckPolicy.Explicit, | ||||
|                 ReplayPolicy = ConsumerConfigReplayPolicy.Instant, | ||||
|                 DeliverPolicy = ConsumerConfigDeliverPolicy.All, | ||||
|                 AckWait = ToNanoseconds(_options.AckWait), | ||||
|                 MaxAckPending = _options.MaxInFlight, | ||||
|                 MaxDeliver = Math.Max(1, _queueOptions.MaxDeliveryAttempts), | ||||
|                 FilterSubjects = new[] { _options.Subject } | ||||
|             }; | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 _consumer = await js.CreateConsumerAsync( | ||||
|                         _options.Stream, | ||||
|                         consumerConfig, | ||||
|                         cancellationToken) | ||||
|                     .ConfigureAwait(false); | ||||
|             } | ||||
|             catch (NatsJSApiException apiEx) | ||||
|             { | ||||
|                 _logger.LogDebug(apiEx, | ||||
|                     "CreateConsumerAsync failed with code {Code}; attempting to fetch existing durable consumer {Durable}.", | ||||
|                     apiEx.Error?.Code, | ||||
|                     _options.DurableConsumer); | ||||
|  | ||||
|                 _consumer = await js.GetConsumerAsync( | ||||
|                         _options.Stream, | ||||
|                         _options.DurableConsumer, | ||||
|                         cancellationToken) | ||||
|                     .ConfigureAwait(false); | ||||
|             } | ||||
|  | ||||
|             return _consumer; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _connectionGate.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task<NatsConnection> EnsureConnectionAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (_connection is not null) | ||||
|         { | ||||
|             return _connection; | ||||
|         } | ||||
|  | ||||
|         await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             if (_connection is not null) | ||||
|             { | ||||
|                 return _connection; | ||||
|             } | ||||
|  | ||||
|             var opts = new NatsOpts | ||||
|             { | ||||
|                 Url = _options.Url!, | ||||
|                 Name = "stellaops-scanner-queue", | ||||
|                 CommandTimeout = TimeSpan.FromSeconds(10), | ||||
|                 RequestTimeout = TimeSpan.FromSeconds(20), | ||||
|                 PingInterval = TimeSpan.FromSeconds(30) | ||||
|             }; | ||||
|  | ||||
|             _connection = await _connectionFactory(opts, cancellationToken).ConfigureAwait(false); | ||||
|             await _connection.ConnectAsync().ConfigureAwait(false); | ||||
|             return _connection; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _connectionGate.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task EnsureStreamAsync(NatsJSContext js, CancellationToken cancellationToken) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             await js.GetStreamAsync( | ||||
|                     _options.Stream, | ||||
|                     new StreamInfoRequest(), | ||||
|                     cancellationToken) | ||||
|                 .ConfigureAwait(false); | ||||
|         } | ||||
|         catch (NatsJSApiException) | ||||
|         { | ||||
|             var config = new StreamConfig( | ||||
|                 name: _options.Stream, | ||||
|                 subjects: new[] { _options.Subject }) | ||||
|             { | ||||
|                 Retention = StreamConfigRetention.Workqueue, | ||||
|                 Storage = StreamConfigStorage.File, | ||||
|                 MaxConsumers = -1, | ||||
|                 MaxMsgs = -1, | ||||
|                 MaxBytes = -1, | ||||
|                 MaxAge = 0 | ||||
|             }; | ||||
|  | ||||
|             await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false); | ||||
|             _logger.LogInformation("Created NATS JetStream stream {Stream} ({Subject}).", _options.Stream, _options.Subject); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task EnsureDeadLetterStreamAsync(NatsJSContext js, CancellationToken cancellationToken) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             await js.GetStreamAsync( | ||||
|                     _options.DeadLetterStream, | ||||
|                     new StreamInfoRequest(), | ||||
|                     cancellationToken) | ||||
|                 .ConfigureAwait(false); | ||||
|         } | ||||
|         catch (NatsJSApiException) | ||||
|         { | ||||
|             var config = new StreamConfig( | ||||
|                 name: _options.DeadLetterStream, | ||||
|                 subjects: new[] { _options.DeadLetterSubject }) | ||||
|             { | ||||
|                 Retention = StreamConfigRetention.Workqueue, | ||||
|                 Storage = StreamConfigStorage.File, | ||||
|                 MaxConsumers = -1, | ||||
|                 MaxMsgs = -1, | ||||
|                 MaxBytes = -1, | ||||
|                 MaxAge = ToNanoseconds(_queueOptions.DeadLetter.Retention) | ||||
|             }; | ||||
|  | ||||
|             await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false); | ||||
|             _logger.LogInformation("Created NATS dead-letter stream {Stream} ({Subject}).", _options.DeadLetterStream, _options.DeadLetterSubject); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     internal async ValueTask PingAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); | ||||
|         await connection.PingAsync(cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private NatsScanQueueLease? CreateLease( | ||||
|         NatsJSMsg<byte[]> message, | ||||
|         string consumer, | ||||
|         DateTimeOffset now, | ||||
|         TimeSpan leaseDuration) | ||||
|     { | ||||
|         var headers = message.Headers; | ||||
|         if (headers is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (!headers.TryGetValue(QueueEnvelopeFields.JobId, out var jobIdValues) || jobIdValues.Count == 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var jobId = jobIdValues[0]!; | ||||
|         var idempotencyKey = headers.TryGetValue(QueueEnvelopeFields.IdempotencyKey, out var idemValues) && idemValues.Count > 0 | ||||
|             ? idemValues[0] | ||||
|             : null; | ||||
|  | ||||
|         var enqueuedAt = headers.TryGetValue(QueueEnvelopeFields.EnqueuedAt, out var enqueuedValues) && enqueuedValues.Count > 0 | ||||
|             && long.TryParse(enqueuedValues[0], out var unix) | ||||
|             ? DateTimeOffset.FromUnixTimeMilliseconds(unix) | ||||
|             : now; | ||||
|  | ||||
|         var attempt = headers.TryGetValue(QueueEnvelopeFields.Attempt, out var attemptValues) && attemptValues.Count > 0 | ||||
|             && int.TryParse(attemptValues[0], out var parsedAttempt) | ||||
|             ? parsedAttempt | ||||
|             : 1; | ||||
|  | ||||
|         if (message.Metadata?.NumDelivered is ulong delivered && delivered > 0) | ||||
|         { | ||||
|             var deliveredInt = delivered > int.MaxValue ? int.MaxValue : (int)delivered; | ||||
|             if (deliveredInt > attempt) | ||||
|             { | ||||
|                 attempt = deliveredInt; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var leaseExpires = now.Add(leaseDuration); | ||||
|         var attributes = ExtractAttributes(headers); | ||||
|  | ||||
|         var messageId = message.Metadata?.Sequence.Stream.ToString() ?? Guid.NewGuid().ToString("n"); | ||||
|         return new NatsScanQueueLease( | ||||
|             this, | ||||
|             message, | ||||
|             messageId, | ||||
|             jobId, | ||||
|             message.Data ?? Array.Empty<byte>(), | ||||
|             attempt, | ||||
|             enqueuedAt, | ||||
|             leaseExpires, | ||||
|             consumer, | ||||
|             idempotencyKey, | ||||
|             attributes); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyDictionary<string, string> ExtractAttributes(NatsHeaders headers) | ||||
|     { | ||||
|         var attributes = new Dictionary<string, string>(StringComparer.Ordinal); | ||||
|  | ||||
|         foreach (var key in headers.Keys) | ||||
|         { | ||||
|             if (!key.StartsWith(QueueEnvelopeFields.AttributePrefix, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (headers.TryGetValue(key, out var values) && values.Count > 0) | ||||
|             { | ||||
|                 attributes[key[QueueEnvelopeFields.AttributePrefix.Length..]] = values[0]!; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return attributes.Count == 0 | ||||
|             ? EmptyReadOnlyDictionary<string, string>.Instance | ||||
|             : new ReadOnlyDictionary<string, string>(attributes); | ||||
|     } | ||||
|  | ||||
|     private NatsHeaders BuildHeaders(ScanQueueMessage message, string idempotencyKey) | ||||
|     { | ||||
|         var headers = new NatsHeaders | ||||
|         { | ||||
|             { QueueEnvelopeFields.JobId, message.JobId }, | ||||
|             { QueueEnvelopeFields.IdempotencyKey, idempotencyKey }, | ||||
|             { QueueEnvelopeFields.Attempt, "1" }, | ||||
|             { QueueEnvelopeFields.EnqueuedAt, _timeProvider.GetUtcNow().ToUnixTimeMilliseconds().ToString() } | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(message.TraceId)) | ||||
|         { | ||||
|             headers.Add(QueueEnvelopeFields.TraceId, message.TraceId!); | ||||
|         } | ||||
|  | ||||
|         if (message.Attributes is not null) | ||||
|         { | ||||
|             foreach (var kvp in message.Attributes) | ||||
|             { | ||||
|                 headers.Add(QueueEnvelopeFields.AttributePrefix + kvp.Key, kvp.Value); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return headers; | ||||
|     } | ||||
|  | ||||
|     private NatsHeaders BuildDeadLetterHeaders(NatsScanQueueLease lease, string reason) | ||||
|     { | ||||
|         var headers = new NatsHeaders | ||||
|         { | ||||
|             { QueueEnvelopeFields.JobId, lease.JobId }, | ||||
|             { QueueEnvelopeFields.IdempotencyKey, lease.IdempotencyKey ?? lease.JobId }, | ||||
|             { QueueEnvelopeFields.Attempt, lease.Attempt.ToString() }, | ||||
|             { QueueEnvelopeFields.EnqueuedAt, lease.EnqueuedAt.ToUnixTimeMilliseconds().ToString() }, | ||||
|             { "deadletter-reason", reason } | ||||
|         }; | ||||
|  | ||||
|         foreach (var kvp in lease.Attributes) | ||||
|         { | ||||
|             headers.Add(QueueEnvelopeFields.AttributePrefix + kvp.Key, kvp.Value); | ||||
|         } | ||||
|  | ||||
|         return headers; | ||||
|     } | ||||
|  | ||||
|     private TimeSpan CalculateBackoff(int attempt) | ||||
|     { | ||||
|         var configuredInitial = _options.RetryDelay > TimeSpan.Zero | ||||
|             ? _options.RetryDelay | ||||
|             : _queueOptions.RetryInitialBackoff; | ||||
|  | ||||
|         if (configuredInitial <= TimeSpan.Zero) | ||||
|         { | ||||
|             return TimeSpan.Zero; | ||||
|         } | ||||
|  | ||||
|         if (attempt <= 1) | ||||
|         { | ||||
|             return configuredInitial; | ||||
|         } | ||||
|  | ||||
|         var max = _queueOptions.RetryMaxBackoff > TimeSpan.Zero | ||||
|             ? _queueOptions.RetryMaxBackoff | ||||
|             : configuredInitial; | ||||
|  | ||||
|         var exponent = attempt - 1; | ||||
|         var scaledTicks = configuredInitial.Ticks * Math.Pow(2, exponent - 1); | ||||
|         var cappedTicks = Math.Min(max.Ticks, scaledTicks); | ||||
|         var resultTicks = Math.Max(configuredInitial.Ticks, (long)cappedTicks); | ||||
|         return TimeSpan.FromTicks(resultTicks); | ||||
|     } | ||||
|  | ||||
|     private static long ToNanoseconds(TimeSpan timeSpan) | ||||
|         => timeSpan <= TimeSpan.Zero ? 0 : timeSpan.Ticks * 100L; | ||||
|  | ||||
|     private static class EmptyReadOnlyDictionary<TKey, TValue> | ||||
|         where TKey : notnull | ||||
|     { | ||||
|         public static readonly IReadOnlyDictionary<TKey, TValue> Instance = | ||||
|             new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default)); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										78
									
								
								src/StellaOps.Scanner.Queue/Nats/NatsScanQueueLease.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								src/StellaOps.Scanner.Queue/Nats/NatsScanQueueLease.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using NATS.Client.JetStream; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue.Nats; | ||||
|  | ||||
| internal sealed class NatsScanQueueLease : IScanQueueLease | ||||
| { | ||||
|     private readonly NatsScanQueue _queue; | ||||
|     private readonly NatsJSMsg<byte[]> _message; | ||||
|     private int _completed; | ||||
|  | ||||
|     internal NatsScanQueueLease( | ||||
|         NatsScanQueue queue, | ||||
|         NatsJSMsg<byte[]> message, | ||||
|         string messageId, | ||||
|         string jobId, | ||||
|         byte[] payload, | ||||
|         int attempt, | ||||
|         DateTimeOffset enqueuedAt, | ||||
|         DateTimeOffset leaseExpiresAt, | ||||
|         string consumer, | ||||
|         string? idempotencyKey, | ||||
|         IReadOnlyDictionary<string, string> attributes) | ||||
|     { | ||||
|         _queue = queue; | ||||
|         _message = message; | ||||
|         MessageId = messageId; | ||||
|         JobId = jobId; | ||||
|         Payload = payload; | ||||
|         Attempt = attempt; | ||||
|         EnqueuedAt = enqueuedAt; | ||||
|         LeaseExpiresAt = leaseExpiresAt; | ||||
|         Consumer = consumer; | ||||
|         IdempotencyKey = idempotencyKey; | ||||
|         Attributes = attributes; | ||||
|     } | ||||
|  | ||||
|     public string MessageId { get; } | ||||
|  | ||||
|     public string JobId { get; } | ||||
|  | ||||
|     public ReadOnlyMemory<byte> Payload { get; } | ||||
|  | ||||
|     public int Attempt { get; internal set; } | ||||
|  | ||||
|     public DateTimeOffset EnqueuedAt { get; } | ||||
|  | ||||
|     public DateTimeOffset LeaseExpiresAt { get; private set; } | ||||
|  | ||||
|     public string Consumer { get; } | ||||
|  | ||||
|     public string? IdempotencyKey { get; } | ||||
|  | ||||
|     public IReadOnlyDictionary<string, string> Attributes { get; } | ||||
|  | ||||
|     internal NatsJSMsg<byte[]> Message => _message; | ||||
|  | ||||
|     public Task AcknowledgeAsync(CancellationToken cancellationToken = default) | ||||
|         => _queue.AcknowledgeAsync(this, cancellationToken); | ||||
|  | ||||
|     public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default) | ||||
|         => _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken); | ||||
|  | ||||
|     public Task ReleaseAsync(QueueReleaseDisposition disposition, CancellationToken cancellationToken = default) | ||||
|         => _queue.ReleaseAsync(this, disposition, cancellationToken); | ||||
|  | ||||
|     public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default) | ||||
|         => _queue.DeadLetterAsync(this, reason, cancellationToken); | ||||
|  | ||||
|     internal bool TryBeginCompletion() | ||||
|         => Interlocked.CompareExchange(ref _completed, 1, 0) == 0; | ||||
|  | ||||
|     internal void RefreshLease(DateTimeOffset expiresAt) | ||||
|         => LeaseExpiresAt = expiresAt; | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/StellaOps.Scanner.Queue/QueueEnvelopeFields.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/StellaOps.Scanner.Queue/QueueEnvelopeFields.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| namespace StellaOps.Scanner.Queue; | ||||
|  | ||||
| internal static class QueueEnvelopeFields | ||||
| { | ||||
|     public const string Payload = "payload"; | ||||
|     public const string JobId = "jobId"; | ||||
|     public const string IdempotencyKey = "idempotency"; | ||||
|     public const string Attempt = "attempt"; | ||||
|     public const string EnqueuedAt = "enqueuedAt"; | ||||
|     public const string TraceId = "traceId"; | ||||
|     public const string AttributePrefix = "attr:"; | ||||
| } | ||||
							
								
								
									
										28
									
								
								src/StellaOps.Scanner.Queue/QueueMetrics.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/StellaOps.Scanner.Queue/QueueMetrics.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| using System.Diagnostics.Metrics; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue; | ||||
|  | ||||
| internal static class QueueMetrics | ||||
| { | ||||
|     private const string TransportTagName = "transport"; | ||||
|  | ||||
|     private static readonly Meter Meter = new("StellaOps.Scanner.Queue"); | ||||
|     private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("scanner_queue_enqueued_total"); | ||||
|     private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("scanner_queue_deduplicated_total"); | ||||
|     private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("scanner_queue_ack_total"); | ||||
|     private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("scanner_queue_retry_total"); | ||||
|     private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("scanner_queue_deadletter_total"); | ||||
|  | ||||
|     public static void RecordEnqueued(string transport) => EnqueuedCounter.Add(1, BuildTags(transport)); | ||||
|  | ||||
|     public static void RecordDeduplicated(string transport) => DeduplicatedCounter.Add(1, BuildTags(transport)); | ||||
|  | ||||
|     public static void RecordAck(string transport) => AckCounter.Add(1, BuildTags(transport)); | ||||
|  | ||||
|     public static void RecordRetry(string transport) => RetryCounter.Add(1, BuildTags(transport)); | ||||
|  | ||||
|     public static void RecordDeadLetter(string transport) => DeadLetterCounter.Add(1, BuildTags(transport)); | ||||
|  | ||||
|     private static KeyValuePair<string, object?>[] BuildTags(string transport) | ||||
|         => new[] { new KeyValuePair<string, object?>(TransportTagName, transport) }; | ||||
| } | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Scanner.Queue/QueueTransportKind.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Scanner.Queue/QueueTransportKind.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| namespace StellaOps.Scanner.Queue; | ||||
|  | ||||
| public enum QueueTransportKind | ||||
| { | ||||
|     Redis, | ||||
|     Nats | ||||
| } | ||||
							
								
								
									
										764
									
								
								src/StellaOps.Scanner.Queue/Redis/RedisScanQueue.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										764
									
								
								src/StellaOps.Scanner.Queue/Redis/RedisScanQueue.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,764 @@ | ||||
| using System; | ||||
| using System.Buffers; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StackExchange.Redis; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue.Redis; | ||||
|  | ||||
| internal sealed class RedisScanQueue : IScanQueue, IAsyncDisposable | ||||
| { | ||||
|     private const string TransportName = "redis"; | ||||
|  | ||||
|     private readonly ScannerQueueOptions _queueOptions; | ||||
|     private readonly RedisQueueOptions _options; | ||||
|     private readonly ILogger<RedisScanQueue> _logger; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|     private readonly SemaphoreSlim _connectionLock = new(1, 1); | ||||
|     private readonly SemaphoreSlim _groupInitLock = new(1, 1); | ||||
|     private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory; | ||||
|     private IConnectionMultiplexer? _connection; | ||||
|     private volatile bool _groupInitialized; | ||||
|     private bool _disposed; | ||||
|  | ||||
|     private string BuildIdempotencyKey(string key) | ||||
|         => string.Concat(_options.IdempotencyKeyPrefix, key); | ||||
|  | ||||
|     public RedisScanQueue( | ||||
|         ScannerQueueOptions queueOptions, | ||||
|         RedisQueueOptions options, | ||||
|         ILogger<RedisScanQueue> logger, | ||||
|         TimeProvider timeProvider, | ||||
|         Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null) | ||||
|     { | ||||
|         _queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions)); | ||||
|         _options = options ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         _timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         _connectionFactory = connectionFactory ?? (config => Task.FromResult<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(config))); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(_options.ConnectionString)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Redis connection string must be configured for the scanner queue."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<QueueEnqueueResult> EnqueueAsync( | ||||
|         ScanQueueMessage message, | ||||
|         CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(message); | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|         await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var attempt = 1; | ||||
|         var entries = BuildEntries(message, now, attempt); | ||||
|         var messageId = await AddToStreamAsync( | ||||
|                 db, | ||||
|                 _options.StreamName, | ||||
|                 entries, | ||||
|                 _options.ApproximateMaxLength, | ||||
|                 _options.ApproximateMaxLength is not null) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         var idempotencyToken = message.IdempotencyKey ?? message.JobId; | ||||
|         var idempotencyKey = BuildIdempotencyKey(idempotencyToken); | ||||
|  | ||||
|         var stored = await db.StringSetAsync( | ||||
|                 key: idempotencyKey, | ||||
|                 value: messageId, | ||||
|                 when: When.NotExists, | ||||
|                 expiry: _options.IdempotencyWindow) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         if (!stored) | ||||
|         { | ||||
|             // Duplicate enqueue – delete the freshly added entry and surface cached ID. | ||||
|             await db.StreamDeleteAsync( | ||||
|                     _options.StreamName, | ||||
|                     new RedisValue[] { messageId }) | ||||
|                 .ConfigureAwait(false); | ||||
|  | ||||
|             var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false); | ||||
|             var duplicateId = existing.IsNullOrEmpty ? messageId : existing; | ||||
|  | ||||
|             _logger.LogDebug( | ||||
|                 "Duplicate queue enqueue detected for job {JobId} (token {Token}), returning existing stream id {StreamId}.", | ||||
|                 message.JobId, | ||||
|                 idempotencyToken, | ||||
|                 duplicateId.ToString()); | ||||
|  | ||||
|             QueueMetrics.RecordDeduplicated(TransportName); | ||||
|             return new QueueEnqueueResult(duplicateId.ToString()!, true); | ||||
|         } | ||||
|  | ||||
|         _logger.LogDebug( | ||||
|             "Enqueued job {JobId} into stream {Stream} with id {StreamId}.", | ||||
|             message.JobId, | ||||
|             _options.StreamName, | ||||
|             messageId.ToString()); | ||||
|  | ||||
|         QueueMetrics.RecordEnqueued(TransportName); | ||||
|         return new QueueEnqueueResult(messageId.ToString()!, false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<IReadOnlyList<IScanQueueLease>> LeaseAsync( | ||||
|         QueueLeaseRequest request, | ||||
|         CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(request); | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); | ||||
|         await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var entries = await db.StreamReadGroupAsync( | ||||
|                 _options.StreamName, | ||||
|                 _options.ConsumerGroup, | ||||
|                 request.Consumer, | ||||
|                 position: ">", | ||||
|                 count: request.BatchSize, | ||||
|                 flags: CommandFlags.None) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         if (entries is null || entries.Length == 0) | ||||
|         { | ||||
|             return Array.Empty<IScanQueueLease>(); | ||||
|         } | ||||
|  | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|         var leases = new List<IScanQueueLease>(entries.Length); | ||||
|  | ||||
|         foreach (var entry in entries) | ||||
|         { | ||||
|             var lease = TryMapLease( | ||||
|                 entry, | ||||
|                 request.Consumer, | ||||
|                 now, | ||||
|                 request.LeaseDuration, | ||||
|                 default); | ||||
|  | ||||
|             if (lease is null) | ||||
|             { | ||||
|                 _logger.LogWarning( | ||||
|                     "Stream entry {StreamId} is missing required metadata; acknowledging to avoid poison message.", | ||||
|                     entry.Id.ToString()); | ||||
|                 await db.StreamAcknowledgeAsync( | ||||
|                         _options.StreamName, | ||||
|                         _options.ConsumerGroup, | ||||
|                         new RedisValue[] { entry.Id }) | ||||
|                     .ConfigureAwait(false); | ||||
|                 await db.StreamDeleteAsync( | ||||
|                         _options.StreamName, | ||||
|                         new RedisValue[] { entry.Id }) | ||||
|                     .ConfigureAwait(false); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             leases.Add(lease); | ||||
|         } | ||||
|  | ||||
|         return leases; | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<IReadOnlyList<IScanQueueLease>> ClaimExpiredLeasesAsync( | ||||
|         QueueClaimOptions options, | ||||
|         CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); | ||||
|         await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var pending = await db.StreamPendingMessagesAsync( | ||||
|                 _options.StreamName, | ||||
|                 _options.ConsumerGroup, | ||||
|                 options.BatchSize, | ||||
|                 RedisValue.Null, | ||||
|                 (long)options.MinIdleTime.TotalMilliseconds) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         if (pending is null || pending.Length == 0) | ||||
|         { | ||||
|             return Array.Empty<IScanQueueLease>(); | ||||
|         } | ||||
|  | ||||
|         var eligible = pending | ||||
|             .Where(p => p.IdleTimeInMilliseconds >= options.MinIdleTime.TotalMilliseconds) | ||||
|             .ToArray(); | ||||
|  | ||||
|         if (eligible.Length == 0) | ||||
|         { | ||||
|             return Array.Empty<IScanQueueLease>(); | ||||
|         } | ||||
|  | ||||
|         var messageIds = eligible | ||||
|             .Select(static p => (RedisValue)p.MessageId) | ||||
|             .ToArray(); | ||||
|  | ||||
|         var entries = await db.StreamClaimAsync( | ||||
|                 _options.StreamName, | ||||
|                 _options.ConsumerGroup, | ||||
|                 options.ClaimantConsumer, | ||||
|                 0, | ||||
|                 messageIds, | ||||
|                 CommandFlags.None) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         if (entries is null || entries.Length == 0) | ||||
|         { | ||||
|             return Array.Empty<IScanQueueLease>(); | ||||
|         } | ||||
|  | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|         var pendingById = Enumerable.ToDictionary<StreamPendingMessageInfo, string, StreamPendingMessageInfo>( | ||||
|             eligible, | ||||
|             static p => p.MessageId.IsNullOrEmpty ? string.Empty : p.MessageId.ToString(), | ||||
|             static p => p, | ||||
|             StringComparer.Ordinal); | ||||
|  | ||||
|         var leases = new List<IScanQueueLease>(entries.Length); | ||||
|         foreach (var entry in entries) | ||||
|         { | ||||
|             var entryIdValue = entry.Id; | ||||
|             var entryId = entryIdValue.IsNullOrEmpty ? string.Empty : entryIdValue.ToString(); | ||||
|             var hasPending = pendingById.TryGetValue(entryId, out var pendingInfo); | ||||
|             var attempt = hasPending | ||||
|                 ? (int)Math.Max(1, pendingInfo.DeliveryCount) | ||||
|                 : 1; | ||||
|  | ||||
|             var lease = TryMapLease( | ||||
|                 entry, | ||||
|                 options.ClaimantConsumer, | ||||
|                 now, | ||||
|                 _queueOptions.DefaultLeaseDuration, | ||||
|                 attempt); | ||||
|  | ||||
|             if (lease is null) | ||||
|             { | ||||
|                 _logger.LogWarning( | ||||
|                     "Unable to map claimed stream entry {StreamId}; acknowledging to unblock queue.", | ||||
|                     entry.Id.ToString()); | ||||
|                 await db.StreamAcknowledgeAsync( | ||||
|                         _options.StreamName, | ||||
|                         _options.ConsumerGroup, | ||||
|                         new RedisValue[] { entry.Id }) | ||||
|                     .ConfigureAwait(false); | ||||
|                 await db.StreamDeleteAsync( | ||||
|                         _options.StreamName, | ||||
|                         new RedisValue[] { entry.Id }) | ||||
|                     .ConfigureAwait(false); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             leases.Add(lease); | ||||
|         } | ||||
|  | ||||
|         return leases; | ||||
|     } | ||||
|  | ||||
|     public async ValueTask DisposeAsync() | ||||
|     { | ||||
|         if (_disposed) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         _disposed = true; | ||||
|         if (_connection is not null) | ||||
|         { | ||||
|             await _connection.CloseAsync(); | ||||
|             _connection.Dispose(); | ||||
|         } | ||||
|  | ||||
|         _connectionLock.Dispose(); | ||||
|         _groupInitLock.Dispose(); | ||||
|         GC.SuppressFinalize(this); | ||||
|     } | ||||
|  | ||||
|     internal async Task AcknowledgeAsync( | ||||
|         RedisScanQueueLease lease, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!lease.TryBeginCompletion()) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         await db.StreamAcknowledgeAsync( | ||||
|                 _options.StreamName, | ||||
|                 _options.ConsumerGroup, | ||||
|                 new RedisValue[] { lease.MessageId }) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         await db.StreamDeleteAsync( | ||||
|                 _options.StreamName, | ||||
|                 new RedisValue[] { lease.MessageId }) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         _logger.LogDebug( | ||||
|             "Acknowledged job {JobId} ({MessageId}) on consumer {Consumer}.", | ||||
|             lease.JobId, | ||||
|             lease.MessageId, | ||||
|             lease.Consumer); | ||||
|  | ||||
|         QueueMetrics.RecordAck(TransportName); | ||||
|     } | ||||
|  | ||||
|     internal async Task RenewLeaseAsync( | ||||
|         RedisScanQueueLease lease, | ||||
|         TimeSpan leaseDuration, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         await db.StreamClaimAsync( | ||||
|                 _options.StreamName, | ||||
|                 _options.ConsumerGroup, | ||||
|                 lease.Consumer, | ||||
|                 0, | ||||
|                 new RedisValue[] { lease.MessageId }, | ||||
|                 CommandFlags.None) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         var expires = _timeProvider.GetUtcNow().Add(leaseDuration); | ||||
|         lease.RefreshLease(expires); | ||||
|  | ||||
|         _logger.LogDebug( | ||||
|             "Renewed lease for job {JobId} until {LeaseExpiry:u}.", | ||||
|             lease.JobId, | ||||
|             expires); | ||||
|     } | ||||
|  | ||||
|     internal async Task ReleaseAsync( | ||||
|         RedisScanQueueLease lease, | ||||
|         QueueReleaseDisposition disposition, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (disposition == QueueReleaseDisposition.Retry | ||||
|             && lease.Attempt >= _queueOptions.MaxDeliveryAttempts) | ||||
|         { | ||||
|             _logger.LogWarning( | ||||
|                 "Job {JobId} reached max delivery attempts ({Attempts}); moving to dead-letter.", | ||||
|                 lease.JobId, | ||||
|                 lease.Attempt); | ||||
|  | ||||
|             await DeadLetterAsync( | ||||
|                 lease, | ||||
|                 $"max-delivery-attempts:{lease.Attempt}", | ||||
|                 cancellationToken).ConfigureAwait(false); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!lease.TryBeginCompletion()) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); | ||||
|         await db.StreamAcknowledgeAsync( | ||||
|                 _options.StreamName, | ||||
|                 _options.ConsumerGroup, | ||||
|                 new RedisValue[] { lease.MessageId }) | ||||
|             .ConfigureAwait(false); | ||||
|         await db.StreamDeleteAsync( | ||||
|                 _options.StreamName, | ||||
|                 new RedisValue[] { lease.MessageId }) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         QueueMetrics.RecordAck(TransportName); | ||||
|  | ||||
|         if (disposition == QueueReleaseDisposition.Retry) | ||||
|         { | ||||
|             QueueMetrics.RecordRetry(TransportName); | ||||
|  | ||||
|             var delay = CalculateBackoff(lease.Attempt); | ||||
|             if (delay > TimeSpan.Zero) | ||||
|             { | ||||
|                 _logger.LogDebug( | ||||
|                     "Delaying retry for job {JobId} by {Delay} (attempt {Attempt}).", | ||||
|                     lease.JobId, | ||||
|                     delay, | ||||
|                     lease.Attempt); | ||||
|  | ||||
|                 try | ||||
|                 { | ||||
|                     await Task.Delay(delay, cancellationToken).ConfigureAwait(false); | ||||
|                 } | ||||
|                 catch (TaskCanceledException) | ||||
|                 { | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             var requeueMessage = new ScanQueueMessage(lease.JobId, lease.Payload) | ||||
|             { | ||||
|                 IdempotencyKey = lease.IdempotencyKey, | ||||
|                 Attributes = lease.Attributes, | ||||
|                 TraceId = null | ||||
|             }; | ||||
|  | ||||
|             var now = _timeProvider.GetUtcNow(); | ||||
|             var entries = BuildEntries(requeueMessage, now, lease.Attempt + 1); | ||||
|  | ||||
|             await AddToStreamAsync( | ||||
|                     db, | ||||
|                     _options.StreamName, | ||||
|                     entries, | ||||
|                     _options.ApproximateMaxLength, | ||||
|                     _options.ApproximateMaxLength is not null) | ||||
|                 .ConfigureAwait(false); | ||||
|  | ||||
|             QueueMetrics.RecordEnqueued(TransportName); | ||||
|             _logger.LogWarning( | ||||
|                 "Released job {JobId} for retry (attempt {Attempt}).", | ||||
|                 lease.JobId, | ||||
|                 lease.Attempt + 1); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             _logger.LogInformation( | ||||
|                 "Abandoned job {JobId} after {Attempt} attempt(s).", | ||||
|                 lease.JobId, | ||||
|                 lease.Attempt); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     internal async Task DeadLetterAsync( | ||||
|         RedisScanQueueLease lease, | ||||
|         string reason, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (!lease.TryBeginCompletion()) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         await db.StreamAcknowledgeAsync( | ||||
|                 _options.StreamName, | ||||
|                 _options.ConsumerGroup, | ||||
|                 new RedisValue[] { lease.MessageId }) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         await db.StreamDeleteAsync( | ||||
|                 _options.StreamName, | ||||
|                 new RedisValue[] { lease.MessageId }) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|         var entries = BuildEntries( | ||||
|             new ScanQueueMessage(lease.JobId, lease.Payload) | ||||
|             { | ||||
|                 IdempotencyKey = lease.IdempotencyKey, | ||||
|                 Attributes = lease.Attributes, | ||||
|                 TraceId = null | ||||
|             }, | ||||
|             now, | ||||
|             lease.Attempt); | ||||
|  | ||||
|         await AddToStreamAsync( | ||||
|                 db, | ||||
|                 _queueOptions.DeadLetter.StreamName, | ||||
|                 entries, | ||||
|                 null, | ||||
|                 false) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         _logger.LogError( | ||||
|             "Dead-lettered job {JobId} (attempt {Attempt}): {Reason}", | ||||
|             lease.JobId, | ||||
|             lease.Attempt, | ||||
|             reason); | ||||
|  | ||||
|         QueueMetrics.RecordDeadLetter(TransportName); | ||||
|     } | ||||
|  | ||||
|     private async ValueTask<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (_connection is not null) | ||||
|         { | ||||
|             return _connection.GetDatabase(_options.Database ?? -1); | ||||
|         } | ||||
|  | ||||
|         await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             if (_connection is null) | ||||
|             { | ||||
|                 var config = ConfigurationOptions.Parse(_options.ConnectionString!); | ||||
|                 config.AbortOnConnectFail = false; | ||||
|                 config.ConnectTimeout = (int)_options.InitializationTimeout.TotalMilliseconds; | ||||
|                 config.ConnectRetry = 3; | ||||
|                 if (_options.Database is not null) | ||||
|                 { | ||||
|                     config.DefaultDatabase = _options.Database; | ||||
|                 } | ||||
|  | ||||
|                 _connection = await _connectionFactory(config).ConfigureAwait(false); | ||||
|             } | ||||
|  | ||||
|             return _connection.GetDatabase(_options.Database ?? -1); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _connectionLock.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task EnsureConsumerGroupAsync( | ||||
|         IDatabase database, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (_groupInitialized) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await _groupInitLock.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             if (_groupInitialized) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 await database.StreamCreateConsumerGroupAsync( | ||||
|                         _options.StreamName, | ||||
|                         _options.ConsumerGroup, | ||||
|                         StreamPosition.Beginning, | ||||
|                         createStream: true) | ||||
|                     .ConfigureAwait(false); | ||||
|             } | ||||
|             catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 // Already exists. | ||||
|             } | ||||
|  | ||||
|             _groupInitialized = true; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _groupInitLock.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private NameValueEntry[] BuildEntries( | ||||
|         ScanQueueMessage message, | ||||
|         DateTimeOffset enqueuedAt, | ||||
|         int attempt) | ||||
|     { | ||||
|         var attributeCount = message.Attributes?.Count ?? 0; | ||||
|         var entries = ArrayPool<NameValueEntry>.Shared.Rent(6 + attributeCount); | ||||
|         var index = 0; | ||||
|  | ||||
|         entries[index++] = new NameValueEntry(QueueEnvelopeFields.JobId, message.JobId); | ||||
|         entries[index++] = new NameValueEntry(QueueEnvelopeFields.Attempt, attempt); | ||||
|         entries[index++] = new NameValueEntry(QueueEnvelopeFields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds()); | ||||
|         entries[index++] = new NameValueEntry( | ||||
|             QueueEnvelopeFields.IdempotencyKey, | ||||
|             message.IdempotencyKey ?? message.JobId); | ||||
|         entries[index++] = new NameValueEntry( | ||||
|             QueueEnvelopeFields.Payload, | ||||
|             message.Payload.ToArray()); | ||||
|         entries[index++] = new NameValueEntry( | ||||
|             QueueEnvelopeFields.TraceId, | ||||
|             message.TraceId ?? string.Empty); | ||||
|  | ||||
|         if (attributeCount > 0) | ||||
|         { | ||||
|             foreach (var kvp in message.Attributes!) | ||||
|             { | ||||
|                 entries[index++] = new NameValueEntry( | ||||
|                     QueueEnvelopeFields.AttributePrefix + kvp.Key, | ||||
|                     kvp.Value); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var result = entries.AsSpan(0, index).ToArray(); | ||||
|         ArrayPool<NameValueEntry>.Shared.Return(entries, clearArray: true); | ||||
|         return result; | ||||
|     } | ||||
|  | ||||
|     private RedisScanQueueLease? TryMapLease( | ||||
|         StreamEntry entry, | ||||
|         string consumer, | ||||
|         DateTimeOffset now, | ||||
|         TimeSpan leaseDuration, | ||||
|         int? attemptOverride) | ||||
|     { | ||||
|         if (entry.Values is null || entry.Values.Length == 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         string? jobId = null; | ||||
|         string? idempotency = null; | ||||
|         long? enqueuedAtUnix = null; | ||||
|         byte[]? payload = null; | ||||
|         string? traceId = null; | ||||
|         var attributes = new Dictionary<string, string>(StringComparer.Ordinal); | ||||
|         var attempt = attemptOverride ?? 1; | ||||
|  | ||||
|         foreach (var field in entry.Values) | ||||
|         { | ||||
|             var name = field.Name.ToString(); | ||||
|             if (name.Equals(QueueEnvelopeFields.JobId, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 jobId = field.Value.ToString(); | ||||
|             } | ||||
|             else if (name.Equals(QueueEnvelopeFields.IdempotencyKey, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 idempotency = field.Value.ToString(); | ||||
|             } | ||||
|             else if (name.Equals(QueueEnvelopeFields.EnqueuedAt, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 if (long.TryParse(field.Value.ToString(), out var unix)) | ||||
|                 { | ||||
|                     enqueuedAtUnix = unix; | ||||
|                 } | ||||
|             } | ||||
|             else if (name.Equals(QueueEnvelopeFields.Payload, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 payload = (byte[]?)field.Value ?? Array.Empty<byte>(); | ||||
|             } | ||||
|             else if (name.Equals(QueueEnvelopeFields.Attempt, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 if (int.TryParse(field.Value.ToString(), out var parsedAttempt)) | ||||
|                 { | ||||
|                     attempt = Math.Max(parsedAttempt, attempt); | ||||
|                 } | ||||
|             } | ||||
|             else if (name.Equals(QueueEnvelopeFields.TraceId, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 traceId = field.Value.ToString(); | ||||
|             } | ||||
|             else if (name.StartsWith(QueueEnvelopeFields.AttributePrefix, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 attributes[name[QueueEnvelopeFields.AttributePrefix.Length..]] = field.Value.ToString(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (jobId is null || payload is null || enqueuedAtUnix is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value); | ||||
|         var leaseExpires = now.Add(leaseDuration); | ||||
|  | ||||
|         var attributeView = attributes.Count == 0 | ||||
|             ? EmptyReadOnlyDictionary<string, string>.Instance | ||||
|             : new ReadOnlyDictionary<string, string>(attributes); | ||||
|  | ||||
|         return new RedisScanQueueLease( | ||||
|             this, | ||||
|             entry.Id.ToString(), | ||||
|             jobId, | ||||
|             payload, | ||||
|             attempt, | ||||
|             enqueuedAt, | ||||
|             leaseExpires, | ||||
|             consumer, | ||||
|             idempotency, | ||||
|             attributeView); | ||||
|     } | ||||
|  | ||||
|     private TimeSpan CalculateBackoff(int attempt) | ||||
|     { | ||||
|         var configuredInitial = _options.RetryInitialBackoff > TimeSpan.Zero | ||||
|             ? _options.RetryInitialBackoff | ||||
|             : _queueOptions.RetryInitialBackoff; | ||||
|  | ||||
|         var initial = configuredInitial > TimeSpan.Zero | ||||
|             ? configuredInitial | ||||
|             : TimeSpan.Zero; | ||||
|  | ||||
|         if (initial <= TimeSpan.Zero) | ||||
|         { | ||||
|             return TimeSpan.Zero; | ||||
|         } | ||||
|  | ||||
|         if (attempt <= 1) | ||||
|         { | ||||
|             return initial; | ||||
|         } | ||||
|  | ||||
|         var configuredMax = _queueOptions.RetryMaxBackoff > TimeSpan.Zero | ||||
|             ? _queueOptions.RetryMaxBackoff | ||||
|             : initial; | ||||
|  | ||||
|         var max = configuredMax <= TimeSpan.Zero | ||||
|             ? initial | ||||
|             : configuredMax; | ||||
|  | ||||
|         var exponent = attempt - 1; | ||||
|         var scale = Math.Pow(2, exponent - 1); | ||||
|         var scaledTicks = initial.Ticks * scale; | ||||
|         var cappedTicks = Math.Min(max.Ticks, scaledTicks); | ||||
|  | ||||
|         var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks); | ||||
|         return TimeSpan.FromTicks(resultTicks); | ||||
|     } | ||||
|  | ||||
|     private async Task<RedisValue> AddToStreamAsync( | ||||
|         IDatabase database, | ||||
|         RedisKey stream, | ||||
|         NameValueEntry[] entries, | ||||
|         int? maxLength, | ||||
|         bool useApproximateLength) | ||||
|     { | ||||
|         var capacity = 4 + (entries.Length * 2); | ||||
|         var args = new List<object>(capacity) | ||||
|         { | ||||
|             stream | ||||
|         }; | ||||
|  | ||||
|         if (maxLength.HasValue) | ||||
|         { | ||||
|             args.Add("MAXLEN"); | ||||
|             if (useApproximateLength) | ||||
|             { | ||||
|                 args.Add("~"); | ||||
|             } | ||||
|  | ||||
|             args.Add(maxLength.Value); | ||||
|         } | ||||
|  | ||||
|         args.Add("*"); | ||||
|         for (var i = 0; i < entries.Length; i++) | ||||
|         { | ||||
|             args.Add(entries[i].Name); | ||||
|             args.Add(entries[i].Value); | ||||
|         } | ||||
|  | ||||
|         var result = await database.ExecuteAsync("XADD", args.ToArray()).ConfigureAwait(false); | ||||
|         return (RedisValue)result!; | ||||
|     } | ||||
|  | ||||
|     private static class EmptyReadOnlyDictionary<TKey, TValue> | ||||
|         where TKey : notnull | ||||
|     { | ||||
|         public static readonly IReadOnlyDictionary<TKey, TValue> Instance = | ||||
|             new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default)); | ||||
|     } | ||||
|  | ||||
|     internal async ValueTask PingAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); | ||||
|         await db.ExecuteAsync("PING").ConfigureAwait(false); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										72
									
								
								src/StellaOps.Scanner.Queue/Redis/RedisScanQueueLease.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/StellaOps.Scanner.Queue/Redis/RedisScanQueueLease.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue.Redis; | ||||
|  | ||||
| internal sealed class RedisScanQueueLease : IScanQueueLease | ||||
| { | ||||
|     private readonly RedisScanQueue _queue; | ||||
|     private int _completed; | ||||
|  | ||||
|     internal RedisScanQueueLease( | ||||
|         RedisScanQueue queue, | ||||
|         string messageId, | ||||
|         string jobId, | ||||
|         byte[] payload, | ||||
|         int attempt, | ||||
|         DateTimeOffset enqueuedAt, | ||||
|         DateTimeOffset leaseExpiresAt, | ||||
|         string consumer, | ||||
|         string? idempotencyKey, | ||||
|         IReadOnlyDictionary<string, string> attributes) | ||||
|     { | ||||
|         _queue = queue; | ||||
|         MessageId = messageId; | ||||
|         JobId = jobId; | ||||
|         Payload = payload; | ||||
|         Attempt = attempt; | ||||
|         EnqueuedAt = enqueuedAt; | ||||
|         LeaseExpiresAt = leaseExpiresAt; | ||||
|         Consumer = consumer; | ||||
|         IdempotencyKey = idempotencyKey; | ||||
|         Attributes = attributes; | ||||
|     } | ||||
|  | ||||
|     public string MessageId { get; } | ||||
|  | ||||
|     public string JobId { get; } | ||||
|  | ||||
|     public ReadOnlyMemory<byte> Payload { get; } | ||||
|  | ||||
|     public int Attempt { get; } | ||||
|  | ||||
|     public DateTimeOffset EnqueuedAt { get; } | ||||
|  | ||||
|     public DateTimeOffset LeaseExpiresAt { get; private set; } | ||||
|  | ||||
|     public string Consumer { get; } | ||||
|  | ||||
|     public string? IdempotencyKey { get; } | ||||
|  | ||||
|     public IReadOnlyDictionary<string, string> Attributes { get; } | ||||
|  | ||||
|     public Task AcknowledgeAsync(CancellationToken cancellationToken = default) | ||||
|         => _queue.AcknowledgeAsync(this, cancellationToken); | ||||
|  | ||||
|     public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default) | ||||
|         => _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken); | ||||
|  | ||||
|     public Task ReleaseAsync(QueueReleaseDisposition disposition, CancellationToken cancellationToken = default) | ||||
|         => _queue.ReleaseAsync(this, disposition, cancellationToken); | ||||
|  | ||||
|     public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default) | ||||
|         => _queue.DeadLetterAsync(this, reason, cancellationToken); | ||||
|  | ||||
|     internal bool TryBeginCompletion() | ||||
|         => Interlocked.CompareExchange(ref _completed, 1, 0) == 0; | ||||
|  | ||||
|     internal void RefreshLease(DateTimeOffset expiresAt) | ||||
|         => LeaseExpiresAt = expiresAt; | ||||
| } | ||||
							
								
								
									
										115
									
								
								src/StellaOps.Scanner.Queue/ScanQueueContracts.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/StellaOps.Scanner.Queue/ScanQueueContracts.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue; | ||||
|  | ||||
| public sealed class ScanQueueMessage | ||||
| { | ||||
|     private readonly byte[] _payload; | ||||
|  | ||||
|     public ScanQueueMessage(string jobId, ReadOnlyMemory<byte> payload) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(jobId)) | ||||
|         { | ||||
|             throw new ArgumentException("Job identifier must be provided.", nameof(jobId)); | ||||
|         } | ||||
|  | ||||
|         JobId = jobId; | ||||
|         _payload = CopyPayload(payload); | ||||
|     } | ||||
|  | ||||
|     public string JobId { get; } | ||||
|  | ||||
|     public string? IdempotencyKey { get; init; } | ||||
|  | ||||
|     public string? TraceId { get; init; } | ||||
|  | ||||
|     public IReadOnlyDictionary<string, string>? Attributes { get; init; } | ||||
|  | ||||
|     public ReadOnlyMemory<byte> Payload => _payload; | ||||
|  | ||||
|     private static byte[] CopyPayload(ReadOnlyMemory<byte> payload) | ||||
|     { | ||||
|         if (payload.Length == 0) | ||||
|         { | ||||
|             return Array.Empty<byte>(); | ||||
|         } | ||||
|  | ||||
|         var copy = new byte[payload.Length]; | ||||
|         payload.Span.CopyTo(copy); | ||||
|         return copy; | ||||
|     } | ||||
| } | ||||
|  | ||||
| public readonly record struct QueueEnqueueResult(string MessageId, bool Deduplicated); | ||||
|  | ||||
| public sealed class QueueLeaseRequest | ||||
| { | ||||
|     public QueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(consumer)) | ||||
|         { | ||||
|             throw new ArgumentException("Consumer name must be provided.", nameof(consumer)); | ||||
|         } | ||||
|  | ||||
|         if (batchSize <= 0) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive."); | ||||
|         } | ||||
|  | ||||
|         if (leaseDuration <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive."); | ||||
|         } | ||||
|  | ||||
|         Consumer = consumer; | ||||
|         BatchSize = batchSize; | ||||
|         LeaseDuration = leaseDuration; | ||||
|     } | ||||
|  | ||||
|     public string Consumer { get; } | ||||
|  | ||||
|     public int BatchSize { get; } | ||||
|  | ||||
|     public TimeSpan LeaseDuration { get; } | ||||
| } | ||||
|  | ||||
| public sealed class QueueClaimOptions | ||||
| { | ||||
|     public QueueClaimOptions( | ||||
|         string claimantConsumer, | ||||
|         int batchSize, | ||||
|         TimeSpan minIdleTime) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(claimantConsumer)) | ||||
|         { | ||||
|             throw new ArgumentException("Consumer must be provided.", nameof(claimantConsumer)); | ||||
|         } | ||||
|  | ||||
|         if (batchSize <= 0) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive."); | ||||
|         } | ||||
|  | ||||
|         if (minIdleTime < TimeSpan.Zero) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Idle time cannot be negative."); | ||||
|         } | ||||
|  | ||||
|         ClaimantConsumer = claimantConsumer; | ||||
|         BatchSize = batchSize; | ||||
|         MinIdleTime = minIdleTime; | ||||
|     } | ||||
|  | ||||
|     public string ClaimantConsumer { get; } | ||||
|  | ||||
|     public int BatchSize { get; } | ||||
|  | ||||
|     public TimeSpan MinIdleTime { get; } | ||||
| } | ||||
|  | ||||
| public enum QueueReleaseDisposition | ||||
| { | ||||
|     Retry, | ||||
|     Abandon | ||||
| } | ||||
							
								
								
									
										55
									
								
								src/StellaOps.Scanner.Queue/ScannerQueueHealthCheck.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/StellaOps.Scanner.Queue/ScannerQueueHealthCheck.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Diagnostics.HealthChecks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Scanner.Queue.Nats; | ||||
| using StellaOps.Scanner.Queue.Redis; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue; | ||||
|  | ||||
| public sealed class ScannerQueueHealthCheck : IHealthCheck | ||||
| { | ||||
|     private readonly IScanQueue _queue; | ||||
|     private readonly ILogger<ScannerQueueHealthCheck> _logger; | ||||
|  | ||||
|     public ScannerQueueHealthCheck( | ||||
|         IScanQueue queue, | ||||
|         ILogger<ScannerQueueHealthCheck> logger) | ||||
|     { | ||||
|         _queue = queue ?? throw new ArgumentNullException(nameof(queue)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<HealthCheckResult> CheckHealthAsync( | ||||
|         HealthCheckContext context, | ||||
|         CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             switch (_queue) | ||||
|             { | ||||
|                 case RedisScanQueue redisQueue: | ||||
|                     await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false); | ||||
|                     return HealthCheckResult.Healthy("Redis queue reachable."); | ||||
|  | ||||
|                 case NatsScanQueue natsQueue: | ||||
|                     await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false); | ||||
|                     return HealthCheckResult.Healthy("NATS queue reachable."); | ||||
|  | ||||
|                 default: | ||||
|                     return HealthCheckResult.Healthy("Queue transport without dedicated ping returned healthy."); | ||||
|             } | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             _logger.LogError(ex, "Scanner queue health check failed."); | ||||
|             return new HealthCheckResult( | ||||
|                 context.Registration.FailureStatus, | ||||
|                 "Queue transport unreachable.", | ||||
|                 ex); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										92
									
								
								src/StellaOps.Scanner.Queue/ScannerQueueOptions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/StellaOps.Scanner.Queue/ScannerQueueOptions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue; | ||||
|  | ||||
| public sealed class ScannerQueueOptions | ||||
| { | ||||
|     public QueueTransportKind Kind { get; set; } = QueueTransportKind.Redis; | ||||
|  | ||||
|     public RedisQueueOptions Redis { get; set; } = new(); | ||||
|  | ||||
|     public NatsQueueOptions Nats { get; set; } = new(); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Default lease duration applied when callers do not override the visibility timeout. | ||||
|     /// </summary> | ||||
|     public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Maximum number of times a message may be delivered before it is shunted to the dead-letter queue. | ||||
|     /// </summary> | ||||
|     public int MaxDeliveryAttempts { get; set; } = 5; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Options controlling retry/backoff/dead-letter handling. | ||||
|     /// </summary> | ||||
|     public DeadLetterQueueOptions DeadLetter { get; set; } = new(); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Initial backoff applied when a job is retried after failure. | ||||
|     /// </summary> | ||||
|     public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Maximum backoff window applied for exponential retry. | ||||
|     /// </summary> | ||||
|     public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2); | ||||
| } | ||||
|  | ||||
| public sealed class RedisQueueOptions | ||||
| { | ||||
|     public string? ConnectionString { get; set; } | ||||
|  | ||||
|     public int? Database { get; set; } | ||||
|  | ||||
|     public string StreamName { get; set; } = "scanner:jobs"; | ||||
|  | ||||
|     public string ConsumerGroup { get; set; } = "scanner-workers"; | ||||
|  | ||||
|     public string IdempotencyKeyPrefix { get; set; } = "scanner:jobs:idemp:"; | ||||
|  | ||||
|     public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12); | ||||
|  | ||||
|     public int? ApproximateMaxLength { get; set; } | ||||
|  | ||||
|     public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30); | ||||
|  | ||||
|     public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(10); | ||||
|  | ||||
|     public TimeSpan PendingScanWindow { get; set; } = TimeSpan.FromMinutes(30); | ||||
|  | ||||
|     public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5); | ||||
| } | ||||
|  | ||||
| public sealed class NatsQueueOptions | ||||
| { | ||||
|     public string? Url { get; set; } | ||||
|  | ||||
|     public string Stream { get; set; } = "SCANNER_JOBS"; | ||||
|  | ||||
|     public string Subject { get; set; } = "scanner.jobs"; | ||||
|  | ||||
|     public string DurableConsumer { get; set; } = "scanner-workers"; | ||||
|  | ||||
|     public int MaxInFlight { get; set; } = 64; | ||||
|  | ||||
|     public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5); | ||||
|  | ||||
|     public string DeadLetterStream { get; set; } = "SCANNER_JOBS_DEAD"; | ||||
|  | ||||
|     public string DeadLetterSubject { get; set; } = "scanner.jobs.dead"; | ||||
|  | ||||
|     public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10); | ||||
|  | ||||
|     public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30); | ||||
| } | ||||
|  | ||||
| public sealed class DeadLetterQueueOptions | ||||
| { | ||||
|     public string StreamName { get; set; } = "scanner:jobs:dead"; | ||||
|  | ||||
|     public TimeSpan Retention { get; set; } = TimeSpan.FromDays(7); | ||||
| } | ||||
| @@ -0,0 +1,67 @@ | ||||
| using System; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||
| using Microsoft.Extensions.Diagnostics.HealthChecks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Scanner.Queue.Nats; | ||||
| using StellaOps.Scanner.Queue.Redis; | ||||
|  | ||||
| namespace StellaOps.Scanner.Queue; | ||||
|  | ||||
| public static class ScannerQueueServiceCollectionExtensions | ||||
| { | ||||
|     public static IServiceCollection AddScannerQueue( | ||||
|         this IServiceCollection services, | ||||
|         IConfiguration configuration, | ||||
|         string sectionName = "scanner:queue") | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|         ArgumentNullException.ThrowIfNull(configuration); | ||||
|  | ||||
|         var options = new ScannerQueueOptions(); | ||||
|         configuration.GetSection(sectionName).Bind(options); | ||||
|  | ||||
|         services.TryAddSingleton(TimeProvider.System); | ||||
|         services.AddSingleton(options); | ||||
|  | ||||
|         services.AddSingleton<IScanQueue>(sp => | ||||
|         { | ||||
|             var loggerFactory = sp.GetRequiredService<ILoggerFactory>(); | ||||
|             var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System; | ||||
|  | ||||
|             return options.Kind switch | ||||
|             { | ||||
|                 QueueTransportKind.Redis => new RedisScanQueue( | ||||
|                     options, | ||||
|                     options.Redis, | ||||
|                     loggerFactory.CreateLogger<RedisScanQueue>(), | ||||
|                     timeProvider), | ||||
|                 QueueTransportKind.Nats => new NatsScanQueue( | ||||
|                     options, | ||||
|                     options.Nats, | ||||
|                     loggerFactory.CreateLogger<NatsScanQueue>(), | ||||
|                     timeProvider), | ||||
|                 _ => throw new InvalidOperationException($"Unsupported queue transport kind '{options.Kind}'.") | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         services.AddSingleton<ScannerQueueHealthCheck>(); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
|  | ||||
|     public static IHealthChecksBuilder AddScannerQueueHealthCheck( | ||||
|         this IHealthChecksBuilder builder) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(builder); | ||||
|  | ||||
|         builder.Services.TryAddSingleton<ScannerQueueHealthCheck>(); | ||||
|         builder.AddCheck<ScannerQueueHealthCheck>( | ||||
|             name: "scanner-queue", | ||||
|             failureStatus: HealthStatus.Unhealthy, | ||||
|             tags: new[] { "scanner", "queue" }); | ||||
|  | ||||
|         return builder; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										21
									
								
								src/StellaOps.Scanner.Queue/StellaOps.Scanner.Queue.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/StellaOps.Scanner.Queue/StellaOps.Scanner.Queue.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <GenerateAssemblyInfo>false</GenerateAssemblyInfo> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" /> | ||||
|     <PackageReference Include="StackExchange.Redis" Version="2.7.33" /> | ||||
|     <PackageReference Include="NATS.Client.Core" Version="2.0.0" /> | ||||
|     <PackageReference Include="NATS.Client.JetStream" Version="2.0.0" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
							
								
								
									
										7
									
								
								src/StellaOps.Scanner.Queue/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/StellaOps.Scanner.Queue/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Scanner Queue Task Board (Sprint 9) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCANNER-QUEUE-09-401 | DONE (2025-10-19) | Scanner Queue Guild | — | Implement queue abstraction + Redis Streams adapter with ack/lease semantics, idempotency tokens, and deterministic job IDs. | Interfaces finalized; Redis adapter passes enqueue/dequeue/ack/claim lease tests; structured logs exercised. | | ||||
| | SCANNER-QUEUE-09-402 | DONE (2025-10-19) | Scanner Queue Guild | SCANNER-QUEUE-09-401 | Add pluggable backend support (Redis, NATS) with configuration binding, health probes, failover documentation. | NATS adapter + DI bindings delivered; health checks documented; configuration tests green. | | ||||
| | SCANNER-QUEUE-09-403 | DONE (2025-10-19) | Scanner Queue Guild | SCANNER-QUEUE-09-401 | Implement retry and dead-letter flow with structured metrics/logs for offline deployments. | Retry policy configurable; dead-letter queue persisted; metrics counters validated in integration tests. | | ||||
| @@ -0,0 +1,82 @@ | ||||
| using System; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Attestation; | ||||
|  | ||||
| public sealed class AttestorClientTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task SendPlaceholderAsync_PostsJsonPayload() | ||||
|     { | ||||
|         var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.Accepted)); | ||||
|         using var httpClient = new HttpClient(handler); | ||||
|         var client = new AttestorClient(httpClient); | ||||
|  | ||||
|         var document = BuildDescriptorDocument(); | ||||
|         var attestorUri = new Uri("https://attestor.example.com/api/v1/provenance"); | ||||
|  | ||||
|         await client.SendPlaceholderAsync(attestorUri, document, CancellationToken.None); | ||||
|  | ||||
|         Assert.NotNull(handler.CapturedRequest); | ||||
|         Assert.Equal(HttpMethod.Post, handler.CapturedRequest!.Method); | ||||
|         Assert.Equal(attestorUri, handler.CapturedRequest.RequestUri); | ||||
|  | ||||
|         var content = await handler.CapturedRequest.Content!.ReadAsStringAsync(); | ||||
|         var json = JsonDocument.Parse(content); | ||||
|         Assert.Equal(document.Subject.Digest, json.RootElement.GetProperty("imageDigest").GetString()); | ||||
|         Assert.Equal(document.Artifact.Digest, json.RootElement.GetProperty("sbomDigest").GetString()); | ||||
|         Assert.Equal(document.Provenance.ExpectedDsseSha256, json.RootElement.GetProperty("expectedDsseSha256").GetString()); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task SendPlaceholderAsync_ThrowsOnFailure() | ||||
|     { | ||||
|         var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.BadRequest) | ||||
|         { | ||||
|             Content = new StringContent("invalid") | ||||
|         }); | ||||
|         using var httpClient = new HttpClient(handler); | ||||
|         var client = new AttestorClient(httpClient); | ||||
|  | ||||
|         var document = BuildDescriptorDocument(); | ||||
|         var attestorUri = new Uri("https://attestor.example.com/api/v1/provenance"); | ||||
|  | ||||
|         await Assert.ThrowsAsync<BuildxPluginException>(() => client.SendPlaceholderAsync(attestorUri, document, CancellationToken.None)); | ||||
|     } | ||||
|  | ||||
|     private static DescriptorDocument BuildDescriptorDocument() | ||||
|     { | ||||
|         var subject = new DescriptorSubject("application/vnd.oci.image.manifest.v1+json", "sha256:img"); | ||||
|         var artifact = new DescriptorArtifact("application/vnd.cyclonedx+json", "sha256:sbom", 42, new System.Collections.Generic.Dictionary<string, string>()); | ||||
|         var provenance = new DescriptorProvenance("pending", "sha256:dsse", "nonce", "https://attestor.example.com/api/v1/provenance", "https://slsa.dev/provenance/v1"); | ||||
|         var generatorMetadata = new DescriptorGeneratorMetadata("generator", "1.0.0"); | ||||
|         var metadata = new System.Collections.Generic.Dictionary<string, string>(); | ||||
|         return new DescriptorDocument("schema", DateTimeOffset.UtcNow, generatorMetadata, subject, artifact, provenance, metadata); | ||||
|     } | ||||
|  | ||||
|     private sealed class RecordingHandler : HttpMessageHandler | ||||
|     { | ||||
|         private readonly HttpResponseMessage response; | ||||
|  | ||||
|         public RecordingHandler(HttpResponseMessage response) | ||||
|         { | ||||
|             this.response = response; | ||||
|         } | ||||
|  | ||||
|         public HttpRequestMessage? CapturedRequest { get; private set; } | ||||
|  | ||||
|         protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||||
|         { | ||||
|             CapturedRequest = request; | ||||
|             return Task.FromResult(response); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,34 @@ | ||||
| using System.IO; | ||||
| using System.Security.Cryptography; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Cas; | ||||
|  | ||||
| public sealed class LocalCasClientTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task VerifyWriteAsync_WritesProbeObject() | ||||
|     { | ||||
|         await using var temp = new TempDirectory(); | ||||
|         var client = new LocalCasClient(new LocalCasOptions | ||||
|         { | ||||
|             RootDirectory = temp.Path, | ||||
|             Algorithm = "sha256" | ||||
|         }); | ||||
|  | ||||
|         var result = await client.VerifyWriteAsync(CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal("sha256", result.Algorithm); | ||||
|         Assert.True(File.Exists(result.Path)); | ||||
|  | ||||
|         var bytes = await File.ReadAllBytesAsync(result.Path); | ||||
|         Assert.Equal("stellaops-buildx-probe"u8.ToArray(), bytes); | ||||
|  | ||||
|         var expectedDigest = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); | ||||
|         Assert.Equal(expectedDigest, result.Digest); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,80 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Time.Testing; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Descriptor; | ||||
|  | ||||
| public sealed class DescriptorGeneratorTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task CreateAsync_BuildsDeterministicDescriptor() | ||||
|     { | ||||
|         await using var temp = new TempDirectory(); | ||||
|         var sbomPath = Path.Combine(temp.Path, "sample.cdx.json"); | ||||
|         await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}"); | ||||
|  | ||||
|         var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero)); | ||||
|         var generator = new DescriptorGenerator(fakeTime); | ||||
|  | ||||
|         var request = new DescriptorRequest | ||||
|         { | ||||
|             ImageDigest = "sha256:0123456789abcdef", | ||||
|             SbomPath = sbomPath, | ||||
|             SbomMediaType = "application/vnd.cyclonedx+json", | ||||
|             SbomFormat = "cyclonedx-json", | ||||
|             SbomKind = "inventory", | ||||
|             SbomArtifactType = "application/vnd.stellaops.sbom.layer+json", | ||||
|             SubjectMediaType = "application/vnd.oci.image.manifest.v1+json", | ||||
|             GeneratorVersion = "1.2.3", | ||||
|             GeneratorName = "StellaOps.Scanner.Sbomer.BuildXPlugin", | ||||
|             LicenseId = "lic-123", | ||||
|             SbomName = "sample.cdx.json", | ||||
|             Repository = "git.stella-ops.org/stellaops", | ||||
|             BuildRef = "refs/heads/main", | ||||
|             AttestorUri = "https://attestor.local/api/v1/provenance" | ||||
|         }.Validate(); | ||||
|  | ||||
|         var document = await generator.CreateAsync(request, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(DescriptorGenerator.Schema, document.Schema); | ||||
|         Assert.Equal(fakeTime.GetUtcNow(), document.GeneratedAt); | ||||
|         Assert.Equal(request.ImageDigest, document.Subject.Digest); | ||||
|         Assert.Equal(request.SbomMediaType, document.Artifact.MediaType); | ||||
|         Assert.Equal(request.SbomName, document.Artifact.Annotations["org.opencontainers.image.title"]); | ||||
|         Assert.Equal("pending", document.Provenance.Status); | ||||
|         Assert.Equal(request.AttestorUri, document.Provenance.AttestorUri); | ||||
|         Assert.Equal(request.PredicateType, document.Provenance.PredicateType); | ||||
|  | ||||
|         var expectedSbomDigest = ComputeSha256File(sbomPath); | ||||
|         Assert.Equal(expectedSbomDigest, document.Artifact.Digest); | ||||
|         Assert.Equal(expectedSbomDigest, document.Metadata["sbomDigest"]); | ||||
|  | ||||
|         var expectedDsse = ComputeExpectedDsse(request.ImageDigest, expectedSbomDigest, document.Provenance.Nonce); | ||||
|         Assert.Equal(expectedDsse, document.Provenance.ExpectedDsseSha256); | ||||
|         Assert.Equal(expectedDsse, document.Artifact.Annotations["org.stellaops.provenance.dsse.sha256"]); | ||||
|     } | ||||
|  | ||||
|     private static string ComputeSha256File(string path) | ||||
|     { | ||||
|         using var stream = File.OpenRead(path); | ||||
|         var hash = SHA256.HashData(stream); | ||||
|         return $"sha256:{Convert.ToHexString(hash).ToLower(CultureInfo.InvariantCulture)}"; | ||||
|     } | ||||
|  | ||||
|     private static string ComputeExpectedDsse(string imageDigest, string sbomDigest, string nonce) | ||||
|     { | ||||
|         var payload = $"{imageDigest}\n{sbomDigest}\n{nonce}"; | ||||
|         Span<byte> hash = stackalloc byte[32]; | ||||
|         SHA256.HashData(Encoding.UTF8.GetBytes(payload), hash); | ||||
|         return $"sha256:{Convert.ToHexString(hash).ToLower(CultureInfo.InvariantCulture)}"; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,80 @@ | ||||
| using System.IO; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Manifest; | ||||
|  | ||||
| public sealed class BuildxPluginManifestLoaderTests | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) | ||||
|     { | ||||
|         WriteIndented = true | ||||
|     }; | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task LoadAsync_ReturnsManifestWithSourceInformation() | ||||
|     { | ||||
|         await using var temp = new TempDirectory(); | ||||
|         var manifestPath = System.IO.Path.Combine(temp.Path, "stellaops.manifest.json"); | ||||
|         await File.WriteAllTextAsync(manifestPath, BuildSampleManifestJson("stellaops.sbom-indexer")); | ||||
|  | ||||
|         var loader = new BuildxPluginManifestLoader(temp.Path); | ||||
|         var manifests = await loader.LoadAsync(CancellationToken.None); | ||||
|  | ||||
|         var manifest = Assert.Single(manifests); | ||||
|         Assert.Equal("stellaops.sbom-indexer", manifest.Id); | ||||
|         Assert.Equal("0.1.0", manifest.Version); | ||||
|         Assert.Equal(manifestPath, manifest.SourcePath); | ||||
|         Assert.Equal(Path.GetDirectoryName(manifestPath), manifest.SourceDirectory); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task LoadDefaultAsync_ThrowsWhenNoManifests() | ||||
|     { | ||||
|         await using var temp = new TempDirectory(); | ||||
|         var loader = new BuildxPluginManifestLoader(temp.Path); | ||||
|  | ||||
|         await Assert.ThrowsAsync<BuildxPluginException>(() => loader.LoadDefaultAsync(CancellationToken.None)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task LoadAsync_ThrowsWhenRestartRequiredMissing() | ||||
|     { | ||||
|         await using var temp = new TempDirectory(); | ||||
|         var manifestPath = Path.Combine(temp.Path, "failure.manifest.json"); | ||||
|         await File.WriteAllTextAsync(manifestPath, BuildSampleManifestJson("stellaops.failure", requiresRestart: false)); | ||||
|  | ||||
|         var loader = new BuildxPluginManifestLoader(temp.Path); | ||||
|  | ||||
|         await Assert.ThrowsAsync<BuildxPluginException>(() => loader.LoadAsync(CancellationToken.None)); | ||||
|     } | ||||
|  | ||||
|     private static string BuildSampleManifestJson(string id, bool requiresRestart = true) | ||||
|     { | ||||
|         var manifest = new BuildxPluginManifest | ||||
|         { | ||||
|             SchemaVersion = BuildxPluginManifest.CurrentSchemaVersion, | ||||
|             Id = id, | ||||
|             DisplayName = "Sample", | ||||
|             Version = "0.1.0", | ||||
|             RequiresRestart = requiresRestart, | ||||
|             EntryPoint = new BuildxPluginEntryPoint | ||||
|             { | ||||
|                 Type = "dotnet", | ||||
|                 Executable = "StellaOps.Scanner.Sbomer.BuildXPlugin.dll" | ||||
|             }, | ||||
|             Cas = new BuildxPluginCas | ||||
|             { | ||||
|                 Protocol = "filesystem", | ||||
|                 DefaultRoot = "cas" | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         return JsonSerializer.Serialize(manifest, SerializerOptions); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Sbomer.BuildXPlugin\StellaOps.Scanner.Sbomer.BuildXPlugin.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,44 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities; | ||||
|  | ||||
| internal sealed class TempDirectory : IDisposable, IAsyncDisposable | ||||
| { | ||||
|     public string Path { get; } | ||||
|  | ||||
|     public TempDirectory() | ||||
|     { | ||||
|         Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-buildx-{Guid.NewGuid():N}"); | ||||
|         Directory.CreateDirectory(Path); | ||||
|     } | ||||
|  | ||||
|     public void Dispose() | ||||
|     { | ||||
|         Cleanup(); | ||||
|         GC.SuppressFinalize(this); | ||||
|     } | ||||
|  | ||||
|     public ValueTask DisposeAsync() | ||||
|     { | ||||
|         Cleanup(); | ||||
|         GC.SuppressFinalize(this); | ||||
|         return ValueTask.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     private void Cleanup() | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             if (Directory.Exists(Path)) | ||||
|             { | ||||
|                 Directory.Delete(Path, recursive: true); | ||||
|             } | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             // Best effort cleanup only. | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,49 @@ | ||||
| using System; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation; | ||||
|  | ||||
| /// <summary> | ||||
| /// Sends provenance placeholders to the Attestor service for asynchronous DSSE signing. | ||||
| /// </summary> | ||||
| public sealed class AttestorClient | ||||
| { | ||||
|     private readonly HttpClient httpClient; | ||||
|  | ||||
|     public AttestorClient(HttpClient httpClient) | ||||
|     { | ||||
|         this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); | ||||
|     } | ||||
|  | ||||
|     public async Task SendPlaceholderAsync(Uri attestorUri, DescriptorDocument document, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (attestorUri is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(attestorUri)); | ||||
|         } | ||||
|  | ||||
|         if (document is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(document)); | ||||
|         } | ||||
|  | ||||
|         var payload = new AttestorProvenanceRequest( | ||||
|             ImageDigest: document.Subject.Digest, | ||||
|             SbomDigest: document.Artifact.Digest, | ||||
|             ExpectedDsseSha256: document.Provenance.ExpectedDsseSha256, | ||||
|             Nonce: document.Provenance.Nonce, | ||||
|             PredicateType: document.Provenance.PredicateType, | ||||
|             Schema: document.Schema); | ||||
|  | ||||
|         using var response = await httpClient.PostAsJsonAsync(attestorUri, payload, cancellationToken).ConfigureAwait(false); | ||||
|         if (!response.IsSuccessStatusCode) | ||||
|         { | ||||
|             var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); | ||||
|             throw new BuildxPluginException($"Attestor rejected provenance placeholder ({(int)response.StatusCode}): {body}"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation; | ||||
|  | ||||
| public sealed record AttestorProvenanceRequest( | ||||
|     [property: JsonPropertyName("imageDigest")] string ImageDigest, | ||||
|     [property: JsonPropertyName("sbomDigest")] string SbomDigest, | ||||
|     [property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256, | ||||
|     [property: JsonPropertyName("nonce")] string Nonce, | ||||
|     [property: JsonPropertyName("predicateType")] string PredicateType, | ||||
|     [property: JsonPropertyName("schema")] string Schema); | ||||
| @@ -0,0 +1,19 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents user-facing errors raised by the BuildX plug-in. | ||||
| /// </summary> | ||||
| public sealed class BuildxPluginException : Exception | ||||
| { | ||||
|     public BuildxPluginException(string message) | ||||
|         : base(message) | ||||
|     { | ||||
|     } | ||||
|  | ||||
|     public BuildxPluginException(string message, Exception innerException) | ||||
|         : base(message, innerException) | ||||
|     { | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,6 @@ | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas; | ||||
|  | ||||
| /// <summary> | ||||
| /// Result of persisting bytes into the local CAS. | ||||
| /// </summary> | ||||
| public sealed record CasWriteResult(string Algorithm, string Digest, string Path); | ||||
| @@ -0,0 +1,74 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Security.Cryptography; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas; | ||||
|  | ||||
| /// <summary> | ||||
| /// Minimal filesystem-backed CAS used when the BuildX generator runs inside CI. | ||||
| /// </summary> | ||||
| public sealed class LocalCasClient | ||||
| { | ||||
|     private readonly string rootDirectory; | ||||
|     private readonly string algorithm; | ||||
|  | ||||
|     public LocalCasClient(LocalCasOptions options) | ||||
|     { | ||||
|         if (options is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(options)); | ||||
|         } | ||||
|  | ||||
|         algorithm = options.Algorithm.ToLowerInvariant(); | ||||
|         if (!string.Equals(algorithm, "sha256", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             throw new ArgumentException("Only the sha256 algorithm is supported.", nameof(options)); | ||||
|         } | ||||
|  | ||||
|         rootDirectory = Path.GetFullPath(options.RootDirectory); | ||||
|     } | ||||
|  | ||||
|     public Task<CasWriteResult> VerifyWriteAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         ReadOnlyMemory<byte> probe = "stellaops-buildx-probe"u8.ToArray(); | ||||
|         return WriteAsync(probe, cancellationToken); | ||||
|     } | ||||
|  | ||||
|     public async Task<CasWriteResult> WriteAsync(ReadOnlyMemory<byte> content, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var digest = ComputeDigest(content.Span); | ||||
|         var path = BuildObjectPath(digest); | ||||
|  | ||||
|         Directory.CreateDirectory(Path.GetDirectoryName(path)!); | ||||
|  | ||||
|         await using var stream = new FileStream( | ||||
|             path, | ||||
|             FileMode.Create, | ||||
|             FileAccess.Write, | ||||
|             FileShare.Read, | ||||
|             bufferSize: 16 * 1024, | ||||
|             FileOptions.Asynchronous | FileOptions.SequentialScan); | ||||
|  | ||||
|         await stream.WriteAsync(content, cancellationToken).ConfigureAwait(false); | ||||
|         await stream.FlushAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         return new CasWriteResult(algorithm, digest, path); | ||||
|     } | ||||
|  | ||||
|     private string BuildObjectPath(string digest) | ||||
|     { | ||||
|         // Layout: <root>/<algorithm>/<first two>/<rest>.bin | ||||
|         var prefix = digest.Substring(0, 2); | ||||
|         var suffix = digest[2..]; | ||||
|         return Path.Combine(rootDirectory, algorithm, prefix, $"{suffix}.bin"); | ||||
|     } | ||||
|  | ||||
|     private static string ComputeDigest(ReadOnlySpan<byte> content) | ||||
|     { | ||||
|         Span<byte> buffer = stackalloc byte[32]; | ||||
|         SHA256.HashData(content, buffer); | ||||
|         return Convert.ToHexString(buffer).ToLowerInvariant(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,40 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas; | ||||
|  | ||||
| /// <summary> | ||||
| /// Configuration for the on-disk content-addressable store used during CI. | ||||
| /// </summary> | ||||
| public sealed record LocalCasOptions | ||||
| { | ||||
|     private string rootDirectory = string.Empty; | ||||
|     private string algorithm = "sha256"; | ||||
|  | ||||
|     public string RootDirectory | ||||
|     { | ||||
|         get => rootDirectory; | ||||
|         init | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(value)) | ||||
|             { | ||||
|                 throw new ArgumentException("Root directory must be provided.", nameof(value)); | ||||
|             } | ||||
|  | ||||
|             rootDirectory = value; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public string Algorithm | ||||
|     { | ||||
|         get => algorithm; | ||||
|         init | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(value)) | ||||
|             { | ||||
|                 throw new ArgumentException("Algorithm must be provided.", nameof(value)); | ||||
|             } | ||||
|  | ||||
|             algorithm = value; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents an OCI artifact descriptor emitted by the BuildX generator. | ||||
| /// </summary> | ||||
| public sealed record DescriptorArtifact( | ||||
|     [property: JsonPropertyName("mediaType")] string MediaType, | ||||
|     [property: JsonPropertyName("digest")] string Digest, | ||||
|     [property: JsonPropertyName("size")] long Size, | ||||
|     [property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string> Annotations); | ||||
| @@ -0,0 +1,17 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; | ||||
|  | ||||
| /// <summary> | ||||
| /// Root payload describing BuildX generator output with provenance placeholders. | ||||
| /// </summary> | ||||
| public sealed record DescriptorDocument( | ||||
|     [property: JsonPropertyName("schema")] string Schema, | ||||
|     [property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt, | ||||
|     [property: JsonPropertyName("generator")] DescriptorGeneratorMetadata Generator, | ||||
|     [property: JsonPropertyName("subject")] DescriptorSubject Subject, | ||||
|     [property: JsonPropertyName("artifact")] DescriptorArtifact Artifact, | ||||
|     [property: JsonPropertyName("provenance")] DescriptorProvenance Provenance, | ||||
|     [property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata); | ||||
| @@ -0,0 +1,180 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; | ||||
|  | ||||
| /// <summary> | ||||
| /// Builds immutable OCI descriptors enriched with provenance placeholders. | ||||
| /// </summary> | ||||
| public sealed class DescriptorGenerator | ||||
| { | ||||
|     public const string Schema = "stellaops.buildx.descriptor.v1"; | ||||
|  | ||||
|     private readonly TimeProvider timeProvider; | ||||
|  | ||||
|     public DescriptorGenerator(TimeProvider timeProvider) | ||||
|     { | ||||
|         timeProvider ??= TimeProvider.System; | ||||
|         this.timeProvider = timeProvider; | ||||
|     } | ||||
|  | ||||
|     public async Task<DescriptorDocument> CreateAsync(DescriptorRequest request, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (request is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(request)); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(request.ImageDigest)) | ||||
|         { | ||||
|             throw new BuildxPluginException("Image digest must be provided."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(request.SbomPath)) | ||||
|         { | ||||
|             throw new BuildxPluginException("SBOM path must be provided."); | ||||
|         } | ||||
|  | ||||
|         var sbomFile = new FileInfo(request.SbomPath); | ||||
|         if (!sbomFile.Exists) | ||||
|         { | ||||
|             throw new BuildxPluginException($"SBOM file '{request.SbomPath}' was not found."); | ||||
|         } | ||||
|  | ||||
|         var sbomDigest = await ComputeFileDigestAsync(sbomFile, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var nonce = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); | ||||
|         var expectedDsseSha = ComputeExpectedDsseDigest(request.ImageDigest, sbomDigest, nonce); | ||||
|  | ||||
|         var artifactAnnotations = BuildArtifactAnnotations(request, nonce, expectedDsseSha); | ||||
|  | ||||
|         var subject = new DescriptorSubject( | ||||
|             MediaType: request.SubjectMediaType, | ||||
|             Digest: request.ImageDigest); | ||||
|  | ||||
|         var artifact = new DescriptorArtifact( | ||||
|             MediaType: request.SbomMediaType, | ||||
|             Digest: sbomDigest, | ||||
|             Size: sbomFile.Length, | ||||
|             Annotations: artifactAnnotations); | ||||
|  | ||||
|         var provenance = new DescriptorProvenance( | ||||
|             Status: "pending", | ||||
|             ExpectedDsseSha256: expectedDsseSha, | ||||
|             Nonce: nonce, | ||||
|             AttestorUri: request.AttestorUri, | ||||
|             PredicateType: request.PredicateType); | ||||
|  | ||||
|         var generatorMetadata = new DescriptorGeneratorMetadata( | ||||
|             Name: request.GeneratorName ?? "StellaOps.Scanner.Sbomer.BuildXPlugin", | ||||
|             Version: request.GeneratorVersion); | ||||
|  | ||||
|         var metadata = BuildDocumentMetadata(request, sbomFile, sbomDigest); | ||||
|  | ||||
|         return new DescriptorDocument( | ||||
|             Schema: Schema, | ||||
|             GeneratedAt: timeProvider.GetUtcNow(), | ||||
|             Generator: generatorMetadata, | ||||
|             Subject: subject, | ||||
|             Artifact: artifact, | ||||
|             Provenance: provenance, | ||||
|             Metadata: metadata); | ||||
|     } | ||||
|  | ||||
|     private static async Task<string> ComputeFileDigestAsync(FileInfo file, CancellationToken cancellationToken) | ||||
|     { | ||||
|         await using var stream = new FileStream( | ||||
|             file.FullName, | ||||
|             FileMode.Open, | ||||
|             FileAccess.Read, | ||||
|             FileShare.Read, | ||||
|             bufferSize: 128 * 1024, | ||||
|             FileOptions.Asynchronous | FileOptions.SequentialScan); | ||||
|  | ||||
|         using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); | ||||
|  | ||||
|         var buffer = new byte[128 * 1024]; | ||||
|         int bytesRead; | ||||
|         while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) | ||||
|         { | ||||
|             hash.AppendData(buffer, 0, bytesRead); | ||||
|         } | ||||
|  | ||||
|         var digest = hash.GetHashAndReset(); | ||||
|         return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}"; | ||||
|     } | ||||
|  | ||||
|     private static string ComputeExpectedDsseDigest(string imageDigest, string sbomDigest, string nonce) | ||||
|     { | ||||
|         var payload = $"{imageDigest}\n{sbomDigest}\n{nonce}"; | ||||
|         var bytes = System.Text.Encoding.UTF8.GetBytes(payload); | ||||
|         Span<byte> hash = stackalloc byte[32]; | ||||
|         SHA256.HashData(bytes, hash); | ||||
|         return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyDictionary<string, string> BuildArtifactAnnotations(DescriptorRequest request, string nonce, string expectedDsse) | ||||
|     { | ||||
|         var annotations = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) | ||||
|         { | ||||
|             ["org.opencontainers.artifact.type"] = request.SbomArtifactType, | ||||
|             ["org.stellaops.scanner.version"] = request.GeneratorVersion, | ||||
|             ["org.stellaops.sbom.kind"] = request.SbomKind, | ||||
|             ["org.stellaops.sbom.format"] = request.SbomFormat, | ||||
|             ["org.stellaops.provenance.status"] = "pending", | ||||
|             ["org.stellaops.provenance.dsse.sha256"] = expectedDsse, | ||||
|             ["org.stellaops.provenance.nonce"] = nonce | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(request.LicenseId)) | ||||
|         { | ||||
|             annotations["org.stellaops.license.id"] = request.LicenseId!; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(request.SbomName)) | ||||
|         { | ||||
|             annotations["org.opencontainers.image.title"] = request.SbomName!; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(request.Repository)) | ||||
|         { | ||||
|             annotations["org.stellaops.repository"] = request.Repository!; | ||||
|         } | ||||
|  | ||||
|         return annotations; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyDictionary<string, string> BuildDocumentMetadata(DescriptorRequest request, FileInfo fileInfo, string sbomDigest) | ||||
|     { | ||||
|         var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) | ||||
|         { | ||||
|             ["sbomDigest"] = sbomDigest, | ||||
|             ["sbomPath"] = fileInfo.FullName, | ||||
|             ["sbomMediaType"] = request.SbomMediaType, | ||||
|             ["subjectMediaType"] = request.SubjectMediaType | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(request.Repository)) | ||||
|         { | ||||
|             metadata["repository"] = request.Repository!; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(request.BuildRef)) | ||||
|         { | ||||
|             metadata["buildRef"] = request.BuildRef!; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(request.AttestorUri)) | ||||
|         { | ||||
|             metadata["attestorUri"] = request.AttestorUri!; | ||||
|         } | ||||
|  | ||||
|         return metadata; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,7 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor; | ||||
|  | ||||
| public sealed record DescriptorGeneratorMetadata( | ||||
|     [property: JsonPropertyName("name")] string Name, | ||||
|     [property: JsonPropertyName("version")] string Version); | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user