save progress
This commit is contained in:
@@ -61,6 +61,28 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_ForTestingAssemblyNames()
|
||||
{
|
||||
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.Testing");
|
||||
Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CodeFix_RewritesToFactoryCall()
|
||||
@@ -88,7 +110,45 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
{
|
||||
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 client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* 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());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CodeFix_PreservesHttpClientArguments()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Service;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
var client = new HttpClient(handler, disposeHandler: false);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string expected = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Service;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* 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"), clientFactory: () => new global::System.Net.Http.HttpClient(handler, disposeHandler: false));
|
||||
}
|
||||
}
|
||||
""";
|
||||
@@ -183,6 +243,9 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
{
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request)
|
||||
=> throw new System.NotImplementedException();
|
||||
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request, System.Func<System.Net.Http.HttpClient> clientFactory)
|
||||
=> throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -303,7 +303,7 @@ public sealed class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
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 client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* 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"));
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -54,8 +54,14 @@ public sealed class HttpClientUsageAnalyzer : DiagnosticAnalyzer
|
||||
return;
|
||||
}
|
||||
|
||||
var httpClientSymbol = context.Compilation.GetTypeByMetadataName(HttpClientMetadataName);
|
||||
if (httpClientSymbol is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var createdType = creation.Type;
|
||||
if (createdType is null || !string.Equals(createdType.ToDisplayString(), HttpClientMetadataName, StringComparison.Ordinal))
|
||||
if (createdType is null || !SymbolEqualityComparer.Default.Equals(createdType, httpClientSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -88,7 +94,9 @@ public sealed class HttpClientUsageAnalyzer : DiagnosticAnalyzer
|
||||
return true;
|
||||
}
|
||||
|
||||
if (assemblyName.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase))
|
||||
if (assemblyName.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase) ||
|
||||
assemblyName.EndsWith(".Test", StringComparison.OrdinalIgnoreCase) ||
|
||||
assemblyName.EndsWith(".Testing", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Composition;
|
||||
using System.Threading;
|
||||
@@ -59,13 +60,7 @@ public sealed class HttpClientUsageCodeFixProvider : CodeFixProvider
|
||||
|
||||
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 replacementExpression = BuildReplacementExpression(creation);
|
||||
|
||||
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (root is null)
|
||||
@@ -76,4 +71,55 @@ public sealed class HttpClientUsageCodeFixProvider : CodeFixProvider
|
||||
var updatedRoot = root.ReplaceNode(creation, replacementExpression.WithTriviaFrom(creation));
|
||||
return document.WithSyntaxRoot(updatedRoot);
|
||||
}
|
||||
|
||||
private static ExpressionSyntax BuildReplacementExpression(ObjectCreationExpressionSyntax creation)
|
||||
{
|
||||
var requestExpression = SyntaxFactory.ParseExpression(
|
||||
"new global::StellaOps.AirGap.Policy.EgressRequest(" +
|
||||
"component: \"REPLACE_COMPONENT\", " +
|
||||
"destination: new global::System.Uri(\"https://replace-with-endpoint\"), " +
|
||||
"intent: \"REPLACE_INTENT\")");
|
||||
|
||||
var egressPolicyExpression = SyntaxFactory.ParseExpression(
|
||||
"default(global::StellaOps.AirGap.Policy.IEgressPolicy)");
|
||||
|
||||
var arguments = new List<ArgumentSyntax>
|
||||
{
|
||||
SyntaxFactory.Argument(egressPolicyExpression)
|
||||
.WithNameColon(SyntaxFactory.NameColon("egressPolicy"))
|
||||
.WithTrailingTrivia(
|
||||
SyntaxFactory.Space,
|
||||
SyntaxFactory.Comment("/* TODO: provide IEgressPolicy instance */")),
|
||||
SyntaxFactory.Argument(requestExpression)
|
||||
.WithNameColon(SyntaxFactory.NameColon("request"))
|
||||
};
|
||||
|
||||
if (ShouldUseClientFactory(creation))
|
||||
{
|
||||
var clientFactoryLambda = SyntaxFactory.ParenthesizedLambdaExpression(
|
||||
SyntaxFactory.ParameterList(),
|
||||
CreateHttpClientExpression(creation));
|
||||
|
||||
arguments.Add(
|
||||
SyntaxFactory.Argument(clientFactoryLambda)
|
||||
.WithNameColon(SyntaxFactory.NameColon("clientFactory")));
|
||||
}
|
||||
|
||||
return SyntaxFactory.InvocationExpression(
|
||||
SyntaxFactory.ParseExpression("global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create"))
|
||||
.WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)));
|
||||
}
|
||||
|
||||
private static bool ShouldUseClientFactory(ObjectCreationExpressionSyntax creation)
|
||||
=> (creation.ArgumentList?.Arguments.Count ?? 0) > 0 || creation.Initializer is not null;
|
||||
|
||||
private static ObjectCreationExpressionSyntax CreateHttpClientExpression(ObjectCreationExpressionSyntax creation)
|
||||
{
|
||||
var httpClientType = SyntaxFactory.ParseTypeName("global::System.Net.Http.HttpClient");
|
||||
var arguments = creation.ArgumentList ?? SyntaxFactory.ArgumentList();
|
||||
|
||||
return SyntaxFactory.ObjectCreationExpression(httpClientType)
|
||||
.WithArgumentList(arguments)
|
||||
.WithInitializer(creation.Initializer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0031-M | DONE | Maintainability audit for StellaOps.AirGap.Policy.Analyzers. |
|
||||
| AUDIT-0031-T | DONE | Test coverage audit for StellaOps.AirGap.Policy.Analyzers. |
|
||||
| AUDIT-0031-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0031-A | DONE | Applied analyzer symbol match, test assembly exemptions, and code-fix preservation. |
|
||||
|
||||
@@ -26,6 +26,35 @@ public static class EgressHttpClientFactory
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="HttpClient"/> from a caller-provided factory after validating the supplied egress request.
|
||||
/// </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="clientFactory">Factory used to supply a configured client (for example, from IHttpClientFactory).</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,
|
||||
Func<HttpClient> clientFactory,
|
||||
Action<HttpClient>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(egressPolicy);
|
||||
ArgumentNullException.ThrowIfNull(clientFactory);
|
||||
|
||||
egressPolicy.EnsureAllowed(request);
|
||||
|
||||
var client = clientFactory();
|
||||
if (client is null)
|
||||
{
|
||||
throw new InvalidOperationException("EgressHttpClientFactory received a null HttpClient from the factory.");
|
||||
}
|
||||
|
||||
configure?.Invoke(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and configures an <see cref="HttpClient"/> after validating the supplied egress request against the policy.
|
||||
/// </summary>
|
||||
@@ -42,4 +71,23 @@ public static class EgressHttpClientFactory
|
||||
string intent,
|
||||
Action<HttpClient>? configure = null)
|
||||
=> Create(egressPolicy, new EgressRequest(component, destination, intent), configure);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a configured <see cref="HttpClient"/> using a caller-provided factory after policy validation.
|
||||
/// </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="clientFactory">Factory used to supply a configured client.</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,
|
||||
Func<HttpClient> clientFactory,
|
||||
Action<HttpClient>? configure = null)
|
||||
=> Create(egressPolicy, new EgressRequest(component, destination, intent), clientFactory, configure);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
@@ -11,8 +12,9 @@ namespace StellaOps.AirGap.Policy;
|
||||
/// </summary>
|
||||
public sealed class EgressPolicy : IEgressPolicy
|
||||
{
|
||||
private readonly EgressRule[] _rules;
|
||||
private readonly EgressPolicyOptions _options;
|
||||
private readonly IDisposable? _optionsSubscription;
|
||||
private EgressRule[] _rules = Array.Empty<EgressRule>();
|
||||
private EgressPolicyOptions _options = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EgressPolicy"/> class.
|
||||
@@ -20,35 +22,56 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
/// <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();
|
||||
ApplyOptions(options ?? throw new ArgumentNullException(nameof(options)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EgressPolicy"/> class with reload support.
|
||||
/// </summary>
|
||||
/// <param name="optionsMonitor">Options monitor that supplies updated policy settings.</param>
|
||||
public EgressPolicy(IOptionsMonitor<EgressPolicyOptions> optionsMonitor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(optionsMonitor);
|
||||
|
||||
ApplyOptions(optionsMonitor.CurrentValue);
|
||||
_optionsSubscription = optionsMonitor.OnChange((updated, _) => ApplyOptions(updated));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSealed => Mode == EgressPolicyMode.Sealed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public EgressPolicyMode Mode => _options.Mode;
|
||||
public EgressPolicyMode Mode => Volatile.Read(ref _options).Mode;
|
||||
|
||||
/// <inheritdoc />
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
if (!HasValidDestination(request))
|
||||
{
|
||||
return EgressDecision.Blocked(
|
||||
"Egress request is missing a valid destination URI.",
|
||||
BuildInvalidRequestRemediation(request));
|
||||
}
|
||||
|
||||
var options = Volatile.Read(ref _options);
|
||||
var rules = Volatile.Read(ref _rules);
|
||||
|
||||
if (!IsSealed)
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
if (_options.AllowLoopback && IsLoopback(request.Destination))
|
||||
if (options.AllowLoopback && IsLoopback(request.Destination))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
if (_options.AllowPrivateNetworks && IsPrivateNetwork(request.Destination))
|
||||
if (options.AllowPrivateNetworks && IsPrivateNetwork(request.Destination))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
foreach (var rule in _rules)
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (rule.Allows(request))
|
||||
{
|
||||
@@ -56,8 +79,9 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
}
|
||||
}
|
||||
|
||||
var reason = $"Destination '{request.Destination.Host}' is not present in the sealed-mode allow list.";
|
||||
var remediation = BuildRemediation(request);
|
||||
var destinationLabel = request.Destination?.Host ?? "unknown-host";
|
||||
var reason = $"Destination '{destinationLabel}' is not present in the sealed-mode allow list.";
|
||||
var remediation = BuildRemediation(request, rules);
|
||||
return EgressDecision.Blocked(reason, remediation);
|
||||
}
|
||||
|
||||
@@ -95,14 +119,21 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
=> new(
|
||||
request,
|
||||
decision.Reason ?? "Egress blocked.",
|
||||
decision.Remediation ?? BuildRemediation(request),
|
||||
_options.RemediationDocumentationUrl,
|
||||
_options.SupportContact);
|
||||
decision.Remediation ?? BuildRemediation(request, Volatile.Read(ref _rules)),
|
||||
Volatile.Read(ref _options).RemediationDocumentationUrl,
|
||||
Volatile.Read(ref _options).SupportContact);
|
||||
|
||||
private string BuildRemediation(EgressRequest request)
|
||||
private string BuildRemediation(EgressRequest request, EgressRule[] rules)
|
||||
{
|
||||
var host = request.Destination.Host;
|
||||
var portSegment = request.Destination.IsDefaultPort ? string.Empty : $":{request.Destination.Port.ToString(CultureInfo.InvariantCulture)}";
|
||||
var host = request.Destination?.Host;
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
host = "unknown-host";
|
||||
}
|
||||
|
||||
var portSegment = request.Destination is { IsDefaultPort: false }
|
||||
? $":{request.Destination.Port.ToString(CultureInfo.InvariantCulture)}"
|
||||
: string.Empty;
|
||||
var transport = request.Transport.ToString().ToUpperInvariant();
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
@@ -113,14 +144,14 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
.Append(transport)
|
||||
.Append(") to the airgap.egressAllowlist configuration.");
|
||||
|
||||
if (_rules.Length == 0)
|
||||
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);
|
||||
var limit = Math.Min(rules.Length, 3);
|
||||
for (var i = 0; i < limit; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
@@ -128,15 +159,15 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
builder.Append(", ");
|
||||
}
|
||||
|
||||
builder.Append(_rules[i].HostPattern);
|
||||
if (_rules[i].Port is int port)
|
||||
builder.Append(rules[i].HostPattern);
|
||||
if (rules[i].Port is int port)
|
||||
{
|
||||
builder.Append(':')
|
||||
.Append(port.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
if (_rules.Length > limit)
|
||||
if (rules.Length > limit)
|
||||
{
|
||||
builder.Append(", ...");
|
||||
}
|
||||
@@ -147,6 +178,16 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildInvalidRequestRemediation(EgressRequest request)
|
||||
{
|
||||
var component = string.IsNullOrWhiteSpace(request.Component) ? "unknown-component" : request.Component;
|
||||
var intent = string.IsNullOrWhiteSpace(request.Intent) ? "unknown-intent" : request.Intent;
|
||||
return $"Provide an absolute destination URI for component '{component}' (intent: {intent}) before evaluating sealed-mode egress.";
|
||||
}
|
||||
|
||||
private static bool HasValidDestination(EgressRequest request)
|
||||
=> request.Destination is { IsAbsoluteUri: true };
|
||||
|
||||
private static bool IsLoopback(Uri destination)
|
||||
{
|
||||
if (string.Equals(destination.Host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -183,9 +224,20 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return address.IsIPv6LinkLocal || address.IsIPv6SiteLocal;
|
||||
var bytes = address.GetAddressBytes();
|
||||
var isUniqueLocal = bytes.Length > 0 && (bytes[0] & 0xFE) == 0xFC; // fc00::/7
|
||||
return address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || isUniqueLocal;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyOptions(EgressPolicyOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var rules = options.BuildRuleSet();
|
||||
Volatile.Write(ref _rules, rules);
|
||||
Volatile.Write(ref _options, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
|
||||
services.TryAddSingleton<IEgressPolicy>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<EgressPolicyOptions>>().Value;
|
||||
return new EgressPolicy(options);
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<EgressPolicyOptions>>();
|
||||
return new EgressPolicy(optionsMonitor);
|
||||
});
|
||||
|
||||
return services;
|
||||
@@ -122,6 +122,7 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
}
|
||||
|
||||
var rules = new List<EgressRule>();
|
||||
var seenRules = new HashSet<RuleKey>();
|
||||
foreach (var ruleSection in EnumerateAllowRuleSections(effectiveSection, primarySection, root))
|
||||
{
|
||||
var hostPattern = ruleSection["HostPattern"]
|
||||
@@ -141,7 +142,11 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
var description = ruleSection["Description"] ?? ruleSection["Notes"];
|
||||
description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||
|
||||
rules.Add(new EgressRule(hostPattern, port, transport, description));
|
||||
var ruleKey = RuleKey.Create(hostPattern, port, transport);
|
||||
if (seenRules.Add(ruleKey))
|
||||
{
|
||||
rules.Add(new EgressRule(hostPattern, port, transport, description));
|
||||
}
|
||||
}
|
||||
|
||||
options.SetAllowRules(rules);
|
||||
@@ -279,4 +284,10 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
? parsed
|
||||
: EgressTransport.Any;
|
||||
}
|
||||
|
||||
private readonly record struct RuleKey(string HostPattern, int? Port, EgressTransport Transport)
|
||||
{
|
||||
public static RuleKey Create(string hostPattern, int? port, EgressTransport transport)
|
||||
=> new(hostPattern.Trim().ToLowerInvariant(), port, transport);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0030-M | DONE | Maintainability audit for StellaOps.AirGap.Policy. |
|
||||
| AUDIT-0030-T | DONE | Test coverage audit for StellaOps.AirGap.Policy. |
|
||||
| AUDIT-0030-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0030-A | DONE | Applied reloadable policy, allowlist de-dup, request guards, and client factory overload. |
|
||||
|
||||
Reference in New Issue
Block a user