feat: Implement vulnerability token signing and verification utilities
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:
master
2025-11-03 10:02:29 +02:00
parent bf2bf4b395
commit b1e78fe412
215 changed files with 19441 additions and 12185 deletions

View File

@@ -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

View File

@@ -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();
}
}
""";
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</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>

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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,
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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,
};
}

View File

@@ -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})";
}

View File

@@ -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,
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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. |