save progress

This commit is contained in:
StellaOps Bot
2026-01-02 15:52:31 +02:00
parent 2dec7e6a04
commit f46bde5575
174 changed files with 20793 additions and 8307 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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