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:
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user