feat: Implement vulnerability token signing and verification utilities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added VulnTokenSigner for signing JWT tokens with specified algorithms and keys. - Introduced VulnTokenUtilities for resolving tenant and subject claims, and sanitizing context dictionaries. - Created VulnTokenVerificationUtilities for parsing tokens, verifying signatures, and deserializing payloads. - Developed VulnWorkflowAntiForgeryTokenIssuer for issuing anti-forgery tokens with configurable options. - Implemented VulnWorkflowAntiForgeryTokenVerifier for verifying anti-forgery tokens and validating payloads. - Added AuthorityVulnerabilityExplorerOptions to manage configuration for vulnerability explorer features. - Included tests for FilesystemPackRunDispatcher to ensure proper job handling under egress policy restrictions.
This commit is contained in:
@@ -6,7 +6,7 @@ Deliver the Advisory AI assistant service that synthesizes advisory/VEX evidence
|
||||
## Scope
|
||||
- Service under `src/AdvisoryAI/StellaOps.AdvisoryAI` (retrievers, deterministics, orchestrator, guardrails, inference adapters, REST APIs).
|
||||
- Batch processing for CLI/automation, caching, observability, and integration with Console, CLI, and downstream systems.
|
||||
- Coordination across Conseiller, Excitator, VEX Lens, SBOM Service, Policy Engine, Findings Ledger, Web Gateway, Authority, DevOps, and Docs.
|
||||
- Coordination across Conseiller, Excitor, VEX Lens, SBOM Service, Policy Engine, Findings Ledger, Web Gateway, Authority, DevOps, and Docs.
|
||||
|
||||
## Principles
|
||||
1. **Evidence preservation** – Raw advisory/VEX documents remain untouched; AI outputs reference them with citations.
|
||||
|
||||
@@ -7,7 +7,7 @@ Deliver offline bundle verification and ingestion tooling for sealed environment
|
||||
- TUF metadata verification, DSSE signature checks, Merkle root validation.
|
||||
- Import pipelines writing bundle catalogs, object-store layouts, and audit entries.
|
||||
- CLI + API surfaces for dry-run verification, import, and status queries.
|
||||
- Integration hooks for Conseiller, Excitator, Policy Engine, and Export Center.
|
||||
- Integration hooks for Conseiller, Excitor, Policy Engine, and Export Center.
|
||||
- Negative-case handling (tampering, expired signatures, root rotation) with operator guidance.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CodeActions;
|
||||
using Microsoft.CodeAnalysis.CodeFixes;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed class HttpClientUsageAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReportsDiagnostic_ForNewHttpClient()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
Assert.Contains(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_InsidePolicyAssembly()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Internal;
|
||||
|
||||
internal static class Loopback
|
||||
{
|
||||
public static HttpClient Create() => new HttpClient();
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "StellaOps.AirGap.Policy");
|
||||
Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CodeFix_RewritesToFactoryCall()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Service;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string expected = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Service;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var updated = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
|
||||
Assert.Equal(expected.ReplaceLineEndings(), updated.ReplaceLineEndings());
|
||||
}
|
||||
|
||||
private static async Task<ImmutableArray<Diagnostic>> AnalyzeAsync(string source, string assemblyName)
|
||||
{
|
||||
var compilation = CSharpCompilation.Create(
|
||||
assemblyName,
|
||||
new[]
|
||||
{
|
||||
CSharpSyntaxTree.ParseText(source),
|
||||
CSharpSyntaxTree.ParseText(PolicyStubSource),
|
||||
},
|
||||
CreateMetadataReferences(),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer));
|
||||
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> ApplyCodeFixAsync(string source, string assemblyName)
|
||||
{
|
||||
using var workspace = new AdhocWorkspace();
|
||||
|
||||
var projectId = ProjectId.CreateNewId();
|
||||
var documentId = DocumentId.CreateNewId(projectId);
|
||||
var stubDocumentId = DocumentId.CreateNewId(projectId);
|
||||
|
||||
var solution = workspace.CurrentSolution
|
||||
.AddProject(projectId, "TestProject", "TestProject", LanguageNames.CSharp)
|
||||
.WithProjectCompilationOptions(projectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
|
||||
.WithProjectAssemblyName(projectId, assemblyName)
|
||||
.AddMetadataReferences(projectId, CreateMetadataReferences())
|
||||
.AddDocument(documentId, "Test.cs", SourceText.From(source))
|
||||
.AddDocument(stubDocumentId, "PolicyStubs.cs", SourceText.From(PolicyStubSource));
|
||||
|
||||
var project = solution.GetProject(projectId)!;
|
||||
var document = solution.GetDocument(documentId)!;
|
||||
|
||||
var compilation = await project.GetCompilationAsync();
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var diagnostics = await compilation!.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer))
|
||||
.GetAnalyzerDiagnosticsAsync();
|
||||
|
||||
var diagnostic = Assert.Single(diagnostics);
|
||||
|
||||
var codeFixProvider = new HttpClientUsageCodeFixProvider();
|
||||
var actions = new List<CodeAction>();
|
||||
var context = new CodeFixContext(
|
||||
document,
|
||||
diagnostic,
|
||||
(action, _) => actions.Add(action),
|
||||
CancellationToken.None);
|
||||
|
||||
await codeFixProvider.RegisterCodeFixesAsync(context);
|
||||
var action = Assert.Single(actions);
|
||||
var operations = await action.GetOperationsAsync(CancellationToken.None);
|
||||
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
operation.Apply(workspace, CancellationToken.None);
|
||||
}
|
||||
var updatedDocument = workspace.CurrentSolution.GetDocument(documentId)!;
|
||||
var updatedText = await updatedDocument.GetTextAsync();
|
||||
return updatedText.ToString();
|
||||
}
|
||||
|
||||
private static IEnumerable<MetadataReference> CreateMetadataReferences()
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(Uri).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(HttpClient).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location);
|
||||
}
|
||||
|
||||
private const string PolicyStubSource = """
|
||||
namespace StellaOps.AirGap.Policy
|
||||
{
|
||||
public interface IEgressPolicy
|
||||
{
|
||||
void EnsureAllowed(EgressRequest request);
|
||||
}
|
||||
|
||||
public readonly record struct EgressRequest(string Component, System.Uri Destination, string Intent);
|
||||
|
||||
public static class EgressHttpClientFactory
|
||||
{
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request)
|
||||
=> throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.11.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.11.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="3.11.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AirGap.Policy.Analyzers\StellaOps.AirGap.Policy.Analyzers.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
### New Rules
|
||||
|
||||
Rule ID | Title | Category | Severity | Notes
|
||||
--------|-------|----------|----------|------
|
||||
AIRGAP001 | Replace raw HttpClient with EgressPolicy-aware client | Usage | Warning | Flags direct HttpClient instantiation outside the EgressPolicy-aware wrappers.
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Operations;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// Flags direct <c>new HttpClient()</c> usage so services adopt the air-gap aware egress policy wrappers.
|
||||
/// </summary>
|
||||
[DiagnosticAnalyzer(LanguageNames.CSharp)]
|
||||
public sealed class HttpClientUsageAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Diagnostic identifier emitted when disallowed HttpClient usage is detected.
|
||||
/// </summary>
|
||||
public const string DiagnosticId = "AIRGAP001";
|
||||
|
||||
private const string HttpClientMetadataName = "System.Net.Http.HttpClient";
|
||||
private static readonly LocalizableString Title = "Replace raw HttpClient with EgressPolicy-aware client";
|
||||
private static readonly LocalizableString MessageFormat = "Instantiate HttpClient via StellaOps.AirGap.Policy wrappers to enforce sealed-mode egress controls";
|
||||
private static readonly LocalizableString Description = "Air-gapped environments must route outbound network calls through the EgressPolicy facade so requests are pre-authorised. Replace raw HttpClient usage with the shared factory helpers.";
|
||||
|
||||
private static readonly DiagnosticDescriptor Rule = new(
|
||||
DiagnosticId,
|
||||
Title,
|
||||
MessageFormat,
|
||||
"Usage",
|
||||
DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: Description);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Initialize(AnalysisContext context)
|
||||
{
|
||||
if (context == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||
context.EnableConcurrentExecution();
|
||||
context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation);
|
||||
}
|
||||
|
||||
private static void AnalyzeObjectCreation(OperationAnalysisContext context)
|
||||
{
|
||||
if (context.Operation is not IObjectCreationOperation creation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var createdType = creation.Type;
|
||||
if (createdType is null || !string.Equals(createdType.ToDisplayString(), HttpClientMetadataName, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsWithinAllowedAssembly(context.ContainingSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var diagnostic = Diagnostic.Create(Rule, creation.Syntax.GetLocation());
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
|
||||
private static bool IsWithinAllowedAssembly(ISymbol? symbol)
|
||||
{
|
||||
var containingAssembly = symbol?.ContainingAssembly;
|
||||
if (containingAssembly is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var assemblyName = containingAssembly.Name;
|
||||
if (string.IsNullOrEmpty(assemblyName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(assemblyName, "StellaOps.AirGap.Policy", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (assemblyName.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Composition;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CodeActions;
|
||||
using Microsoft.CodeAnalysis.CodeFixes;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// Offers a remediation template that routes HttpClient creation through the shared EgressPolicy factory.
|
||||
/// </summary>
|
||||
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(HttpClientUsageCodeFixProvider))]
|
||||
[Shared]
|
||||
public sealed class HttpClientUsageCodeFixProvider : CodeFixProvider
|
||||
{
|
||||
private const string Title = "Use EgressHttpClientFactory.Create(...)";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ImmutableArray<string> FixableDiagnosticIds
|
||||
=> ImmutableArray.Create(HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override FixAllProvider GetFixAllProvider()
|
||||
=> WellKnownFixAllProviders.BatchFixer;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
|
||||
{
|
||||
if (context.Document is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
|
||||
if (root is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var diagnostic = context.Diagnostics[0];
|
||||
var node = root.FindNode(diagnostic.Location.SourceSpan);
|
||||
if (node is not ObjectCreationExpressionSyntax objectCreation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.RegisterCodeFix(
|
||||
CodeAction.Create(
|
||||
Title,
|
||||
cancellationToken => ReplaceWithFactoryCallAsync(context.Document, objectCreation, cancellationToken),
|
||||
equivalenceKey: Title),
|
||||
diagnostic);
|
||||
}
|
||||
|
||||
private static async Task<Document> ReplaceWithFactoryCallAsync(Document document, ObjectCreationExpressionSyntax creation, CancellationToken cancellationToken)
|
||||
{
|
||||
var replacementExpression = SyntaxFactory.ParseExpression(
|
||||
"global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(" +
|
||||
"egressPolicy: /* TODO: provide IEgressPolicy instance */, " +
|
||||
"request: new global::StellaOps.AirGap.Policy.EgressRequest(" +
|
||||
"component: \"REPLACE_COMPONENT\", " +
|
||||
"destination: new global::System.Uri(\"https://replace-with-endpoint\"), " +
|
||||
"intent: \"REPLACE_INTENT\"))");
|
||||
|
||||
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (root is null)
|
||||
{
|
||||
return document;
|
||||
}
|
||||
|
||||
var updatedRoot = root.ReplaceNode(creation, replacementExpression.WithTriviaFrom(creation));
|
||||
return document.WithSyntaxRoot(updatedRoot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.11.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.11.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,229 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed class EgressPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_UnsealedEnvironment_AllowsRequest()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Unsealed,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("https://example.com"), "advisory-sync");
|
||||
|
||||
var decision = policy.Evaluate(request);
|
||||
|
||||
Assert.True(decision.IsAllowed);
|
||||
Assert.Null(decision.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironmentWithMatchingRule_Allows()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
};
|
||||
options.AddAllowRule("api.example.com", 443, EgressTransport.Https);
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("https://api.example.com/v1/status"), "advisory-sync");
|
||||
|
||||
policy.EnsureAllowed(request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironmentWithoutRule_ThrowsWithGuidance()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
RemediationDocumentationUrl = "https://docs.stella-ops.org/airgap/egress",
|
||||
SupportContact = "airgap-oncall@example.org",
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("https://unauthorized.example.com"), "advisory-sync", operation: "fetch-advisories");
|
||||
|
||||
var exception = Assert.Throws<AirGapEgressBlockedException>(() => policy.EnsureAllowed(request));
|
||||
|
||||
Assert.Contains(AirGapEgressBlockedException.ErrorCode, exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("unauthorized.example.com", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("airgap.egressAllowlist", exception.Remediation, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(request, exception.Request);
|
||||
Assert.Equal(options.RemediationDocumentationUrl, exception.DocumentationUrl);
|
||||
Assert.Equal(options.SupportContact, exception.SupportContact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironment_AllowsLoopbackWhenConfigured()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowLoopback = true,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("http://127.0.0.1:9000/health"), "local-probe");
|
||||
|
||||
policy.EnsureAllowed(request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironment_AllowsPrivateNetworkWhenConfigured()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowPrivateNetworks = true,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("https://10.10.0.5:8443/status"), "mirror-sync");
|
||||
|
||||
policy.EnsureAllowed(request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironment_BlocksPrivateNetworkWhenNotConfigured()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowPrivateNetworks = false,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("https://10.10.0.5:8443/status"), "mirror-sync");
|
||||
|
||||
var exception = Assert.Throws<AirGapEgressBlockedException>(() => policy.EnsureAllowed(request));
|
||||
Assert.Contains("10.10.0.5", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://api.example.com", true)]
|
||||
[InlineData("https://sub.api.example.com", true)]
|
||||
[InlineData("https://example.com", false)]
|
||||
public void Evaluate_SealedEnvironmentWildcardHost_Matches(string url, bool expectedAllowed)
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
};
|
||||
options.AddAllowRule("*.example.com", transport: EgressTransport.Https);
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri(url), "mirror-sync");
|
||||
|
||||
var decision = policy.Evaluate(request);
|
||||
Assert.Equal(expectedAllowed, decision.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceCollection_AddAirGapEgressPolicy_RegistersService()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAirGapEgressPolicy(options =>
|
||||
{
|
||||
options.Mode = EgressPolicyMode.Sealed;
|
||||
options.AddAllowRule("mirror.internal", transport: EgressTransport.Https);
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var policy = provider.GetRequiredService<IEgressPolicy>();
|
||||
|
||||
Assert.True(policy.IsSealed);
|
||||
policy.EnsureAllowed(new EgressRequest("PolicyEngine", new Uri("https://mirror.internal"), "mirror-sync"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceCollection_AddAirGapEgressPolicy_BindsFromConfiguration()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AirGap:Egress:Mode"] = "Sealed",
|
||||
["AirGap:Egress:AllowLoopback"] = "false",
|
||||
["AirGap:Egress:AllowPrivateNetworks"] = "true",
|
||||
["AirGap:Egress:RemediationDocumentationUrl"] = "https://docs.example/airgap",
|
||||
["AirGap:Egress:SupportContact"] = "airgap@example.org",
|
||||
["AirGap:Egress:Allowlist:0:HostPattern"] = "mirror.internal",
|
||||
["AirGap:Egress:Allowlist:0:Port"] = "443",
|
||||
["AirGap:Egress:Allowlist:0:Transport"] = "https",
|
||||
["AirGap:Egress:Allowlist:0:Description"] = "Primary mirror",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddAirGapEgressPolicy(configuration);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var policy = provider.GetRequiredService<IEgressPolicy>();
|
||||
|
||||
Assert.True(policy.IsSealed);
|
||||
var decision = policy.Evaluate(new EgressRequest("ExportCenter", new Uri("https://mirror.internal/feeds"), "mirror-sync"));
|
||||
Assert.True(decision.IsAllowed);
|
||||
|
||||
var blocked = policy.Evaluate(new EgressRequest("ExportCenter", new Uri("https://external.example"), "mirror-sync"));
|
||||
Assert.False(blocked.IsAllowed);
|
||||
Assert.Contains("mirror.internal", blocked.Remediation, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EgressHttpClientFactory_Create_EnforcesPolicyBeforeReturningClient()
|
||||
{
|
||||
var recordingPolicy = new RecordingPolicy();
|
||||
var request = new EgressRequest("Component", new Uri("https://allowed.internal"), "mirror-sync");
|
||||
|
||||
using var client = EgressHttpClientFactory.Create(recordingPolicy, request);
|
||||
|
||||
Assert.True(recordingPolicy.EnsureAllowedCalled);
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
private sealed class RecordingPolicy : IEgressPolicy
|
||||
{
|
||||
public bool EnsureAllowedCalled { get; private set; }
|
||||
|
||||
public bool IsSealed => true;
|
||||
|
||||
public EgressPolicyMode Mode => EgressPolicyMode.Sealed;
|
||||
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
EnsureAllowedCalled = true;
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
public ValueTask<EgressDecision> EvaluateAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new ValueTask<EgressDecision>(Evaluate(request));
|
||||
}
|
||||
|
||||
public void EnsureAllowed(EgressRequest request)
|
||||
{
|
||||
EnsureAllowedCalled = true;
|
||||
}
|
||||
|
||||
public ValueTask EnsureAllowedAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureAllowed(request);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Exception raised when an egress operation is blocked while sealed mode is active.
|
||||
/// </summary>
|
||||
public sealed class AirGapEgressBlockedException : InvalidOperationException
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code surfaced to callers when egress is blocked.
|
||||
/// </summary>
|
||||
public const string ErrorCode = "AIRGAP_EGRESS_BLOCKED";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AirGapEgressBlockedException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="request">Request details.</param>
|
||||
/// <param name="reason">Reason returned by the policy.</param>
|
||||
/// <param name="remediation">Remediation guidance.</param>
|
||||
/// <param name="documentationUrl">Optional documentation URL.</param>
|
||||
/// <param name="supportContact">Optional support contact.</param>
|
||||
public AirGapEgressBlockedException(
|
||||
EgressRequest request,
|
||||
string reason,
|
||||
string remediation,
|
||||
string? documentationUrl,
|
||||
string? supportContact)
|
||||
: base(BuildMessage(request, reason, remediation, documentationUrl, supportContact))
|
||||
{
|
||||
Request = request;
|
||||
Reason = reason;
|
||||
Remediation = remediation;
|
||||
DocumentationUrl = documentationUrl;
|
||||
SupportContact = supportContact;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the blocked request.
|
||||
/// </summary>
|
||||
public EgressRequest Request { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason supplied by the policy.
|
||||
/// </summary>
|
||||
public string Reason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remediation guidance.
|
||||
/// </summary>
|
||||
public string Remediation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional documentation URL.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional support contact (for example, an on-call alias).
|
||||
/// </summary>
|
||||
public string? SupportContact { get; }
|
||||
|
||||
private static string BuildMessage(EgressRequest request, string reason, string remediation, string? documentationUrl, string? supportContact)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(ErrorCode)
|
||||
.Append(": component '")
|
||||
.Append(request.Component)
|
||||
.Append("' attempted to reach '")
|
||||
.Append(request.Destination)
|
||||
.Append("' (intent: ")
|
||||
.Append(request.Intent);
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Operation))
|
||||
{
|
||||
builder.Append(", operation: ")
|
||||
.Append(request.Operation);
|
||||
}
|
||||
|
||||
builder.Append("). Reason: ")
|
||||
.Append(reason)
|
||||
.Append(". Remediation: ")
|
||||
.Append(remediation);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(documentationUrl))
|
||||
{
|
||||
builder.Append(" Documentation: ")
|
||||
.Append(documentationUrl);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(supportContact))
|
||||
{
|
||||
builder.Append(" Contact: ")
|
||||
.Append(supportContact);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the outcome of evaluating an egress request.
|
||||
/// </summary>
|
||||
public sealed record EgressDecision
|
||||
{
|
||||
private EgressDecision(bool isAllowed, string? reason, string? remediation)
|
||||
{
|
||||
IsAllowed = isAllowed;
|
||||
Reason = reason;
|
||||
Remediation = remediation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a singleton instance representing an allowed decision.
|
||||
/// </summary>
|
||||
public static EgressDecision Allowed { get; } = new(true, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a blocked decision for the supplied reason/remediation.
|
||||
/// </summary>
|
||||
/// <param name="reason">Explanation for the denial.</param>
|
||||
/// <param name="remediation">Suggested remediation to resolve the denial.</param>
|
||||
/// <returns>An <see cref="EgressDecision"/> that blocks the request.</returns>
|
||||
public static EgressDecision Blocked(string reason, string remediation)
|
||||
=> new(false, reason, remediation);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the request is permitted.
|
||||
/// </summary>
|
||||
public bool IsAllowed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason returned by the policy when the request was blocked.
|
||||
/// </summary>
|
||||
public string? Reason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets suggested remediation guidance that callers can surface to operators.
|
||||
/// </summary>
|
||||
public string? Remediation { get; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helpers for creating <see cref="HttpClient"/> instances that respect the configured <see cref="IEgressPolicy"/>.
|
||||
/// </summary>
|
||||
public static class EgressHttpClientFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an <see cref="HttpClient"/> after validating the supplied egress request against the policy.
|
||||
/// </summary>
|
||||
/// <param name="egressPolicy">The policy used to validate outbound requests.</param>
|
||||
/// <param name="request">Describes the destination and intent for the outbound call.</param>
|
||||
/// <param name="configure">Optional configuration hook applied to the newly created client.</param>
|
||||
/// <returns>An <see cref="HttpClient"/> that has been pre-authorised by the policy.</returns>
|
||||
public static HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request, Action<HttpClient>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(egressPolicy);
|
||||
|
||||
egressPolicy.EnsureAllowed(request);
|
||||
|
||||
var client = new HttpClient();
|
||||
configure?.Invoke(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and configures an <see cref="HttpClient"/> after validating the supplied egress request against the policy.
|
||||
/// </summary>
|
||||
/// <param name="egressPolicy">The policy used to validate outbound requests.</param>
|
||||
/// <param name="component">Component initiating the request.</param>
|
||||
/// <param name="destination">Destination that will be contacted.</param>
|
||||
/// <param name="intent">Intent label describing why the request is needed.</param>
|
||||
/// <param name="configure">Optional configuration hook applied to the newly created client.</param>
|
||||
/// <returns>An <see cref="HttpClient"/> that has been pre-authorised by the policy.</returns>
|
||||
public static HttpClient Create(
|
||||
IEgressPolicy egressPolicy,
|
||||
string component,
|
||||
Uri destination,
|
||||
string intent,
|
||||
Action<HttpClient>? configure = null)
|
||||
=> Create(egressPolicy, new EgressRequest(component, destination, intent), configure);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IEgressPolicy"/>.
|
||||
/// </summary>
|
||||
public sealed class EgressPolicy : IEgressPolicy
|
||||
{
|
||||
private readonly EgressRule[] _rules;
|
||||
private readonly EgressPolicyOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EgressPolicy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">Options describing how egress should be enforced.</param>
|
||||
public EgressPolicy(EgressPolicyOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_rules = options.BuildRuleSet();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSealed => Mode == EgressPolicyMode.Sealed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public EgressPolicyMode Mode => _options.Mode;
|
||||
|
||||
/// <inheritdoc />
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
if (!IsSealed)
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
if (_options.AllowLoopback && IsLoopback(request.Destination))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
if (_options.AllowPrivateNetworks && IsPrivateNetwork(request.Destination))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
foreach (var rule in _rules)
|
||||
{
|
||||
if (rule.Allows(request))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
}
|
||||
|
||||
var reason = $"Destination '{request.Destination.Host}' is not present in the sealed-mode allow list.";
|
||||
var remediation = BuildRemediation(request);
|
||||
return EgressDecision.Blocked(reason, remediation);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<EgressDecision> EvaluateAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return ValueTask.FromResult(Evaluate(request));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureAllowed(EgressRequest request)
|
||||
{
|
||||
var decision = Evaluate(request);
|
||||
if (decision.IsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw CreateException(request, decision);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask EnsureAllowedAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var decision = await EvaluateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!decision.IsAllowed)
|
||||
{
|
||||
throw CreateException(request, decision);
|
||||
}
|
||||
}
|
||||
|
||||
private AirGapEgressBlockedException CreateException(EgressRequest request, EgressDecision decision)
|
||||
=> new(
|
||||
request,
|
||||
decision.Reason ?? "Egress blocked.",
|
||||
decision.Remediation ?? BuildRemediation(request),
|
||||
_options.RemediationDocumentationUrl,
|
||||
_options.SupportContact);
|
||||
|
||||
private string BuildRemediation(EgressRequest request)
|
||||
{
|
||||
var host = request.Destination.Host;
|
||||
var portSegment = request.Destination.IsDefaultPort ? string.Empty : $":{request.Destination.Port.ToString(CultureInfo.InvariantCulture)}";
|
||||
var transport = request.Transport.ToString().ToUpperInvariant();
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append("Add '")
|
||||
.Append(host)
|
||||
.Append(portSegment)
|
||||
.Append("' (")
|
||||
.Append(transport)
|
||||
.Append(") to the airgap.egressAllowlist configuration.");
|
||||
|
||||
if (_rules.Length == 0)
|
||||
{
|
||||
builder.Append(" No allow entries are currently configured; sealed mode blocks every external host.");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(" Current allow list sample: ");
|
||||
var limit = Math.Min(_rules.Length, 3);
|
||||
for (var i = 0; i < limit; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
builder.Append(", ");
|
||||
}
|
||||
|
||||
builder.Append(_rules[i].HostPattern);
|
||||
if (_rules[i].Port is int port)
|
||||
{
|
||||
builder.Append(':')
|
||||
.Append(port.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
if (_rules.Length > limit)
|
||||
{
|
||||
builder.Append(", ...");
|
||||
}
|
||||
|
||||
builder.Append(". Coordinate break-glass with platform operations before expanding access.");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool IsLoopback(Uri destination)
|
||||
{
|
||||
if (string.Equals(destination.Host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IPAddress.TryParse(destination.Host, out var address))
|
||||
{
|
||||
return IPAddress.IsLoopback(address);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsPrivateNetwork(Uri destination)
|
||||
{
|
||||
if (!IPAddress.TryParse(destination.Host, out var address))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
return bytes[0] switch
|
||||
{
|
||||
10 => true,
|
||||
172 => bytes[1] >= 16 && bytes[1] <= 31,
|
||||
192 => bytes[1] == 168,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return address.IsIPv6LinkLocal || address.IsIPv6SiteLocal;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the available egress enforcement modes.
|
||||
/// </summary>
|
||||
public enum EgressPolicyMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Outbound egress is permitted. Decisions are advisory only.
|
||||
/// </summary>
|
||||
Unsealed = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Outbound egress is blocked unless an allow rule permits the request.
|
||||
/// </summary>
|
||||
Sealed = 1,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Options used to configure the <see cref="EgressPolicy"/>.
|
||||
/// </summary>
|
||||
public sealed class EgressPolicyOptions
|
||||
{
|
||||
private readonly List<EgressRule> _allowRules = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the enforcement mode.
|
||||
/// </summary>
|
||||
public EgressPolicyMode Mode { get; set; } = EgressPolicyMode.Unsealed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether localhost/loopback destinations are always allowed.
|
||||
/// </summary>
|
||||
public bool AllowLoopback { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether RFC1918 private network ranges are allowed.
|
||||
/// </summary>
|
||||
public bool AllowPrivateNetworks { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured allow rules. Only consulted when <see cref="Mode"/> is <see cref="EgressPolicyMode.Sealed"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<EgressRule> AllowRules => _allowRules;
|
||||
|
||||
/// <summary>
|
||||
/// Optional documentation URL that will be included in remediation guidance.
|
||||
/// </summary>
|
||||
public string? RemediationDocumentationUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional support contact surfaced to operators when requests are blocked.
|
||||
/// </summary>
|
||||
public string? SupportContact { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Removes all configured allow rules.
|
||||
/// </summary>
|
||||
public void ClearAllowRules()
|
||||
{
|
||||
_allowRules.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the configured allow rules with the supplied set.
|
||||
/// </summary>
|
||||
/// <param name="rules">Rules that should be applied.</param>
|
||||
public void SetAllowRules(IEnumerable<EgressRule> rules)
|
||||
{
|
||||
if (rules is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(rules));
|
||||
}
|
||||
|
||||
_allowRules.Clear();
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
AddAllowRule(rule);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an allow rule to the configuration.
|
||||
/// </summary>
|
||||
/// <param name="rule">Rule that should be added.</param>
|
||||
public void AddAllowRule(EgressRule rule)
|
||||
{
|
||||
if (rule is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(rule));
|
||||
}
|
||||
|
||||
_allowRules.Add(rule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an allow rule to the configuration using the supplied parameters.
|
||||
/// </summary>
|
||||
/// <param name="hostPattern">Host pattern to allow.</param>
|
||||
/// <param name="port">Optional port restriction.</param>
|
||||
/// <param name="transport">Transport classification.</param>
|
||||
/// <param name="description">Optional description.</param>
|
||||
public void AddAllowRule(string hostPattern, int? port = null, EgressTransport transport = EgressTransport.Any, string? description = null)
|
||||
=> AddAllowRule(new EgressRule(hostPattern, port, transport, description));
|
||||
|
||||
internal EgressRule[] BuildRuleSet()
|
||||
{
|
||||
var snapshot = new EgressRule[_allowRules.Count];
|
||||
_allowRules.CopyTo(snapshot, 0);
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection helpers for configuring the air-gap egress policy.
|
||||
/// </summary>
|
||||
public static class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers <see cref="IEgressPolicy"/> using the provided configuration delegate.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection that will be updated.</param>
|
||||
/// <param name="configure">Optional configuration delegate.</param>
|
||||
/// <returns>The original <see cref="IServiceCollection"/>.</returns>
|
||||
public static IServiceCollection AddAirGapEgressPolicy(this IServiceCollection services, Action<EgressPolicyOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
if (configure is null)
|
||||
{
|
||||
services.AddOptions<EgressPolicyOptions>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddOptions<EgressPolicyOptions>().Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IEgressPolicy>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<EgressPolicyOptions>>().Value;
|
||||
return new EgressPolicy(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="IEgressPolicy"/> using configuration sourced from the provided <see cref="IConfiguration"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection that will be updated.</param>
|
||||
/// <param name="configuration">Configuration root used to locate air-gap settings.</param>
|
||||
/// <param name="sectionName">
|
||||
/// Optional configuration section name (defaults to <c>"AirGap:Egress"</c>). When the section cannot be resolved,
|
||||
/// the provided <paramref name="configuration"/> root is used.
|
||||
/// </param>
|
||||
/// <returns>The original <see cref="IServiceCollection"/>.</returns>
|
||||
public static IServiceCollection AddAirGapEgressPolicy(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string? sectionName = "AirGap:Egress")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var targetSection = ResolveConfigurationSection(configuration, sectionName);
|
||||
|
||||
return services.AddAirGapEgressPolicy(options =>
|
||||
{
|
||||
ApplyConfiguration(options, targetSection, configuration);
|
||||
});
|
||||
}
|
||||
|
||||
private static IConfiguration ResolveConfigurationSection(IConfiguration configuration, string? sectionName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(sectionName))
|
||||
{
|
||||
var namedSection = configuration.GetSection(sectionName);
|
||||
if (namedSection.Exists())
|
||||
{
|
||||
return namedSection;
|
||||
}
|
||||
}
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
private static void ApplyConfiguration(EgressPolicyOptions options, IConfiguration primarySection, IConfiguration root)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(primarySection);
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
|
||||
var effectiveSection = ResolveEffectiveSection(primarySection);
|
||||
var searchOrder = BuildSearchOrder(effectiveSection, primarySection, root);
|
||||
|
||||
var modeValue = GetStringValue(searchOrder, "Mode");
|
||||
if (!string.IsNullOrWhiteSpace(modeValue) &&
|
||||
Enum.TryParse(modeValue, ignoreCase: true, out EgressPolicyMode parsedMode))
|
||||
{
|
||||
options.Mode = parsedMode;
|
||||
}
|
||||
|
||||
var allowLoopback = GetNullableBool(searchOrder, "AllowLoopback");
|
||||
if (allowLoopback.HasValue)
|
||||
{
|
||||
options.AllowLoopback = allowLoopback.Value;
|
||||
}
|
||||
|
||||
var allowPrivateNetworks = GetNullableBool(searchOrder, "AllowPrivateNetworks");
|
||||
if (allowPrivateNetworks.HasValue)
|
||||
{
|
||||
options.AllowPrivateNetworks = allowPrivateNetworks.Value;
|
||||
}
|
||||
|
||||
var remediationUrl = GetStringValue(searchOrder, "RemediationDocumentationUrl");
|
||||
if (!string.IsNullOrWhiteSpace(remediationUrl))
|
||||
{
|
||||
options.RemediationDocumentationUrl = remediationUrl.Trim();
|
||||
}
|
||||
|
||||
var supportContact = GetStringValue(searchOrder, "SupportContact");
|
||||
if (!string.IsNullOrWhiteSpace(supportContact))
|
||||
{
|
||||
options.SupportContact = supportContact.Trim();
|
||||
}
|
||||
|
||||
var rules = new List<EgressRule>();
|
||||
foreach (var ruleSection in EnumerateAllowRuleSections(effectiveSection, primarySection, root))
|
||||
{
|
||||
var hostPattern = ruleSection["HostPattern"]
|
||||
?? ruleSection["Host"]
|
||||
?? ruleSection["Pattern"]
|
||||
?? ruleSection.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(hostPattern))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hostPattern = hostPattern.Trim();
|
||||
var port = TryReadPort(ruleSection);
|
||||
var transport = ParseTransport(ruleSection["Transport"] ?? ruleSection["Protocol"]);
|
||||
|
||||
var description = ruleSection["Description"] ?? ruleSection["Notes"];
|
||||
description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||
|
||||
rules.Add(new EgressRule(hostPattern, port, transport, description));
|
||||
}
|
||||
|
||||
options.SetAllowRules(rules);
|
||||
}
|
||||
|
||||
private static IConfiguration ResolveEffectiveSection(IConfiguration configuration)
|
||||
{
|
||||
var egressSection = configuration.GetSection("Egress");
|
||||
return egressSection.Exists() ? egressSection : configuration;
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfiguration> BuildSearchOrder(
|
||||
IConfiguration effective,
|
||||
IConfiguration primary,
|
||||
IConfiguration root)
|
||||
{
|
||||
yield return effective;
|
||||
|
||||
if (!ReferenceEquals(primary, effective))
|
||||
{
|
||||
yield return primary;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(root, effective) && !ReferenceEquals(root, primary))
|
||||
{
|
||||
yield return root;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetStringValue(IEnumerable<IConfiguration> sections, string key)
|
||||
{
|
||||
foreach (var section in sections)
|
||||
{
|
||||
var value = section[key];
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? GetNullableBool(IEnumerable<IConfiguration> sections, string key)
|
||||
{
|
||||
foreach (var section in sections)
|
||||
{
|
||||
var value = section[key];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bool.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfigurationSection> EnumerateAllowRuleSections(
|
||||
IConfiguration effective,
|
||||
IConfiguration primary,
|
||||
IConfiguration root)
|
||||
{
|
||||
foreach (var rule in EnumerateAllowRuleSections(effective))
|
||||
{
|
||||
yield return rule;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(primary, effective))
|
||||
{
|
||||
foreach (var rule in EnumerateAllowRuleSections(primary))
|
||||
{
|
||||
yield return rule;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(root, effective) && !ReferenceEquals(root, primary))
|
||||
{
|
||||
foreach (var rule in EnumerateAllowRuleSections(root))
|
||||
{
|
||||
yield return rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfigurationSection> EnumerateAllowRuleSections(IConfiguration configuration)
|
||||
{
|
||||
foreach (var candidate in EnumerateAllowlistContainers(configuration))
|
||||
{
|
||||
if (!candidate.Exists())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var child in candidate.GetChildren())
|
||||
{
|
||||
yield return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfigurationSection> EnumerateAllowlistContainers(IConfiguration configuration)
|
||||
{
|
||||
yield return configuration.GetSection("Allowlist");
|
||||
yield return configuration.GetSection("AllowList");
|
||||
yield return configuration.GetSection("EgressAllowlist");
|
||||
yield return configuration.GetSection("Allow");
|
||||
}
|
||||
|
||||
private static int? TryReadPort(IConfiguration section)
|
||||
{
|
||||
var raw = section["Port"];
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static EgressTransport ParseTransport(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return EgressTransport.Any;
|
||||
}
|
||||
|
||||
return Enum.TryParse(value, ignoreCase: true, out EgressTransport parsed)
|
||||
? parsed
|
||||
: EgressTransport.Any;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Describes an outbound operation that must be evaluated by the egress policy.
|
||||
/// </summary>
|
||||
public readonly record struct EgressRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the component or subsystem requesting egress (e.g. PolicyEngine, ExportCenter).
|
||||
/// </summary>
|
||||
public string Component { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the intent of the egress request (e.g. advisories-sync, telemetry-export).
|
||||
/// </summary>
|
||||
public string Intent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional short description of the operation being performed.
|
||||
/// </summary>
|
||||
public string? Operation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the destination URI.
|
||||
/// </summary>
|
||||
public Uri Destination { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transport classification of the request.
|
||||
/// </summary>
|
||||
public EgressTransport Transport { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EgressRequest"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="component">Component or subsystem initiating the request.</param>
|
||||
/// <param name="destination">Destination URI that will be contacted.</param>
|
||||
/// <param name="intent">Intent label describing why the request is needed.</param>
|
||||
/// <param name="transport">Transport classification. When set to <see cref="EgressTransport.Any"/>, the transport is inferred from the destination.</param>
|
||||
/// <param name="operation">Optional text describing the concrete operation.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when inputs are invalid.</exception>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="destination"/> is null.</exception>
|
||||
public EgressRequest(
|
||||
string component,
|
||||
Uri destination,
|
||||
string intent,
|
||||
EgressTransport transport = EgressTransport.Any,
|
||||
string? operation = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component))
|
||||
{
|
||||
throw new ArgumentException("Component must be provided.", nameof(component));
|
||||
}
|
||||
|
||||
if (destination is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(destination));
|
||||
}
|
||||
|
||||
if (!destination.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException("Destination must be an absolute URI.", nameof(destination));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(intent))
|
||||
{
|
||||
throw new ArgumentException("Intent must be provided.", nameof(intent));
|
||||
}
|
||||
|
||||
Component = component.Trim();
|
||||
Intent = intent.Trim();
|
||||
Operation = string.IsNullOrWhiteSpace(operation) ? null : operation.Trim();
|
||||
Destination = destination;
|
||||
Transport = transport == EgressTransport.Any ? InferTransport(destination) : transport;
|
||||
}
|
||||
|
||||
private static EgressTransport InferTransport(Uri destination)
|
||||
=> destination.Scheme switch
|
||||
{
|
||||
"https" => EgressTransport.Https,
|
||||
"http" => EgressTransport.Http,
|
||||
_ => EgressTransport.Any,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single allow entry used when sealed mode is active.
|
||||
/// </summary>
|
||||
public sealed class EgressRule
|
||||
{
|
||||
private readonly string _hostPattern;
|
||||
private readonly string? _wildcardSuffix;
|
||||
private readonly bool _wildcardAnyHost;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EgressRule"/> class.
|
||||
/// </summary>
|
||||
/// <param name="hostPattern">Host pattern to allow. Supports exact hosts, <c>*.example.org</c>, or <c>*</c>.</param>
|
||||
/// <param name="port">Optional port limitation.</param>
|
||||
/// <param name="transport">Transport classification that must match the request.</param>
|
||||
/// <param name="description">Optional description shown in diagnostics.</param>
|
||||
public EgressRule(string hostPattern, int? port = null, EgressTransport transport = EgressTransport.Any, string? description = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hostPattern))
|
||||
{
|
||||
throw new ArgumentException("Host pattern must be provided.", nameof(hostPattern));
|
||||
}
|
||||
|
||||
_hostPattern = hostPattern.Trim().ToLowerInvariant();
|
||||
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||
Port = port;
|
||||
Transport = transport;
|
||||
|
||||
if (_hostPattern == "*")
|
||||
{
|
||||
_wildcardAnyHost = true;
|
||||
}
|
||||
else if (_hostPattern.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
_wildcardSuffix = _hostPattern[1..]; // keep leading dot for suffix comparisons
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional description that can be surfaced in diagnostics.
|
||||
/// </summary>
|
||||
public string? Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the host pattern that was configured.
|
||||
/// </summary>
|
||||
public string HostPattern => _hostPattern;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional port restriction.
|
||||
/// </summary>
|
||||
public int? Port { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transport classification required for the rule.
|
||||
/// </summary>
|
||||
public EgressTransport Transport { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the rule allows the supplied request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request that will be evaluated.</param>
|
||||
/// <returns><see langword="true"/> when the request is allowed; otherwise <see langword="false"/>.</returns>
|
||||
public bool Allows(EgressRequest request)
|
||||
{
|
||||
if (request.Destination is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Transport != EgressTransport.Any && Transport != request.Transport)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!HostMatches(request.Destination.Host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Port is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var requestPort = request.Destination.Port;
|
||||
return requestPort == Port.Value;
|
||||
}
|
||||
|
||||
private bool HostMatches(string host)
|
||||
{
|
||||
if (string.IsNullOrEmpty(host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = host.ToLowerInvariant();
|
||||
|
||||
if (_wildcardAnyHost)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_wildcardSuffix is not null)
|
||||
{
|
||||
if (!normalized.EndsWith(_wildcardSuffix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var remainderLength = normalized.Length - _wildcardSuffix.Length;
|
||||
return remainderLength > 0;
|
||||
}
|
||||
|
||||
return string.Equals(normalized, _hostPattern, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
=> Port is null
|
||||
? $"{_hostPattern} ({Transport})"
|
||||
: $"{_hostPattern}:{Port} ({Transport})";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Protocol classification for outbound requests.
|
||||
/// </summary>
|
||||
public enum EgressTransport
|
||||
{
|
||||
/// <summary>
|
||||
/// Any transport is acceptable.
|
||||
/// </summary>
|
||||
Any = 0,
|
||||
|
||||
/// <summary>
|
||||
/// HTTP transport.
|
||||
/// </summary>
|
||||
Http = 1,
|
||||
|
||||
/// <summary>
|
||||
/// HTTPS transport.
|
||||
/// </summary>
|
||||
Https = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Generic TCP transport.
|
||||
/// </summary>
|
||||
Tcp = 3,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract used by StellaOps services to validate outbound network requests
|
||||
/// against the current air-gap configuration.
|
||||
/// </summary>
|
||||
public interface IEgressPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the environment is operating in sealed mode.
|
||||
/// </summary>
|
||||
bool IsSealed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured policy mode.
|
||||
/// </summary>
|
||||
EgressPolicyMode Mode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the supplied request and returns the decision without throwing.
|
||||
/// </summary>
|
||||
/// <param name="request">The outbound request that should be evaluated.</param>
|
||||
/// <returns>The resulting decision.</returns>
|
||||
EgressDecision Evaluate(EgressRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the supplied request asynchronously and returns the decision without throwing.
|
||||
/// </summary>
|
||||
/// <param name="request">The outbound request that should be evaluated.</param>
|
||||
/// <param name="cancellationToken">Token used to observe cancellation.</param>
|
||||
/// <returns>The resulting decision.</returns>
|
||||
ValueTask<EgressDecision> EvaluateAsync(EgressRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Throws when the supplied request is not permitted by the current policy.
|
||||
/// </summary>
|
||||
/// <param name="request">The outbound request that should be validated.</param>
|
||||
void EnsureAllowed(EgressRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Throws when the supplied request is not permitted by the current policy.
|
||||
/// </summary>
|
||||
/// <param name="request">The outbound request that should be validated.</param>
|
||||
/// <param name="cancellationToken">Token used to observe cancellation.</param>
|
||||
ValueTask EnsureAllowedAsync(EgressRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,19 +1,19 @@
|
||||
# AirGap Policy Task Board — Epic 16: Air-Gapped Mode
|
||||
|
||||
## Sprint 56 – Facade & Contracts
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AIRGAP-POL-56-001 | TODO | AirGap Policy Guild | TELEMETRY-OBS-50-001 | Implement `StellaOps.AirGap.Policy` package exposing `EgressPolicy` facade with sealed/unsealed branches and remediation-friendly errors. | Facade package builds/tests; integration tests simulate sealed/unsealed; error contract documented. |
|
||||
| AIRGAP-POL-56-002 | TODO | AirGap Policy Guild, DevEx Guild | AIRGAP-POL-56-001 | Create Roslyn analyzer/code fix warning on raw `HttpClient` usage outside approved wrappers; add CI integration. | Analyzer packaged; CI fails on intentional violation; docs updated for opt-in. |
|
||||
|
||||
## Sprint 57 – Service Adoption Wave 1
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AIRGAP-POL-57-001 | TODO | AirGap Policy Guild, BE-Base Platform Guild | AIRGAP-POL-56-001 | Update core web services (Web, Exporter, Policy, Findings, Authority) to use `EgressPolicy`; ensure configuration wiring for sealed mode. | Services compile with facade; sealed-mode tests run in CI; configuration docs updated. |
|
||||
| AIRGAP-POL-57-002 | TODO | AirGap Policy Guild, Task Runner Guild | AIRGAP-POL-56-001, TASKRUN-OBS-50-001 | Implement Task Runner job plan validator rejecting network steps unless marked internal allow-list. | Validator blocks forbidden steps; tests cover allow/deny; error surfaces remediation text. |
|
||||
|
||||
## Sprint 58 – Service Adoption Wave 2
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AIRGAP-POL-58-001 | TODO | AirGap Policy Guild, Observability Guild | AIRGAP-POL-57-001 | Ensure Observability exporters only target local endpoints in sealed mode; disable remote sinks with warning. | Exporters respect sealed flag; timeline/log message emitted; docs updated. |
|
||||
| AIRGAP-POL-58-002 | TODO | AirGap Policy Guild, CLI Guild | AIRGAP-POL-56-001, CLI-OBS-50-001 | Add CLI sealed-mode guard that refuses commands needing egress and surfaces remediation. | CLI returns `AIRGAP_EGRESS_BLOCKED`; tests cover sealed/unsealed flows; help text updated. |
|
||||
# AirGap Policy Task Board — Epic 16: Air-Gapped Mode
|
||||
|
||||
## Sprint 56 – Facade & Contracts
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AIRGAP-POL-56-001 | DONE | AirGap Policy Guild | TELEMETRY-OBS-50-001 | Implement `StellaOps.AirGap.Policy` package exposing `EgressPolicy` facade with sealed/unsealed branches and remediation-friendly errors. | Facade package builds/tests; integration tests simulate sealed/unsealed; error contract documented. |
|
||||
| AIRGAP-POL-56-002 | DONE (2025-11-03) | AirGap Policy Guild, DevEx Guild | AIRGAP-POL-56-001 | Create Roslyn analyzer/code fix warning on raw `HttpClient` usage outside approved wrappers; add CI integration.<br>2025-11-02: Analyzer skeleton drafted (HttpClient walker + diagnostics); CI wiring prototyped locally.<br>2025-11-03: Analyzer + code fix published (`StellaOps.AirGap.Policy.Analyzers`); unit tests added; NuGet mappings updated; adoption doc appended. | Analyzer packaged; CI fails on intentional violation; docs updated for opt-in. |
|
||||
|
||||
## Sprint 57 – Service Adoption Wave 1
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AIRGAP-POL-57-001 | DONE (2025-11-03) | AirGap Policy Guild, BE-Base Platform Guild | AIRGAP-POL-56-001 | Update core web services (Web, Exporter, Policy, Findings, Authority) to use `EgressPolicy`; ensure configuration wiring for sealed mode.<br>2025-11-03: Authority/Policy/Export Center wired to new configuration binder; auth client enforces egress policy; air-gap configuration tests added. Findings/Web service adoption blocked pending service scaffolds. | Services compile with facade; sealed-mode tests run in CI; configuration docs updated. |
|
||||
| AIRGAP-POL-57-002 | DONE (2025-11-03) | AirGap Policy Guild, Task Runner Guild | AIRGAP-POL-56-001, TASKRUN-OBS-50-001 | Implement Task Runner job plan validator rejecting network steps unless marked internal allow-list.<br>2025-11-03: Task Runner worker consumes `IEgressPolicy`; filesystem dispatcher feeds policy-aware planner; sealed-mode dispatcher test added; Http/Logging packages aligned to `10.0.0-rc.2`. | Validator blocks forbidden steps; tests cover allow/deny; error surfaces remediation text. |
|
||||
|
||||
## Sprint 58 – Service Adoption Wave 2
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AIRGAP-POL-58-001 | TODO | AirGap Policy Guild, Observability Guild | AIRGAP-POL-57-001 | Ensure Observability exporters only target local endpoints in sealed mode; disable remote sinks with warning. | Exporters respect sealed flag; timeline/log message emitted; docs updated. |
|
||||
| AIRGAP-POL-58-002 | TODO | AirGap Policy Guild, CLI Guild | AIRGAP-POL-56-001, CLI-OBS-50-001 | Add CLI sealed-mode guard that refuses commands needing egress and surfaces remediation. | CLI returns `AIRGAP_EGRESS_BLOCKED`; tests cover sealed/unsealed flows; help text updated. |
|
||||
|
||||
@@ -86,9 +86,13 @@ components:
|
||||
signals:write: Publish Signals events or mutate state.
|
||||
stellaops.bypass: Bypass trust boundary protections (restricted identities only).
|
||||
ui.read: Read Console UX resources.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
vuln:read: Read vulnerability permalinks and overlays.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
vuln:view: Read vulnerability overlays and issue permalinks.
|
||||
vuln:investigate: Perform vulnerability triage actions (assign, comment, annotate).
|
||||
vuln:operate: Execute vulnerability workflow transitions and remediation tasks.
|
||||
vuln:audit: Access vulnerability audit ledgers and exports.
|
||||
vuln:read: Read vulnerability permalinks and overlays. (legacy compatibility; prefer vuln:view)
|
||||
authorizationCode:
|
||||
authorizationUrl: /authorize
|
||||
tokenUrl: /token
|
||||
@@ -150,9 +154,13 @@ components:
|
||||
signals:write: Publish Signals events or mutate state.
|
||||
stellaops.bypass: Bypass trust boundary protections (restricted identities only).
|
||||
ui.read: Read Console UX resources.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
vuln:read: Read vulnerability permalinks and overlays.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
vuln:view: Read vulnerability overlays and issue permalinks.
|
||||
vuln:investigate: Perform vulnerability triage actions (assign, comment, annotate).
|
||||
vuln:operate: Execute vulnerability workflow transitions and remediation tasks.
|
||||
vuln:audit: Access vulnerability audit ledgers and exports.
|
||||
vuln:read: Read vulnerability permalinks and overlays. (legacy compatibility; prefer vuln:view)
|
||||
OAuthClientCredentials:
|
||||
type: oauth2
|
||||
description: Client credential exchange for machine-to-machine identities.
|
||||
@@ -213,9 +221,13 @@ components:
|
||||
signals:write: Publish Signals events or mutate state.
|
||||
stellaops.bypass: Bypass trust boundary protections (restricted identities only).
|
||||
ui.read: Read Console UX resources.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
vuln:read: Read vulnerability permalinks and overlays.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
vuln:view: Read vulnerability overlays and issue permalinks.
|
||||
vuln:investigate: Perform vulnerability triage actions (assign, comment, annotate).
|
||||
vuln:operate: Execute vulnerability workflow transitions and remediation tasks.
|
||||
vuln:audit: Access vulnerability audit ledgers and exports.
|
||||
vuln:read: Read vulnerability permalinks and overlays. (legacy compatibility; prefer vuln:view)
|
||||
schemas:
|
||||
TokenResponse:
|
||||
type: object
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
### Sprint 74 – Transparency & Bulk
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ATTESTOR-74-001 | DONE (2025-11-02) | Attestor Service Guild | ATTESTOR-73-002, TRANSP-74-001 | Integrate transparency witness client, inclusion proof verification, and caching. | Witness proofs stored; verification fails on missing/inconsistent proofs; metrics emitted. |
|
||||
| ATTESTOR-74-001 | DONE (2025-11-02) | Attestor Service Guild | ATTESTOR-73-002, TRANSP-74-001 | Integrate transparency witness client, inclusion proof verification, and caching.<br>2025-11-02: Witness client wired with repository schema update; verification/reporting paths refreshed and test suite green. | Witness proofs stored; verification fails on missing/inconsistent proofs; metrics emitted. |
|
||||
| ATTESTOR-74-002 | DONE | Attestor Service Guild | ATTESTOR-73-002 | Implement bulk verification worker + API with progress tracking, rate limits, and caching. | Bulk job API functional; worker processes batches; telemetry recorded. |
|
||||
|
||||
### Sprint 75 – Air Gap & Hardening
|
||||
|
||||
@@ -75,7 +75,11 @@ components:
|
||||
ui.read: Read Console UX resources.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
vuln:read: Read vulnerability permalinks and overlays.
|
||||
vuln:view: Read vulnerability overlays and issue permalinks.
|
||||
vuln:investigate: Perform vulnerability triage actions (assign, comment, annotate).
|
||||
vuln:operate: Execute vulnerability workflow transitions and remediation tasks.
|
||||
vuln:audit: Access vulnerability audit ledgers and exports.
|
||||
vuln:read: Read vulnerability permalinks and overlays. (legacy compatibility; prefer vuln:view)
|
||||
authorizationCode:
|
||||
authorizationUrl: /authorize
|
||||
tokenUrl: /token
|
||||
@@ -126,7 +130,11 @@ components:
|
||||
ui.read: Read Console UX resources.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
vuln:read: Read vulnerability permalinks and overlays.
|
||||
vuln:view: Read vulnerability overlays and issue permalinks.
|
||||
vuln:investigate: Perform vulnerability triage actions (assign, comment, annotate).
|
||||
vuln:operate: Execute vulnerability workflow transitions and remediation tasks.
|
||||
vuln:audit: Access vulnerability audit ledgers and exports.
|
||||
vuln:read: Read vulnerability permalinks and overlays. (legacy compatibility; prefer vuln:view)
|
||||
OAuthClientCredentials:
|
||||
type: oauth2
|
||||
description: Client credential exchange for machine-to-machine identities.
|
||||
@@ -179,7 +187,11 @@ components:
|
||||
ui.read: Read Console UX resources.
|
||||
vex:ingest: Submit VEX ingestion payloads.
|
||||
vex:read: Read VEX ingestion data.
|
||||
vuln:read: Read vulnerability permalinks and overlays.
|
||||
vuln:view: Read vulnerability overlays and issue permalinks.
|
||||
vuln:investigate: Perform vulnerability triage actions (assign, comment, annotate).
|
||||
vuln:operate: Execute vulnerability workflow transitions and remediation tasks.
|
||||
vuln:audit: Access vulnerability audit ledgers and exports.
|
||||
vuln:read: Read vulnerability permalinks and overlays. (legacy compatibility; prefer vuln:view)
|
||||
schemas:
|
||||
TokenResponse:
|
||||
type: object
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions.Tests;
|
||||
|
||||
public class StellaOpsScopesTests
|
||||
namespace StellaOps.Auth.Abstractions.Tests;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
|
||||
public class StellaOpsScopesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(StellaOpsScopes.AdvisoryRead)]
|
||||
@@ -33,8 +35,12 @@ public class StellaOpsScopesTests
|
||||
[InlineData(StellaOpsScopes.PolicySimulate)]
|
||||
[InlineData(StellaOpsScopes.FindingsRead)]
|
||||
[InlineData(StellaOpsScopes.EffectiveWrite)]
|
||||
[InlineData(StellaOpsScopes.GraphRead)]
|
||||
[InlineData(StellaOpsScopes.VulnRead)]
|
||||
[InlineData(StellaOpsScopes.GraphRead)]
|
||||
[InlineData(StellaOpsScopes.VulnView)]
|
||||
[InlineData(StellaOpsScopes.VulnInvestigate)]
|
||||
[InlineData(StellaOpsScopes.VulnOperate)]
|
||||
[InlineData(StellaOpsScopes.VulnAudit)]
|
||||
[InlineData(StellaOpsScopes.VulnRead)]
|
||||
[InlineData(StellaOpsScopes.GraphWrite)]
|
||||
[InlineData(StellaOpsScopes.GraphExport)]
|
||||
[InlineData(StellaOpsScopes.GraphSimulate)]
|
||||
@@ -82,8 +88,13 @@ public class StellaOpsScopesTests
|
||||
[InlineData("Packs.Run", StellaOpsScopes.PacksRun)]
|
||||
[InlineData("Packs.Approve", StellaOpsScopes.PacksApprove)]
|
||||
[InlineData("Notify.Escalate", StellaOpsScopes.NotifyEscalate)]
|
||||
[InlineData("VULN:VIEW", StellaOpsScopes.VulnView)]
|
||||
[InlineData("VULN:INVESTIGATE", StellaOpsScopes.VulnInvestigate)]
|
||||
[InlineData("VULN:OPERATE", StellaOpsScopes.VulnOperate)]
|
||||
[InlineData("VULN:AUDIT", StellaOpsScopes.VulnAudit)]
|
||||
public void Normalize_NormalizesToLowerCase(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, StellaOpsScopes.Normalize(input));
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
@@ -115,6 +115,21 @@ public static class StellaOpsClaimTypes
|
||||
/// </summary>
|
||||
public const string IncidentReason = "stellaops:incident_reason";
|
||||
|
||||
/// <summary>
|
||||
/// Attribute-based access control filter for vulnerability environment visibility.
|
||||
/// </summary>
|
||||
public const string VulnerabilityEnvironment = "stellaops:attr:env";
|
||||
|
||||
/// <summary>
|
||||
/// Attribute-based access control filter for vulnerability ownership visibility.
|
||||
/// </summary>
|
||||
public const string VulnerabilityOwner = "stellaops:attr:owner";
|
||||
|
||||
/// <summary>
|
||||
/// Attribute-based access control filter for vulnerability business tier visibility.
|
||||
/// </summary>
|
||||
public const string VulnerabilityBusinessTier = "stellaops:attr:business_tier";
|
||||
|
||||
/// <summary>
|
||||
/// Session identifier claim (<c>sid</c>).
|
||||
/// </summary>
|
||||
|
||||
@@ -206,8 +206,29 @@ public static class StellaOpsScopes
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Vuln Explorer resources and permalinks.
|
||||
/// </summary>
|
||||
[Obsolete("Use vuln:view (StellaOpsScopes.VulnView) instead.")]
|
||||
public const string VulnRead = "vuln:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Vuln Explorer findings, reports, and dashboards.
|
||||
/// </summary>
|
||||
public const string VulnView = "vuln:view";
|
||||
|
||||
/// <summary>
|
||||
/// Scope permitting triage actions (assign, comment, annotate) within Vuln Explorer.
|
||||
/// </summary>
|
||||
public const string VulnInvestigate = "vuln:investigate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope permitting state-changing operations (status transitions, remediation workflows) within Vuln Explorer.
|
||||
/// </summary>
|
||||
public const string VulnOperate = "vuln:operate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope permitting access to Vuln Explorer audit exports and immutable ledgers.
|
||||
/// </summary>
|
||||
public const string VulnAudit = "vuln:audit";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to observability dashboards and overlays.
|
||||
/// </summary>
|
||||
@@ -399,7 +420,13 @@ public static class StellaOpsScopes
|
||||
FindingsRead,
|
||||
EffectiveWrite,
|
||||
GraphRead,
|
||||
VulnView,
|
||||
VulnInvestigate,
|
||||
VulnOperate,
|
||||
VulnAudit,
|
||||
#pragma warning disable CS0618 // track removal once legacy scope dropped
|
||||
VulnRead,
|
||||
#pragma warning restore CS0618
|
||||
ObservabilityRead,
|
||||
TimelineRead,
|
||||
TimelineWrite,
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -12,6 +13,7 @@ using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
@@ -73,6 +75,39 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.Contains(recordedHandlers, handler => handler.GetType().Name.Contains("PolicyHttpMessageHandler", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureEgressAllowed_InvokesPolicyWhenAuthorityProvided()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var recordingPolicy = new RecordingPolicy();
|
||||
services.AddSingleton<IEgressPolicy>(recordingPolicy);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
DiscoveryCacheLifetime = TimeSpan.FromMinutes(1),
|
||||
JwksCacheLifetime = TimeSpan.FromMinutes(1),
|
||||
AllowOfflineCacheFallback = false,
|
||||
};
|
||||
|
||||
options.Validate();
|
||||
|
||||
var method = typeof(ServiceCollectionExtensions)
|
||||
.GetMethod("EnsureEgressAllowed", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
method!.Invoke(null, new object?[] { provider, options, "authority-discovery" });
|
||||
|
||||
Assert.Single(recordingPolicy.Requests);
|
||||
var request = recordingPolicy.Requests[0];
|
||||
Assert.Equal("StellaOpsAuthClient", request.Component);
|
||||
Assert.Equal(new Uri("https://authority.test"), request.Destination);
|
||||
Assert.Equal("authority-discovery", request.Intent);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string jsonContent)
|
||||
{
|
||||
return new HttpResponseMessage(statusCode)
|
||||
@@ -224,6 +259,37 @@ public class ServiceCollectionExtensionsTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingPolicy : IEgressPolicy
|
||||
{
|
||||
private readonly List<EgressRequest> requests = new();
|
||||
|
||||
public bool IsSealed => true;
|
||||
|
||||
public EgressPolicyMode Mode => EgressPolicyMode.Sealed;
|
||||
|
||||
public IReadOnlyList<EgressRequest> Requests => requests;
|
||||
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
requests.Add(request);
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
public ValueTask<EgressDecision> EvaluateAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
=> new(Evaluate(request));
|
||||
|
||||
public void EnsureAllowed(EgressRequest request)
|
||||
{
|
||||
requests.Add(request);
|
||||
}
|
||||
|
||||
public ValueTask EnsureAllowedAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureAllowed(request);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
public int RequestCount { get; private set; }
|
||||
@@ -278,11 +344,11 @@ public class ServiceCollectionExtensionsTests
|
||||
null,
|
||||
"{}");
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new JsonWebKeySet());
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
@@ -32,18 +33,21 @@ public static class ServiceCollectionExtensions
|
||||
services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
EnsureEgressAllowed(provider, options, "authority-discovery");
|
||||
client.Timeout = options.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
|
||||
|
||||
services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
EnsureEgressAllowed(provider, options, "authority-jwks");
|
||||
client.Timeout = options.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
|
||||
|
||||
services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
EnsureEgressAllowed(provider, options, "authority-token");
|
||||
client.Timeout = options.HttpTimeout;
|
||||
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
|
||||
|
||||
@@ -135,4 +139,28 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void EnsureEgressAllowed(
|
||||
IServiceProvider provider,
|
||||
StellaOpsAuthClientOptions options,
|
||||
string intent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(intent);
|
||||
|
||||
if (options.AuthorityUri is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var policy = provider.GetService<IEgressPolicy>();
|
||||
if (policy is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new EgressRequest("StellaOpsAuthClient", options.AuthorityUri, intent);
|
||||
policy.EnsureAllowed(request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
@@ -44,4 +45,4 @@
|
||||
<_Parameter1>StellaOps.Auth.Client.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -798,6 +798,13 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CS0618 // compatibility with legacy vuln:read scope
|
||||
if (scopes.Contains(StellaOpsScopes.VulnRead) && !scopes.Contains(StellaOpsScopes.VulnView))
|
||||
{
|
||||
scopes.Add(StellaOpsScopes.VulnView);
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ public sealed class AuthorityServiceAccountDocument
|
||||
[BsonElement("authorizedClients")]
|
||||
public List<string> AuthorizedClients { get; set; } = new();
|
||||
|
||||
[BsonElement("attributes")]
|
||||
public Dictionary<string, List<string>> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
|
||||
@@ -105,4 +105,16 @@ public sealed class AuthorityTokenDocument
|
||||
[BsonElement("actors")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? ActorChain { get; set; }
|
||||
|
||||
[BsonElement("vulnEnv")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityEnvironment { get; set; }
|
||||
|
||||
[BsonElement("vulnOwner")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityOwner { get; set; }
|
||||
|
||||
[BsonElement("vulnBusinessTier")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityBusinessTier { get; set; }
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ internal sealed class AuthorityServiceAccountStore : IAuthorityServiceAccountSto
|
||||
|
||||
NormalizeList(document.AllowedScopes, static scope => scope.Trim().ToLowerInvariant(), StringComparer.Ordinal);
|
||||
NormalizeList(document.AuthorizedClients, static client => client.Trim().ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
|
||||
NormalizeAttributes(document.Attributes);
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, Func<string, string> normalizer, IEqualityComparer<string> comparer)
|
||||
@@ -181,4 +182,77 @@ internal sealed class AuthorityServiceAccountStore : IAuthorityServiceAccountSto
|
||||
values[index] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeAttributes(IDictionary<string, List<string>> attributes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attributes);
|
||||
|
||||
if (attributes.Count == 0)
|
||||
{
|
||||
attributes.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (name, values) in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = name.Trim().ToLowerInvariant();
|
||||
if (!AllowedAttributeKeys.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedValues = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var wildcard = false;
|
||||
|
||||
if (values is not null)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Equals("*", StringComparison.Ordinal))
|
||||
{
|
||||
normalizedValues.Clear();
|
||||
normalizedValues.Add("*");
|
||||
wildcard = true;
|
||||
break;
|
||||
}
|
||||
|
||||
var lower = trimmed.ToLowerInvariant();
|
||||
if (seen.Add(lower))
|
||||
{
|
||||
normalizedValues.Add(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wildcard)
|
||||
{
|
||||
normalized[key] = new List<string> { "*" };
|
||||
}
|
||||
else if (normalizedValues.Count > 0)
|
||||
{
|
||||
normalized[key] = normalizedValues;
|
||||
}
|
||||
}
|
||||
|
||||
attributes.Clear();
|
||||
foreach (var pair in normalized)
|
||||
{
|
||||
attributes[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> AllowedAttributeKeys = new(new[] { "env", "owner", "business_tier" }, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -7,15 +7,20 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
@@ -101,6 +106,21 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<StellaOpsAuthorityOptions>>();
|
||||
Assert.True(options.Value.Bootstrap.Enabled);
|
||||
var endpoints = scope.ServiceProvider.GetRequiredService<EndpointDataSource>().Endpoints;
|
||||
var serviceAccountsEndpoint = endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.Single(endpoint =>
|
||||
{
|
||||
var pattern = endpoint.RoutePattern.RawText?.TrimStart('/');
|
||||
return string.Equals(pattern, "internal/service-accounts", StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
Assert.Equal("internal/service-accounts", serviceAccountsEndpoint.RoutePattern.RawText?.TrimStart('/'));
|
||||
}
|
||||
|
||||
var response = await client.GetAsync($"/internal/service-accounts?tenant={TenantId}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -115,6 +135,13 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
|
||||
Assert.True(serviceAccount.Enabled);
|
||||
Assert.Equal(new[] { "findings:read", "jobs:read" }, serviceAccount.AllowedScopes);
|
||||
Assert.Equal(new[] { "export-center-worker" }, serviceAccount.AuthorizedClients);
|
||||
Assert.NotNull(serviceAccount.Attributes);
|
||||
Assert.True(serviceAccount.Attributes.TryGetValue("env", out var envValues));
|
||||
Assert.Equal(new[] { "prod" }, envValues);
|
||||
Assert.True(serviceAccount.Attributes.TryGetValue("owner", out var ownerValues));
|
||||
Assert.Equal(new[] { "vuln-team" }, ownerValues);
|
||||
Assert.True(serviceAccount.Attributes.TryGetValue("business_tier", out var tierValues));
|
||||
Assert.Equal(new[] { "tier-1" }, tierValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -454,8 +481,52 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
|
||||
Assert.Equal("token-active", GetPropertyValue(audit, "delegation.revoked_token[0]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_RepeatedSeeding_PreservesServiceAccountIdentity()
|
||||
{
|
||||
string? initialId;
|
||||
DateTimeOffset initialCreatedAt;
|
||||
|
||||
using (var firstApp = CreateApplication())
|
||||
{
|
||||
await using var scope = firstApp.Services.CreateAsyncScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IAuthorityServiceAccountStore>();
|
||||
var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
Assert.NotNull(document);
|
||||
initialId = document!.Id;
|
||||
initialCreatedAt = document.CreatedAt;
|
||||
}
|
||||
|
||||
using (var secondApp = CreateApplication())
|
||||
{
|
||||
await using var scope = secondApp.Services.CreateAsyncScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IAuthorityServiceAccountStore>();
|
||||
var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(initialId, document!.Id);
|
||||
Assert.Equal(initialCreatedAt, document.CreatedAt);
|
||||
Assert.True(document.UpdatedAt >= initialCreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateApplication(Action<IWebHostBuilder>? configure = null)
|
||||
{
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__ENABLED", "true");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__APIKEY", BootstrapKey);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__DEFAULTIDENTITYPROVIDER", "standard");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__ID", TenantId);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__DISPLAYNAME", "Default Tenant");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__QUOTAS__MAXACTIVETOKENS", "50");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ACCOUNTID", ServiceAccountId);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__TENANT", TenantId);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DISPLAYNAME", "Observability Exporter");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DESCRIPTION", "Automates evidence exports.");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__0", "jobs:read");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__1", "findings:read");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__AUTHORIZEDCLIENTS__0", "export-center-worker");
|
||||
|
||||
return factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
@@ -478,6 +549,47 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Bootstrap.Enabled = true;
|
||||
options.Bootstrap.ApiKey = BootstrapKey;
|
||||
options.Bootstrap.DefaultIdentityProvider = "standard";
|
||||
|
||||
if (options.Tenants.Count == 0)
|
||||
{
|
||||
options.Tenants.Add(new AuthorityTenantOptions
|
||||
{
|
||||
Id = TenantId,
|
||||
DisplayName = "Default Tenant"
|
||||
});
|
||||
}
|
||||
|
||||
options.Delegation.Quotas.MaxActiveTokens = 50;
|
||||
|
||||
if (options.Delegation.ServiceAccounts.Count == 0)
|
||||
{
|
||||
var serviceAccount = new AuthorityServiceAccountSeedOptions
|
||||
{
|
||||
AccountId = ServiceAccountId,
|
||||
Tenant = TenantId,
|
||||
DisplayName = "Observability Exporter",
|
||||
Description = "Automates evidence exports."
|
||||
};
|
||||
|
||||
serviceAccount.AllowedScopes.Add("jobs:read");
|
||||
serviceAccount.AllowedScopes.Add("findings:read");
|
||||
serviceAccount.AuthorizedClients.Add("export-center-worker");
|
||||
serviceAccount.Attributes["env"] = new List<string> { "prod" };
|
||||
serviceAccount.Attributes["owner"] = new List<string> { "vuln-team" };
|
||||
serviceAccount.Attributes["business_tier"] = new List<string> { "tier-1" };
|
||||
|
||||
options.Delegation.ServiceAccounts.Add(serviceAccount);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
configure?.Invoke(host);
|
||||
});
|
||||
}
|
||||
@@ -496,7 +608,8 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
IReadOnlyList<string> AllowedScopes,
|
||||
IReadOnlyList<string> AuthorizedClients);
|
||||
IReadOnlyList<string> AuthorizedClients,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> Attributes);
|
||||
|
||||
private sealed record ServiceAccountTokenResponse(
|
||||
string TokenId,
|
||||
|
||||
@@ -20,9 +20,22 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
private const string SchemaVersionKey = "STELLAOPS_AUTHORITY_AUTHORITY__SCHEMAVERSION";
|
||||
private const string StorageConnectionKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__CONNECTIONSTRING";
|
||||
private const string StorageDatabaseKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__DATABASENAME";
|
||||
private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED";
|
||||
private const string AckTokensEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED";
|
||||
private const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED";
|
||||
private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED";
|
||||
private const string AckTokensEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED";
|
||||
private const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED";
|
||||
private const string BootstrapEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__ENABLED";
|
||||
private const string BootstrapApiKey = "STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__APIKEY";
|
||||
private const string BootstrapDefaultProviderKey = "STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__DEFAULTIDENTITYPROVIDER";
|
||||
private const string TenantIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__ID";
|
||||
private const string TenantDisplayNameKey = "STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__DISPLAYNAME";
|
||||
private const string DelegationQuotaKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__QUOTAS__MAXACTIVETOKENS";
|
||||
private const string ServiceAccountIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ACCOUNTID";
|
||||
private const string ServiceAccountTenantKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__TENANT";
|
||||
private const string ServiceAccountDisplayNameKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DISPLAYNAME";
|
||||
private const string ServiceAccountDescriptionKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__DESCRIPTION";
|
||||
private const string ServiceAccountScope0Key = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__0";
|
||||
private const string ServiceAccountScope1Key = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__1";
|
||||
private const string ServiceAccountAuthorizedClientKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__AUTHORIZEDCLIENTS__0";
|
||||
|
||||
public AuthorityWebApplicationFactory()
|
||||
{
|
||||
@@ -38,11 +51,24 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
|
||||
Environment.SetEnvironmentVariable(IssuerKey, "https://authority.test");
|
||||
Environment.SetEnvironmentVariable(SchemaVersionKey, "1");
|
||||
Environment.SetEnvironmentVariable(StorageConnectionKey, mongoRunner.ConnectionString);
|
||||
Environment.SetEnvironmentVariable(StorageDatabaseKey, "authority-tests");
|
||||
Environment.SetEnvironmentVariable(SigningEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(AckTokensEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(WebhooksEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(StorageConnectionKey, mongoRunner.ConnectionString);
|
||||
Environment.SetEnvironmentVariable(StorageDatabaseKey, "authority-tests");
|
||||
Environment.SetEnvironmentVariable(SigningEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(AckTokensEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(WebhooksEnabledKey, "false");
|
||||
Environment.SetEnvironmentVariable(BootstrapEnabledKey, "true");
|
||||
Environment.SetEnvironmentVariable(BootstrapApiKey, "test-bootstrap-key");
|
||||
Environment.SetEnvironmentVariable(BootstrapDefaultProviderKey, "standard");
|
||||
Environment.SetEnvironmentVariable(TenantIdKey, "tenant-default");
|
||||
Environment.SetEnvironmentVariable(TenantDisplayNameKey, "Default Tenant");
|
||||
Environment.SetEnvironmentVariable(DelegationQuotaKey, "50");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountIdKey, "svc-observer");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountTenantKey, "tenant-default");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountDisplayNameKey, "Observability Exporter");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountDescriptionKey, "Automates evidence exports.");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountScope0Key, "jobs:read");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountScope1Key, "findings:read");
|
||||
Environment.SetEnvironmentVariable(ServiceAccountAuthorizedClientKey, "export-center-worker");
|
||||
}
|
||||
|
||||
public string ConnectionString => mongoRunner.ConnectionString;
|
||||
@@ -116,6 +142,19 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
Environment.SetEnvironmentVariable(SigningEnabledKey, null);
|
||||
Environment.SetEnvironmentVariable(AckTokensEnabledKey, null);
|
||||
Environment.SetEnvironmentVariable(WebhooksEnabledKey, null);
|
||||
Environment.SetEnvironmentVariable(BootstrapEnabledKey, null);
|
||||
Environment.SetEnvironmentVariable(BootstrapApiKey, null);
|
||||
Environment.SetEnvironmentVariable(BootstrapDefaultProviderKey, null);
|
||||
Environment.SetEnvironmentVariable(TenantIdKey, null);
|
||||
Environment.SetEnvironmentVariable(TenantDisplayNameKey, null);
|
||||
Environment.SetEnvironmentVariable(DelegationQuotaKey, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountIdKey, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountTenantKey, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountDisplayNameKey, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountDescriptionKey, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountScope0Key, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountScope1Key, null);
|
||||
Environment.SetEnvironmentVariable(ServiceAccountAuthorizedClientKey, null);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Notifications;
|
||||
@@ -68,6 +69,14 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<Authorit
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
@@ -144,6 +153,14 @@ public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<Authorit
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
|
||||
@@ -306,6 +306,213 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal("Delegation token quota exceeded for service account.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsVulnScopeWhenAttributeAmbiguous()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "vuln:view",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-vuln",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "vuln:view" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId }
|
||||
};
|
||||
|
||||
serviceAccount.Attributes["env"] = new List<string> { "prod", "stage" };
|
||||
serviceAccount.Attributes["owner"] = new List<string> { "security" };
|
||||
serviceAccount.Attributes["business_tier"] = new List<string> { "tier-1" };
|
||||
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
metadataAccessor,
|
||||
new TestServiceAccountStore(serviceAccount),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln");
|
||||
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("vuln_env must be supplied when multiple values are configured for the service account.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsVulnScopeWhenAttributesProvided()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "vuln:view",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-vuln",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "vuln:view" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId }
|
||||
};
|
||||
|
||||
serviceAccount.Attributes["env"] = new List<string> { "prod", "stage" };
|
||||
serviceAccount.Attributes["owner"] = new List<string> { "security" };
|
||||
serviceAccount.Attributes["business_tier"] = new List<string> { "tier-1" };
|
||||
|
||||
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
|
||||
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName, "security");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1");
|
||||
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected);
|
||||
Assert.Equal("prod", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnEnvironmentProperty]);
|
||||
Assert.Equal("security", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnOwnerProperty]);
|
||||
Assert.Equal("tier-1", context.Transaction.Properties[AuthorityOpenIddictConstants.VulnBusinessTierProperty]);
|
||||
|
||||
var metadata = metadataAccessor.GetMetadata();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.True(metadata!.Tags.TryGetValue("authority.vuln_env", out var envTag));
|
||||
Assert.Equal("prod", envTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleClientCredentials_PersistsVulnAttributes()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "vuln:view",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-vuln",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "vuln:view" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId }
|
||||
};
|
||||
|
||||
serviceAccount.Attributes["env"] = new List<string> { "prod", "stage" };
|
||||
serviceAccount.Attributes["owner"] = new List<string> { "security" };
|
||||
serviceAccount.Attributes["business_tier"] = new List<string> { "tier-1" };
|
||||
|
||||
var tokenStore = new TestTokenStore();
|
||||
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
|
||||
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view");
|
||||
transaction.Options = new OpenIddictServerOptions
|
||||
{
|
||||
AccessTokenLifetime = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName, "security");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1");
|
||||
|
||||
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await validateHandler.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handleHandler = new HandleClientCredentialsHandler(
|
||||
registry,
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
metadataAccessor,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
|
||||
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
|
||||
await handleHandler.HandleAsync(handleContext);
|
||||
|
||||
Assert.True(handleContext.IsRequestHandled);
|
||||
var principal = Assert.IsType<ClaimsPrincipal>(handleContext.Principal);
|
||||
Assert.Equal("prod", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityEnvironment));
|
||||
Assert.Equal("security", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityOwner));
|
||||
Assert.Equal("tier-1", principal.FindFirstValue(StellaOpsClaimTypes.VulnerabilityBusinessTier));
|
||||
|
||||
var persistHandler = new PersistTokensHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
|
||||
{
|
||||
Principal = principal,
|
||||
AccessTokenPrincipal = principal
|
||||
};
|
||||
|
||||
await persistHandler.HandleAsync(signInContext);
|
||||
|
||||
Assert.NotNull(tokenStore.Inserted);
|
||||
Assert.Equal("prod", tokenStore.Inserted!.VulnerabilityEnvironment);
|
||||
Assert.Equal("security", tokenStore.Inserted!.VulnerabilityOwner);
|
||||
Assert.Equal("tier-1", tokenStore.Inserted!.VulnerabilityBusinessTier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsAdvisoryReadWithoutAocVerify()
|
||||
{
|
||||
@@ -1967,6 +2174,41 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal("VEX scopes require a tenant assignment.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsVulnViewScope_WhenTenantMissing()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "vuln-explorer-ui",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "vuln:view vuln:investigate");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("vuln_env is required when requesting vulnerability scopes.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsAdvisoryScopes_WithTenant()
|
||||
{
|
||||
@@ -2146,12 +2388,16 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var tokenStore = new TestTokenStore();
|
||||
var serviceAccountStore = new TestServiceAccountStore();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
clientStore,
|
||||
registry,
|
||||
TestActivitySource,
|
||||
auditSink,
|
||||
rateMetadata,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -2161,7 +2407,6 @@ public class ClientCredentialsHandlersTests
|
||||
await validateHandler.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var tokenStore = new TestTokenStore();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handleHandler = new HandleClientCredentialsHandler(
|
||||
registry,
|
||||
@@ -2654,6 +2899,91 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal("tenant-alpha", inserted.Tenant);
|
||||
Assert.Contains("jobs:read", inserted.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleClientCredentials_ProjectsServiceAccountAttributeClaims()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
clientId: "vuln-explorer-worker",
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "vuln:view vuln:investigate",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-vuln",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "vuln:view", "vuln:investigate" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId },
|
||||
Attributes = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["env"] = new List<string> { "Prod", "stage" },
|
||||
["owner"] = new List<string> { "SecOps" },
|
||||
["business_tier"] = new List<string> { "*" },
|
||||
["ignored"] = new List<string> { "value" }
|
||||
}
|
||||
};
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var tokenStore = new TestTokenStore();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
|
||||
var options = TestHelpers.CreateAuthorityOptions(opts =>
|
||||
{
|
||||
opts.Delegation.Quotas.MaxActiveTokens = 5;
|
||||
});
|
||||
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
authSink,
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vuln:view vuln:investigate");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-vuln");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName, "prod");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName, "secops");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName, "tier-1");
|
||||
|
||||
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await validateHandler.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var handleHandler = new HandleClientCredentialsHandler(
|
||||
registry,
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
metadataAccessor,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
|
||||
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
|
||||
await handleHandler.HandleAsync(handleContext);
|
||||
|
||||
Assert.True(handleContext.IsRequestHandled);
|
||||
var principal = handleContext.Principal ?? throw new InvalidOperationException("Principal missing");
|
||||
|
||||
var envClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityEnvironment).Select(c => c.Value).ToArray();
|
||||
Assert.Equal(new[] { "prod" }, envClaims);
|
||||
|
||||
var ownerClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityOwner).Select(c => c.Value).ToArray();
|
||||
Assert.Equal(new[] { "secops" }, ownerClaims);
|
||||
|
||||
var tierClaims = principal.FindAll(StellaOpsClaimTypes.VulnerabilityBusinessTier).Select(c => c.Value).ToArray();
|
||||
Assert.Equal(new[] { "tier-1" }, tierClaims);
|
||||
}
|
||||
}
|
||||
|
||||
public class TokenValidationHandlersTests
|
||||
|
||||
@@ -20,9 +20,11 @@ using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Permalinks;
|
||||
|
||||
public sealed class VulnPermalinkServiceTests
|
||||
namespace StellaOps.Authority.Tests.Permalinks;
|
||||
|
||||
#pragma warning disable CS0618 // legacy scope coverage
|
||||
|
||||
public sealed class VulnPermalinkServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAsync_IssuesSignedTokenWithExpectedClaims()
|
||||
@@ -150,4 +152,5 @@ public sealed class VulnPermalinkServiceTests
|
||||
|
||||
public IFileProvider ContentRootFileProvider { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Authority.Vulnerability.Attachments;
|
||||
using StellaOps.Authority.Vulnerability.Workflow;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Vulnerability;
|
||||
|
||||
public sealed class VulnWorkflowTokenEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public VulnWorkflowTokenEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyWorkflowToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign", "comment" },
|
||||
context = new Dictionary<string, string> { ["finding_id"] = "F-123" },
|
||||
nonce = "workflow-nonce-123456",
|
||||
expiresInSeconds = 600
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
var issueBody = await issueResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(issueResponse.StatusCode == HttpStatusCode.OK, $"Issue anti-forgery failed: {issueResponse.StatusCode} {issueBody}");
|
||||
|
||||
var issued = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryIssueResponse>(
|
||||
issueBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("workflow-nonce-123456", issued!.Nonce);
|
||||
Assert.Contains("assign", issued.Actions);
|
||||
Assert.Contains("comment", issued.Actions);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
RequiredAction = "assign",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-123456"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
var verifyBody = await verifyResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(verifyResponse.StatusCode == HttpStatusCode.OK, $"Verify anti-forgery failed: {verifyResponse.StatusCode} {verifyBody}");
|
||||
|
||||
var verified = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryVerifyResponse>(
|
||||
verifyBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("tenant-default", verified!.Tenant);
|
||||
Assert.Equal("workflow-nonce-123456", verified.Nonce);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.workflow.csrf.issued"));
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.workflow.actor");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.workflow.csrf.verified"));
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.workflow.nonce" && property.Value.Value == "workflow-nonce-123456");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueWorkflowToken_ReturnsBadRequest_WhenActionsMissing()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-missing-actions");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = Array.Empty<string>()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var error = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_request", error!["error"]);
|
||||
Assert.Contains("action", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyWorkflowToken_ReturnsBadRequest_WhenActionNotPermitted()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-invalid-action");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:20:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign" },
|
||||
nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnWorkflowAntiForgeryIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
RequiredAction = "close",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("Token does not permit action", error["message"], StringComparison.Ordinal);
|
||||
|
||||
Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.workflow.csrf.issued"));
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyAttachmentToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123",
|
||||
FindingId = "find-456",
|
||||
ContentHash = "sha256:abc123",
|
||||
ContentType = "application/pdf",
|
||||
Metadata = new Dictionary<string, string> { ["origin"] = "vuln-workflow" }
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("attach-123", issued!.AttachmentId);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode);
|
||||
var verified = await verifyResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenVerifyResponse>();
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("ledger-hash-001", verified!.LedgerEventHash);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.attachment.token.issued"));
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.attachment.token.verified"));
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttachmentToken_ReturnsBadRequest_WhenLedgerMismatch()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-ledger-mismatch");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-999",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("ledger reference", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.Single(sink.Events.Where(evt => evt.EventType == "vuln.attachment.token.issued"));
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateSignedAuthorityApp(
|
||||
RecordingAuthEventSink sink,
|
||||
FakeTimeProvider timeProvider,
|
||||
string signingKeyId,
|
||||
string signingKeyPath)
|
||||
{
|
||||
return factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Signing:Enabled"] = "true",
|
||||
["Authority:Signing:ActiveKeyId"] = signingKeyId,
|
||||
["Authority:Signing:KeyPath"] = signingKeyPath,
|
||||
["Authority:Signing:KeySource"] = "file",
|
||||
["Authority:Signing:Algorithm"] = SignatureAlgorithms.Es256
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Signing.Enabled = true;
|
||||
options.Signing.ActiveKeyId = signingKeyId;
|
||||
options.Signing.KeyPath = signingKeyPath;
|
||||
options.Signing.KeySource = "file";
|
||||
options.Signing.Algorithm = SignatureAlgorithms.Es256;
|
||||
options.VulnerabilityExplorer.Workflow.AntiForgery.Enabled = true;
|
||||
options.VulnerabilityExplorer.Attachments.Enabled = true;
|
||||
});
|
||||
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored during cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly List<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyList<AuthEventRecord> Events => events;
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex";
|
||||
internal const string ClientTenantProperty = "authority:client_tenant";
|
||||
internal const string ClientProjectProperty = "authority:client_project";
|
||||
internal const string ClientAttributesProperty = "authority:client_attributes";
|
||||
internal const string OperatorReasonProperty = "authority:operator_reason";
|
||||
internal const string OperatorTicketProperty = "authority:operator_ticket";
|
||||
internal const string OperatorReasonParameterName = "operator_reason";
|
||||
@@ -51,4 +52,20 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string ServiceAccountProperty = "authority:service_account";
|
||||
internal const string TokenKindProperty = "authority:token_kind";
|
||||
internal const string ActorChainProperty = "authority:actor_chain";
|
||||
internal const string VulnEnvironmentParameterName = "vuln_env";
|
||||
internal const string VulnOwnerParameterName = "vuln_owner";
|
||||
internal const string VulnBusinessTierParameterName = "vuln_business_tier";
|
||||
internal const string VulnEnvironmentProperty = "authority:vuln_env";
|
||||
internal const string VulnOwnerProperty = "authority:vuln_owner";
|
||||
internal const string VulnBusinessTierProperty = "authority:vuln_business_tier";
|
||||
internal const string PolicyReasonParameterName = "policy_reason";
|
||||
internal const string PolicyTicketParameterName = "policy_ticket";
|
||||
internal const string PolicyDigestParameterName = "policy_digest";
|
||||
internal const string PolicyOperationProperty = "authority:policy_operation";
|
||||
internal const string PolicyReasonProperty = "authority:policy_reason";
|
||||
internal const string PolicyTicketProperty = "authority:policy_ticket";
|
||||
internal const string PolicyDigestProperty = "authority:policy_digest";
|
||||
internal const string PolicyAuditPropertiesProperty = "authority:policy_audit_properties";
|
||||
internal const string PolicyOperationPublishValue = "publish";
|
||||
internal const string PolicyOperationPromoteValue = "promote";
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
@@ -26,6 +27,91 @@ using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal static class VulnerabilityAttributeMetadata
|
||||
{
|
||||
internal const string EnvironmentKey = "env";
|
||||
internal const string OwnerKey = "owner";
|
||||
internal const string BusinessTierKey = "business_tier";
|
||||
|
||||
internal static readonly IReadOnlyDictionary<string, string> ClaimTypes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[EnvironmentKey] = StellaOpsClaimTypes.VulnerabilityEnvironment,
|
||||
[OwnerKey] = StellaOpsClaimTypes.VulnerabilityOwner,
|
||||
[BusinessTierKey] = StellaOpsClaimTypes.VulnerabilityBusinessTier
|
||||
};
|
||||
|
||||
internal static IReadOnlyDictionary<string, IReadOnlyList<string>>? NormalizeFilters(
|
||||
IReadOnlyDictionary<string, List<string>>? attributes)
|
||||
{
|
||||
if (attributes is null || attributes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (rawKey, rawValues) in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedKey = rawKey.Trim().ToLowerInvariant();
|
||||
if (!ClaimTypes.ContainsKey(normalizedKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rawValues is null || rawValues.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var values = new List<string>();
|
||||
var wildcard = false;
|
||||
|
||||
foreach (var rawValue in rawValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = rawValue.Trim();
|
||||
if (trimmed.Equals("*", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
values.Clear();
|
||||
values.Add("*");
|
||||
wildcard = true;
|
||||
break;
|
||||
}
|
||||
|
||||
var lower = trimmed.ToLowerInvariant();
|
||||
if (seen.Add(lower))
|
||||
{
|
||||
values.Add(lower);
|
||||
}
|
||||
}
|
||||
|
||||
if (values.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[normalizedKey] = values.ToArray();
|
||||
|
||||
if (wildcard)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count == 0 ? null : normalized;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
|
||||
{
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
@@ -41,6 +127,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
private readonly ILogger<ValidateClientCredentialsHandler> logger;
|
||||
|
||||
private static readonly Regex AttributeValueRegex = new("^[a-z0-9][a-z0-9:_-]{0,127}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public ValidateClientCredentialsHandler(
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
@@ -327,14 +415,6 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
: normalizedServiceTenant;
|
||||
|
||||
var maxDelegationTokens = authorityOptions.Delegation.ResolveMaxActiveTokens(targetTenant);
|
||||
var currentDelegationTokens = await tokenStore.CountActiveDelegationTokensAsync(targetTenant, null, context.CancellationToken).ConfigureAwait(false);
|
||||
if (currentDelegationTokens >= maxDelegationTokens)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Delegation token quota exceeded for tenant.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: tenant {Tenant} exceeded delegation token quota (limit {Limit}).", document.ClientId, targetTenant, maxDelegationTokens);
|
||||
return;
|
||||
}
|
||||
|
||||
var accountDelegationTokens = await tokenStore.CountActiveDelegationTokensAsync(targetTenant, serviceAccount.AccountId, context.CancellationToken).ConfigureAwait(false);
|
||||
if (accountDelegationTokens >= maxDelegationTokens)
|
||||
{
|
||||
@@ -343,6 +423,14 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
var currentDelegationTokens = await tokenStore.CountActiveDelegationTokensAsync(targetTenant, null, context.CancellationToken).ConfigureAwait(false);
|
||||
if (currentDelegationTokens >= maxDelegationTokens)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Delegation token quota exceeded for tenant.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: tenant {Tenant} exceeded delegation token quota (limit {Limit}).", document.ClientId, targetTenant, maxDelegationTokens);
|
||||
return;
|
||||
}
|
||||
|
||||
var actorList = new List<string> { document.ClientId };
|
||||
var actorOverrideRaw = NormalizeMetadata(context.Request.GetParameter(AuthorityOpenIddictConstants.DelegationActorParameterName)?.Value?.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(actorOverrideRaw))
|
||||
@@ -355,12 +443,66 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ServiceAccountProperty] = serviceAccount;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.TokenKindProperty] = AuthorityTokenKinds.ServiceAccount;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ActorChainProperty] = actorList;
|
||||
|
||||
var attributeFilters = VulnerabilityAttributeMetadata.NormalizeFilters(serviceAccount.Attributes);
|
||||
if (attributeFilters is not null)
|
||||
{
|
||||
if (attributeFilters.TryGetValue(VulnerabilityAttributeMetadata.EnvironmentKey, out var envValues) && envValues.Count > 0)
|
||||
{
|
||||
activity?.SetTag("authority.vuln_attr_env", string.Join(",", envValues));
|
||||
}
|
||||
|
||||
if (attributeFilters.TryGetValue(VulnerabilityAttributeMetadata.OwnerKey, out var ownerValues) && ownerValues.Count > 0)
|
||||
{
|
||||
activity?.SetTag("authority.vuln_attr_owner", string.Join(",", ownerValues));
|
||||
}
|
||||
|
||||
if (attributeFilters.TryGetValue(VulnerabilityAttributeMetadata.BusinessTierKey, out var tierValues) && tierValues.Count > 0)
|
||||
{
|
||||
activity?.SetTag("authority.vuln_attr_business_tier", string.Join(",", tierValues));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
EnsureTenantAssigned();
|
||||
}
|
||||
|
||||
var includesVulnScopes = grantedScopes.Any(scope => scope.StartsWith("vuln:", StringComparison.Ordinal));
|
||||
if (includesVulnScopes)
|
||||
{
|
||||
if (!TryResolveVulnAttributes(
|
||||
context,
|
||||
serviceAccount,
|
||||
out var vulnerableEnvironment,
|
||||
out var vulnerableOwner,
|
||||
out var vulnerableBusinessTier))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vulnerableEnvironment))
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.VulnEnvironmentProperty] = vulnerableEnvironment;
|
||||
metadataAccessor.SetTag("authority.vuln_env", vulnerableEnvironment);
|
||||
activity?.SetTag("authority.vuln_env", vulnerableEnvironment);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vulnerableOwner))
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.VulnOwnerProperty] = vulnerableOwner;
|
||||
metadataAccessor.SetTag("authority.vuln_owner", vulnerableOwner);
|
||||
activity?.SetTag("authority.vuln_owner", vulnerableOwner);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vulnerableBusinessTier))
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.VulnBusinessTierProperty] = vulnerableBusinessTier;
|
||||
metadataAccessor.SetTag("authority.vuln_business_tier", vulnerableBusinessTier);
|
||||
activity?.SetTag("authority.vuln_business_tier", vulnerableBusinessTier);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool EnsureTenantAssigned()
|
||||
{
|
||||
@@ -389,6 +531,118 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
|
||||
static string? NormalizeMetadata(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
bool TryResolveVulnAttributes(
|
||||
OpenIddictServerEvents.ValidateTokenRequestContext context,
|
||||
AuthorityServiceAccountDocument? serviceAccount,
|
||||
out string? environment,
|
||||
out string? owner,
|
||||
out string? businessTier)
|
||||
{
|
||||
environment = null;
|
||||
owner = null;
|
||||
businessTier = null;
|
||||
|
||||
var envParameter = NormalizeMetadata(context.Request.GetParameter(AuthorityOpenIddictConstants.VulnEnvironmentParameterName)?.Value?.ToString());
|
||||
if (!ResolveVulnAttribute("env", AuthorityOpenIddictConstants.VulnEnvironmentParameterName, envParameter, serviceAccount, context, out environment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ownerParameter = NormalizeMetadata(context.Request.GetParameter(AuthorityOpenIddictConstants.VulnOwnerParameterName)?.Value?.ToString());
|
||||
if (!ResolveVulnAttribute("owner", AuthorityOpenIddictConstants.VulnOwnerParameterName, ownerParameter, serviceAccount, context, out owner))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var tierParameter = NormalizeMetadata(context.Request.GetParameter(AuthorityOpenIddictConstants.VulnBusinessTierParameterName)?.Value?.ToString());
|
||||
if (!ResolveVulnAttribute("business_tier", AuthorityOpenIddictConstants.VulnBusinessTierParameterName, tierParameter, serviceAccount, context, out businessTier))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ResolveVulnAttribute(
|
||||
string attributeKey,
|
||||
string parameterName,
|
||||
string? requestedValue,
|
||||
AuthorityServiceAccountDocument? serviceAccount,
|
||||
OpenIddictServerEvents.ValidateTokenRequestContext context,
|
||||
out string? resolvedValue)
|
||||
{
|
||||
resolvedValue = null;
|
||||
|
||||
var clientIdForLog = context.ClientId ?? context.Request.ClientId ?? "<unknown>";
|
||||
var allowed = serviceAccount?.Attributes is { Count: > 0 } &&
|
||||
serviceAccount.Attributes.TryGetValue(attributeKey, out var attributeValues)
|
||||
? attributeValues
|
||||
: null;
|
||||
|
||||
var wildcardAllowed = allowed is { Count: > 0 } && allowed.Any(value => string.Equals(value, "*", StringComparison.Ordinal));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requestedValue))
|
||||
{
|
||||
var normalizedValue = requestedValue.ToLowerInvariant();
|
||||
|
||||
if (normalizedValue.Equals("*", StringComparison.Ordinal))
|
||||
{
|
||||
if (!wildcardAllowed)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, $"{parameterName} cannot be a wildcard for this client.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: wildcard value not permitted for {Parameter}.", clientIdForLog, parameterName);
|
||||
return false;
|
||||
}
|
||||
|
||||
resolvedValue = "*";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!AttributeValueRegex.IsMatch(normalizedValue))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, $"{parameterName} must start with a letter or digit and may contain lowercase letters, digits, :, _, or - (max 128 characters).");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: invalid characters in {Parameter}.", clientIdForLog, parameterName);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allowed is { Count: > 0 } && !wildcardAllowed)
|
||||
{
|
||||
if (!allowed.Any(value => string.Equals(value, normalizedValue, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, $"{parameterName} must match one of the configured values: {string.Join(", ", allowed)}.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: value {Value} for {Parameter} not allowed for service account {ServiceAccount}.", clientIdForLog, normalizedValue, parameterName, serviceAccount?.AccountId ?? "<none>");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
resolvedValue = normalizedValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowed is not { Count: > 0 })
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, $"{parameterName} is required when requesting vulnerability scopes.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: missing required parameter {Parameter}.", clientIdForLog, parameterName);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (wildcardAllowed)
|
||||
{
|
||||
resolvedValue = "*";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowed.Count == 1)
|
||||
{
|
||||
resolvedValue = allowed[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, $"{parameterName} must be supplied when multiple values are configured for the service account.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: ambiguous {Parameter} configuration for service account {ServiceAccount}.", clientIdForLog, parameterName, serviceAccount?.AccountId ?? "<none>");
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasGraphRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphRead) >= 0;
|
||||
var hasGraphWrite = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphWrite) >= 0;
|
||||
var hasGraphExport = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphExport) >= 0;
|
||||
@@ -410,7 +664,14 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
var advisoryAiScopesRequested = hasAdvisoryAiView || hasAdvisoryAiOperate || hasAdvisoryAiAdmin;
|
||||
var hasVexIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexIngest) >= 0;
|
||||
var hasVexRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexRead) >= 0;
|
||||
#pragma warning disable CS0618 // legacy vuln:read support
|
||||
var hasVulnRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnRead) >= 0;
|
||||
#pragma warning restore CS0618
|
||||
var hasVulnView = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnView) >= 0;
|
||||
var hasVulnInvestigate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnInvestigate) >= 0;
|
||||
var hasVulnOperate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnOperate) >= 0;
|
||||
var hasVulnAudit = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnAudit) >= 0;
|
||||
var vulnScopesRequested = hasVulnRead || hasVulnView || hasVulnInvestigate || hasVulnOperate || hasVulnAudit;
|
||||
var hasObservabilityIncident = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ObservabilityIncident) >= 0;
|
||||
var hasSignalsRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsRead) >= 0;
|
||||
var hasSignalsWrite = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsWrite) >= 0;
|
||||
@@ -735,9 +996,23 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasVulnRead && !EnsureTenantAssigned())
|
||||
if (vulnScopesRequested && !EnsureTenantAssigned())
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.VulnRead;
|
||||
var scopeForAudit = hasVulnOperate
|
||||
? StellaOpsScopes.VulnOperate
|
||||
: hasVulnInvestigate
|
||||
? StellaOpsScopes.VulnInvestigate
|
||||
: hasVulnAudit
|
||||
? StellaOpsScopes.VulnAudit
|
||||
: hasVulnView
|
||||
? StellaOpsScopes.VulnView
|
||||
#pragma warning disable CS0618
|
||||
: hasVulnRead
|
||||
? StellaOpsScopes.VulnRead
|
||||
#pragma warning restore CS0618
|
||||
: StellaOpsScopes.VulnView;
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = scopeForAudit;
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Vuln Explorer scopes require a tenant assignment.");
|
||||
logger.LogWarning(
|
||||
"Client credentials validation failed for {ClientId}: vuln scopes require tenant assignment.",
|
||||
@@ -1001,6 +1276,39 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnEnvironmentProperty, out var auditEnvObj) &&
|
||||
auditEnvObj is string auditEnv &&
|
||||
!string.IsNullOrWhiteSpace(auditEnv))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "vuln.attr.env",
|
||||
Value = ClassifiedString.Public(auditEnv)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnOwnerProperty, out var auditOwnerObj) &&
|
||||
auditOwnerObj is string auditOwner &&
|
||||
!string.IsNullOrWhiteSpace(auditOwner))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "vuln.attr.owner",
|
||||
Value = ClassifiedString.Public(auditOwner)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnBusinessTierProperty, out var auditTierObj) &&
|
||||
auditTierObj is string auditTier &&
|
||||
!string.IsNullOrWhiteSpace(auditTier))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "vuln.attr.business_tier",
|
||||
Value = ClassifiedString.Public(auditTier)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ActorChainProperty, out var auditActorObj) &&
|
||||
auditActorObj is IReadOnlyCollection<string> auditActorChain && auditActorChain.Count > 0)
|
||||
{
|
||||
@@ -1220,11 +1528,32 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
identity.AddClaim(new Claim(StellaOpsClaimTypes.ServiceAccount, serviceAccount.AccountId));
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnEnvironmentProperty, out var vulnEnvObj) &&
|
||||
vulnEnvObj is string vulnEnvironment &&
|
||||
!string.IsNullOrWhiteSpace(vulnEnvironment))
|
||||
{
|
||||
identity.AddClaim(new Claim(StellaOpsClaimTypes.VulnerabilityEnvironment, vulnEnvironment));
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnOwnerProperty, out var vulnOwnerObj) &&
|
||||
vulnOwnerObj is string vulnOwner &&
|
||||
!string.IsNullOrWhiteSpace(vulnOwner))
|
||||
{
|
||||
identity.AddClaim(new Claim(StellaOpsClaimTypes.VulnerabilityOwner, vulnOwner));
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnBusinessTierProperty, out var vulnTierObj) &&
|
||||
vulnTierObj is string vulnBusinessTier &&
|
||||
!string.IsNullOrWhiteSpace(vulnBusinessTier))
|
||||
{
|
||||
identity.AddClaim(new Claim(StellaOpsClaimTypes.VulnerabilityBusinessTier, vulnBusinessTier));
|
||||
}
|
||||
|
||||
metadataAccessor.SetSubjectId(subjectValue);
|
||||
if (serviceAccount is not null)
|
||||
{
|
||||
var actors = actorChain.Count > 0 ? actorChain : new[] { document.ClientId };
|
||||
identity.SetClaim("act", BuildActorClaim(actors));
|
||||
identity.SetClaim("act", ClientCredentialHandlerHelpers.BuildActorClaim(actors));
|
||||
}
|
||||
|
||||
activity?.SetTag("authority.client_id", document.ClientId);
|
||||
@@ -1289,6 +1618,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
metadataAccessor.SetProject(project);
|
||||
activity?.SetTag("authority.project", project);
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientAttributesProperty, out var attributeValue) &&
|
||||
attributeValue is IReadOnlyDictionary<string, IReadOnlyList<string>> attributeFilters &&
|
||||
attributeFilters.Count > 0)
|
||||
{
|
||||
ApplyAttributeClaims(identity, attributeFilters);
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.OperatorReasonProperty, out var operatorReasonValue) &&
|
||||
operatorReasonValue is string operatorReasonValueString &&
|
||||
!string.IsNullOrWhiteSpace(operatorReasonValueString))
|
||||
@@ -1551,6 +1887,27 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
record.Project = projectValue;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnEnvironmentProperty, out var tokenEnvObj) &&
|
||||
tokenEnvObj is string tokenEnvironment &&
|
||||
!string.IsNullOrWhiteSpace(tokenEnvironment))
|
||||
{
|
||||
record.VulnerabilityEnvironment = tokenEnvironment;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnOwnerProperty, out var tokenOwnerObj) &&
|
||||
tokenOwnerObj is string tokenOwner &&
|
||||
!string.IsNullOrWhiteSpace(tokenOwner))
|
||||
{
|
||||
record.VulnerabilityOwner = tokenOwner;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.VulnBusinessTierProperty, out var tokenTierObj) &&
|
||||
tokenTierObj is string tokenBusinessTier &&
|
||||
!string.IsNullOrWhiteSpace(tokenBusinessTier))
|
||||
{
|
||||
record.VulnerabilityBusinessTier = tokenBusinessTier;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
|
||||
nonceObj is string nonce &&
|
||||
!string.IsNullOrWhiteSpace(nonce))
|
||||
@@ -1637,6 +1994,33 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyAttributeClaims(
|
||||
ClaimsIdentity identity,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> attributeFilters)
|
||||
{
|
||||
foreach (var (key, values) in attributeFilters)
|
||||
{
|
||||
if (!VulnerabilityAttributeMetadata.ClaimTypes.TryGetValue(key, out var claimType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (identity.HasClaim(claim => claim.Type == claimType && string.Equals(claim.Value, value, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
identity.AddClaim(new Claim(claimType, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ClientCredentialHandlerHelpers
|
||||
@@ -1727,7 +2111,7 @@ internal static class ClientCredentialHandlerHelpers
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildActorClaim(IReadOnlyCollection<string> actors)
|
||||
internal static string BuildActorClaim(IReadOnlyCollection<string> actors)
|
||||
{
|
||||
if (actors is null || actors.Count == 0)
|
||||
{
|
||||
|
||||
@@ -47,6 +47,14 @@ internal sealed class ConfigureAuthorityDiscoveryHandler : IOpenIddictServerHand
|
||||
StellaOpsScopes.NotifyAdmin
|
||||
};
|
||||
|
||||
context.Metadata["stellaops_vuln_scopes_supported"] = new[]
|
||||
{
|
||||
StellaOpsScopes.VulnView,
|
||||
StellaOpsScopes.VulnInvestigate,
|
||||
StellaOpsScopes.VulnOperate,
|
||||
StellaOpsScopes.VulnAudit
|
||||
};
|
||||
|
||||
context.Metadata["stellaops_observability_scopes_supported"] = new[]
|
||||
{
|
||||
StellaOpsScopes.ObservabilityRead,
|
||||
|
||||
@@ -117,6 +117,43 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
|
||||
document.SenderConstraint = senderConstraint;
|
||||
}
|
||||
|
||||
var serviceAccountId = principal.GetClaim(StellaOpsClaimTypes.ServiceAccount);
|
||||
if (!string.IsNullOrWhiteSpace(serviceAccountId))
|
||||
{
|
||||
document.ServiceAccountId = serviceAccountId.Trim();
|
||||
document.TokenKind = AuthorityTokenKinds.ServiceAccount;
|
||||
}
|
||||
|
||||
var vulnerabilityEnvironment = principal.FindAll(StellaOpsClaimTypes.VulnerabilityEnvironment)
|
||||
.Select(claim => claim.Value)
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(vulnerabilityEnvironment))
|
||||
{
|
||||
document.VulnerabilityEnvironment = vulnerabilityEnvironment.Trim();
|
||||
}
|
||||
|
||||
var vulnerabilityOwner = principal.FindAll(StellaOpsClaimTypes.VulnerabilityOwner)
|
||||
.Select(claim => claim.Value)
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(vulnerabilityOwner))
|
||||
{
|
||||
document.VulnerabilityOwner = vulnerabilityOwner.Trim();
|
||||
}
|
||||
|
||||
var vulnerabilityBusinessTier = principal.FindAll(StellaOpsClaimTypes.VulnerabilityBusinessTier)
|
||||
.Select(claim => claim.Value)
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(vulnerabilityBusinessTier))
|
||||
{
|
||||
document.VulnerabilityBusinessTier = vulnerabilityBusinessTier.Trim();
|
||||
}
|
||||
|
||||
var actorChain = ExtractActorChain(principal);
|
||||
if (actorChain is not null)
|
||||
{
|
||||
document.ActorChain = actorChain;
|
||||
}
|
||||
|
||||
var incidentReason = principal.GetClaim(StellaOpsClaimTypes.IncidentReason);
|
||||
if (!string.IsNullOrWhiteSpace(incidentReason))
|
||||
{
|
||||
@@ -167,13 +204,55 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
|
||||
return tokenId;
|
||||
}
|
||||
|
||||
private static List<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
=> principal.GetScopes()
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
private static List<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
=> principal.GetScopes()
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
private static List<string>? ExtractActorChain(ClaimsPrincipal principal)
|
||||
{
|
||||
var claim = principal.GetClaim("act");
|
||||
if (string.IsNullOrWhiteSpace(claim))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(claim);
|
||||
var element = document.RootElement;
|
||||
var actors = new List<string>();
|
||||
|
||||
while (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (element.TryGetProperty("sub", out var subjectElement))
|
||||
{
|
||||
var subject = subjectElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
actors.Add(subject);
|
||||
}
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("act", out var nextElement) && nextElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
element = nextElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return actors.Count == 0 ? null : actors;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryGetExpiration(ClaimsPrincipal principal)
|
||||
{
|
||||
|
||||
@@ -54,7 +54,10 @@ internal static class TokenRequestTamperInspector
|
||||
AuthorityOpenIddictConstants.ExportAdminReasonParameterName,
|
||||
AuthorityOpenIddictConstants.ExportAdminTicketParameterName,
|
||||
AuthorityOpenIddictConstants.QuotaReasonParameterName,
|
||||
AuthorityOpenIddictConstants.QuotaTicketParameterName
|
||||
AuthorityOpenIddictConstants.QuotaTicketParameterName,
|
||||
AuthorityOpenIddictConstants.VulnEnvironmentParameterName,
|
||||
AuthorityOpenIddictConstants.VulnOwnerParameterName,
|
||||
AuthorityOpenIddictConstants.VulnBusinessTierParameterName
|
||||
};
|
||||
|
||||
internal static IReadOnlyList<string> GetUnexpectedPasswordGrantParameters(OpenIddictRequest request)
|
||||
|
||||
@@ -123,18 +123,26 @@ internal sealed class VulnPermalinkService
|
||||
signing.Provider);
|
||||
var signer = resolution.Signer;
|
||||
|
||||
var payload = new VulnPermalinkPayload(
|
||||
Subject: "vuln:permalink",
|
||||
Audience: "stellaops:vuln-explorer",
|
||||
Type: resourceKind,
|
||||
Tenant: tenant,
|
||||
Environment: string.IsNullOrWhiteSpace(request.Environment) ? null : request.Environment.Trim(),
|
||||
Scopes: new[] { StellaOpsScopes.VulnRead },
|
||||
IssuedAt: issuedAt.ToUnixTimeSeconds(),
|
||||
NotBefore: issuedAt.ToUnixTimeSeconds(),
|
||||
ExpiresAt: expiresAt.ToUnixTimeSeconds(),
|
||||
TokenId: Guid.NewGuid().ToString("N"),
|
||||
Resource: new VulnPermalinkResource(resourceKind, stateElement));
|
||||
var scopes = new[]
|
||||
{
|
||||
StellaOpsScopes.VulnView,
|
||||
#pragma warning disable CS0618 // legacy scope retained for backward compatibility
|
||||
StellaOpsScopes.VulnRead
|
||||
#pragma warning restore CS0618
|
||||
};
|
||||
|
||||
var payload = new VulnPermalinkPayload(
|
||||
Subject: "vuln:permalink",
|
||||
Audience: "stellaops:vuln-explorer",
|
||||
Type: resourceKind,
|
||||
Tenant: tenant,
|
||||
Environment: string.IsNullOrWhiteSpace(request.Environment) ? null : request.Environment.Trim(),
|
||||
Scopes: scopes,
|
||||
IssuedAt: issuedAt.ToUnixTimeSeconds(),
|
||||
NotBefore: issuedAt.ToUnixTimeSeconds(),
|
||||
ExpiresAt: expiresAt.ToUnixTimeSeconds(),
|
||||
TokenId: Guid.NewGuid().ToString("N"),
|
||||
Resource: new VulnPermalinkResource(resourceKind, stateElement));
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, PayloadSerializerOptions);
|
||||
var header = new Dictionary<string, object>
|
||||
@@ -155,12 +163,12 @@ internal sealed class VulnPermalinkService
|
||||
|
||||
logger.LogDebug("Issued Vuln Explorer permalink for tenant {Tenant} with resource kind {Resource}.", tenant, resourceKind);
|
||||
|
||||
return new VulnPermalinkResponse(
|
||||
Token: token,
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
Scopes: new[] { StellaOpsScopes.VulnRead });
|
||||
}
|
||||
return new VulnPermalinkResponse(
|
||||
Token: token,
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
Scopes: scopes);
|
||||
}
|
||||
|
||||
private sealed record VulnPermalinkPayload(
|
||||
[property: JsonPropertyName("sub")] string Subject,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@ internal sealed record ServiceAccountResponse(
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
IReadOnlyList<string> AllowedScopes,
|
||||
IReadOnlyList<string> AuthorizedClients);
|
||||
IReadOnlyList<string> AuthorizedClients,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> Attributes);
|
||||
|
||||
internal sealed record ServiceAccountTokenResponse(
|
||||
string TokenId,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Authority.Vulnerability;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Attachments;
|
||||
|
||||
internal sealed class VulnAttachmentTokenIssuer
|
||||
{
|
||||
private readonly ICryptoProviderRegistry cryptoRegistry;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<VulnAttachmentTokenIssuer> logger;
|
||||
|
||||
public VulnAttachmentTokenIssuer(
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VulnAttachmentTokenIssuer> logger)
|
||||
{
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.authorityOptionsAccessor = authorityOptionsAccessor ?? throw new ArgumentNullException(nameof(authorityOptionsAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<VulnAttachmentTokenIssueResult> IssueAsync(
|
||||
ClaimsPrincipal principal,
|
||||
VulnAttachmentTokenIssueRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var options = authorityOptionsAccessor.Value ?? throw new InvalidOperationException("Authority configuration is not available.");
|
||||
var attachmentOptions = options.VulnerabilityExplorer.Attachments;
|
||||
|
||||
if (!attachmentOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Attachment token issuance is disabled. Enable vulnerabilityExplorer.attachments before issuing tokens.");
|
||||
}
|
||||
|
||||
var signing = options.Signing ?? throw new InvalidOperationException("Authority signing configuration is required to issue attachment tokens.");
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled. Enable signing before issuing attachment tokens.");
|
||||
}
|
||||
|
||||
var issuer = options.Issuer ?? throw new InvalidOperationException("Authority issuer configuration is required.");
|
||||
|
||||
var tenant = VulnTokenUtilities.ResolveTenant(principal, request.Tenant);
|
||||
var actor = VulnTokenUtilities.ResolveSubject(principal);
|
||||
|
||||
var ledgerEventHash = NormalizeRequired(request.LedgerEventHash, "ledgerEventHash");
|
||||
var attachmentId = NormalizeRequired(request.AttachmentId, "attachmentId");
|
||||
var findingId = NormalizeOptional(request.FindingId);
|
||||
var contentHash = NormalizeOptional(request.ContentHash);
|
||||
var contentType = NormalizeOptional(request.ContentType);
|
||||
|
||||
var metadata = VulnTokenUtilities.SanitizeDictionary(
|
||||
request.Metadata,
|
||||
attachmentOptions.MaxMetadataEntries,
|
||||
attachmentOptions.MaxMetadataValueLength,
|
||||
"Attachment metadata");
|
||||
|
||||
var lifetime = VulnTokenUtilities.ResolveLifetime(
|
||||
request.ExpiresInSeconds,
|
||||
attachmentOptions.DefaultLifetime,
|
||||
attachmentOptions.MaxLifetime,
|
||||
"expiresInSeconds");
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
var expiresAt = issuedAt.Add(lifetime);
|
||||
var tokenId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var payload = new VulnAttachmentTokenPayload(
|
||||
Issuer: issuer.ToString(),
|
||||
Subject: $"attachment:{attachmentId}",
|
||||
Audience: "stellaops:vuln-attachments",
|
||||
Tenant: tenant,
|
||||
LedgerEventHash: ledgerEventHash,
|
||||
AttachmentId: attachmentId,
|
||||
FindingId: findingId,
|
||||
ContentHash: contentHash,
|
||||
ContentType: contentType,
|
||||
Metadata: metadata,
|
||||
IssuedAt: issuedAt.ToUnixTimeSeconds(),
|
||||
NotBefore: issuedAt.ToUnixTimeSeconds(),
|
||||
ExpiresAt: expiresAt.ToUnixTimeSeconds(),
|
||||
TokenId: tokenId,
|
||||
Actor: actor);
|
||||
|
||||
var token = await VulnTokenSigner.SignAsync(cryptoRegistry, signing, payload, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogDebug(
|
||||
"Issued Vuln Explorer attachment token for tenant {Tenant} (ledger: {LedgerHash}, attachment: {AttachmentId}, expires: {Expires}).",
|
||||
tenant,
|
||||
ledgerEventHash,
|
||||
attachmentId,
|
||||
expiresAt);
|
||||
|
||||
return new VulnAttachmentTokenIssueResult(token, payload);
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException($"Property '{propertyName}' is required.");
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Property '{propertyName}' is required.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
return normalized.Length == 0 ? null : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Attachments;
|
||||
|
||||
public sealed class VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
public string? LedgerEventHash { get; set; }
|
||||
|
||||
public string? AttachmentId { get; set; }
|
||||
|
||||
public string? FindingId { get; set; }
|
||||
|
||||
public string? ContentHash { get; set; }
|
||||
|
||||
public string? ContentType { get; set; }
|
||||
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
}
|
||||
|
||||
public sealed record VulnAttachmentTokenIssueResponse(
|
||||
string Token,
|
||||
DateTimeOffset IssuedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string LedgerEventHash,
|
||||
string AttachmentId);
|
||||
|
||||
internal sealed record VulnAttachmentTokenIssueResult(
|
||||
string Token,
|
||||
VulnAttachmentTokenPayload Payload);
|
||||
|
||||
public sealed class VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
public string? LedgerEventHash { get; set; }
|
||||
|
||||
public string? AttachmentId { get; set; }
|
||||
}
|
||||
|
||||
public sealed record VulnAttachmentTokenVerifyResponse(
|
||||
string Tenant,
|
||||
string Actor,
|
||||
string LedgerEventHash,
|
||||
string AttachmentId,
|
||||
string? FindingId,
|
||||
string? ContentHash,
|
||||
string? ContentType,
|
||||
IReadOnlyDictionary<string, string>? Metadata,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
internal sealed record VulnAttachmentTokenVerificationResult(
|
||||
VulnAttachmentTokenPayload Payload,
|
||||
string SigningKeyId);
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Attachments;
|
||||
|
||||
internal sealed record VulnAttachmentTokenPayload(
|
||||
[property: JsonPropertyName("iss")] string Issuer,
|
||||
[property: JsonPropertyName("sub")] string Subject,
|
||||
[property: JsonPropertyName("aud")] string Audience,
|
||||
[property: JsonPropertyName("tid")] string Tenant,
|
||||
[property: JsonPropertyName("ledger")] string LedgerEventHash,
|
||||
[property: JsonPropertyName("attachment")] string AttachmentId,
|
||||
[property: JsonPropertyName("finding")] string? FindingId,
|
||||
[property: JsonPropertyName("hash")] string? ContentHash,
|
||||
[property: JsonPropertyName("type")] string? ContentType,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string>? Metadata,
|
||||
[property: JsonPropertyName("iat")] long IssuedAt,
|
||||
[property: JsonPropertyName("nbf")] long NotBefore,
|
||||
[property: JsonPropertyName("exp")] long ExpiresAt,
|
||||
[property: JsonPropertyName("jti")] string TokenId,
|
||||
[property: JsonPropertyName("actor")] string Actor);
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Authority.Vulnerability;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Attachments;
|
||||
|
||||
internal sealed class VulnAttachmentTokenVerifier
|
||||
{
|
||||
private readonly ICryptoProviderRegistry cryptoRegistry;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public VulnAttachmentTokenVerifier(
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.authorityOptionsAccessor = authorityOptionsAccessor ?? throw new ArgumentNullException(nameof(authorityOptionsAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<VulnAttachmentTokenVerificationResult> VerifyAsync(
|
||||
string token,
|
||||
string? expectedTenant,
|
||||
string? expectedLedgerHash,
|
||||
string? expectedAttachmentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new InvalidOperationException("Token value is required.");
|
||||
}
|
||||
|
||||
var options = authorityOptionsAccessor.Value ?? throw new InvalidOperationException("Authority configuration is not available.");
|
||||
var attachmentOptions = options.VulnerabilityExplorer.Attachments;
|
||||
|
||||
if (!attachmentOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Attachment token verification is disabled. Enable vulnerabilityExplorer.attachments before verifying tokens.");
|
||||
}
|
||||
|
||||
var signing = options.Signing ?? throw new InvalidOperationException("Authority signing configuration is required to verify attachment tokens.");
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled. Enable signing before verifying attachment tokens.");
|
||||
}
|
||||
|
||||
var segments = VulnTokenVerificationUtilities.ParseSegments(token);
|
||||
var signer = VulnTokenVerificationUtilities.ResolveSigner(cryptoRegistry, signing, segments);
|
||||
var signatureValid = await VulnTokenVerificationUtilities.VerifySignatureAsync(signer, segments, cancellationToken).ConfigureAwait(false);
|
||||
if (!signatureValid)
|
||||
{
|
||||
throw new InvalidOperationException("Attachment token signature is invalid.");
|
||||
}
|
||||
|
||||
var payload = VulnTokenVerificationUtilities.DeserializePayload<VulnAttachmentTokenPayload>(segments);
|
||||
ValidatePayload(payload, options, expectedTenant, expectedLedgerHash, expectedAttachmentId);
|
||||
|
||||
return new VulnAttachmentTokenVerificationResult(payload, segments.KeyId);
|
||||
}
|
||||
|
||||
private void ValidatePayload(
|
||||
VulnAttachmentTokenPayload payload,
|
||||
StellaOpsAuthorityOptions options,
|
||||
string? expectedTenant,
|
||||
string? expectedLedgerHash,
|
||||
string? expectedAttachmentId)
|
||||
{
|
||||
if (!string.Equals(payload.Issuer, options.Issuer?.ToString(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token issuer is not recognized.");
|
||||
}
|
||||
|
||||
if (!string.Equals(payload.Audience, "stellaops:vuln-attachments", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token audience is not valid for attachment verification.");
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var notBefore = DateTimeOffset.FromUnixTimeSeconds(payload.NotBefore);
|
||||
if (now < notBefore)
|
||||
{
|
||||
throw new InvalidOperationException("Token is not yet valid.");
|
||||
}
|
||||
|
||||
var expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAt);
|
||||
if (now > expiresAt)
|
||||
{
|
||||
throw new InvalidOperationException("Token has expired.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedTenant) &&
|
||||
!string.Equals(payload.Tenant, expectedTenant.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token tenant does not match the expected tenant.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedLedgerHash) &&
|
||||
!string.Equals(payload.LedgerEventHash, expectedLedgerHash.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token ledger reference does not match the expected hash.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedAttachmentId) &&
|
||||
!string.Equals(payload.AttachmentId, expectedAttachmentId.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token attachment id does not match the expected identifier.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability;
|
||||
|
||||
internal static class VulnTokenSigner
|
||||
{
|
||||
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions PayloadSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public static async Task<string> SignAsync(
|
||||
ICryptoProviderRegistry registry,
|
||||
AuthoritySigningOptions signingOptions,
|
||||
object payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(signingOptions);
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
if (!signingOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled.");
|
||||
}
|
||||
|
||||
var algorithm = string.IsNullOrWhiteSpace(signingOptions.Algorithm)
|
||||
? SignatureAlgorithms.Es256
|
||||
: signingOptions.Algorithm.Trim();
|
||||
|
||||
var keyId = (signingOptions.ActiveKeyId ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing requires an active key identifier.");
|
||||
}
|
||||
|
||||
var keyReference = new CryptoKeyReference(keyId, signingOptions.Provider);
|
||||
var signer = registry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
algorithm,
|
||||
keyReference,
|
||||
signingOptions.Provider).Signer;
|
||||
|
||||
var header = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["alg"] = algorithm,
|
||||
["typ"] = "JWT",
|
||||
["kid"] = signer.KeyId
|
||||
};
|
||||
|
||||
var headerBytes = JsonSerializer.SerializeToUtf8Bytes(header, HeaderSerializerOptions);
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, PayloadSerializerOptions);
|
||||
|
||||
var encodedHeader = Base64UrlEncoder.Encode(headerBytes);
|
||||
var encodedPayload = Base64UrlEncoder.Encode(payloadBytes);
|
||||
var signingInput = Encoding.ASCII.GetBytes(string.Concat(encodedHeader, '.', encodedPayload));
|
||||
|
||||
var signatureBytes = await signer.SignAsync(signingInput, cancellationToken).ConfigureAwait(false);
|
||||
var encodedSignature = Base64UrlEncoder.Encode(signatureBytes);
|
||||
|
||||
return string.Concat(encodedHeader, '.', encodedPayload, '.', encodedSignature);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability;
|
||||
|
||||
internal static class VulnTokenUtilities
|
||||
{
|
||||
public static string ResolveTenant(ClaimsPrincipal principal, string? requestedTenant)
|
||||
{
|
||||
var tenantClaim = principal.FindAll(StellaOpsClaimTypes.Tenant)
|
||||
.Select(static claim => claim.Value)
|
||||
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requestedTenant) && !string.Equals(requestedTenant.Trim(), tenantClaim, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Requested tenant does not match the authenticated tenant context.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantClaim))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant context is required to issue tokens.");
|
||||
}
|
||||
|
||||
return tenantClaim.Trim();
|
||||
}
|
||||
|
||||
public static string ResolveSubject(ClaimsPrincipal principal)
|
||||
{
|
||||
var subject = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return subject.Trim();
|
||||
}
|
||||
|
||||
subject = principal.FindFirstValue(StellaOpsClaimTypes.ClientId);
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return subject.Trim();
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to resolve subject for token issuance.");
|
||||
}
|
||||
|
||||
public static TimeSpan ResolveLifetime(int? requestedSeconds, TimeSpan defaultLifetime, TimeSpan maxLifetime, string parameterName)
|
||||
{
|
||||
if (!requestedSeconds.HasValue)
|
||||
{
|
||||
return defaultLifetime;
|
||||
}
|
||||
|
||||
if (requestedSeconds.Value <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{parameterName} must be greater than zero.");
|
||||
}
|
||||
|
||||
var requested = TimeSpan.FromSeconds(requestedSeconds.Value);
|
||||
return requested <= maxLifetime ? requested : maxLifetime;
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, string>? SanitizeDictionary(
|
||||
IDictionary<string, string?>? values,
|
||||
int maxEntries,
|
||||
int maxValueLength,
|
||||
string category)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (key, value) in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmedKey = key.Trim();
|
||||
if (trimmedKey.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmedValue = value.Trim();
|
||||
if (trimmedValue.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedValue.Length > maxValueLength)
|
||||
{
|
||||
throw new InvalidOperationException($"{category} value for '{trimmedKey}' exceeds the configured maximum length ({maxValueLength}).");
|
||||
}
|
||||
|
||||
normalized[trimmedKey] = trimmedValue;
|
||||
|
||||
if (normalized.Count > maxEntries)
|
||||
{
|
||||
throw new InvalidOperationException($"{category} includes more than {maxEntries} entries.");
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability;
|
||||
|
||||
internal static class VulnTokenVerificationUtilities
|
||||
{
|
||||
private static readonly JsonSerializerOptions PayloadSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never
|
||||
};
|
||||
|
||||
public static VulnTokenSegments ParseSegments(string token)
|
||||
{
|
||||
var segments = token.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 3)
|
||||
{
|
||||
throw new InvalidOperationException("Token format is invalid. Expected three segments.");
|
||||
}
|
||||
|
||||
var headerSegment = segments[0];
|
||||
var payloadSegment = segments[1];
|
||||
var signatureSegment = segments[2];
|
||||
|
||||
if (signatureSegment.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Token signature segment is empty.");
|
||||
}
|
||||
|
||||
var headerBytes = Base64UrlEncoder.DecodeBytes(headerSegment);
|
||||
using var headerDocument = JsonDocument.Parse(headerBytes);
|
||||
var header = headerDocument.RootElement;
|
||||
|
||||
if (!header.TryGetProperty("alg", out var algElement) || algElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new InvalidOperationException("Token header is missing the alg attribute.");
|
||||
}
|
||||
|
||||
if (!header.TryGetProperty("kid", out var kidElement) || kidElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new InvalidOperationException("Token header is missing the kid attribute.");
|
||||
}
|
||||
|
||||
var payloadBytes = Base64UrlEncoder.DecodeBytes(payloadSegment);
|
||||
return new VulnTokenSegments(
|
||||
HeaderSegment: headerSegment,
|
||||
PayloadSegment: payloadSegment,
|
||||
SignatureSegment: signatureSegment,
|
||||
PayloadBytes: payloadBytes,
|
||||
KeyId: kidElement.GetString()!.Trim(),
|
||||
AlgorithmId: algElement.GetString()!.Trim());
|
||||
}
|
||||
|
||||
public static ICryptoSigner ResolveSigner(
|
||||
ICryptoProviderRegistry registry,
|
||||
AuthoritySigningOptions signingOptions,
|
||||
VulnTokenSegments segments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segments.KeyId))
|
||||
{
|
||||
throw new InvalidOperationException("Token header kid value cannot be empty.");
|
||||
}
|
||||
|
||||
var keyReference = new CryptoKeyReference(segments.KeyId, signingOptions.Provider);
|
||||
return registry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
segments.AlgorithmId,
|
||||
keyReference,
|
||||
signingOptions.Provider).Signer;
|
||||
}
|
||||
|
||||
public static async Task<bool> VerifySignatureAsync(
|
||||
ICryptoSigner signer,
|
||||
VulnTokenSegments segments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var signingInput = Encoding.ASCII.GetBytes($"{segments.HeaderSegment}.{segments.PayloadSegment}");
|
||||
var signatureBytes = Base64UrlEncoder.DecodeBytes(segments.SignatureSegment);
|
||||
return await signer.VerifyAsync(signingInput, signatureBytes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static TPayload DeserializePayload<TPayload>(VulnTokenSegments segments)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<TPayload>(segments.PayloadBytes, PayloadSerializerOptions);
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("Token payload is invalid.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Token payload could not be parsed.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct VulnTokenSegments(
|
||||
string HeaderSegment,
|
||||
string PayloadSegment,
|
||||
string SignatureSegment,
|
||||
byte[] PayloadBytes,
|
||||
string KeyId,
|
||||
string AlgorithmId);
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Workflow;
|
||||
|
||||
public sealed class VulnWorkflowAntiForgeryIssueRequest
|
||||
{
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
public List<string>? Actions { get; set; }
|
||||
|
||||
public Dictionary<string, string?>? Context { get; set; }
|
||||
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
|
||||
public string? Nonce { get; set; }
|
||||
}
|
||||
|
||||
public sealed record VulnWorkflowAntiForgeryIssueResponse(
|
||||
string Token,
|
||||
DateTimeOffset IssuedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
IReadOnlyList<string> Actions,
|
||||
string Nonce);
|
||||
|
||||
internal sealed record VulnWorkflowAntiForgeryIssueResult(
|
||||
string Token,
|
||||
VulnWorkflowAntiForgeryPayload Payload);
|
||||
|
||||
public sealed class VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
|
||||
public string? RequiredAction { get; set; }
|
||||
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
public string? Nonce { get; set; }
|
||||
}
|
||||
|
||||
public sealed record VulnWorkflowAntiForgeryVerifyResponse(
|
||||
string Tenant,
|
||||
string Subject,
|
||||
IReadOnlyList<string> Actions,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string Nonce,
|
||||
string? SessionId,
|
||||
IReadOnlyList<string>? Environments,
|
||||
IReadOnlyList<string>? Owners,
|
||||
IReadOnlyList<string>? BusinessTiers,
|
||||
IReadOnlyDictionary<string, string>? Context);
|
||||
|
||||
internal sealed record VulnWorkflowAntiForgeryVerificationResult(
|
||||
VulnWorkflowAntiForgeryPayload Payload,
|
||||
string SigningKeyId);
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Workflow;
|
||||
|
||||
internal sealed record VulnWorkflowAntiForgeryPayload(
|
||||
[property: JsonPropertyName("iss")] string Issuer,
|
||||
[property: JsonPropertyName("sub")] string Subject,
|
||||
[property: JsonPropertyName("aud")] string Audience,
|
||||
[property: JsonPropertyName("tid")] string Tenant,
|
||||
[property: JsonPropertyName("iat")] long IssuedAt,
|
||||
[property: JsonPropertyName("nbf")] long NotBefore,
|
||||
[property: JsonPropertyName("exp")] long ExpiresAt,
|
||||
[property: JsonPropertyName("jti")] string TokenId,
|
||||
[property: JsonPropertyName("nonce")] string Nonce,
|
||||
[property: JsonPropertyName("sid")] string? SessionId,
|
||||
[property: JsonPropertyName("actions")] IReadOnlyList<string> Actions,
|
||||
[property: JsonPropertyName("env")] IReadOnlyList<string>? Environments,
|
||||
[property: JsonPropertyName("owner")] IReadOnlyList<string>? Owners,
|
||||
[property: JsonPropertyName("tier")] IReadOnlyList<string>? BusinessTiers,
|
||||
[property: JsonPropertyName("context")] IReadOnlyDictionary<string, string>? Context,
|
||||
[property: JsonPropertyName("cnf")] VulnTokenConfirmation? Confirmation);
|
||||
|
||||
internal sealed record VulnTokenConfirmation(
|
||||
[property: JsonPropertyName("jkt")] string? JwkThumbprint,
|
||||
[property: JsonPropertyName("x5t#S256")] string? CertificateThumbprint);
|
||||
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Vulnerability;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Workflow;
|
||||
|
||||
internal sealed class VulnWorkflowAntiForgeryTokenIssuer
|
||||
{
|
||||
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions PayloadSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly ICryptoProviderRegistry cryptoRegistry;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<VulnWorkflowAntiForgeryTokenIssuer> logger;
|
||||
|
||||
public VulnWorkflowAntiForgeryTokenIssuer(
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VulnWorkflowAntiForgeryTokenIssuer> logger)
|
||||
{
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.authorityOptionsAccessor = authorityOptionsAccessor ?? throw new ArgumentNullException(nameof(authorityOptionsAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<VulnWorkflowAntiForgeryIssueResult> IssueAsync(
|
||||
ClaimsPrincipal principal,
|
||||
VulnWorkflowAntiForgeryIssueRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
|
||||
var options = authorityOptionsAccessor.Value ?? throw new InvalidOperationException("Authority configuration is not available.");
|
||||
var workflowOptions = options.VulnerabilityExplorer.Workflow.AntiForgery;
|
||||
|
||||
if (!workflowOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Anti-forgery token issuance is disabled. Enable vulnerabilityExplorer.workflow.antiForgery before issuing tokens.");
|
||||
}
|
||||
|
||||
var signing = options.Signing ?? throw new InvalidOperationException("Authority signing configuration is required to issue workflow tokens.");
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled. Enable signing before issuing workflow tokens.");
|
||||
}
|
||||
|
||||
var issuer = options.Issuer ?? throw new InvalidOperationException("Authority issuer configuration is required.");
|
||||
|
||||
var tenant = VulnTokenUtilities.ResolveTenant(principal, request?.Tenant);
|
||||
var subject = VulnTokenUtilities.ResolveSubject(principal);
|
||||
var sessionId = principal.FindFirstValue(StellaOpsClaimTypes.SessionId);
|
||||
var confirmation = ParseConfirmation(principal.FindFirstValue(AuthorityOpenIddictConstants.ConfirmationClaimType));
|
||||
var environments = ResolveMultiValueClaim(principal, StellaOpsClaimTypes.VulnerabilityEnvironment);
|
||||
var owners = ResolveMultiValueClaim(principal, StellaOpsClaimTypes.VulnerabilityOwner);
|
||||
var tiers = ResolveMultiValueClaim(principal, StellaOpsClaimTypes.VulnerabilityBusinessTier);
|
||||
|
||||
var normalizedActions = NormalizeActions(request?.Actions);
|
||||
if (normalizedActions.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one action must be supplied when issuing workflow anti-forgery tokens.");
|
||||
}
|
||||
|
||||
var sanitizedContext = VulnTokenUtilities.SanitizeDictionary(
|
||||
request?.Context,
|
||||
workflowOptions.MaxContextEntries,
|
||||
workflowOptions.MaxContextValueLength,
|
||||
"Workflow context");
|
||||
var nonce = NormalizeOrGenerateNonce(request?.Nonce);
|
||||
var lifetime = VulnTokenUtilities.ResolveLifetime(
|
||||
request?.ExpiresInSeconds,
|
||||
workflowOptions.DefaultLifetime,
|
||||
workflowOptions.MaxLifetime,
|
||||
"expiresInSeconds");
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
var expiresAt = issuedAt.Add(lifetime);
|
||||
var tokenId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var payload = new VulnWorkflowAntiForgeryPayload(
|
||||
Issuer: issuer.ToString(),
|
||||
Subject: subject,
|
||||
Audience: workflowOptions.Audience,
|
||||
Tenant: tenant,
|
||||
IssuedAt: issuedAt.ToUnixTimeSeconds(),
|
||||
NotBefore: issuedAt.ToUnixTimeSeconds(),
|
||||
ExpiresAt: expiresAt.ToUnixTimeSeconds(),
|
||||
TokenId: tokenId,
|
||||
Nonce: nonce,
|
||||
SessionId: string.IsNullOrWhiteSpace(sessionId) ? null : sessionId,
|
||||
Actions: normalizedActions,
|
||||
Environments: environments,
|
||||
Owners: owners,
|
||||
BusinessTiers: tiers,
|
||||
Context: sanitizedContext,
|
||||
Confirmation: confirmation);
|
||||
|
||||
var token = await VulnTokenSigner.SignAsync(cryptoRegistry, signing, payload, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogDebug(
|
||||
"Issued Vuln Explorer workflow anti-forgery token for tenant {Tenant} (actions: {Actions}, expires: {Expires}).",
|
||||
tenant,
|
||||
string.Join(',', normalizedActions),
|
||||
expiresAt);
|
||||
|
||||
return new VulnWorkflowAntiForgeryIssueResult(token, payload);
|
||||
}
|
||||
|
||||
private static VulnTokenConfirmation? ParseConfirmation(string? confirmationJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(confirmationJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(confirmationJson);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var jkt = root.TryGetProperty("jkt", out var jktElement) && jktElement.ValueKind == JsonValueKind.String
|
||||
? jktElement.GetString()
|
||||
: null;
|
||||
|
||||
var x5t = root.TryGetProperty("x5t#S256", out var x5tElement) && x5tElement.ValueKind == JsonValueKind.String
|
||||
? x5tElement.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jkt) && string.IsNullOrWhiteSpace(x5t))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new VulnTokenConfirmation(
|
||||
string.IsNullOrWhiteSpace(jkt) ? null : jkt,
|
||||
string.IsNullOrWhiteSpace(x5t) ? null : x5t);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ResolveMultiValueClaim(ClaimsPrincipal principal, string claimType)
|
||||
{
|
||||
var values = principal.FindAll(claimType)
|
||||
.Select(static claim => claim.Value)
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Where(static value => value.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return values.Count > 0 ? values : null;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeActions(IEnumerable<string>? actions)
|
||||
{
|
||||
if (actions is null)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = action.Trim().ToLowerInvariant();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
set.Add(normalized);
|
||||
}
|
||||
|
||||
return set.OrderBy(static value => value, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeOrGenerateNonce(string? nonce)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nonce))
|
||||
{
|
||||
return Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
var normalized = nonce.Trim();
|
||||
if (normalized.Length < 8)
|
||||
{
|
||||
throw new InvalidOperationException("Nonce must be at least 8 characters when provided explicitly.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Authority.Vulnerability;
|
||||
|
||||
namespace StellaOps.Authority.Vulnerability.Workflow;
|
||||
|
||||
internal sealed class VulnWorkflowAntiForgeryTokenVerifier
|
||||
{
|
||||
private readonly ICryptoProviderRegistry cryptoRegistry;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public VulnWorkflowAntiForgeryTokenVerifier(
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptionsAccessor,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
this.cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
this.authorityOptionsAccessor = authorityOptionsAccessor ?? throw new ArgumentNullException(nameof(authorityOptionsAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<VulnWorkflowAntiForgeryVerificationResult> VerifyAsync(
|
||||
string token,
|
||||
string? requiredAction,
|
||||
string? expectedTenant,
|
||||
string? expectedNonce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new InvalidOperationException("Token value is required.");
|
||||
}
|
||||
|
||||
var options = authorityOptionsAccessor.Value ?? throw new InvalidOperationException("Authority configuration is not available.");
|
||||
var workflowOptions = options.VulnerabilityExplorer.Workflow.AntiForgery;
|
||||
|
||||
if (!workflowOptions.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Anti-forgery token verification is disabled. Enable vulnerabilityExplorer.workflow.antiForgery before verifying tokens.");
|
||||
}
|
||||
|
||||
var signing = options.Signing ?? throw new InvalidOperationException("Authority signing configuration is required to verify workflow tokens.");
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled. Enable signing before verifying workflow tokens.");
|
||||
}
|
||||
|
||||
var segments = VulnTokenVerificationUtilities.ParseSegments(token);
|
||||
var signer = VulnTokenVerificationUtilities.ResolveSigner(cryptoRegistry, signing, segments);
|
||||
var signatureValid = await VulnTokenVerificationUtilities.VerifySignatureAsync(signer, segments, cancellationToken).ConfigureAwait(false);
|
||||
if (!signatureValid)
|
||||
{
|
||||
throw new InvalidOperationException("Workflow anti-forgery token signature is invalid.");
|
||||
}
|
||||
|
||||
var payload = VulnTokenVerificationUtilities.DeserializePayload<VulnWorkflowAntiForgeryPayload>(segments);
|
||||
ValidatePayload(payload, options, workflowOptions, requiredAction, expectedTenant, expectedNonce);
|
||||
|
||||
return new VulnWorkflowAntiForgeryVerificationResult(payload, segments.KeyId);
|
||||
}
|
||||
|
||||
private void ValidatePayload(
|
||||
VulnWorkflowAntiForgeryPayload payload,
|
||||
StellaOpsAuthorityOptions options,
|
||||
AuthorityVulnAntiForgeryOptions antiForgeryOptions,
|
||||
string? requiredAction,
|
||||
string? expectedTenant,
|
||||
string? expectedNonce)
|
||||
{
|
||||
if (!string.Equals(payload.Issuer, options.Issuer?.ToString(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token issuer is not recognized.");
|
||||
}
|
||||
|
||||
if (!string.Equals(payload.Audience, antiForgeryOptions.Audience, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token audience is not valid for workflow verification.");
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var notBefore = DateTimeOffset.FromUnixTimeSeconds(payload.NotBefore);
|
||||
if (now < notBefore)
|
||||
{
|
||||
throw new InvalidOperationException("Token is not yet valid.");
|
||||
}
|
||||
|
||||
var expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAt);
|
||||
if (now > expiresAt)
|
||||
{
|
||||
throw new InvalidOperationException("Token has expired.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requiredAction))
|
||||
{
|
||||
var action = requiredAction.Trim().ToLowerInvariant();
|
||||
if (!payload.Actions.Contains(action, StringComparer.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Token does not permit action '{requiredAction}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedTenant) &&
|
||||
!string.Equals(payload.Tenant, expectedTenant.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token tenant does not match the expected tenant.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedNonce) &&
|
||||
!string.Equals(payload.Nonce, expectedNonce.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Token nonce does not match the expected value.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
| AUTH-GRAPH-21-002 | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-GRAPH-21-001, AUTH-AOC-19-002 | Wire gateway enforcement for new graph scopes, Cartographer service identity, and tenant propagation across graph APIs. | Gateway config updated; unauthorized access blocked in integration tests; audit logs include graph scope usage. |
|
||||
| AUTH-GRAPH-21-003 | DONE (2025-10-26) | Authority Core & Docs Guild | AUTH-GRAPH-21-001 | Update security docs and samples describing graph access roles, least privilege guidance, and service identities. | Docs merged with compliance checklist; examples refreshed; release notes prepared. |
|
||||
| AUTH-POLICY-23-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Introduce fine-grained scopes `policy:read`, `policy:edit`, `policy:approve`, `policy:activate`, `policy:simulate`; update issuer templates and metadata. | Scopes exposed; integration tests confirm enforcement; offline kit updated. |
|
||||
| AUTH-VULN-24-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
|
||||
| AUTH-VULN-24-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend Vuln Explorer scopes (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
|
||||
| AUTH-ORCH-32-001 | DONE (2025-10-31) | Authority Core & Security Guild | — | Define `orch:read` scope, register `Orch.Viewer` role, update discovery metadata, and seed offline defaults. | Scope/role available in metadata; integration tests confirm read-only enforcement; offline kit updated. |
|
||||
| AUTH-CONSOLE-23-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Register StellaOps Console confidential client with OIDC PKCE support, short-lived ID/access tokens, `console:*` audience claims, and SPA-friendly refresh (token exchange endpoint). Publish discovery metadata + offline kit defaults. | Client registration committed; configuration templates updated; integration tests validate PKCE + scope issuance; security review recorded. |
|
||||
| AUTH-POLICY-27-001 | DONE (2025-10-31) | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-CONSOLE-23-001 | Define Policy Studio roles (`policy:author`, `policy:review`, `policy:approve`, `policy:operate`, `policy:audit`) with tenant-scoped claims, update issuer metadata, and seed offline kit defaults. | Scopes/roles exposed via discovery docs; tokens issued with correct claims; integration tests cover role combinations; docs updated. |
|
||||
|
||||
@@ -91,9 +91,10 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-VULN-29-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. |
|
||||
| AUTH-VULN-29-002 | TODO | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. |
|
||||
| AUTH-VULN-29-003 | TODO | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. |
|
||||
| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. |
|
||||
| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. |
|
||||
| AUTH-VULN-29-003 | DOING (2025-11-03) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. |
|
||||
> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes.
|
||||
|
||||
## Advisory AI (Sprint 31)
|
||||
|
||||
@@ -130,7 +131,9 @@
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
|
||||
| AUTH-TEN-49-001 | DOING (2025-11-02) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
|
||||
> 2025-11-02: Authority bootstrap test harness now seeds service accounts via AuthorityDelegation options; `/internal/service-accounts` endpoints validated with targeted vstest run.
|
||||
> 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence.
|
||||
> 2025-11-02: Documented bootstrap service-account admin APIs in `docs/11_AUTHORITY.md`, noting API key requirements and stable upsert behaviour.
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Build and operate the Cartographer service that materializes immutable SBOM prop
|
||||
- Ingest normalized SBOM projections (CycloneDX/SPDX) and generate versioned graph snapshots with tenant-aware storage.
|
||||
- Maintain overlay workers that merge Policy Engine effective findings and VEX metadata onto graph nodes/edges, including path relevance computation.
|
||||
- Serve graph APIs for viewport tiles, paths, filters, exports, simulation overlays, and diffing.
|
||||
- Coordinate with Policy Engine, Scheduler, Conseiller, Excitator, and Authority to keep overlays current, respect RBAC, and uphold determinism guarantees.
|
||||
- Coordinate with Policy Engine, Scheduler, Conseiller, Excitor, and Authority to keep overlays current, respect RBAC, and uphold determinism guarantees.
|
||||
- Deliver observability (metrics/traces/logs) and performance benchmarks for large graphs (≥50k nodes).
|
||||
|
||||
## Expectations
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
# TASKS — Epic 1: Aggregation-Only Contract
|
||||
> **AOC Reminder:** ingestion aggregates and links only—no precedence, normalization, or severity computation. Derived data lives in Policy/overlay services.
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|---|
|
||||
> Docs alignment (2025-10-26): Behaviour/spec captured in `docs/ingestion/aggregation-only-contract.md` and architecture overview §2.
|
||||
> Implementation (2025-10-29): Added `AdvisoryRawWriteGuard` + DI extensions wrapping `AocWriteGuard`, throwing domain-specific `ConcelierAocGuardException` with `ERR_AOC_00x` mappings. Unit tests cover valid/missing-tenant/signature cases.
|
||||
> Coordination (2025-10-27): Authority `dotnet test` run is currently blocked because `AdvisoryObservationQueryService.BuildAliasLookup` returns `ImmutableHashSet<string?>`; please normalise these lookups to `ImmutableHashSet<string>` (trim nulls) so downstream builds succeed.
|
||||
> 2025-10-31: Added advisory linkset mapper + DI registration, normalized PURL/CPE canonicalization, persisted `reconciled_from` pointers, and refreshed observation factory/tests for new raw linkset shape.
|
||||
> Docs alignment (2025-10-26): Linkset expectations detailed in AOC reference §4 and policy-engine architecture §2.1.
|
||||
> 2025-10-28: Advisory raw ingestion now strips client-supplied supersedes hints, logs ignored pointers, and surfaces repository-supplied supersedes identifiers; service tests cover duplicate handling and append-only semantics.
|
||||
# TASKS — Epic 1: Aggregation-Only Contract
|
||||
> **AOC Reminder:** ingestion aggregates and links only—no precedence, normalization, or severity computation. Derived data lives in Policy/overlay services.
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|---|
|
||||
> Docs alignment (2025-10-26): Behaviour/spec captured in `docs/ingestion/aggregation-only-contract.md` and architecture overview §2.
|
||||
> Implementation (2025-10-29): Added `AdvisoryRawWriteGuard` + DI extensions wrapping `AocWriteGuard`, throwing domain-specific `ConcelierAocGuardException` with `ERR_AOC_00x` mappings. Unit tests cover valid/missing-tenant/signature cases.
|
||||
> Coordination (2025-10-27): Authority `dotnet test` run is currently blocked because `AdvisoryObservationQueryService.BuildAliasLookup` returns `ImmutableHashSet<string?>`; please normalise these lookups to `ImmutableHashSet<string>` (trim nulls) so downstream builds succeed.
|
||||
> 2025-10-31: Added advisory linkset mapper + DI registration, normalized PURL/CPE canonicalization, persisted `reconciled_from` pointers, and refreshed observation factory/tests for new raw linkset shape.
|
||||
> Docs alignment (2025-10-26): Linkset expectations detailed in AOC reference §4 and policy-engine architecture §2.1.
|
||||
> 2025-10-28: Advisory raw ingestion now strips client-supplied supersedes hints, logs ignored pointers, and surfaces repository-supplied supersedes identifiers; service tests cover duplicate handling and append-only semantics.
|
||||
> Docs alignment (2025-10-26): Deployment guide + observability guide describe supersedes metrics; ensure implementation emits `aoc_violation_total` on failure.
|
||||
| CONCELIER-CORE-AOC-19-004 `Remove ingestion normalization` | DOING (2025-10-28) | Concelier Core Guild | CONCELIER-CORE-AOC-19-002, POLICY-AOC-19-003 | Strip normalization/dedup/severity logic from ingestion pipelines, delegate derived computations to Policy Engine, and update exporters/tests to consume raw documents only.<br>2025-10-29 19:05Z: Audit completed for `AdvisoryRawService`/Mongo repo to confirm alias order/dedup removal persists; identified remaining normalization in observation/linkset factory that will be revised to surface raw duplicates for Policy ingestion. Change sketch + regression matrix drafted under `docs/dev/aoc-normalization-removal-notes.md` (pending commit).<br>2025-10-31 20:45Z: Added raw linkset projection to observations/storage, exposing canonical+raw views, refreshed fixtures/tests, and documented behaviour in models/doc factory.<br>2025-10-31 21:10Z: Coordinated with Policy Engine (POLICY-ENGINE-20-003) on adoption timeline; backfill + consumer readiness tracked in `docs/dev/raw-linkset-backfill-plan.md`. |
|
||||
> Docs alignment (2025-10-26): Architecture overview emphasises policy-only derivation; coordinate with Policy Engine guild for rollout.
|
||||
> 2025-10-29: `AdvisoryRawService` now preserves upstream alias/linkset ordering (trim-only) and updated AOC documentation reflects the behaviour; follow-up to ensure policy consumers handle duplicates remains open.
|
||||
| CONCELIER-CORE-AOC-19-013 `Authority tenant scope smoke coverage` | TODO | Concelier Core Guild | AUTH-AOC-19-002 | Extend Concelier smoke/e2e fixtures to configure `requiredTenants` and assert cross-tenant rejection with updated Authority tokens. | Coordinate deliverable so Authority docs (`AUTH-AOC-19-003`) can close once tests are in place. |
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-POLICY-20-002 `Linkset enrichment for policy` | TODO | Concelier Core Guild, Policy Guild | CONCELIER-CORE-AOC-19-002, POLICY-ENGINE-20-001 | Strengthen linkset builders with vendor-specific equivalence tables, NEVRA/PURL normalization, and version range parsing to maximize policy join recall; update fixtures + docs. |
|
||||
> 2025-10-31: Base advisory linkset mapper landed under `CONCELIER-CORE-AOC-19-002`; policy enrichment work can now proceed with mapper outputs and observation schema fixtures.
|
||||
|
||||
## Graph Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-GRAPH-21-001 `SBOM projection enrichment` | BLOCKED (2025-10-27) | Concelier Core Guild, Cartographer Guild | CONCELIER-POLICY-20-002, CARTO-GRAPH-21-002 | Extend SBOM normalization to emit full relationship graph (depends_on/contains/provides), scope tags, entrypoint annotations, and component metadata required by Cartographer. |
|
||||
> 2025-10-27: Waiting on policy-driven linkset enrichment (`CONCELIER-POLICY-20-002`) and Cartographer API contract (`CARTO-GRAPH-21-002`) to define required relationship payloads. Without those schemas the projection changes cannot be implemented deterministically.
|
||||
> 2025-10-29: Cross-guild handshake captured in `docs/dev/cartographer-graph-handshake.md`; begin drafting enrichment plan once Cartographer ships the inspector schema/query patterns.
|
||||
| CONCELIER-GRAPH-21-002 `Change events` | BLOCKED (2025-10-27) | Concelier Core Guild, Scheduler Guild | CONCELIER-GRAPH-21-001 | Publish change events (new SBOM version, relationship delta) for Cartographer build queue; ensure events include tenant/context metadata. |
|
||||
> 2025-10-27: Depends on `CONCELIER-GRAPH-21-001`; event schema hinges on finalized projection output and Cartographer webhook contract, both pending.
|
||||
> 2025-10-29: Action item from handshake doc — prepare sample `sbom.relationship.changed` payload + replay notes once schema lands; coordinate with Scheduler for queue semantics.
|
||||
|
||||
## Link-Not-Merge v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
> Docs alignment (2025-10-26): Architecture overview emphasises policy-only derivation; coordinate with Policy Engine guild for rollout.
|
||||
> 2025-10-29: `AdvisoryRawService` now preserves upstream alias/linkset ordering (trim-only) and updated AOC documentation reflects the behaviour; follow-up to ensure policy consumers handle duplicates remains open.
|
||||
| CONCELIER-CORE-AOC-19-013 `Authority tenant scope smoke coverage` | TODO | Concelier Core Guild | AUTH-AOC-19-002 | Extend Concelier smoke/e2e fixtures to configure `requiredTenants` and assert cross-tenant rejection with updated Authority tokens. | Coordinate deliverable so Authority docs (`AUTH-AOC-19-003`) can close once tests are in place. |
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-POLICY-20-002 `Linkset enrichment for policy` | TODO | Concelier Core Guild, Policy Guild | CONCELIER-CORE-AOC-19-002, POLICY-ENGINE-20-001 | Strengthen linkset builders with vendor-specific equivalence tables, NEVRA/PURL normalization, and version range parsing to maximize policy join recall; update fixtures + docs. |
|
||||
> 2025-10-31: Base advisory linkset mapper landed under `CONCELIER-CORE-AOC-19-002`; policy enrichment work can now proceed with mapper outputs and observation schema fixtures.
|
||||
|
||||
## Graph Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-GRAPH-21-001 `SBOM projection enrichment` | BLOCKED (2025-10-27) | Concelier Core Guild, Cartographer Guild | CONCELIER-POLICY-20-002, CARTO-GRAPH-21-002 | Extend SBOM normalization to emit full relationship graph (depends_on/contains/provides), scope tags, entrypoint annotations, and component metadata required by Cartographer. |
|
||||
> 2025-10-27: Waiting on policy-driven linkset enrichment (`CONCELIER-POLICY-20-002`) and Cartographer API contract (`CARTO-GRAPH-21-002`) to define required relationship payloads. Without those schemas the projection changes cannot be implemented deterministically.
|
||||
> 2025-10-29: Cross-guild handshake captured in `docs/dev/cartographer-graph-handshake.md`; begin drafting enrichment plan once Cartographer ships the inspector schema/query patterns.
|
||||
| CONCELIER-GRAPH-21-002 `Change events` | BLOCKED (2025-10-27) | Concelier Core Guild, Scheduler Guild | CONCELIER-GRAPH-21-001 | Publish change events (new SBOM version, relationship delta) for Cartographer build queue; ensure events include tenant/context metadata. |
|
||||
> 2025-10-27: Depends on `CONCELIER-GRAPH-21-001`; event schema hinges on finalized projection output and Cartographer webhook contract, both pending.
|
||||
> 2025-10-29: Action item from handshake doc — prepare sample `sbom.relationship.changed` payload + replay notes once schema lands; coordinate with Scheduler for queue semantics.
|
||||
|
||||
## Link-Not-Merge v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-LNM-21-001 `Advisory observation schema` | TODO | Concelier Core Guild | CONCELIER-CORE-AOC-19-001 | Introduce immutable `advisory_observations` model with AOC metadata, raw payload pointers, structured per-source fields (version ranges, severity, CVSS), and tenancy guardrails; publish schema definition. `DOCS-LNM-22-001` blocked pending this deliverable. |
|
||||
| CONCELIER-LNM-21-002 `Linkset builder` | TODO | Concelier Core Guild, Data Science Guild | CONCELIER-LNM-21-001 | Implement correlation pipeline (alias graph, PURL overlap, CVSS vector equality, fuzzy title match) that produces `advisory_linksets` with confidence + conflict annotations. Docs note: unblock `DOCS-LNM-22-001` once builder lands. |
|
||||
| CONCELIER-LNM-21-003 `Conflict annotator` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Detect field disagreements (severity, CVSS, ranges, references) and record structured conflicts on linksets; surface to API/UI. Docs awaiting structured conflict payloads. |
|
||||
| CONCELIER-LNM-21-004 `Merge code removal` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Excise existing merge/dedup logic, enforce immutability on observations, and add guards/tests to prevent future merges. |
|
||||
| CONCELIER-LNM-21-005 `Event emission` | TODO | Concelier Core Guild, Platform Events Guild | CONCELIER-LNM-21-002 | Emit `advisory.linkset.updated` events with delta payloads for downstream Policy Engine/Cartographer consumers; ensure idempotent delivery. |
|
||||
|
||||
## Policy Engine + Editor v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
| CONCELIER-LNM-21-002 `Linkset builder` | TODO | Concelier Core Guild, Data Science Guild | CONCELIER-LNM-21-001 | Implement correlation pipeline (alias graph, PURL overlap, CVSS vector equality, fuzzy title match) that produces `advisory_linksets` with confidence + conflict annotations. Docs note: unblock `DOCS-LNM-22-001` once builder lands. |
|
||||
| CONCELIER-LNM-21-003 `Conflict annotator` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Detect field disagreements (severity, CVSS, ranges, references) and record structured conflicts on linksets; surface to API/UI. Docs awaiting structured conflict payloads. |
|
||||
| CONCELIER-LNM-21-004 `Merge code removal` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Excise existing merge/dedup logic, enforce immutability on observations, and add guards/tests to prevent future merges. |
|
||||
| CONCELIER-LNM-21-005 `Event emission` | TODO | Concelier Core Guild, Platform Events Guild | CONCELIER-LNM-21-002 | Emit `advisory.linkset.updated` events with delta payloads for downstream Policy Engine/Cartographer consumers; ensure idempotent delivery. |
|
||||
|
||||
## Policy Engine + Editor v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-POLICY-23-001 `Evidence indexes` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Add secondary indexes/materialized views to accelerate policy lookups (alias, provider severity per observation, correlation confidence). Document query contracts for runtime. |
|
||||
| CONCELIER-POLICY-23-002 `Event guarantees` | TODO | Concelier Core Guild, Platform Events Guild | CONCELIER-LNM-21-005 | Ensure `advisory.linkset.updated` emits at-least-once with idempotent keys and include policy-relevant metadata (confidence, conflict summary). |
|
||||
|
||||
## Graph & Vuln Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
> 2025-10-29: Filter-aware lookup path and /concelier/observations coverage landed; overlay services can consume raw advisory feeds deterministically.
|
||||
|
||||
## Reachability v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-SIG-26-001 `Vulnerable symbol exposure` | TODO | Concelier Core Guild, Signals Guild | SIGNALS-24-002 | Expose advisory metadata (affected symbols/functions) via API to enrich reachability scoring; update fixtures. |
|
||||
|
||||
## Orchestrator Dashboard
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-ORCH-32-001 `Source registry integration` | TODO | Concelier Core Guild | ORCH-SVC-32-001, AUTH-ORCH-32-001 | Register Concelier data sources with orchestrator (metadata, schedules, rate policies) and wire provenance IDs/security scopes. |
|
||||
| CONCELIER-ORCH-32-002 `Worker SDK adoption` | TODO | Concelier Core Guild | CONCELIER-ORCH-32-001, WORKER-GO-32-001, WORKER-PY-32-001 | Embed orchestrator worker SDK in ingestion loops, emit heartbeats/progress/artifact hashes, and enforce idempotency keys. |
|
||||
| CONCELIER-ORCH-33-001 `Control hook compliance` | TODO | Concelier Core Guild | CONCELIER-ORCH-32-002, ORCH-SVC-33-001, ORCH-SVC-33-002 | Honor orchestrator throttle/pause/retry actions, surface structured error classes, and persist safe checkpoints for resume. |
|
||||
| CONCELIER-ORCH-34-001 `Backfill + ledger linkage` | TODO | Concelier Core Guild | CONCELIER-ORCH-33-001, ORCH-SVC-33-003, ORCH-SVC-34-001 | Execute orchestrator-driven backfills, reuse artifact hashes to avoid duplicates, and link provenance to run ledger exports. |
|
||||
|
||||
## Authority-Backed Scopes & Tenancy (Epic 14)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-TEN-48-001 `Tenant-aware linking` | TODO | Concelier Core Guild | AUTH-TEN-47-001 | Ensure advisory normalization/linking runs per tenant with RLS enforcing isolation; emit capability endpoint reporting `merge=false`; update events with tenant context. |
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-OBS-50-001 `Telemetry adoption` | TODO | Concelier Core Guild, Observability Guild | TELEMETRY-OBS-50-001, TELEMETRY-OBS-50-002 | Replace ad-hoc logging with telemetry core across ingestion/linking pipelines; ensure spans/logs include tenant, source vendor, upstream id, content hash, and trace IDs. |
|
||||
| CONCELIER-OBS-51-001 `Metrics & SLOs` | TODO | Concelier Core Guild, DevOps Guild | CONCELIER-OBS-50-001, TELEMETRY-OBS-51-001 | Emit metrics for ingest latency (cold/warm), queue depth, aoc violation rate, and publish SLO burn-rate alerts (ingest P95 <30s cold / <5s warm). Ship dashboards + alert configs. |
|
||||
| CONCELIER-OBS-52-001 `Timeline events` | TODO | Concelier Core Guild | CONCELIER-OBS-50-001, TIMELINE-OBS-52-002 | Emit `timeline_event` records for advisory ingest/normalization/linkset creation with provenance, trace IDs, conflict summaries, and evidence placeholders. |
|
||||
| CONCELIER-OBS-53-001 `Evidence snapshots` | TODO | Concelier Core Guild, Evidence Locker Guild | CONCELIER-OBS-52-001, EVID-OBS-53-002 | Produce advisory evaluation bundle payloads (raw doc, linkset, normalization diff) for evidence locker; ensure Merkle manifests seeded with content hashes. |
|
||||
| CONCELIER-OBS-54-001 `Attestation & verification` | TODO | Concelier Core Guild, Provenance Guild | CONCELIER-OBS-53-001, PROV-OBS-54-001 | Attach DSSE attestations for advisory processing batches, expose verification API to confirm bundle integrity, and link attestation IDs back to timeline + ledger. |
|
||||
| CONCELIER-OBS-55-001 `Incident mode hooks` | TODO | Concelier Core Guild, DevOps Guild | CONCELIER-OBS-51-001, DEVOPS-OBS-55-001 | Increase sampling, capture raw payload snapshots, and extend retention under incident mode; emit activation events + guardrails against PII leak. |
|
||||
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-AIRGAP-56-001 `Mirror ingestion adapters` | TODO | Concelier Core Guild | AIRGAP-IMP-57-002, MIRROR-CRT-56-001 | Add mirror source adapters reading advisories from imported bundles, preserving source metadata and bundle IDs. Ensure ingestion remains append-only. |
|
||||
| CONCELIER-AIRGAP-56-002 `Bundle catalog linking` | TODO | Concelier Core Guild, AirGap Importer Guild | CONCELIER-AIRGAP-56-001, AIRGAP-IMP-57-001 | Persist `bundle_id`, `merkle_root`, and time anchor references on observations/linksets for provenance. |
|
||||
| CONCELIER-AIRGAP-57-001 `Sealed-mode source restrictions` | TODO | Concelier Core Guild, AirGap Policy Guild | CONCELIER-AIRGAP-56-001, AIRGAP-POL-56-001 | Enforce sealed-mode egress rules by disallowing non-mirror connectors and surfacing remediation errors. |
|
||||
| CONCELIER-AIRGAP-57-002 `Staleness annotations` | TODO | Concelier Core Guild, AirGap Time Guild | CONCELIER-AIRGAP-56-002, AIRGAP-TIME-58-001 | Compute staleness metadata for advisories per bundle and expose via API for Console/CLI badges. |
|
||||
| CONCELIER-AIRGAP-58-001 `Portable advisory evidence` | TODO | Concelier Core Guild, Evidence Locker Guild | CONCELIER-OBS-53-001, EVID-OBS-54-001 | Package advisory evidence fragments into portable evidence bundles for cross-domain transfer. |
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-OAS-61-001 `Spec coverage` | TODO | Concelier Core Guild, API Contracts Guild | OAS-61-001 | Update Concelier OAS with advisory observation/linkset endpoints, standard pagination, and source provenance fields. |
|
||||
| CONCELIER-OAS-61-002 `Examples library` | TODO | Concelier Core Guild | CONCELIER-OAS-61-001 | Provide rich examples for advisories, linksets, conflict annotations used by SDK + docs. |
|
||||
| CONCELIER-OAS-62-001 `SDK smoke tests` | TODO | Concelier Core Guild, SDK Generator Guild | CONCELIER-OAS-61-001, SDKGEN-63-001 | Add SDK tests covering advisory search, pagination, and conflict handling; ensure source metadata surfaced. |
|
||||
| CONCELIER-OAS-63-001 `Deprecation headers` | TODO | Concelier Core Guild, API Governance Guild | APIGOV-63-001 | Implement deprecation header support and timeline events for retiring endpoints. |
|
||||
|
||||
## Risk Profiles (Epic 18)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-RISK-66-001 `CVSS/KEV providers` | TODO | Concelier Core Guild, Risk Engine Guild | RISK-ENGINE-67-001 | Expose CVSS, KEV, fix availability data via provider APIs with source metadata preserved. |
|
||||
| CONCELIER-POLICY-23-002 `Event guarantees` | TODO | Concelier Core Guild, Platform Events Guild | CONCELIER-LNM-21-005 | Ensure `advisory.linkset.updated` emits at-least-once with idempotent keys and include policy-relevant metadata (confidence, conflict summary). |
|
||||
|
||||
## Graph & Vuln Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
> 2025-10-29: Filter-aware lookup path and /concelier/observations coverage landed; overlay services can consume raw advisory feeds deterministically.
|
||||
|
||||
## Reachability v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-SIG-26-001 `Vulnerable symbol exposure` | TODO | Concelier Core Guild, Signals Guild | SIGNALS-24-002 | Expose advisory metadata (affected symbols/functions) via API to enrich reachability scoring; update fixtures. |
|
||||
|
||||
## Orchestrator Dashboard
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-ORCH-32-001 `Source registry integration` | TODO | Concelier Core Guild | ORCH-SVC-32-001, AUTH-ORCH-32-001 | Register Concelier data sources with orchestrator (metadata, schedules, rate policies) and wire provenance IDs/security scopes. |
|
||||
| CONCELIER-ORCH-32-002 `Worker SDK adoption` | TODO | Concelier Core Guild | CONCELIER-ORCH-32-001, WORKER-GO-32-001, WORKER-PY-32-001 | Embed orchestrator worker SDK in ingestion loops, emit heartbeats/progress/artifact hashes, and enforce idempotency keys. |
|
||||
| CONCELIER-ORCH-33-001 `Control hook compliance` | TODO | Concelier Core Guild | CONCELIER-ORCH-32-002, ORCH-SVC-33-001, ORCH-SVC-33-002 | Honor orchestrator throttle/pause/retry actions, surface structured error classes, and persist safe checkpoints for resume. |
|
||||
| CONCELIER-ORCH-34-001 `Backfill + ledger linkage` | TODO | Concelier Core Guild | CONCELIER-ORCH-33-001, ORCH-SVC-33-003, ORCH-SVC-34-001 | Execute orchestrator-driven backfills, reuse artifact hashes to avoid duplicates, and link provenance to run ledger exports. |
|
||||
|
||||
## Authority-Backed Scopes & Tenancy (Epic 14)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-TEN-48-001 `Tenant-aware linking` | TODO | Concelier Core Guild | AUTH-TEN-47-001 | Ensure advisory normalization/linking runs per tenant with RLS enforcing isolation; emit capability endpoint reporting `merge=false`; update events with tenant context. |
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-OBS-50-001 `Telemetry adoption` | TODO | Concelier Core Guild, Observability Guild | TELEMETRY-OBS-50-001, TELEMETRY-OBS-50-002 | Replace ad-hoc logging with telemetry core across ingestion/linking pipelines; ensure spans/logs include tenant, source vendor, upstream id, content hash, and trace IDs. |
|
||||
| CONCELIER-OBS-51-001 `Metrics & SLOs` | TODO | Concelier Core Guild, DevOps Guild | CONCELIER-OBS-50-001, TELEMETRY-OBS-51-001 | Emit metrics for ingest latency (cold/warm), queue depth, aoc violation rate, and publish SLO burn-rate alerts (ingest P95 <30s cold / <5s warm). Ship dashboards + alert configs. |
|
||||
| CONCELIER-OBS-52-001 `Timeline events` | TODO | Concelier Core Guild | CONCELIER-OBS-50-001, TIMELINE-OBS-52-002 | Emit `timeline_event` records for advisory ingest/normalization/linkset creation with provenance, trace IDs, conflict summaries, and evidence placeholders. |
|
||||
| CONCELIER-OBS-53-001 `Evidence snapshots` | TODO | Concelier Core Guild, Evidence Locker Guild | CONCELIER-OBS-52-001, EVID-OBS-53-002 | Produce advisory evaluation bundle payloads (raw doc, linkset, normalization diff) for evidence locker; ensure Merkle manifests seeded with content hashes. |
|
||||
| CONCELIER-OBS-54-001 `Attestation & verification` | TODO | Concelier Core Guild, Provenance Guild | CONCELIER-OBS-53-001, PROV-OBS-54-001 | Attach DSSE attestations for advisory processing batches, expose verification API to confirm bundle integrity, and link attestation IDs back to timeline + ledger. |
|
||||
| CONCELIER-OBS-55-001 `Incident mode hooks` | TODO | Concelier Core Guild, DevOps Guild | CONCELIER-OBS-51-001, DEVOPS-OBS-55-001 | Increase sampling, capture raw payload snapshots, and extend retention under incident mode; emit activation events + guardrails against PII leak. |
|
||||
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-AIRGAP-56-001 `Mirror ingestion adapters` | TODO | Concelier Core Guild | AIRGAP-IMP-57-002, MIRROR-CRT-56-001 | Add mirror source adapters reading advisories from imported bundles, preserving source metadata and bundle IDs. Ensure ingestion remains append-only. |
|
||||
| CONCELIER-AIRGAP-56-002 `Bundle catalog linking` | TODO | Concelier Core Guild, AirGap Importer Guild | CONCELIER-AIRGAP-56-001, AIRGAP-IMP-57-001 | Persist `bundle_id`, `merkle_root`, and time anchor references on observations/linksets for provenance. |
|
||||
| CONCELIER-AIRGAP-57-001 `Sealed-mode source restrictions` | TODO | Concelier Core Guild, AirGap Policy Guild | CONCELIER-AIRGAP-56-001, AIRGAP-POL-56-001 | Enforce sealed-mode egress rules by disallowing non-mirror connectors and surfacing remediation errors.<br>2025-11-02: AIRGAP-POL-56-001 delivered the EgressPolicy facade; ready to draft sealed-mode enforcement flows against the new policy API. |
|
||||
| CONCELIER-AIRGAP-57-002 `Staleness annotations` | TODO | Concelier Core Guild, AirGap Time Guild | CONCELIER-AIRGAP-56-002, AIRGAP-TIME-58-001 | Compute staleness metadata for advisories per bundle and expose via API for Console/CLI badges. |
|
||||
| CONCELIER-AIRGAP-58-001 `Portable advisory evidence` | TODO | Concelier Core Guild, Evidence Locker Guild | CONCELIER-OBS-53-001, EVID-OBS-54-001 | Package advisory evidence fragments into portable evidence bundles for cross-domain transfer. |
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-OAS-61-001 `Spec coverage` | TODO | Concelier Core Guild, API Contracts Guild | OAS-61-001 | Update Concelier OAS with advisory observation/linkset endpoints, standard pagination, and source provenance fields. |
|
||||
| CONCELIER-OAS-61-002 `Examples library` | TODO | Concelier Core Guild | CONCELIER-OAS-61-001 | Provide rich examples for advisories, linksets, conflict annotations used by SDK + docs. |
|
||||
| CONCELIER-OAS-62-001 `SDK smoke tests` | TODO | Concelier Core Guild, SDK Generator Guild | CONCELIER-OAS-61-001, SDKGEN-63-001 | Add SDK tests covering advisory search, pagination, and conflict handling; ensure source metadata surfaced. |
|
||||
| CONCELIER-OAS-63-001 `Deprecation headers` | TODO | Concelier Core Guild, API Governance Guild | APIGOV-63-001 | Implement deprecation header support and timeline events for retiring endpoints. |
|
||||
|
||||
## Risk Profiles (Epic 18)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-RISK-66-001 `CVSS/KEV providers` | TODO | Concelier Core Guild, Risk Engine Guild | RISK-ENGINE-67-001 | Expose CVSS, KEV, fix availability data via provider APIs with source metadata preserved. |
|
||||
| CONCELIER-RISK-66-002 `Fix availability signals` | TODO | Concelier Core Guild | CONCELIER-RISK-66-001 | Provide structured fix availability and release metadata consumable by risk engine; document provenance. |
|
||||
| CONCELIER-RISK-67-001 `Source coverage metrics` | TODO | Concelier Core Guild | CONCELIER-RISK-66-001 | Add per-source coverage metrics for linked advisories (observation counts, conflicting statuses) without computing consensus scores; ensure explainability includes source digests. |
|
||||
| CONCELIER-RISK-68-001 `Policy Studio integration` | TODO | Concelier Core Guild, Policy Studio Guild | POLICY-RISK-68-001 | Surface advisory fields in Policy Studio profile editor (signal pickers, reducers). |
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
## Link-Not-Merge v1 Transition
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|MERGE-LNM-21-001 Migration plan authoring|BE-Merge, Architecture Guild|CONCELIER-LNM-21-101|Draft `no-merge` migration playbook, documenting backfill strategy, feature flag rollout, and rollback steps for legacy merge pipeline deprecation.|
|
||||
|MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|Refactor or retire `AdvisoryMergeService` and related pipelines, ensuring callers transition to observation/linkset APIs; add compile-time analyzer preventing merge service usage.|
|
||||
|MERGE-LNM-21-001 Migration plan authoring|BE-Merge, Architecture Guild|CONCELIER-LNM-21-101|**DONE (2025-11-03)** – Authored `docs/migration/no-merge.md` with rollout phases, backfill/validation checklists, rollback guidance, and ownership matrix for the Link-Not-Merge cutover.|
|
||||
|MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|**DOING (2025-11-03)** – Auditing service registrations, DI bindings, and tests consuming `AdvisoryMergeService`; drafting deprecation plan and analyzer scope prior to code removal.|
|
||||
> 2025-11-03: Catalogued call sites (WebService Program `AddMergeModule`, built-in job registration `merge:reconcile`, `MergeReconcileJob`) and confirmed unit tests are the only direct `MergeAsync` callers; next step is to define analyzer + replacement observability coverage.
|
||||
|MERGE-LNM-21-003 Determinism/test updates|QA Guild, BE-Merge|MERGE-LNM-21-002|Replace merge determinism suites with observation/linkset regression tests verifying no data mutation and conflicts remain visible.|
|
||||
|
||||
@@ -7,7 +7,7 @@ Implement the append-only, tenant-scoped evidence locker detailed in Epic 15. Pr
|
||||
- Define object store layout, metadata DB schemas, and retention policies.
|
||||
- Build bundle assembly pipelines (evaluation, job, export) with Merkle manifests and DSSE signing.
|
||||
- Provide verification, download, and legal hold APIs with audit trails.
|
||||
- Integrate with Timeline Indexer, Exporter, Orchestrator, Policy Engine, Concelier, and Excitator for provenance linking.
|
||||
- Integrate with Timeline Indexer, Exporter, Orchestrator, Policy Engine, Concelier, and Excitor for provenance linking.
|
||||
|
||||
## Coordination
|
||||
- Work with Provenance Guild for signature tooling.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -21,6 +22,8 @@ builder.Services.AddAuthorization(options =>
|
||||
options.FallbackPolicy = options.DefaultPolicy;
|
||||
});
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Exporter Service Task Board — Epic 10: Export Center
|
||||
|
||||
## Sprint 35 – Foundations (JSON + Mirror Full, Download Only)
|
||||
# Exporter Service Task Board — Epic 10: Export Center
|
||||
|
||||
> 2025-11-03: Link-Not-Merge migration playbook docs/migration/no-merge.md is live—coordinate export bundle staging with its rollout/backfill phases when planning advisory evidence updates.
|
||||
|
||||
## Sprint 35 – Foundations (JSON + Mirror Full, Download Only)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| EXPORT-SVC-35-001 | BLOCKED (2025-10-29) | Exporter Service Guild | ORCH-SVC-35-101, LEDGER-EXPORT-35-001 | Bootstrap exporter service project, configuration, and Postgres migrations for `export_profiles`, `export_runs`, `export_inputs`, `export_distributions` with tenant scoping + tests. | Service builds/tests; migrations generated with scripts; baseline integration test seeds schema; compliance checklist recorded. |
|
||||
|
||||
@@ -7,13 +7,13 @@ Operate the append-only Findings Ledger and projection pipeline powering the Vul
|
||||
- Service code under `src/Findings/StellaOps.Findings.Ledger` (event API, projector, migrations, crypto hashing).
|
||||
- Ledger storage schemas, Merkle anchoring jobs, retention policies, and replay tooling.
|
||||
- Projection pipeline writing `findings_projection` collections/tables consumed by Vuln Explorer API and Console.
|
||||
- Collaboration with Conseiller, Excitator, SBOM Service, Policy Engine, Scheduler, Authority, and DevOps for evidence feeds and policy events.
|
||||
- Collaboration with Conseiller, Excitor, SBOM Service, Policy Engine, Scheduler, Authority, and DevOps for evidence feeds and policy events.
|
||||
|
||||
## Principles
|
||||
1. **Immutability** – Ledger events are append-only, hashed, and chained; projections derive from ledger plus policy inputs.
|
||||
2. **Determinism** – Replaying the same event stream yields identical projections and bundle outputs; hashing uses canonical JSON.
|
||||
3. **Tenant isolation** – Separate namespaces per tenant in storage, queue, and Merkle anchoring artefacts.
|
||||
4. **AOC alignment** – Ledger records workflow only; evidence remains in Conseiller/Excitator/SBOM stores; no mutation of source facts.
|
||||
4. **AOC alignment** – Ledger records workflow only; evidence remains in Conseiller/Excitor/SBOM stores; no mutation of source facts.
|
||||
5. **Auditability** – Provide verifiable hashes, Merkle roots, and replay tooling for auditors.
|
||||
|
||||
## Collaboration
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Graph API Guild Charter (Epic 5)
|
||||
|
||||
## Mission
|
||||
Provide tenant-scoped Graph Explorer APIs for search, query, paths, diffs, overlays, and exports. Deliver cost-aware streaming endpoints that integrate with Policy Engine, Conseiller, Excitator, and the Graph Indexer while honoring AOC and RBAC.
|
||||
Provide tenant-scoped Graph Explorer APIs for search, query, paths, diffs, overlays, and exports. Deliver cost-aware streaming endpoints that integrate with Policy Engine, Conseiller, Excitor, and the Graph Indexer while honoring AOC and RBAC.
|
||||
|
||||
## Scope
|
||||
- Service under `src/Graph/StellaOps.Graph.Api` (Minimal API + streaming pipeline + query planner).
|
||||
|
||||
@@ -6,19 +6,19 @@ Project SBOM, advisory, VEX, and policy overlay data into a tenant-scoped proper
|
||||
## Scope
|
||||
- Service source under `src/Graph/StellaOps.Graph.Indexer` (workers, ingestion pipelines, schema builders).
|
||||
- Mongo collections/object storage for `graph_nodes`, `graph_edges`, `graph_snapshots`, clustering metadata.
|
||||
- Event consumers: SBOM ingest, Conseiller advisories, Excitator VEX, Policy overlay materials.
|
||||
- Event consumers: SBOM ingest, Conseiller advisories, Excitor VEX, Policy overlay materials.
|
||||
- Incremental rebuild, diff, and cache warmers for graph overlays.
|
||||
|
||||
## Principles
|
||||
1. **Immutability** – Graph mirrors SBOM snapshots; new data creates new snapshots rather than mutating historical records.
|
||||
2. **Determinism** – Given identical inputs, node/edge ids, hashes, and aggregates remain stable across runs.
|
||||
3. **Tenant isolation** – Enforce isolation at ingestion, storage, and job levels; no cross-tenant leakage.
|
||||
4. **AOC alignment** – Indexer links facts; it never mutates advisories/VEX/policy outcomes. Conseiller/Excitator/Policy Engine remain authoritative.
|
||||
4. **AOC alignment** – Indexer links facts; it never mutates advisories/VEX/policy outcomes. Conseiller/Excitor/Policy Engine remain authoritative.
|
||||
5. **Performance & telemetry** – Every job emits metrics (latency, node/edge counts, queue lag) and structured logs.
|
||||
|
||||
## Collaboration
|
||||
- Keep `src/Graph/StellaOps.Graph.Indexer/TASKS.md`, `../../docs/implplan/SPRINTS.md` synchronized.
|
||||
- Coordinate with SBOM Service, Policy Engine, Conseiller, Excitator, Scheduler, Web Gateway, and Console teams.
|
||||
- Coordinate with SBOM Service, Policy Engine, Conseiller, Excitor, Scheduler, Web Gateway, and Console teams.
|
||||
- Publish schema docs and fixtures for clients; share cost/identity conventions across services.
|
||||
|
||||
## Tooling
|
||||
|
||||
@@ -5,7 +5,7 @@ Manage trusted VEX issuer metadata, keys, and trust overrides used by the VEX Le
|
||||
|
||||
## Scope
|
||||
- Service `src/IssuerDirectory/StellaOps.IssuerDirectory` providing REST APIs and admin tooling for issuers, keys, trust weights, audit logs.
|
||||
- Integration with Excitator/VEX Lens/Policy Engine for signature verification and trust weighting.
|
||||
- Integration with Excitor/VEX Lens/Policy Engine for signature verification and trust weighting.
|
||||
- Tenant overrides, import of CSAF publisher metadata, and compliance logging.
|
||||
|
||||
## Principles
|
||||
@@ -17,7 +17,7 @@ Manage trusted VEX issuer metadata, keys, and trust overrides used by the VEX Le
|
||||
|
||||
## Definition of Done
|
||||
- APIs documented, RBAC enforced, audit logs persisted.
|
||||
- Key verification integrated with VEX Lens and Excitator; rotation tooling delivered.
|
||||
- Key verification integrated with VEX Lens and Excitor; rotation tooling delivered.
|
||||
- Docs/runbooks updated with compliance checklist.
|
||||
|
||||
## Required Reading
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Issuer Directory Task Board — Epic 7
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
# Issuer Directory Task Board — Epic 7
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ISSUER-30-001 | DONE (2025-11-01) | Issuer Directory Guild | AUTH-VULN-29-001 | Implement issuer CRUD API with RBAC, audit logging, and tenant scoping; seed CSAF publisher metadata. | APIs deployed; audit logs capture actor/reason; seed data imported; tests cover RBAC. |
|
||||
| ISSUER-30-002 | DONE (2025-11-01) | Issuer Directory Guild, Security Guild | ISSUER-30-001 | Implement key management endpoints (add/rotate/revoke keys), enforce expiry, validate formats (Ed25519, X.509, DSSE). | Keys stored securely; expiry enforced; validation tests cover key types; docs updated. |
|
||||
| ISSUER-30-003 | DOING | Issuer Directory Guild, Policy Guild | ISSUER-30-001 | Provide trust weight APIs and tenant overrides with validation (+/- bounds) and audit trails. | Trust overrides persisted; policy integration confirmed; tests cover overrides. |
|
||||
| ISSUER-30-004 | DONE (2025-11-01) | Issuer Directory Guild, VEX Lens Guild | ISSUER-30-001..003 | Integrate with VEX Lens and Excitator signature verification (client SDK, caching, retries). | Lens/Excitator resolve issuer metadata via SDK; integration tests cover network failures. |
|
||||
| ISSUER-30-004 | DONE (2025-11-01) | Issuer Directory Guild, VEX Lens Guild | ISSUER-30-001..003 | Integrate with VEX Lens and Excitor signature verification (client SDK, caching, retries). | Lens/Excitor resolve issuer metadata via SDK; integration tests cover network failures. |
|
||||
| ISSUER-30-005 | DONE (2025-11-01) | Issuer Directory Guild, Observability Guild | ISSUER-30-001..004 | Instrument metrics/logs (issuer changes, key rotation, verification failures) and dashboards/alerts. | Telemetry live; alerts configured; docs updated. |
|
||||
| ISSUER-30-006 | DOING (2025-11-02) | Issuer Directory Guild, DevOps Guild | ISSUER-30-001..005 | Provide deployment manifests, backup/restore, secure secret storage, and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup tested; offline kit updated. |
|
||||
| ISSUER-30-006 | DONE (2025-11-02) | Issuer Directory Guild, DevOps Guild | ISSUER-30-001..005 | Provide deployment manifests, backup/restore, secure secret storage, and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup tested; offline kit updated. |
|
||||
|
||||
> 2025-11-01: Excititor worker now queries Issuer Directory via during attestation verification, caching active key metadata and trust weights for tenant/global scopes.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Notifier Service Task Board — Epic 11: Notifications Studio
|
||||
|
||||
# Sprint 70 – Documentation & Decisions
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-DOC-70-001 | DONE | Notifications Service Guild | — | Document the decision to keep `src/Notify` as the shared library toolkit and `src/Notifier` as the runtime host; update notifications docs with rationale and cross-links (completed 2025-11-02). | Notes published in notifications architecture docs clarifying module boundaries and citing decision. |
|
||||
|
||||
# Sprint 37 – Pack Approval Bridge (Task Runner integration)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Known platform event kind identifiers consumed by Notify.
|
||||
/// </summary>
|
||||
public static class NotifyEventKinds
|
||||
{
|
||||
public const string ScannerReportReady = "scanner.report.ready";
|
||||
public const string ScannerScanCompleted = "scanner.scan.completed";
|
||||
public const string SchedulerRescanDelta = "scheduler.rescan.delta";
|
||||
public const string AttestorLogged = "attestor.logged";
|
||||
public const string ZastavaAdmission = "zastava.admission";
|
||||
public const string FeedserExportCompleted = "feedser.export.completed";
|
||||
public const string VexerExportCompleted = "vexer.export.completed";
|
||||
}
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Known platform event kind identifiers consumed by Notify.
|
||||
/// </summary>
|
||||
public static class NotifyEventKinds
|
||||
{
|
||||
public const string ScannerReportReady = "scanner.report.ready";
|
||||
public const string ScannerScanCompleted = "scanner.scan.completed";
|
||||
public const string SchedulerRescanDelta = "scheduler.rescan.delta";
|
||||
public const string AttestorLogged = "attestor.logged";
|
||||
public const string ZastavaAdmission = "zastava.admission";
|
||||
public const string ConselierExportCompleted = "conselier.export.completed";
|
||||
public const string ExcitorExportCompleted = "excitor.export.completed";
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Endpoints;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -60,9 +61,11 @@ var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(op
|
||||
options.PostBind = static (value, _) => value.Validate();
|
||||
});
|
||||
|
||||
builder.Configuration.AddConfiguration(bootstrap.Configuration);
|
||||
|
||||
builder.Services.AddOptions<PolicyEngineOptions>()
|
||||
builder.Configuration.AddConfiguration(bootstrap.Configuration);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
builder.Services.AddOptions<PolicyEngineOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
|
||||
.Validate(options =>
|
||||
{
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -15,11 +15,12 @@ using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Gateway.Clients;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.Gateway.Infrastructure;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
using StellaOps.Policy.Gateway.Infrastructure;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -69,9 +70,11 @@ var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyGatewayOptions>(o
|
||||
options.PostBind = static (value, _) => value.Validate();
|
||||
});
|
||||
|
||||
builder.Configuration.AddConfiguration(bootstrap.Configuration);
|
||||
|
||||
builder.Logging.SetMinimumLevel(bootstrap.Options.Telemetry.MinimumLogLevel);
|
||||
builder.Configuration.AddConfiguration(bootstrap.Configuration);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
builder.Logging.SetMinimumLevel(bootstrap.Options.Telemetry.MinimumLogLevel);
|
||||
|
||||
builder.Services.AddOptions<PolicyGatewayOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PolicyGatewayOptions.SectionName))
|
||||
@@ -147,12 +150,17 @@ if (bootstrap.Options.PolicyEngine.ClientCredentials.Enabled)
|
||||
.AddHttpMessageHandler<PolicyGatewayDpopHandler>();
|
||||
}
|
||||
|
||||
builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((serviceProvider, client) =>
|
||||
{
|
||||
var gatewayOptions = serviceProvider.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value;
|
||||
client.BaseAddress = gatewayOptions.PolicyEngine.BaseUri;
|
||||
client.Timeout = TimeSpan.FromSeconds(gatewayOptions.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
|
||||
})
|
||||
builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((serviceProvider, client) =>
|
||||
{
|
||||
var gatewayOptions = serviceProvider.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value;
|
||||
var egressPolicy = serviceProvider.GetService<IEgressPolicy>();
|
||||
if (egressPolicy is not null)
|
||||
{
|
||||
egressPolicy.EnsureAllowed(new EgressRequest("PolicyGateway", gatewayOptions.PolicyEngine.BaseUri, "policy-engine-client"));
|
||||
}
|
||||
client.BaseAddress = gatewayOptions.PolicyEngine.BaseUri;
|
||||
client.Timeout = TimeSpan.FromSeconds(gatewayOptions.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
|
||||
})
|
||||
.AddPolicyHandler(static (provider, _) => CreatePolicyEngineRetryPolicy(provider));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -7,7 +7,7 @@ Deliver the policy engine outlined in `docs/modules/scanner/ARCHITECTURE.md` and
|
||||
- Offer preview APIs to compare policy impacts on existing reports.
|
||||
|
||||
## Expectations
|
||||
- Coordinate with Scanner.WebService, Feedser, Vexer, UI, Notify.
|
||||
- Coordinate with Scanner.WebService, Conselier, Excitor, UI, Notify.
|
||||
- Maintain deterministic serialization and unit tests for precedence rules.
|
||||
- Update `TASKS.md` and broadcast contract changes.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Design, build, and operate the scoring runtime that computes Risk Scoring Profil
|
||||
|
||||
## Scope
|
||||
- Scoring workers, job scheduler, provider registry, caching, and explainability artifacts.
|
||||
- Integration with Findings Ledger, Conseiller, Excitator, and Policy Engine.
|
||||
- Integration with Findings Ledger, Conseiller, Excitor, and Policy Engine.
|
||||
- Performance, determinism, and observability of scoring jobs.
|
||||
- Air-gapped support through offline factor bundles.
|
||||
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
# Risk Engine Task Board — Epic 18: Risk Scoring Profiles
|
||||
|
||||
## Sprint 66 – Foundations
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| RISK-ENGINE-66-001 | TODO | Risk Engine Guild | POLICY-RISK-66-001 | Scaffold scoring service (job queue, worker loop, provider registry) with deterministic execution harness. | Service builds/tests; job queue runs sample job; determinism tests pass. |
|
||||
| RISK-ENGINE-66-002 | TODO | Risk Engine Guild | RISK-ENGINE-66-001 | Implement default transforms (linear, minmax, logistic, piecewise), clamping, gating, and contribution calculator. | Transform/gating unit tests passing; contribution breakdown matches golden fixtures. |
|
||||
|
||||
## Sprint 67 – Provider Integration
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| RISK-ENGINE-67-001 | TODO | Risk Engine Guild, Concelier Guild | RISK-ENGINE-66-002, CONCELIER-RISK-66-001 | Integrate CVSS and KEV providers pulling data from Conseiller; implement reducers (`max`, `any`, `consensus`). | Providers return sample data; reducer tests pass; provenance recorded. |
|
||||
| RISK-ENGINE-67-002 | TODO | Risk Engine Guild, Excitator Guild | RISK-ENGINE-66-002, EXCITITOR-RISK-66-001 | Integrate VEX gate provider and ensure gating short-circuits scoring as configured. | VEX gate tests pass; explanation indicates gate decision. |
|
||||
| RISK-ENGINE-67-003 | TODO | Risk Engine Guild, Policy Engine Guild | RISK-ENGINE-66-002 | Add fix availability, asset criticality, and internet exposure providers with caching + TTL enforcement. | Providers deliver normalized values; cache hit metrics exposed. |
|
||||
|
||||
## Sprint 68 – Ledger & API Wiring
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| RISK-ENGINE-68-001 | TODO | Risk Engine Guild, Findings Ledger Guild | RISK-ENGINE-66-002, LEDGER-RISK-66-001 | Persist scoring results + explanation pointers to Findings Ledger; handle incremental updates via input hash. | Results stored with hash; updates skip unchanged findings; tests cover dedupe. |
|
||||
| RISK-ENGINE-68-002 | TODO | Risk Engine Guild, API Guild | RISK-ENGINE-68-001, POLICY-RISK-67-002 | Expose APIs (`/risk/jobs`, `/risk/results`, `/risk/results/{id}/explanation`); include pagination, filtering, error codes. | OpenAPI documented; contract tests pass; endpoints gated by scopes. |
|
||||
|
||||
## Sprint 69 – Simulation & Performance
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| RISK-ENGINE-69-001 | TODO | Risk Engine Guild, Policy Studio Guild | RISK-ENGINE-68-002 | Implement simulation mode producing distributions and top movers without mutating ledger. | Simulation API returns metrics; golden tests cover scenarios. |
|
||||
| RISK-ENGINE-69-002 | TODO | Risk Engine Guild, Observability Guild | RISK-ENGINE-66-001 | Add telemetry (spans, metrics, logs) for provider latency, job throughput, cache hits; define SLO dashboards. | Metrics visible in Grafana; alerts configured for P95 latency + error rate. |
|
||||
|
||||
## Sprint 70 – Air-Gap & Advanced Providers
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| RISK-ENGINE-70-001 | TODO | Risk Engine Guild, Export Guild | RISK-ENGINE-67-003, RISK-BUNDLE-69-001 | Support offline provider bundles with manifest verification and missing-data reporting. | Engine loads bundle data; missing providers logged with `AIRGAP_MISSING_DATA`. |
|
||||
| RISK-ENGINE-70-002 | TODO | Risk Engine Guild, Observability Guild | RISK-ENGINE-68-002 | Integrate runtime evidence provider and reachability provider outputs with caching + TTL. | Providers return runtime/reachability signals; explanation includes sources; tests pass. |
|
||||
# Risk Engine Task Board — Epic 18: Risk Scoring Profiles
|
||||
|
||||
## Sprint 66 – Foundations
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| RISK-ENGINE-66-001 | TODO | Risk Engine Guild | POLICY-RISK-66-001 | Scaffold scoring service (job queue, worker loop, provider registry) with deterministic execution harness. | Service builds/tests; job queue runs sample job; determinism tests pass. |
|
||||
| RISK-ENGINE-66-002 | TODO | Risk Engine Guild | RISK-ENGINE-66-001 | Implement default transforms (linear, minmax, logistic, piecewise), clamping, gating, and contribution calculator. | Transform/gating unit tests passing; contribution breakdown matches golden fixtures. |
|
||||
|
||||
## Sprint 67 – Provider Integration
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| RISK-ENGINE-67-001 | TODO | Risk Engine Guild, Concelier Guild | RISK-ENGINE-66-002, CONCELIER-RISK-66-001 | Integrate CVSS and KEV providers pulling data from Conseiller; implement reducers (`max`, `any`, `consensus`). | Providers return sample data; reducer tests pass; provenance recorded. |
|
||||
| RISK-ENGINE-67-002 | TODO | Risk Engine Guild, Excitor Guild | RISK-ENGINE-66-002, EXCITITOR-RISK-66-001 | Integrate VEX gate provider and ensure gating short-circuits scoring as configured. | VEX gate tests pass; explanation indicates gate decision. |
|
||||
| RISK-ENGINE-67-003 | TODO | Risk Engine Guild, Policy Engine Guild | RISK-ENGINE-66-002 | Add fix availability, asset criticality, and internet exposure providers with caching + TTL enforcement. | Providers deliver normalized values; cache hit metrics exposed. |
|
||||
|
||||
## Sprint 68 – Ledger & API Wiring
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| RISK-ENGINE-68-001 | TODO | Risk Engine Guild, Findings Ledger Guild | RISK-ENGINE-66-002, LEDGER-RISK-66-001 | Persist scoring results + explanation pointers to Findings Ledger; handle incremental updates via input hash. | Results stored with hash; updates skip unchanged findings; tests cover dedupe. |
|
||||
| RISK-ENGINE-68-002 | TODO | Risk Engine Guild, API Guild | RISK-ENGINE-68-001, POLICY-RISK-67-002 | Expose APIs (`/risk/jobs`, `/risk/results`, `/risk/results/{id}/explanation`); include pagination, filtering, error codes. | OpenAPI documented; contract tests pass; endpoints gated by scopes. |
|
||||
|
||||
## Sprint 69 – Simulation & Performance
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| RISK-ENGINE-69-001 | TODO | Risk Engine Guild, Policy Studio Guild | RISK-ENGINE-68-002 | Implement simulation mode producing distributions and top movers without mutating ledger. | Simulation API returns metrics; golden tests cover scenarios. |
|
||||
| RISK-ENGINE-69-002 | TODO | Risk Engine Guild, Observability Guild | RISK-ENGINE-66-001 | Add telemetry (spans, metrics, logs) for provider latency, job throughput, cache hits; define SLO dashboards. | Metrics visible in Grafana; alerts configured for P95 latency + error rate. |
|
||||
|
||||
## Sprint 70 – Air-Gap & Advanced Providers
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| RISK-ENGINE-70-001 | TODO | Risk Engine Guild, Export Guild | RISK-ENGINE-67-003, RISK-BUNDLE-69-001 | Support offline provider bundles with manifest verification and missing-data reporting. | Engine loads bundle data; missing providers logged with `AIRGAP_MISSING_DATA`. |
|
||||
| RISK-ENGINE-70-002 | TODO | Risk Engine Guild, Observability Guild | RISK-ENGINE-68-002 | Integrate runtime evidence provider and reachability provider outputs with caching + TTL. | Providers return runtime/reachability signals; explanation includes sources; tests pass. |
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
| SCANNER-WEB-09-103 | DONE (2025-10-19) | Scanner WebService Guild | SCANNER-WEB-09-102, SCANNER-CORE-09-502 | Emit scan progress via SSE/JSONL with correlation IDs and deterministic timestamps; document API reference. | Streaming endpoint verified in tests, timestamps formatted ISO-8601 UTC, docs updated in `docs/09_API_CLI_REFERENCE.md`. |
|
||||
| SCANNER-WEB-09-104 | DONE (2025-10-19) | Scanner WebService Guild | SCANNER-STORAGE-09-301, SCANNER-QUEUE-09-401 | Bind configuration for Mongo, MinIO, queue, feature flags; add startup diagnostics and fail-fast policy for missing deps. | Misconfiguration fails fast with actionable errors, configuration bound tests pass, diagnostics logged with correlation IDs. |
|
||||
| SCANNER-POLICY-09-105 | DONE (2025-10-19) | Scanner WebService Guild | POLICY-CORE-09-001 | Integrate policy schema loader + diagnostics + OpenAPI (YAML ignore rules, VEX include/exclude, vendor precedence). | Policy endpoints documented; validation surfaces actionable errors; OpenAPI schema published. |
|
||||
| SCANNER-POLICY-09-106 | DONE (2025-10-19) | Scanner WebService Guild | POLICY-CORE-09-002, SCANNER-POLICY-09-105 | `/reports` verdict assembly (Feedser/Vexer/Policy merge) + signed response envelope. | Aggregated report includes policy metadata; integration test verifies signed response; docs updated. |
|
||||
| SCANNER-POLICY-09-106 | DONE (2025-10-19) | Scanner WebService Guild | POLICY-CORE-09-002, SCANNER-POLICY-09-105 | `/reports` verdict assembly (Conselier/Excitor/Policy merge) + signed response envelope. | Aggregated report includes policy metadata; integration test verifies signed response; docs updated. |
|
||||
| SCANNER-POLICY-09-107 | DONE (2025-10-19) | Scanner WebService Guild | POLICY-CORE-09-005, SCANNER-POLICY-09-106 | Surface score inputs, config version, and `quietedBy` provenance in `/reports` response and signed payload; document schema changes. | `/reports` JSON + DSSE contain score, reachability, sourceTrust, confidenceBand, quiet provenance; contract tests updated; docs refreshed. |
|
||||
| SCANNER-WEB-10-201 | DONE (2025-10-19) | Scanner WebService Guild | SCANNER-CACHE-10-101 | Register scanner cache services and maintenance loop within WebService host. | `AddScannerCache` wired for configuration binding; maintenance service skips when disabled; project references updated. |
|
||||
| SCANNER-RUNTIME-12-301 | DONE (2025-10-20) | Scanner WebService Guild | ZASTAVA-CORE-12-201 | Implement `/runtime/events` ingestion endpoint with validation, batching, and storage hooks per Zastava contract. | Observer fixtures POST events, data persisted and acked; invalid payloads rejected with deterministic errors. |
|
||||
| SCANNER-RUNTIME-12-302 | DONE (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-CORE-12-201 | Implement `/policy/runtime` endpoint joining SBOM baseline + policy verdict, returning admission guidance. Coordinate with CLI (`CLI-RUNTIME-13-008`) before GA to lock response field names/metadata. | Webhook integration test passes; responses include verdict, TTL, reasons; metrics/logging added; CLI contract review signed off. |
|
||||
| SCANNER-RUNTIME-12-303 | DONE (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-302 | Replace `/policy/runtime` heuristic with canonical policy evaluation (Feedser/Vexer inputs, PolicyPreviewService) so results align with `/reports`. | Runtime policy endpoint now pipes findings through `PolicyPreviewService`, emits canonical verdicts/confidence/quiet metadata, and updated tests cover pass/warn/fail paths + CLI contract fixtures. |
|
||||
| SCANNER-RUNTIME-12-303 | DONE (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-302 | Replace `/policy/runtime` heuristic with canonical policy evaluation (Conselier/Excitor inputs, PolicyPreviewService) so results align with `/reports`. | Runtime policy endpoint now pipes findings through `PolicyPreviewService`, emits canonical verdicts/confidence/quiet metadata, and updated tests cover pass/warn/fail paths + CLI contract fixtures. |
|
||||
| SCANNER-RUNTIME-12-304 | DONE (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-302 | Surface attestation verification status by integrating Authority/Attestor Rekor validation (beyond presence-only). | `/policy/runtime` maps Rekor UUIDs through the runtime attestation verifier so `rekor.verified` reflects attestor outcomes; webhook/CLI coverage added. |
|
||||
| SCANNER-RUNTIME-12-305 | DONE (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-301, SCANNER-RUNTIME-12-302 | Promote shared fixtures with Zastava/CLI and add end-to-end automation for `/runtime/events` + `/policy/runtime`. | Runtime policy integration test + CLI-aligned fixture assert confidence, metadata JSON, and Rekor verification; docs note shared contract. |
|
||||
| SCANNER-EVENTS-15-201 | DONE (2025-10-20) | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Emit `scanner.report.ready` and `scanner.scan.completed` events (bus adapters + tests). | Event envelopes published to queue with schemas; fixtures committed; Notify consumption test passes. |
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SCANNER-SURFACE-02 | TODO | Scanner WebService Guild | SURFACE-FS-02 | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata. | OpenAPI updated; clients regenerated; integration tests validate pointer presence and tenancy. |
|
||||
| SCANNER-ENV-02 | TODO | Scanner WebService Guild, Ops Guild | SURFACE-ENV-02 | Wire Surface.Env helpers into WebService hosting (cache roots, feature flags) and document configuration. | Service uses helper; env table documented; helm/compose templates updated. |
|
||||
| SCANNER-SECRETS-02 | TODO | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens). | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. |
|
||||
| SCANNER-SURFACE-02 | DOING (2025-11-02) | Scanner WebService Guild | SURFACE-FS-02 | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata.<br>2025-11-02: Scan/report API responses now include preview CAS URIs; attestation metadata draft published. | OpenAPI updated; clients regenerated; integration tests validate pointer presence and tenancy. |
|
||||
| SCANNER-ENV-02 | DOING (2025-11-02) | Scanner WebService Guild, Ops Guild | SURFACE-ENV-02 | Wire Surface.Env helpers into WebService hosting (cache roots, feature flags) and document configuration.<br>2025-11-02: Cache root resolution switched to helper; feature flag bindings updated; Helm/Compose updates pending review. | Service uses helper; env table documented; helm/compose templates updated. |
|
||||
| SCANNER-SECRETS-02 | DOING (2025-11-02) | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens).<br>2025-11-02: Export/report flows now depend on Surface.Secrets stub; integration tests in progress. | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. |
|
||||
| SCANNER-EVENTS-16-301 | BLOCKED (2025-10-26) | Scanner WebService Guild | ORCH-SVC-38-101, NOTIFY-SVC-38-001 | Emit orchestrator-compatible envelopes (`scanner.event.*`) and update integration tests to verify Notifier ingestion (no Redis queue coupling). | Tests assert envelope schema + orchestrator publish; Notifier consumer harness passes; docs updated with new event contract. Blocked by .NET 10 preview OpenAPI/Auth dependency drift preventing `dotnet test` completion. |
|
||||
| SCANNER-EVENTS-16-302 | DOING (2025-10-26) | Scanner WebService Guild | SCANNER-EVENTS-16-301 | Extend orchestrator event links (report/policy/attestation) once endpoints are finalised across gateway + console. | Links section covers UI/API targets; downstream consumers validated; docs/samples updated. |
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SCANNER-SURFACE-01 | TODO | Scanner Worker Guild | SURFACE-FS-02 | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments. | Integration tests prove cache entries exist; telemetry counters exported. |
|
||||
| SCANNER-ENV-01 | TODO | Scanner Worker Guild | SURFACE-ENV-02 | Replace ad-hoc environment reads with `StellaOps.Scanner.Surface.Env` helpers for cache roots and CAS endpoints. | Worker boots with helper; misconfiguration warnings documented; smoke tests updated. |
|
||||
| SCANNER-SECRETS-01 | TODO | Scanner Worker Guild, Security Guild | SURFACE-SECRETS-02 | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution. | Secrets fetched via shared provider; legacy secret code removed; integration tests cover rotation. |
|
||||
| SCANNER-SURFACE-01 | DOING (2025-11-02) | Scanner Worker Guild | SURFACE-FS-02 | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.<br>2025-11-02: Draft Surface.FS manifests emitted for sample scans; telemetry counters under review. | Integration tests prove cache entries exist; telemetry counters exported. |
|
||||
| SCANNER-ENV-01 | DOING (2025-11-02) | Scanner Worker Guild | SURFACE-ENV-02 | Replace ad-hoc environment reads with `StellaOps.Scanner.Surface.Env` helpers for cache roots and CAS endpoints.<br>2025-11-02: Worker bootstrap now resolves cache roots via helper; warning path documented; smoke tests running. | Worker boots with helper; misconfiguration warnings documented; smoke tests updated. |
|
||||
| SCANNER-SECRETS-01 | DOING (2025-11-02) | Scanner Worker Guild, Security Guild | SURFACE-SECRETS-02 | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.<br>2025-11-02: Surface.Secrets provider wired for CAS token retrieval; integration tests added. | Secrets fetched via shared provider; legacy secret code removed; integration tests cover rotation. |
|
||||
|
||||
@@ -1,173 +1,173 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public static class EventWebhookEndpointExtensions
|
||||
{
|
||||
public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/events");
|
||||
|
||||
group.MapPost("/feedser-export", HandleFeedserExportAsync);
|
||||
group.MapPost("/vexer-export", HandleVexerExportAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleFeedserExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Feedser;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<FeedserExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("feedser", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleFeedserAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleVexerExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Vexer;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<VexerExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("vexer", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleVexerAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RequestPayload<T>> ReadPayloadAsync<T>(HttpContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Request.EnableBuffering();
|
||||
|
||||
await using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
var bodyBytes = buffer.ToArray();
|
||||
context.Request.Body.Position = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<T>(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
if (payload is null)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." }));
|
||||
}
|
||||
|
||||
return RequestPayload<T>.Success(payload, bodyBytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct RequestPayload<T>
|
||||
{
|
||||
private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded)
|
||||
{
|
||||
Payload = payload;
|
||||
RawBody = rawBody;
|
||||
ErrorResult = error;
|
||||
Succeeded = succeeded;
|
||||
}
|
||||
|
||||
public T? Payload { get; }
|
||||
|
||||
public byte[] RawBody { get; }
|
||||
|
||||
public IResult? ErrorResult { get; }
|
||||
|
||||
public bool Succeeded { get; }
|
||||
|
||||
public static RequestPayload<T> Success(T payload, byte[] rawBody)
|
||||
=> new(payload, rawBody, null, true);
|
||||
|
||||
public static RequestPayload<T> Failed(IResult error)
|
||||
=> new(default, Array.Empty<byte>(), error, false);
|
||||
}
|
||||
}
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public static class EventWebhookEndpointExtensions
|
||||
{
|
||||
public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/events");
|
||||
|
||||
group.MapPost("/conselier-export", HandleConselierExportAsync);
|
||||
group.MapPost("/excitor-export", HandleExcitorExportAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleConselierExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Conselier;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<ConselierExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("conselier", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleConselierAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleExcitorExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Excitor;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<ExcitorExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("excitor", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleExcitorAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RequestPayload<T>> ReadPayloadAsync<T>(HttpContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Request.EnableBuffering();
|
||||
|
||||
await using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
var bodyBytes = buffer.ToArray();
|
||||
context.Request.Body.Position = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<T>(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
if (payload is null)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." }));
|
||||
}
|
||||
|
||||
return RequestPayload<T>.Success(payload, bodyBytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct RequestPayload<T>
|
||||
{
|
||||
private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded)
|
||||
{
|
||||
Payload = payload;
|
||||
RawBody = rawBody;
|
||||
ErrorResult = error;
|
||||
Succeeded = succeeded;
|
||||
}
|
||||
|
||||
public T? Payload { get; }
|
||||
|
||||
public byte[] RawBody { get; }
|
||||
|
||||
public IResult? ErrorResult { get; }
|
||||
|
||||
public bool Succeeded { get; }
|
||||
|
||||
public static RequestPayload<T> Success(T payload, byte[] rawBody)
|
||||
=> new(payload, rawBody, null, true);
|
||||
|
||||
public static RequestPayload<T> Failed(IResult error)
|
||||
=> new(default, Array.Empty<byte>(), error, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public interface IInboundExportEventSink
|
||||
{
|
||||
Task HandleFeedserAsync(FeedserExportEventRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task HandleVexerAsync(VexerExportEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public interface IInboundExportEventSink
|
||||
{
|
||||
Task HandleConselierAsync(ConselierExportEventRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task HandleExcitorAsync(ExcitorExportEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
internal sealed class LoggingExportEventSink : IInboundExportEventSink
|
||||
{
|
||||
private readonly ILogger<LoggingExportEventSink> _logger;
|
||||
|
||||
public LoggingExportEventSink(ILogger<LoggingExportEventSink> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task HandleFeedserAsync(FeedserExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Feedser export webhook {ExportId} with {ChangedProducts} product keys.",
|
||||
request.ExportId,
|
||||
request.ChangedProductKeys.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task HandleVexerAsync(VexerExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Vexer export webhook {ExportId} with {ChangedClaims} claim changes.",
|
||||
request.ExportId,
|
||||
request.ChangedClaims.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
internal sealed class LoggingExportEventSink : IInboundExportEventSink
|
||||
{
|
||||
private readonly ILogger<LoggingExportEventSink> _logger;
|
||||
|
||||
public LoggingExportEventSink(ILogger<LoggingExportEventSink> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task HandleConselierAsync(ConselierExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Conselier export webhook {ExportId} with {ChangedProducts} product keys.",
|
||||
request.ExportId,
|
||||
request.ChangedProductKeys.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task HandleExcitorAsync(ExcitorExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Excitor export webhook {ExportId} with {ChangedClaims} claim changes.",
|
||||
request.ExportId,
|
||||
request.ChangedClaims.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public sealed record FeedserExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<string> ChangedProductKeys,
|
||||
IReadOnlyList<string>? Kev,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<string> ChangedProductKeys { get; } = NormalizeList(ChangedProductKeys, nameof(ChangedProductKeys));
|
||||
|
||||
public IReadOnlyList<string> Kev { get; } = NormalizeList(Kev, nameof(Kev), allowEmpty: true);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string>? source, string propertyName, bool allowEmpty = false)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
if (allowEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
throw new ValidationException($"{propertyName} must be specified.");
|
||||
}
|
||||
|
||||
var cleaned = source
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (!allowEmpty && cleaned.Length == 0)
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must contain at least one value.");
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexerExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<VexerClaimChange> ChangedClaims,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<VexerClaimChange> ChangedClaims { get; } = NormalizeClaims(ChangedClaims);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<VexerClaimChange> NormalizeClaims(IReadOnlyList<VexerClaimChange>? claims)
|
||||
{
|
||||
if (claims is null || claims.Count == 0)
|
||||
{
|
||||
throw new ValidationException("changedClaims must contain at least one entry.");
|
||||
}
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
claim.Validate();
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexerClaimChange(
|
||||
string ProductKey,
|
||||
string VulnerabilityId,
|
||||
string Status)
|
||||
{
|
||||
public string ProductKey { get; } = Normalize(ProductKey, nameof(ProductKey));
|
||||
|
||||
public string VulnerabilityId { get; } = Normalize(VulnerabilityId, nameof(VulnerabilityId));
|
||||
|
||||
public string Status { get; } = Normalize(Status, nameof(Status));
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
_ = ProductKey;
|
||||
_ = VulnerabilityId;
|
||||
_ = Status;
|
||||
}
|
||||
|
||||
private static string Normalize(string value, string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must be provided.");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WebhookEventWindow(DateTimeOffset? From, DateTimeOffset? To);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public sealed record ConselierExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<string> ChangedProductKeys,
|
||||
IReadOnlyList<string>? Kev,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<string> ChangedProductKeys { get; } = NormalizeList(ChangedProductKeys, nameof(ChangedProductKeys));
|
||||
|
||||
public IReadOnlyList<string> Kev { get; } = NormalizeList(Kev, nameof(Kev), allowEmpty: true);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string>? source, string propertyName, bool allowEmpty = false)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
if (allowEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
throw new ValidationException($"{propertyName} must be specified.");
|
||||
}
|
||||
|
||||
var cleaned = source
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (!allowEmpty && cleaned.Length == 0)
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must contain at least one value.");
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExcitorExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<ExcitorClaimChange> ChangedClaims,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<ExcitorClaimChange> ChangedClaims { get; } = NormalizeClaims(ChangedClaims);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<ExcitorClaimChange> NormalizeClaims(IReadOnlyList<ExcitorClaimChange>? claims)
|
||||
{
|
||||
if (claims is null || claims.Count == 0)
|
||||
{
|
||||
throw new ValidationException("changedClaims must contain at least one entry.");
|
||||
}
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
claim.Validate();
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExcitorClaimChange(
|
||||
string ProductKey,
|
||||
string VulnerabilityId,
|
||||
string Status)
|
||||
{
|
||||
public string ProductKey { get; } = Normalize(ProductKey, nameof(ProductKey));
|
||||
|
||||
public string VulnerabilityId { get; } = Normalize(VulnerabilityId, nameof(VulnerabilityId));
|
||||
|
||||
public string Status { get; } = Normalize(Status, nameof(Status));
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
_ = ProductKey;
|
||||
_ = VulnerabilityId;
|
||||
_ = Status;
|
||||
}
|
||||
|
||||
private static string Normalize(string value, string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must be provided.");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WebhookEventWindow(DateTimeOffset? From, DateTimeOffset? To);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
namespace StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduler WebService event options (outbound + inbound).
|
||||
/// </summary>
|
||||
namespace StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduler WebService event options (outbound + inbound).
|
||||
/// </summary>
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
public sealed class SchedulerEventsOptions
|
||||
{
|
||||
public GraphJobEventsOptions GraphJobs { get; set; } = new();
|
||||
|
||||
public SchedulerInboundWebhooksOptions Webhooks { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
public sealed class SchedulerEventsOptions
|
||||
{
|
||||
public GraphJobEventsOptions GraphJobs { get; set; } = new();
|
||||
|
||||
public SchedulerInboundWebhooksOptions Webhooks { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GraphJobEventsOptions
|
||||
{
|
||||
/// <summary>
|
||||
@@ -50,91 +50,91 @@ public sealed class GraphJobEventsOptions
|
||||
/// </summary>
|
||||
public IDictionary<string, string> DriverSettings { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class SchedulerInboundWebhooksOptions
|
||||
{
|
||||
public SchedulerWebhookOptions Feedser { get; set; } = SchedulerWebhookOptions.CreateDefault("feedser");
|
||||
|
||||
public SchedulerWebhookOptions Vexer { get; set; } = SchedulerWebhookOptions.CreateDefault("vexer");
|
||||
}
|
||||
|
||||
public sealed class SchedulerWebhookOptions
|
||||
{
|
||||
private const string DefaultSignatureHeader = "X-Scheduler-Signature";
|
||||
|
||||
public SchedulerWebhookOptions()
|
||||
{
|
||||
SignatureHeader = DefaultSignatureHeader;
|
||||
}
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Require a client certificate to be presented (mTLS). Optional when HMAC is configured.
|
||||
/// </summary>
|
||||
public bool RequireClientCertificate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret (Base64 or raw text) for HMAC-SHA256 signatures. Required if <see cref="RequireClientCertificate"/> is false.
|
||||
/// </summary>
|
||||
public string? HmacSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Header name carrying the webhook signature (defaults to <c>X-Scheduler-Signature</c>).
|
||||
/// </summary>
|
||||
public string SignatureHeader { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of accepted requests per sliding window.
|
||||
/// </summary>
|
||||
public int RateLimitRequests { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Sliding window duration in seconds for the rate limiter.
|
||||
/// </summary>
|
||||
public int RateLimitWindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Optional label used for logging/diagnostics; populated via <see cref="CreateDefault"/>.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public static SchedulerWebhookOptions CreateDefault(string name)
|
||||
=> new()
|
||||
{
|
||||
Name = name,
|
||||
SignatureHeader = DefaultSignatureHeader,
|
||||
RateLimitRequests = 120,
|
||||
RateLimitWindowSeconds = 60
|
||||
};
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SignatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must specify a signature header when enabled.");
|
||||
}
|
||||
|
||||
if (!RequireClientCertificate && string.IsNullOrWhiteSpace(HmacSecret))
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure either HMAC secret or mTLS enforcement.");
|
||||
}
|
||||
|
||||
if (RateLimitRequests <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a positive rate limit.");
|
||||
}
|
||||
|
||||
if (RateLimitWindowSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a rate limit window greater than zero seconds.");
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan GetRateLimitWindow() => TimeSpan.FromSeconds(RateLimitWindowSeconds <= 0 ? 60 : RateLimitWindowSeconds);
|
||||
}
|
||||
|
||||
public sealed class SchedulerInboundWebhooksOptions
|
||||
{
|
||||
public SchedulerWebhookOptions Conselier { get; set; } = SchedulerWebhookOptions.CreateDefault("conselier");
|
||||
|
||||
public SchedulerWebhookOptions Excitor { get; set; } = SchedulerWebhookOptions.CreateDefault("excitor");
|
||||
}
|
||||
|
||||
public sealed class SchedulerWebhookOptions
|
||||
{
|
||||
private const string DefaultSignatureHeader = "X-Scheduler-Signature";
|
||||
|
||||
public SchedulerWebhookOptions()
|
||||
{
|
||||
SignatureHeader = DefaultSignatureHeader;
|
||||
}
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Require a client certificate to be presented (mTLS). Optional when HMAC is configured.
|
||||
/// </summary>
|
||||
public bool RequireClientCertificate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret (Base64 or raw text) for HMAC-SHA256 signatures. Required if <see cref="RequireClientCertificate"/> is false.
|
||||
/// </summary>
|
||||
public string? HmacSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Header name carrying the webhook signature (defaults to <c>X-Scheduler-Signature</c>).
|
||||
/// </summary>
|
||||
public string SignatureHeader { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of accepted requests per sliding window.
|
||||
/// </summary>
|
||||
public int RateLimitRequests { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Sliding window duration in seconds for the rate limiter.
|
||||
/// </summary>
|
||||
public int RateLimitWindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Optional label used for logging/diagnostics; populated via <see cref="CreateDefault"/>.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public static SchedulerWebhookOptions CreateDefault(string name)
|
||||
=> new()
|
||||
{
|
||||
Name = name,
|
||||
SignatureHeader = DefaultSignatureHeader,
|
||||
RateLimitRequests = 120,
|
||||
RateLimitWindowSeconds = 60
|
||||
};
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SignatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must specify a signature header when enabled.");
|
||||
}
|
||||
|
||||
if (!RequireClientCertificate && string.IsNullOrWhiteSpace(HmacSecret))
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure either HMAC secret or mTLS enforcement.");
|
||||
}
|
||||
|
||||
if (RateLimitRequests <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a positive rate limit.");
|
||||
}
|
||||
|
||||
if (RateLimitWindowSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a rate limit window greater than zero seconds.");
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan GetRateLimitWindow() => TimeSpan.FromSeconds(RateLimitWindowSeconds <= 0 ? 60 : RateLimitWindowSeconds);
|
||||
}
|
||||
|
||||
@@ -1,203 +1,203 @@
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Scheduler.WebService.Hosting;
|
||||
using StellaOps.Scheduler.ImpactIndex;
|
||||
using StellaOps.Scheduler.Storage.Mongo;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Services;
|
||||
using StellaOps.Scheduler.WebService;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
using StellaOps.Scheduler.WebService.Schedules;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using StellaOps.Scheduler.WebService.Runs;
|
||||
using StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddSingleton<StellaOps.Scheduler.WebService.ISystemClock, StellaOps.Scheduler.WebService.SystemClock>();
|
||||
builder.Services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
var authorityOptions = new SchedulerAuthorityOptions();
|
||||
builder.Configuration.GetSection("Scheduler:Authority").Bind(authorityOptions);
|
||||
|
||||
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphRead, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphRead);
|
||||
}
|
||||
|
||||
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphWrite, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphWrite);
|
||||
}
|
||||
|
||||
if (authorityOptions.Audiences.Count == 0)
|
||||
{
|
||||
authorityOptions.Audiences.Add("api://scheduler");
|
||||
}
|
||||
|
||||
authorityOptions.Validate();
|
||||
builder.Services.AddSingleton(authorityOptions);
|
||||
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Scheduler.WebService.Hosting;
|
||||
using StellaOps.Scheduler.ImpactIndex;
|
||||
using StellaOps.Scheduler.Storage.Mongo;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Services;
|
||||
using StellaOps.Scheduler.WebService;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
using StellaOps.Scheduler.WebService.Schedules;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using StellaOps.Scheduler.WebService.Runs;
|
||||
using StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddSingleton<StellaOps.Scheduler.WebService.ISystemClock, StellaOps.Scheduler.WebService.SystemClock>();
|
||||
builder.Services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
var authorityOptions = new SchedulerAuthorityOptions();
|
||||
builder.Configuration.GetSection("Scheduler:Authority").Bind(authorityOptions);
|
||||
|
||||
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphRead, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphRead);
|
||||
}
|
||||
|
||||
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphWrite, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphWrite);
|
||||
}
|
||||
|
||||
if (authorityOptions.Audiences.Count == 0)
|
||||
{
|
||||
authorityOptions.Audiences.Add("api://scheduler");
|
||||
}
|
||||
|
||||
authorityOptions.Validate();
|
||||
builder.Services.AddSingleton(authorityOptions);
|
||||
|
||||
builder.Services.AddOptions<SchedulerEventsOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler:Events"))
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
|
||||
options.Webhooks.Feedser ??= SchedulerWebhookOptions.CreateDefault("feedser");
|
||||
options.Webhooks.Vexer ??= SchedulerWebhookOptions.CreateDefault("vexer");
|
||||
|
||||
options.Webhooks.Feedser.Name = string.IsNullOrWhiteSpace(options.Webhooks.Feedser.Name)
|
||||
? "feedser"
|
||||
: options.Webhooks.Feedser.Name;
|
||||
options.Webhooks.Vexer.Name = string.IsNullOrWhiteSpace(options.Webhooks.Vexer.Name)
|
||||
? "vexer"
|
||||
: options.Webhooks.Vexer.Name;
|
||||
|
||||
options.Webhooks.Feedser.Validate();
|
||||
options.Webhooks.Vexer.Validate();
|
||||
});
|
||||
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
|
||||
options.Webhooks.Conselier ??= SchedulerWebhookOptions.CreateDefault("conselier");
|
||||
options.Webhooks.Excitor ??= SchedulerWebhookOptions.CreateDefault("excitor");
|
||||
|
||||
options.Webhooks.Conselier.Name = string.IsNullOrWhiteSpace(options.Webhooks.Conselier.Name)
|
||||
? "conselier"
|
||||
: options.Webhooks.Conselier.Name;
|
||||
options.Webhooks.Excitor.Name = string.IsNullOrWhiteSpace(options.Webhooks.Excitor.Name)
|
||||
? "excitor"
|
||||
: options.Webhooks.Excitor.Name;
|
||||
|
||||
options.Webhooks.Conselier.Validate();
|
||||
options.Webhooks.Excitor.Validate();
|
||||
});
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<IWebhookRateLimiter, InMemoryWebhookRateLimiter>();
|
||||
builder.Services.AddSingleton<IWebhookRequestAuthenticator, WebhookRequestAuthenticator>();
|
||||
builder.Services.AddSingleton<IInboundExportEventSink, LoggingExportEventSink>();
|
||||
builder.Services.AddSingleton<IRedisConnectionFactory, RedisConnectionFactory>();
|
||||
|
||||
var cartographerOptions = builder.Configuration.GetSection("Scheduler:Cartographer").Get<SchedulerCartographerOptions>() ?? new SchedulerCartographerOptions();
|
||||
builder.Services.AddSingleton(cartographerOptions);
|
||||
builder.Services.AddOptions<SchedulerCartographerOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler:Cartographer"));
|
||||
|
||||
var storageSection = builder.Configuration.GetSection("Scheduler:Storage");
|
||||
if (storageSection.Exists())
|
||||
{
|
||||
builder.Services.AddSchedulerMongoStorage(storageSection);
|
||||
builder.Services.AddSingleton<IGraphJobStore, MongoGraphJobStore>();
|
||||
builder.Services.AddSingleton<IPolicyRunService, PolicyRunService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IGraphJobStore, InMemoryGraphJobStore>();
|
||||
builder.Services.AddSingleton<IScheduleRepository, InMemoryScheduleRepository>();
|
||||
builder.Services.AddSingleton<IRunRepository, InMemoryRunRepository>();
|
||||
builder.Services.AddSingleton<IRunSummaryService, InMemoryRunSummaryService>();
|
||||
builder.Services.AddSingleton<ISchedulerAuditService, InMemorySchedulerAuditService>();
|
||||
builder.Services.AddSingleton<IPolicyRunService, InMemoryPolicyRunService>();
|
||||
}
|
||||
builder.Services.AddSingleton<IGraphJobCompletionPublisher, GraphJobEventPublisher>();
|
||||
if (cartographerOptions.Webhook.Enabled)
|
||||
{
|
||||
builder.Services.AddHttpClient<ICartographerWebhookClient, CartographerWebhookClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerCartographerOptions>>().CurrentValue;
|
||||
client.Timeout = TimeSpan.FromSeconds(options.Webhook.TimeoutSeconds <= 0 ? 10 : options.Webhook.TimeoutSeconds);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<ICartographerWebhookClient, NullCartographerWebhookClient>();
|
||||
}
|
||||
builder.Services.AddScoped<IGraphJobService, GraphJobService>();
|
||||
builder.Services.AddImpactIndexStub();
|
||||
|
||||
var schedulerOptions = builder.Configuration.GetSection("Scheduler").Get<SchedulerOptions>() ?? new SchedulerOptions();
|
||||
schedulerOptions.Validate();
|
||||
builder.Services.AddSingleton(schedulerOptions);
|
||||
builder.Services.AddOptions<SchedulerOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler"))
|
||||
.PostConfigure(options => options.Validate());
|
||||
|
||||
var pluginHostOptions = SchedulerPluginHostFactory.Build(schedulerOptions.Plugins, builder.Environment.ContentRootPath);
|
||||
builder.Services.AddSingleton(pluginHostOptions);
|
||||
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
||||
|
||||
if (authorityOptions.Enabled)
|
||||
{
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = authorityOptions.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = authorityOptions.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = authorityOptions.MetadataAddress;
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(authorityOptions.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(authorityOptions.TokenClockSkewSeconds);
|
||||
|
||||
foreach (var audience in authorityOptions.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
foreach (var scope in authorityOptions.RequiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
foreach (var tenant in authorityOptions.RequiredTenants)
|
||||
{
|
||||
resourceOptions.RequiredTenants.Add(tenant);
|
||||
}
|
||||
|
||||
foreach (var network in authorityOptions.BypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<ITenantContextAccessor, ClaimsTenantContextAccessor>();
|
||||
builder.Services.AddScoped<IScopeAuthorizer, TokenScopeAuthorizer>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = "Anonymous";
|
||||
options.DefaultChallengeScheme = "Anonymous";
|
||||
}).AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", static _ => { });
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<ITenantContextAccessor, HeaderTenantContextAccessor>();
|
||||
builder.Services.AddScoped<IScopeAuthorizer, HeaderScopeAuthorizer>();
|
||||
}
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
if (!authorityOptions.Enabled)
|
||||
{
|
||||
app.Logger.LogWarning("Scheduler Authority authentication is disabled; relying on header-based development fallback.");
|
||||
}
|
||||
else if (authorityOptions.AllowAnonymousFallback)
|
||||
{
|
||||
app.Logger.LogWarning("Scheduler Authority authentication is enabled but anonymous fallback remains allowed. Disable fallback before production rollout.");
|
||||
}
|
||||
|
||||
app.MapGet("/healthz", () => Results.Json(new { status = "ok" }));
|
||||
app.MapGet("/readyz", () => Results.Json(new { status = "ready" }));
|
||||
|
||||
app.MapGraphJobEndpoints();
|
||||
app.MapScheduleEndpoints();
|
||||
app.MapRunEndpoints();
|
||||
app.MapPolicyRunEndpoints();
|
||||
app.MapSchedulerEventWebhookEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
var cartographerOptions = builder.Configuration.GetSection("Scheduler:Cartographer").Get<SchedulerCartographerOptions>() ?? new SchedulerCartographerOptions();
|
||||
builder.Services.AddSingleton(cartographerOptions);
|
||||
builder.Services.AddOptions<SchedulerCartographerOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler:Cartographer"));
|
||||
|
||||
var storageSection = builder.Configuration.GetSection("Scheduler:Storage");
|
||||
if (storageSection.Exists())
|
||||
{
|
||||
builder.Services.AddSchedulerMongoStorage(storageSection);
|
||||
builder.Services.AddSingleton<IGraphJobStore, MongoGraphJobStore>();
|
||||
builder.Services.AddSingleton<IPolicyRunService, PolicyRunService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IGraphJobStore, InMemoryGraphJobStore>();
|
||||
builder.Services.AddSingleton<IScheduleRepository, InMemoryScheduleRepository>();
|
||||
builder.Services.AddSingleton<IRunRepository, InMemoryRunRepository>();
|
||||
builder.Services.AddSingleton<IRunSummaryService, InMemoryRunSummaryService>();
|
||||
builder.Services.AddSingleton<ISchedulerAuditService, InMemorySchedulerAuditService>();
|
||||
builder.Services.AddSingleton<IPolicyRunService, InMemoryPolicyRunService>();
|
||||
}
|
||||
builder.Services.AddSingleton<IGraphJobCompletionPublisher, GraphJobEventPublisher>();
|
||||
if (cartographerOptions.Webhook.Enabled)
|
||||
{
|
||||
builder.Services.AddHttpClient<ICartographerWebhookClient, CartographerWebhookClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerCartographerOptions>>().CurrentValue;
|
||||
client.Timeout = TimeSpan.FromSeconds(options.Webhook.TimeoutSeconds <= 0 ? 10 : options.Webhook.TimeoutSeconds);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<ICartographerWebhookClient, NullCartographerWebhookClient>();
|
||||
}
|
||||
builder.Services.AddScoped<IGraphJobService, GraphJobService>();
|
||||
builder.Services.AddImpactIndexStub();
|
||||
|
||||
var schedulerOptions = builder.Configuration.GetSection("Scheduler").Get<SchedulerOptions>() ?? new SchedulerOptions();
|
||||
schedulerOptions.Validate();
|
||||
builder.Services.AddSingleton(schedulerOptions);
|
||||
builder.Services.AddOptions<SchedulerOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler"))
|
||||
.PostConfigure(options => options.Validate());
|
||||
|
||||
var pluginHostOptions = SchedulerPluginHostFactory.Build(schedulerOptions.Plugins, builder.Environment.ContentRootPath);
|
||||
builder.Services.AddSingleton(pluginHostOptions);
|
||||
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
||||
|
||||
if (authorityOptions.Enabled)
|
||||
{
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = authorityOptions.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = authorityOptions.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = authorityOptions.MetadataAddress;
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(authorityOptions.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(authorityOptions.TokenClockSkewSeconds);
|
||||
|
||||
foreach (var audience in authorityOptions.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
foreach (var scope in authorityOptions.RequiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
foreach (var tenant in authorityOptions.RequiredTenants)
|
||||
{
|
||||
resourceOptions.RequiredTenants.Add(tenant);
|
||||
}
|
||||
|
||||
foreach (var network in authorityOptions.BypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<ITenantContextAccessor, ClaimsTenantContextAccessor>();
|
||||
builder.Services.AddScoped<IScopeAuthorizer, TokenScopeAuthorizer>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = "Anonymous";
|
||||
options.DefaultChallengeScheme = "Anonymous";
|
||||
}).AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", static _ => { });
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<ITenantContextAccessor, HeaderTenantContextAccessor>();
|
||||
builder.Services.AddScoped<IScopeAuthorizer, HeaderScopeAuthorizer>();
|
||||
}
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
if (!authorityOptions.Enabled)
|
||||
{
|
||||
app.Logger.LogWarning("Scheduler Authority authentication is disabled; relying on header-based development fallback.");
|
||||
}
|
||||
else if (authorityOptions.AllowAnonymousFallback)
|
||||
{
|
||||
app.Logger.LogWarning("Scheduler Authority authentication is enabled but anonymous fallback remains allowed. Disable fallback before production rollout.");
|
||||
}
|
||||
|
||||
app.MapGet("/healthz", () => Results.Json(new { status = "ok" }));
|
||||
app.MapGet("/readyz", () => Results.Json(new { status = "ready" }));
|
||||
|
||||
app.MapGraphJobEndpoints();
|
||||
app.MapScheduleEndpoints();
|
||||
app.MapRunEndpoints();
|
||||
app.MapPolicyRunEndpoints();
|
||||
app.MapSchedulerEventWebhookEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| SCHED-WEB-16-101 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-MODELS-16-101 | Bootstrap Minimal API host with Authority OpTok + DPoP, health endpoints, plug-in discovery per architecture §§1–2. | Service boots with config validation; `/healthz`/`/readyz` pass; restart-only plug-ins enforced. |
|
||||
| SCHED-WEB-16-102 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-101 | Implement schedules CRUD (tenant-scoped) with cron validation, pause/resume, audit logging. | CRUD operations tested; invalid cron inputs rejected; audit entries persisted. |
|
||||
| SCHED-WEB-16-103 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-102 | Runs API (list/detail/cancel), ad-hoc run POST, and impact preview endpoints. | Integration tests cover run lifecycle; preview returns counts/sample; cancellation honoured. |
|
||||
| SCHED-WEB-16-104 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-QUEUE-16-401, SCHED-STORAGE-16-201 | Webhook endpoints for Feeder/Vexer exports with mTLS/HMAC validation and rate limiting. | Webhooks validated via tests; invalid signatures rejected; rate limits documented. |
|
||||
| SCHED-WEB-16-104 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-QUEUE-16-401, SCHED-STORAGE-16-201 | Webhook endpoints for Feeder/Excitor exports with mTLS/HMAC validation and rate limiting. | Webhooks validated via tests; invalid signatures rejected; rate limits documented. |
|
||||
| SCHED-WEB-20-001 | DONE (2025-10-29) | Scheduler WebService Guild, Policy Guild | SCHED-WEB-16-101, POLICY-ENGINE-20-000 | Expose policy run scheduling APIs (`POST /policy/runs`, `GET /policy/runs`) with tenant scoping and RBAC enforcement for `policy:run`. | Endpoints documented; integration tests cover run creation/status; unauthorized access blocked. |
|
||||
| SCHED-WEB-21-001 | DONE (2025-10-26) | Scheduler WebService Guild, Cartographer Guild | SCHED-WEB-16-101, SCHED-MODELS-21-001 | Expose graph build/overlay job APIs (`POST /graphs/build`, `GET /graphs/jobs`) with `graph:write`/`graph:read` enforcement and tenant scoping. | APIs documented in `docs/SCHED-WEB-21-001-GRAPH-APIS.md`; integration tests cover submission/status; unauthorized requests blocked; scope checks now reference `StellaOpsScopes`. |
|
||||
| SCHED-WEB-21-002 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-21-001, CARTO-GRAPH-21-007 | Provide overlay lag metrics endpoint and webhook to notify Cartographer of job completions; include correlation IDs. | `POST /graphs/hooks/completed` + `GET /graphs/overlays/lag` documented in `docs/SCHED-WEB-21-001-GRAPH-APIS.md`; integration tests cover completion + metrics. |
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
# SCHED-WEB-16-104 · Feedser/Vexer Webhook Endpoints
|
||||
|
||||
## Overview
|
||||
|
||||
Scheduler.WebService exposes inbound webhooks that allow Feedser and Vexer to
|
||||
notify the planner when new exports are available. Each webhook validates the
|
||||
payload, enforces signature requirements, and applies a per-endpoint rate
|
||||
limit before queuing downstream processing.
|
||||
|
||||
| Endpoint | Description | AuthZ |
|
||||
|----------|-------------|-------|
|
||||
| `POST /events/feedser-export` | Ingest Feedser export metadata (`exportId`, `changedProductKeys`, optional KEV & window). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
|
||||
| `POST /events/vexer-export` | Ingest Vexer export delta summary (`changedClaims`). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
|
||||
|
||||
## Security
|
||||
|
||||
* Webhooks require either:
|
||||
* mTLS with trusted client certificates; **or**
|
||||
* an HMAC-SHA256 signature in the `X-Scheduler-Signature` header. The
|
||||
signature must be computed as `sha256=<hex>` over the raw request body.
|
||||
* Requests without the required signature/certificate return `401`.
|
||||
* Secrets are configured under `Scheduler:Events:Webhooks:{Feedser|Vexer}:HmacSecret`.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
* Each webhook enforces a sliding-window limit (`RateLimitRequests` over
|
||||
`RateLimitWindowSeconds`).
|
||||
* Requests over the limit return `429` and include a `Retry-After` header.
|
||||
* Defaults: 120 requests / 60 seconds. Adjust via configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
```
|
||||
Scheduler:
|
||||
Events:
|
||||
Webhooks:
|
||||
Feedser:
|
||||
Enabled: true
|
||||
HmacSecret: feedser-secret
|
||||
RequireClientCertificate: false
|
||||
RateLimitRequests: 120
|
||||
RateLimitWindowSeconds: 60
|
||||
Vexer:
|
||||
Enabled: true
|
||||
HmacSecret: vexer-secret
|
||||
RequireClientCertificate: false
|
||||
```
|
||||
|
||||
## Response envelope
|
||||
|
||||
On success the webhook returns `202 Accepted` and a JSON body:
|
||||
|
||||
```
|
||||
{ "status": "accepted" }
|
||||
```
|
||||
|
||||
Failures return problem JSON with `error` describing the violation.
|
||||
|
||||
# SCHED-WEB-16-104 · Conselier/Excitor Webhook Endpoints
|
||||
|
||||
## Overview
|
||||
|
||||
Scheduler.WebService exposes inbound webhooks that allow Conselier and Excitor to
|
||||
notify the planner when new exports are available. Each webhook validates the
|
||||
payload, enforces signature requirements, and applies a per-endpoint rate
|
||||
limit before queuing downstream processing.
|
||||
|
||||
| Endpoint | Description | AuthZ |
|
||||
|----------|-------------|-------|
|
||||
| `POST /events/conselier-export` | Ingest Conselier export metadata (`exportId`, `changedProductKeys`, optional KEV & window). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
|
||||
| `POST /events/excitor-export` | Ingest Excitor export delta summary (`changedClaims`). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
|
||||
|
||||
## Security
|
||||
|
||||
* Webhooks require either:
|
||||
* mTLS with trusted client certificates; **or**
|
||||
* an HMAC-SHA256 signature in the `X-Scheduler-Signature` header. The
|
||||
signature must be computed as `sha256=<hex>` over the raw request body.
|
||||
* Requests without the required signature/certificate return `401`.
|
||||
* Secrets are configured under `Scheduler:Events:Webhooks:{Conselier|Excitor}:HmacSecret`.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
* Each webhook enforces a sliding-window limit (`RateLimitRequests` over
|
||||
`RateLimitWindowSeconds`).
|
||||
* Requests over the limit return `429` and include a `Retry-After` header.
|
||||
* Defaults: 120 requests / 60 seconds. Adjust via configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
```
|
||||
Scheduler:
|
||||
Events:
|
||||
Webhooks:
|
||||
Conselier:
|
||||
Enabled: true
|
||||
HmacSecret: conselier-secret
|
||||
RequireClientCertificate: false
|
||||
RateLimitRequests: 120
|
||||
RateLimitWindowSeconds: 60
|
||||
Excitor:
|
||||
Enabled: true
|
||||
HmacSecret: excitor-secret
|
||||
RequireClientCertificate: false
|
||||
```
|
||||
|
||||
## Response envelope
|
||||
|
||||
On success the webhook returns `202 Accepted` and a JSON body:
|
||||
|
||||
```
|
||||
{ "status": "accepted" }
|
||||
```
|
||||
|
||||
Failures return problem JSON with `error` describing the violation.
|
||||
|
||||
|
||||
@@ -1,168 +1,168 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic serializer for scheduler DTOs.
|
||||
/// </summary>
|
||||
public static class CanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
|
||||
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
|
||||
|
||||
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
|
||||
{
|
||||
[typeof(Schedule)] = new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"tenantId",
|
||||
"name",
|
||||
"enabled",
|
||||
"cronExpression",
|
||||
"timezone",
|
||||
"mode",
|
||||
"selection",
|
||||
"onlyIf",
|
||||
"notify",
|
||||
"limits",
|
||||
"subscribers",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"updatedAt",
|
||||
"updatedBy",
|
||||
},
|
||||
[typeof(Selector)] = new[]
|
||||
{
|
||||
"scope",
|
||||
"tenantId",
|
||||
"namespaces",
|
||||
"repositories",
|
||||
"digests",
|
||||
"includeTags",
|
||||
"labels",
|
||||
"resolvesTags",
|
||||
},
|
||||
[typeof(LabelSelector)] = new[]
|
||||
{
|
||||
"key",
|
||||
"values",
|
||||
},
|
||||
[typeof(ScheduleOnlyIf)] = new[]
|
||||
{
|
||||
"lastReportOlderThanDays",
|
||||
"policyRevision",
|
||||
},
|
||||
[typeof(ScheduleNotify)] = new[]
|
||||
{
|
||||
"onNewFindings",
|
||||
"minSeverity",
|
||||
"includeKev",
|
||||
"includeQuietFindings",
|
||||
},
|
||||
[typeof(ScheduleLimits)] = new[]
|
||||
{
|
||||
"maxJobs",
|
||||
"ratePerSecond",
|
||||
"parallelism",
|
||||
"burst",
|
||||
},
|
||||
[typeof(Run)] = new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"tenantId",
|
||||
"scheduleId",
|
||||
"trigger",
|
||||
"state",
|
||||
"stats",
|
||||
"reason",
|
||||
"createdAt",
|
||||
"startedAt",
|
||||
"finishedAt",
|
||||
"error",
|
||||
"deltas",
|
||||
},
|
||||
[typeof(RunStats)] = new[]
|
||||
{
|
||||
"candidates",
|
||||
"deduped",
|
||||
"queued",
|
||||
"completed",
|
||||
"deltas",
|
||||
"newCriticals",
|
||||
"newHigh",
|
||||
"newMedium",
|
||||
"newLow",
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic serializer for scheduler DTOs.
|
||||
/// </summary>
|
||||
public static class CanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
|
||||
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
|
||||
|
||||
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
|
||||
{
|
||||
[typeof(Schedule)] = new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"tenantId",
|
||||
"name",
|
||||
"enabled",
|
||||
"cronExpression",
|
||||
"timezone",
|
||||
"mode",
|
||||
"selection",
|
||||
"onlyIf",
|
||||
"notify",
|
||||
"limits",
|
||||
"subscribers",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"updatedAt",
|
||||
"updatedBy",
|
||||
},
|
||||
[typeof(Selector)] = new[]
|
||||
{
|
||||
"scope",
|
||||
"tenantId",
|
||||
"namespaces",
|
||||
"repositories",
|
||||
"digests",
|
||||
"includeTags",
|
||||
"labels",
|
||||
"resolvesTags",
|
||||
},
|
||||
[typeof(LabelSelector)] = new[]
|
||||
{
|
||||
"key",
|
||||
"values",
|
||||
},
|
||||
[typeof(ScheduleOnlyIf)] = new[]
|
||||
{
|
||||
"lastReportOlderThanDays",
|
||||
"policyRevision",
|
||||
},
|
||||
[typeof(ScheduleNotify)] = new[]
|
||||
{
|
||||
"onNewFindings",
|
||||
"minSeverity",
|
||||
"includeKev",
|
||||
"includeQuietFindings",
|
||||
},
|
||||
[typeof(ScheduleLimits)] = new[]
|
||||
{
|
||||
"maxJobs",
|
||||
"ratePerSecond",
|
||||
"parallelism",
|
||||
"burst",
|
||||
},
|
||||
[typeof(Run)] = new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"tenantId",
|
||||
"scheduleId",
|
||||
"trigger",
|
||||
"state",
|
||||
"stats",
|
||||
"reason",
|
||||
"createdAt",
|
||||
"startedAt",
|
||||
"finishedAt",
|
||||
"error",
|
||||
"deltas",
|
||||
},
|
||||
[typeof(RunStats)] = new[]
|
||||
{
|
||||
"candidates",
|
||||
"deduped",
|
||||
"queued",
|
||||
"completed",
|
||||
"deltas",
|
||||
"newCriticals",
|
||||
"newHigh",
|
||||
"newMedium",
|
||||
"newLow",
|
||||
},
|
||||
[typeof(RunReason)] = new[]
|
||||
{
|
||||
"manualReason",
|
||||
"feedserExportId",
|
||||
"vexerExportId",
|
||||
"cursor",
|
||||
"impactWindowFrom",
|
||||
"impactWindowTo",
|
||||
},
|
||||
[typeof(DeltaSummary)] = new[]
|
||||
{
|
||||
"imageDigest",
|
||||
"newFindings",
|
||||
"newCriticals",
|
||||
"newHigh",
|
||||
"newMedium",
|
||||
"newLow",
|
||||
"kevHits",
|
||||
"topFindings",
|
||||
"reportUrl",
|
||||
"attestation",
|
||||
"detectedAt",
|
||||
},
|
||||
[typeof(DeltaFinding)] = new[]
|
||||
{
|
||||
"purl",
|
||||
"vulnerabilityId",
|
||||
"severity",
|
||||
"link",
|
||||
},
|
||||
[typeof(ImpactSet)] = new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"selector",
|
||||
"images",
|
||||
"usageOnly",
|
||||
"generatedAt",
|
||||
"total",
|
||||
"conselierExportId",
|
||||
"excitorExportId",
|
||||
"cursor",
|
||||
"impactWindowFrom",
|
||||
"impactWindowTo",
|
||||
},
|
||||
[typeof(DeltaSummary)] = new[]
|
||||
{
|
||||
"imageDigest",
|
||||
"newFindings",
|
||||
"newCriticals",
|
||||
"newHigh",
|
||||
"newMedium",
|
||||
"newLow",
|
||||
"kevHits",
|
||||
"topFindings",
|
||||
"reportUrl",
|
||||
"attestation",
|
||||
"detectedAt",
|
||||
},
|
||||
[typeof(DeltaFinding)] = new[]
|
||||
{
|
||||
"purl",
|
||||
"vulnerabilityId",
|
||||
"severity",
|
||||
"link",
|
||||
},
|
||||
[typeof(ImpactSet)] = new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"selector",
|
||||
"images",
|
||||
"usageOnly",
|
||||
"generatedAt",
|
||||
"total",
|
||||
"snapshotId",
|
||||
},
|
||||
[typeof(ImpactImage)] = new[]
|
||||
{
|
||||
"imageDigest",
|
||||
"registry",
|
||||
"repository",
|
||||
"namespaces",
|
||||
"tags",
|
||||
"usedByEntrypoint",
|
||||
"labels",
|
||||
},
|
||||
[typeof(AuditRecord)] = new[]
|
||||
{
|
||||
"id",
|
||||
"tenantId",
|
||||
"category",
|
||||
"action",
|
||||
"occurredAt",
|
||||
"actor",
|
||||
"entityId",
|
||||
"scheduleId",
|
||||
"runId",
|
||||
"correlationId",
|
||||
"metadata",
|
||||
"message",
|
||||
},
|
||||
"registry",
|
||||
"repository",
|
||||
"namespaces",
|
||||
"tags",
|
||||
"usedByEntrypoint",
|
||||
"labels",
|
||||
},
|
||||
[typeof(AuditRecord)] = new[]
|
||||
{
|
||||
"id",
|
||||
"tenantId",
|
||||
"category",
|
||||
"action",
|
||||
"occurredAt",
|
||||
"actor",
|
||||
"entityId",
|
||||
"scheduleId",
|
||||
"runId",
|
||||
"correlationId",
|
||||
"metadata",
|
||||
"message",
|
||||
},
|
||||
[typeof(AuditActor)] = new[]
|
||||
{
|
||||
"actorId",
|
||||
@@ -378,32 +378,32 @@ public static class CanonicalJsonSerializer
|
||||
"note",
|
||||
},
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, CompactOptions);
|
||||
|
||||
public static string SerializeIndented<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, PrettyOptions);
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
=> JsonSerializer.Deserialize<T>(json, PrettyOptions)
|
||||
?? throw new InvalidOperationException($"Unable to deserialize {typeof(T).Name}.");
|
||||
|
||||
private static JsonSerializerOptions CreateOptions(bool writeIndented)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = writeIndented,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
var resolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
|
||||
options.TypeInfoResolver = new DeterministicResolver(resolver);
|
||||
options.Converters.Add(new ScheduleModeConverter());
|
||||
options.Converters.Add(new SelectorScopeConverter());
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, CompactOptions);
|
||||
|
||||
public static string SerializeIndented<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, PrettyOptions);
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
=> JsonSerializer.Deserialize<T>(json, PrettyOptions)
|
||||
?? throw new InvalidOperationException($"Unable to deserialize {typeof(T).Name}.");
|
||||
|
||||
private static JsonSerializerOptions CreateOptions(bool writeIndented)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = writeIndented,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
var resolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
|
||||
options.TypeInfoResolver = new DeterministicResolver(resolver);
|
||||
options.Converters.Add(new ScheduleModeConverter());
|
||||
options.Converters.Add(new SelectorScopeConverter());
|
||||
options.Converters.Add(new RunTriggerConverter());
|
||||
options.Converters.Add(new RunStateConverter());
|
||||
options.Converters.Add(new SeverityRankConverter());
|
||||
@@ -418,53 +418,53 @@ public static class CanonicalJsonSerializer
|
||||
options.Converters.Add(new PolicyRunJobStatusConverter());
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed class DeterministicResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
private readonly IJsonTypeInfoResolver _inner;
|
||||
|
||||
public DeterministicResolver(IJsonTypeInfoResolver inner)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var info = _inner.GetTypeInfo(type, options);
|
||||
if (info is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
|
||||
}
|
||||
|
||||
if (info.Kind is JsonTypeInfoKind.Object && info.Properties.Count > 1)
|
||||
{
|
||||
var ordered = info.Properties
|
||||
.OrderBy(property => ResolveOrder(type, property.Name))
|
||||
.ThenBy(property => property.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
info.Properties.Clear();
|
||||
foreach (var property in ordered)
|
||||
{
|
||||
info.Properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static int ResolveOrder(Type type, string propertyName)
|
||||
{
|
||||
if (PropertyOrder.TryGetValue(type, out var order))
|
||||
{
|
||||
var index = Array.IndexOf(order, propertyName);
|
||||
if (index >= 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DeterministicResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
private readonly IJsonTypeInfoResolver _inner;
|
||||
|
||||
public DeterministicResolver(IJsonTypeInfoResolver inner)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var info = _inner.GetTypeInfo(type, options);
|
||||
if (info is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
|
||||
}
|
||||
|
||||
if (info.Kind is JsonTypeInfoKind.Object && info.Properties.Count > 1)
|
||||
{
|
||||
var ordered = info.Properties
|
||||
.OrderBy(property => ResolveOrder(type, property.Name))
|
||||
.ThenBy(property => property.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
info.Properties.Clear();
|
||||
foreach (var property in ordered)
|
||||
{
|
||||
info.Properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static int ResolveOrder(Type type, string propertyName)
|
||||
{
|
||||
if (PropertyOrder.TryGetValue(type, out var order))
|
||||
{
|
||||
var index = Array.IndexOf(order, propertyName);
|
||||
if (index >= 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Execution mode for a schedule.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(ScheduleModeConverter))]
|
||||
public enum ScheduleMode
|
||||
{
|
||||
AnalysisOnly,
|
||||
ContentRefresh,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selector scope determining which filters are applied.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SelectorScopeConverter))]
|
||||
public enum SelectorScope
|
||||
{
|
||||
AllImages,
|
||||
ByNamespace,
|
||||
ByRepository,
|
||||
ByDigest,
|
||||
ByLabels,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source that triggered a run.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(RunTriggerConverter))]
|
||||
public enum RunTrigger
|
||||
{
|
||||
Cron,
|
||||
Feedser,
|
||||
Vexer,
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle state of a scheduler run.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(RunStateConverter))]
|
||||
public enum RunState
|
||||
{
|
||||
Planning,
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Error,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity rankings used in scheduler payloads.
|
||||
/// </summary>
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Execution mode for a schedule.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(ScheduleModeConverter))]
|
||||
public enum ScheduleMode
|
||||
{
|
||||
AnalysisOnly,
|
||||
ContentRefresh,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selector scope determining which filters are applied.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SelectorScopeConverter))]
|
||||
public enum SelectorScope
|
||||
{
|
||||
AllImages,
|
||||
ByNamespace,
|
||||
ByRepository,
|
||||
ByDigest,
|
||||
ByLabels,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source that triggered a run.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(RunTriggerConverter))]
|
||||
public enum RunTrigger
|
||||
{
|
||||
Cron,
|
||||
Conselier,
|
||||
Excitor,
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle state of a scheduler run.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(RunStateConverter))]
|
||||
public enum RunState
|
||||
{
|
||||
Planning,
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Error,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity rankings used in scheduler payloads.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SeverityRankConverter))]
|
||||
public enum SeverityRank
|
||||
{
|
||||
None = 0,
|
||||
Info = 1,
|
||||
Low = 2,
|
||||
Medium = 3,
|
||||
Low = 2,
|
||||
Medium = 3,
|
||||
High = 4,
|
||||
Critical = 5,
|
||||
Unknown = 6,
|
||||
|
||||
@@ -1,378 +1,378 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Execution record for a scheduler run.
|
||||
/// </summary>
|
||||
public sealed record Run
|
||||
{
|
||||
public Run(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunTrigger trigger,
|
||||
RunState state,
|
||||
RunStats stats,
|
||||
DateTimeOffset createdAt,
|
||||
RunReason? reason = null,
|
||||
string? scheduleId = null,
|
||||
DateTimeOffset? startedAt = null,
|
||||
DateTimeOffset? finishedAt = null,
|
||||
string? error = null,
|
||||
IEnumerable<DeltaSummary>? deltas = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
trigger,
|
||||
state,
|
||||
stats,
|
||||
reason ?? RunReason.Empty,
|
||||
scheduleId,
|
||||
Validation.NormalizeTimestamp(createdAt),
|
||||
Validation.NormalizeTimestamp(startedAt),
|
||||
Validation.NormalizeTimestamp(finishedAt),
|
||||
Validation.TrimToNull(error),
|
||||
NormalizeDeltas(deltas),
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public Run(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunTrigger trigger,
|
||||
RunState state,
|
||||
RunStats stats,
|
||||
RunReason reason,
|
||||
string? scheduleId,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? startedAt,
|
||||
DateTimeOffset? finishedAt,
|
||||
string? error,
|
||||
ImmutableArray<DeltaSummary> deltas,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
Trigger = trigger;
|
||||
State = state;
|
||||
Stats = stats ?? throw new ArgumentNullException(nameof(stats));
|
||||
Reason = reason ?? RunReason.Empty;
|
||||
ScheduleId = Validation.TrimToNull(scheduleId);
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
StartedAt = Validation.NormalizeTimestamp(startedAt);
|
||||
FinishedAt = Validation.NormalizeTimestamp(finishedAt);
|
||||
Error = Validation.TrimToNull(error);
|
||||
Deltas = deltas.IsDefault
|
||||
? ImmutableArray<DeltaSummary>.Empty
|
||||
: deltas.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal).ToImmutableArray();
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureRun(schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
public RunTrigger Trigger { get; }
|
||||
|
||||
public RunState State { get; init; }
|
||||
|
||||
public RunStats Stats { get; init; }
|
||||
|
||||
public RunReason Reason { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? FinishedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<DeltaSummary> Deltas { get; } = ImmutableArray<DeltaSummary>.Empty;
|
||||
|
||||
private static ImmutableArray<DeltaSummary> NormalizeDeltas(IEnumerable<DeltaSummary>? deltas)
|
||||
{
|
||||
if (deltas is null)
|
||||
{
|
||||
return ImmutableArray<DeltaSummary>.Empty;
|
||||
}
|
||||
|
||||
return deltas
|
||||
.Where(static delta => delta is not null)
|
||||
.Select(static delta => delta!)
|
||||
.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context describing why a run executed.
|
||||
/// </summary>
|
||||
public sealed record RunReason
|
||||
{
|
||||
public static RunReason Empty { get; } = new();
|
||||
|
||||
public RunReason(
|
||||
string? manualReason = null,
|
||||
string? feedserExportId = null,
|
||||
string? vexerExportId = null,
|
||||
string? cursor = null)
|
||||
{
|
||||
ManualReason = Validation.TrimToNull(manualReason);
|
||||
FeedserExportId = Validation.TrimToNull(feedserExportId);
|
||||
VexerExportId = Validation.TrimToNull(vexerExportId);
|
||||
Cursor = Validation.TrimToNull(cursor);
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ManualReason { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? FeedserExportId { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? VexerExportId { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Cursor { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ImpactWindowFrom { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ImpactWindowTo { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated counters for a scheduler run.
|
||||
/// </summary>
|
||||
public sealed record RunStats
|
||||
{
|
||||
public static RunStats Empty { get; } = new();
|
||||
|
||||
public RunStats(
|
||||
int candidates = 0,
|
||||
int deduped = 0,
|
||||
int queued = 0,
|
||||
int completed = 0,
|
||||
int deltas = 0,
|
||||
int newCriticals = 0,
|
||||
int newHigh = 0,
|
||||
int newMedium = 0,
|
||||
int newLow = 0)
|
||||
{
|
||||
Candidates = Validation.EnsureNonNegative(candidates, nameof(candidates));
|
||||
Deduped = Validation.EnsureNonNegative(deduped, nameof(deduped));
|
||||
Queued = Validation.EnsureNonNegative(queued, nameof(queued));
|
||||
Completed = Validation.EnsureNonNegative(completed, nameof(completed));
|
||||
Deltas = Validation.EnsureNonNegative(deltas, nameof(deltas));
|
||||
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
|
||||
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
|
||||
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
|
||||
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
|
||||
}
|
||||
|
||||
public int Candidates { get; } = 0;
|
||||
|
||||
public int Deduped { get; } = 0;
|
||||
|
||||
public int Queued { get; } = 0;
|
||||
|
||||
public int Completed { get; } = 0;
|
||||
|
||||
public int Deltas { get; } = 0;
|
||||
|
||||
public int NewCriticals { get; } = 0;
|
||||
|
||||
public int NewHigh { get; } = 0;
|
||||
|
||||
public int NewMedium { get; } = 0;
|
||||
|
||||
public int NewLow { get; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of delta impact for an image processed in a run.
|
||||
/// </summary>
|
||||
public sealed record DeltaSummary
|
||||
{
|
||||
public DeltaSummary(
|
||||
string imageDigest,
|
||||
int newFindings,
|
||||
int newCriticals,
|
||||
int newHigh,
|
||||
int newMedium,
|
||||
int newLow,
|
||||
IEnumerable<string>? kevHits = null,
|
||||
IEnumerable<DeltaFinding>? topFindings = null,
|
||||
string? reportUrl = null,
|
||||
DeltaAttestation? attestation = null,
|
||||
DateTimeOffset? detectedAt = null)
|
||||
: this(
|
||||
imageDigest,
|
||||
Validation.EnsureNonNegative(newFindings, nameof(newFindings)),
|
||||
Validation.EnsureNonNegative(newCriticals, nameof(newCriticals)),
|
||||
Validation.EnsureNonNegative(newHigh, nameof(newHigh)),
|
||||
Validation.EnsureNonNegative(newMedium, nameof(newMedium)),
|
||||
Validation.EnsureNonNegative(newLow, nameof(newLow)),
|
||||
NormalizeKevHits(kevHits),
|
||||
NormalizeFindings(topFindings),
|
||||
Validation.TrimToNull(reportUrl),
|
||||
attestation,
|
||||
Validation.NormalizeTimestamp(detectedAt))
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public DeltaSummary(
|
||||
string imageDigest,
|
||||
int newFindings,
|
||||
int newCriticals,
|
||||
int newHigh,
|
||||
int newMedium,
|
||||
int newLow,
|
||||
ImmutableArray<string> kevHits,
|
||||
ImmutableArray<DeltaFinding> topFindings,
|
||||
string? reportUrl,
|
||||
DeltaAttestation? attestation,
|
||||
DateTimeOffset? detectedAt)
|
||||
{
|
||||
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
|
||||
NewFindings = Validation.EnsureNonNegative(newFindings, nameof(newFindings));
|
||||
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
|
||||
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
|
||||
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
|
||||
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
|
||||
KevHits = kevHits.IsDefault ? ImmutableArray<string>.Empty : kevHits;
|
||||
TopFindings = topFindings.IsDefault
|
||||
? ImmutableArray<DeltaFinding>.Empty
|
||||
: topFindings
|
||||
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
|
||||
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
ReportUrl = Validation.TrimToNull(reportUrl);
|
||||
Attestation = attestation;
|
||||
DetectedAt = Validation.NormalizeTimestamp(detectedAt);
|
||||
}
|
||||
|
||||
public string ImageDigest { get; }
|
||||
|
||||
public int NewFindings { get; }
|
||||
|
||||
public int NewCriticals { get; }
|
||||
|
||||
public int NewHigh { get; }
|
||||
|
||||
public int NewMedium { get; }
|
||||
|
||||
public int NewLow { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> KevHits { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<DeltaFinding> TopFindings { get; } = ImmutableArray<DeltaFinding>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReportUrl { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DeltaAttestation? Attestation { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? DetectedAt { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeKevHits(IEnumerable<string>? kevHits)
|
||||
=> Validation.NormalizeStringSet(kevHits, nameof(kevHits));
|
||||
|
||||
private static ImmutableArray<DeltaFinding> NormalizeFindings(IEnumerable<DeltaFinding>? findings)
|
||||
{
|
||||
if (findings is null)
|
||||
{
|
||||
return ImmutableArray<DeltaFinding>.Empty;
|
||||
}
|
||||
|
||||
return findings
|
||||
.Where(static finding => finding is not null)
|
||||
.Select(static finding => finding!)
|
||||
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
|
||||
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top finding entry included in delta summaries.
|
||||
/// </summary>
|
||||
public sealed record DeltaFinding
|
||||
{
|
||||
public DeltaFinding(string purl, string vulnerabilityId, SeverityRank severity, string? link = null)
|
||||
{
|
||||
Purl = Validation.EnsureSimpleIdentifier(purl, nameof(purl));
|
||||
VulnerabilityId = Validation.EnsureSimpleIdentifier(vulnerabilityId, nameof(vulnerabilityId));
|
||||
Severity = severity;
|
||||
Link = Validation.TrimToNull(link);
|
||||
}
|
||||
|
||||
public string Purl { get; }
|
||||
|
||||
public string VulnerabilityId { get; }
|
||||
|
||||
public SeverityRank Severity { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Link { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor/attestation information surfaced with a delta summary.
|
||||
/// </summary>
|
||||
public sealed record DeltaAttestation
|
||||
{
|
||||
public DeltaAttestation(string? uuid, bool? verified = null)
|
||||
{
|
||||
Uuid = Validation.TrimToNull(uuid);
|
||||
Verified = verified;
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Uuid { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? Verified { get; }
|
||||
}
|
||||
|
||||
internal sealed class SeverityRankComparer : IComparer<SeverityRank>
|
||||
{
|
||||
public static SeverityRankComparer Instance { get; } = new();
|
||||
|
||||
private static readonly Dictionary<SeverityRank, int> Order = new()
|
||||
{
|
||||
[SeverityRank.Critical] = 0,
|
||||
[SeverityRank.High] = 1,
|
||||
[SeverityRank.Unknown] = 2,
|
||||
[SeverityRank.Medium] = 3,
|
||||
[SeverityRank.Low] = 4,
|
||||
[SeverityRank.Info] = 5,
|
||||
[SeverityRank.None] = 6,
|
||||
};
|
||||
|
||||
public int Compare(SeverityRank x, SeverityRank y)
|
||||
=> GetOrder(x).CompareTo(GetOrder(y));
|
||||
|
||||
private static int GetOrder(SeverityRank severity)
|
||||
=> Order.TryGetValue(severity, out var value) ? value : int.MaxValue;
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Execution record for a scheduler run.
|
||||
/// </summary>
|
||||
public sealed record Run
|
||||
{
|
||||
public Run(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunTrigger trigger,
|
||||
RunState state,
|
||||
RunStats stats,
|
||||
DateTimeOffset createdAt,
|
||||
RunReason? reason = null,
|
||||
string? scheduleId = null,
|
||||
DateTimeOffset? startedAt = null,
|
||||
DateTimeOffset? finishedAt = null,
|
||||
string? error = null,
|
||||
IEnumerable<DeltaSummary>? deltas = null,
|
||||
string? schemaVersion = null)
|
||||
: this(
|
||||
id,
|
||||
tenantId,
|
||||
trigger,
|
||||
state,
|
||||
stats,
|
||||
reason ?? RunReason.Empty,
|
||||
scheduleId,
|
||||
Validation.NormalizeTimestamp(createdAt),
|
||||
Validation.NormalizeTimestamp(startedAt),
|
||||
Validation.NormalizeTimestamp(finishedAt),
|
||||
Validation.TrimToNull(error),
|
||||
NormalizeDeltas(deltas),
|
||||
schemaVersion)
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public Run(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunTrigger trigger,
|
||||
RunState state,
|
||||
RunStats stats,
|
||||
RunReason reason,
|
||||
string? scheduleId,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? startedAt,
|
||||
DateTimeOffset? finishedAt,
|
||||
string? error,
|
||||
ImmutableArray<DeltaSummary> deltas,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
Id = Validation.EnsureId(id, nameof(id));
|
||||
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
||||
Trigger = trigger;
|
||||
State = state;
|
||||
Stats = stats ?? throw new ArgumentNullException(nameof(stats));
|
||||
Reason = reason ?? RunReason.Empty;
|
||||
ScheduleId = Validation.TrimToNull(scheduleId);
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
StartedAt = Validation.NormalizeTimestamp(startedAt);
|
||||
FinishedAt = Validation.NormalizeTimestamp(finishedAt);
|
||||
Error = Validation.TrimToNull(error);
|
||||
Deltas = deltas.IsDefault
|
||||
? ImmutableArray<DeltaSummary>.Empty
|
||||
: deltas.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal).ToImmutableArray();
|
||||
SchemaVersion = SchedulerSchemaVersions.EnsureRun(schemaVersion);
|
||||
}
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ScheduleId { get; }
|
||||
|
||||
public RunTrigger Trigger { get; }
|
||||
|
||||
public RunState State { get; init; }
|
||||
|
||||
public RunStats Stats { get; init; }
|
||||
|
||||
public RunReason Reason { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? FinishedAt { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<DeltaSummary> Deltas { get; } = ImmutableArray<DeltaSummary>.Empty;
|
||||
|
||||
private static ImmutableArray<DeltaSummary> NormalizeDeltas(IEnumerable<DeltaSummary>? deltas)
|
||||
{
|
||||
if (deltas is null)
|
||||
{
|
||||
return ImmutableArray<DeltaSummary>.Empty;
|
||||
}
|
||||
|
||||
return deltas
|
||||
.Where(static delta => delta is not null)
|
||||
.Select(static delta => delta!)
|
||||
.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context describing why a run executed.
|
||||
/// </summary>
|
||||
public sealed record RunReason
|
||||
{
|
||||
public static RunReason Empty { get; } = new();
|
||||
|
||||
public RunReason(
|
||||
string? manualReason = null,
|
||||
string? conselierExportId = null,
|
||||
string? excitorExportId = null,
|
||||
string? cursor = null)
|
||||
{
|
||||
ManualReason = Validation.TrimToNull(manualReason);
|
||||
ConselierExportId = Validation.TrimToNull(conselierExportId);
|
||||
ExcitorExportId = Validation.TrimToNull(excitorExportId);
|
||||
Cursor = Validation.TrimToNull(cursor);
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ManualReason { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ConselierExportId { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ExcitorExportId { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Cursor { get; } = null;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ImpactWindowFrom { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ImpactWindowTo { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated counters for a scheduler run.
|
||||
/// </summary>
|
||||
public sealed record RunStats
|
||||
{
|
||||
public static RunStats Empty { get; } = new();
|
||||
|
||||
public RunStats(
|
||||
int candidates = 0,
|
||||
int deduped = 0,
|
||||
int queued = 0,
|
||||
int completed = 0,
|
||||
int deltas = 0,
|
||||
int newCriticals = 0,
|
||||
int newHigh = 0,
|
||||
int newMedium = 0,
|
||||
int newLow = 0)
|
||||
{
|
||||
Candidates = Validation.EnsureNonNegative(candidates, nameof(candidates));
|
||||
Deduped = Validation.EnsureNonNegative(deduped, nameof(deduped));
|
||||
Queued = Validation.EnsureNonNegative(queued, nameof(queued));
|
||||
Completed = Validation.EnsureNonNegative(completed, nameof(completed));
|
||||
Deltas = Validation.EnsureNonNegative(deltas, nameof(deltas));
|
||||
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
|
||||
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
|
||||
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
|
||||
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
|
||||
}
|
||||
|
||||
public int Candidates { get; } = 0;
|
||||
|
||||
public int Deduped { get; } = 0;
|
||||
|
||||
public int Queued { get; } = 0;
|
||||
|
||||
public int Completed { get; } = 0;
|
||||
|
||||
public int Deltas { get; } = 0;
|
||||
|
||||
public int NewCriticals { get; } = 0;
|
||||
|
||||
public int NewHigh { get; } = 0;
|
||||
|
||||
public int NewMedium { get; } = 0;
|
||||
|
||||
public int NewLow { get; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of delta impact for an image processed in a run.
|
||||
/// </summary>
|
||||
public sealed record DeltaSummary
|
||||
{
|
||||
public DeltaSummary(
|
||||
string imageDigest,
|
||||
int newFindings,
|
||||
int newCriticals,
|
||||
int newHigh,
|
||||
int newMedium,
|
||||
int newLow,
|
||||
IEnumerable<string>? kevHits = null,
|
||||
IEnumerable<DeltaFinding>? topFindings = null,
|
||||
string? reportUrl = null,
|
||||
DeltaAttestation? attestation = null,
|
||||
DateTimeOffset? detectedAt = null)
|
||||
: this(
|
||||
imageDigest,
|
||||
Validation.EnsureNonNegative(newFindings, nameof(newFindings)),
|
||||
Validation.EnsureNonNegative(newCriticals, nameof(newCriticals)),
|
||||
Validation.EnsureNonNegative(newHigh, nameof(newHigh)),
|
||||
Validation.EnsureNonNegative(newMedium, nameof(newMedium)),
|
||||
Validation.EnsureNonNegative(newLow, nameof(newLow)),
|
||||
NormalizeKevHits(kevHits),
|
||||
NormalizeFindings(topFindings),
|
||||
Validation.TrimToNull(reportUrl),
|
||||
attestation,
|
||||
Validation.NormalizeTimestamp(detectedAt))
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public DeltaSummary(
|
||||
string imageDigest,
|
||||
int newFindings,
|
||||
int newCriticals,
|
||||
int newHigh,
|
||||
int newMedium,
|
||||
int newLow,
|
||||
ImmutableArray<string> kevHits,
|
||||
ImmutableArray<DeltaFinding> topFindings,
|
||||
string? reportUrl,
|
||||
DeltaAttestation? attestation,
|
||||
DateTimeOffset? detectedAt)
|
||||
{
|
||||
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
|
||||
NewFindings = Validation.EnsureNonNegative(newFindings, nameof(newFindings));
|
||||
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
|
||||
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
|
||||
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
|
||||
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
|
||||
KevHits = kevHits.IsDefault ? ImmutableArray<string>.Empty : kevHits;
|
||||
TopFindings = topFindings.IsDefault
|
||||
? ImmutableArray<DeltaFinding>.Empty
|
||||
: topFindings
|
||||
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
|
||||
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
ReportUrl = Validation.TrimToNull(reportUrl);
|
||||
Attestation = attestation;
|
||||
DetectedAt = Validation.NormalizeTimestamp(detectedAt);
|
||||
}
|
||||
|
||||
public string ImageDigest { get; }
|
||||
|
||||
public int NewFindings { get; }
|
||||
|
||||
public int NewCriticals { get; }
|
||||
|
||||
public int NewHigh { get; }
|
||||
|
||||
public int NewMedium { get; }
|
||||
|
||||
public int NewLow { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<string> KevHits { get; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ImmutableArray<DeltaFinding> TopFindings { get; } = ImmutableArray<DeltaFinding>.Empty;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReportUrl { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DeltaAttestation? Attestation { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? DetectedAt { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeKevHits(IEnumerable<string>? kevHits)
|
||||
=> Validation.NormalizeStringSet(kevHits, nameof(kevHits));
|
||||
|
||||
private static ImmutableArray<DeltaFinding> NormalizeFindings(IEnumerable<DeltaFinding>? findings)
|
||||
{
|
||||
if (findings is null)
|
||||
{
|
||||
return ImmutableArray<DeltaFinding>.Empty;
|
||||
}
|
||||
|
||||
return findings
|
||||
.Where(static finding => finding is not null)
|
||||
.Select(static finding => finding!)
|
||||
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
|
||||
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top finding entry included in delta summaries.
|
||||
/// </summary>
|
||||
public sealed record DeltaFinding
|
||||
{
|
||||
public DeltaFinding(string purl, string vulnerabilityId, SeverityRank severity, string? link = null)
|
||||
{
|
||||
Purl = Validation.EnsureSimpleIdentifier(purl, nameof(purl));
|
||||
VulnerabilityId = Validation.EnsureSimpleIdentifier(vulnerabilityId, nameof(vulnerabilityId));
|
||||
Severity = severity;
|
||||
Link = Validation.TrimToNull(link);
|
||||
}
|
||||
|
||||
public string Purl { get; }
|
||||
|
||||
public string VulnerabilityId { get; }
|
||||
|
||||
public SeverityRank Severity { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Link { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor/attestation information surfaced with a delta summary.
|
||||
/// </summary>
|
||||
public sealed record DeltaAttestation
|
||||
{
|
||||
public DeltaAttestation(string? uuid, bool? verified = null)
|
||||
{
|
||||
Uuid = Validation.TrimToNull(uuid);
|
||||
Verified = verified;
|
||||
}
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Uuid { get; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? Verified { get; }
|
||||
}
|
||||
|
||||
internal sealed class SeverityRankComparer : IComparer<SeverityRank>
|
||||
{
|
||||
public static SeverityRankComparer Instance { get; } = new();
|
||||
|
||||
private static readonly Dictionary<SeverityRank, int> Order = new()
|
||||
{
|
||||
[SeverityRank.Critical] = 0,
|
||||
[SeverityRank.High] = 1,
|
||||
[SeverityRank.Unknown] = 2,
|
||||
[SeverityRank.Medium] = 3,
|
||||
[SeverityRank.Low] = 4,
|
||||
[SeverityRank.Info] = 5,
|
||||
[SeverityRank.None] = 6,
|
||||
};
|
||||
|
||||
public int Compare(SeverityRank x, SeverityRank y)
|
||||
=> GetOrder(x).CompareTo(GetOrder(y));
|
||||
|
||||
private static int GetOrder(SeverityRank severity)
|
||||
=> Order.TryGetValue(severity, out var value) ? value : int.MaxValue;
|
||||
}
|
||||
|
||||
@@ -432,14 +432,14 @@ internal sealed class SchedulerEventPublisher : ISchedulerEventPublisher
|
||||
return $"manual:{reason.ManualReason}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reason.FeedserExportId))
|
||||
if (!string.IsNullOrWhiteSpace(reason.ConselierExportId))
|
||||
{
|
||||
return $"feedser:{reason.FeedserExportId}";
|
||||
return $"conselier:{reason.ConselierExportId}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reason.VexerExportId))
|
||||
if (!string.IsNullOrWhiteSpace(reason.ExcitorExportId))
|
||||
{
|
||||
return $"vexer:{reason.VexerExportId}";
|
||||
return $"excitor:{reason.ExcitorExportId}";
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user