part #2
This commit is contained in:
@@ -19,5 +19,6 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
|
||||
<!-- Determinism abstractions -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -4,8 +4,9 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
using StellaOps.AdvisoryAI.Worker.Services;
|
||||
using StellaOps.Worker.Health;
|
||||
|
||||
var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(args);
|
||||
var builder = WebApplication.CreateSlimBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
@@ -16,5 +17,8 @@ builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
||||
builder.Services.AddSingleton<IAdvisoryJitterSource, DefaultAdvisoryJitterSource>();
|
||||
builder.Services.AddHostedService<AdvisoryTaskWorker>();
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
builder.Services.AddWorkerHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapWorkerHealthEndpoints();
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Worker.Health\StellaOps.Worker.Health.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class HttpClientUsageAnalyzerTests
|
||||
{
|
||||
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().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request, System.Func<System.Net.Http.HttpClient> clientFactory)
|
||||
=> throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -1,28 +1,14 @@
|
||||
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 StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed class HttpClientUsageAnalyzerTests
|
||||
public sealed partial class HttpClientUsageAnalyzerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReportsDiagnostic_ForNewHttpClient()
|
||||
[Fact]
|
||||
public async Task ReportsDiagnostic_ForNewHttpClientAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
@@ -43,8 +29,8 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_InsidePolicyAssembly()
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_InsidePolicyAssemblyAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
@@ -62,8 +48,11 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_ForTestingAssemblyNames()
|
||||
[Theory]
|
||||
[InlineData("Sample.App.Testing")]
|
||||
[InlineData("Sample.App.Test")]
|
||||
[InlineData("Sample.App.Tests")]
|
||||
public async Task DoesNotReportDiagnostic_ForTestAssemblyNamesAsync(string assemblyName)
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
@@ -79,53 +68,7 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App.Testing");
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName);
|
||||
Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
}
|
||||
|
||||
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 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();
|
||||
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request, System.Func<System.Net.Http.HttpClient> clientFactory)
|
||||
=> throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForHttpClientParameterAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run(HttpClient client)
|
||||
{
|
||||
client.GetStringAsync("https://example.com");
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Using HttpClient as parameter should not trigger diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForHttpClientFieldAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
private HttpClient? _client;
|
||||
|
||||
public void SetClient(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Declaring HttpClient field should not trigger diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForFactoryMethodReturnAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public interface IHttpClientFactory
|
||||
{
|
||||
HttpClient CreateClient(string name);
|
||||
}
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
private readonly IHttpClientFactory _factory;
|
||||
|
||||
public Demo(IHttpClientFactory factory) => _factory = factory;
|
||||
|
||||
public void Run()
|
||||
{
|
||||
var client = _factory.CreateClient("default");
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Using factory method should not trigger diagnostic");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_InTestAssemblyAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App.Tests;
|
||||
|
||||
public sealed class DemoTests
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App.Tests");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Test assemblies should be exempt from diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_InPolicyAssemblyAsync()
|
||||
{
|
||||
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");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Policy assembly itself should be exempt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PolicyAnalyzerRoslynTests - AN1 Roslyn compilation tests for AirGap.Policy.Analyzers
|
||||
// -----------------------------------------------------------------------------
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("var client = new HttpClient();", true, "Direct construction should trigger diagnostic")]
|
||||
[InlineData("var client = new System.Net.Http.HttpClient();", true, "Fully qualified construction should trigger diagnostic")]
|
||||
[InlineData("HttpClient client = new();", true, "Target-typed new should trigger diagnostic")]
|
||||
[InlineData("object client = new HttpClient();", true, "Implicit cast construction should trigger diagnostic")]
|
||||
[InlineData("var client = new HttpClient(new HttpClientHandler());", true, "Handler construction should trigger diagnostic")]
|
||||
public async Task DiagnosticTriggered_ForVariousHttpClientConstructionsAsync(string statement, bool shouldTrigger, string reason)
|
||||
{
|
||||
var source = $$"""
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
{{statement}}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var hasDiagnostic = diagnostics.Any(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
hasDiagnostic.Should().Be(shouldTrigger, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Diagnostic_HasCorrectSeverityAsync()
|
||||
{
|
||||
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");
|
||||
var airgapDiagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
airgapDiagnostic.Severity.Should().Be(DiagnosticSeverity.Warning,
|
||||
"Diagnostic should be a warning, not an error");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Diagnostic_HasCorrectLocationAsync()
|
||||
{
|
||||
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");
|
||||
var airgapDiagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
airgapDiagnostic.Location.IsInSource.Should().BeTrue();
|
||||
var lineSpan = airgapDiagnostic.Location.GetLineSpan();
|
||||
lineSpan.StartLinePosition.Line.Should().Be(8, "Diagnostic should point to line 9 (0-indexed: 8)");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MultipleHttpClientUsages_ReportMultipleDiagnosticsAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Method1()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
|
||||
public void Method2()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
|
||||
public void Method3()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var airgapDiagnostics = diagnostics.Where(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId).ToList();
|
||||
|
||||
airgapDiagnostics.Should().HaveCount(3, "Each new HttpClient() should trigger a separate diagnostic");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyzer_SupportedDiagnostics_ContainsExpectedId()
|
||||
{
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var supportedDiagnostics = analyzer.SupportedDiagnostics;
|
||||
|
||||
supportedDiagnostics.Should().HaveCount(1);
|
||||
supportedDiagnostics[0].Id.Should().Be("AIRGAP001");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
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().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
var systemRuntimePath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
|
||||
if (!string.IsNullOrEmpty(systemRuntimePath))
|
||||
{
|
||||
var netstandard = Path.Combine(systemRuntimePath, "netstandard.dll");
|
||||
if (File.Exists(netstandard))
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(netstandard);
|
||||
}
|
||||
|
||||
var systemRuntime = Path.Combine(systemRuntimePath, "System.Runtime.dll");
|
||||
if (File.Exists(systemRuntime))
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(systemRuntime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PolicyAnalyzerRoslynTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0004_airgap_tests
|
||||
// Tasks: AIRGAP-5100-005, AIRGAP-5100-006
|
||||
// Description: AN1 Roslyn compilation tests for AirGap.Policy.Analyzers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
using FluentAssertions;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// AN1 Roslyn Compilation Tests for AirGap.Policy.Analyzers
|
||||
/// Task AIRGAP-5100-005: Expected diagnostics, no false positives
|
||||
/// Task AIRGAP-5100-006: Golden generated code tests for policy analyzers
|
||||
/// </summary>
|
||||
public sealed class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
#region AIRGAP-5100-005: Expected Diagnostics & No False Positives
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("var client = new HttpClient();", true, "Direct construction should trigger diagnostic")]
|
||||
[InlineData("var client = new System.Net.Http.HttpClient();", true, "Fully qualified construction should trigger diagnostic")]
|
||||
[InlineData("HttpClient client = new();", true, "Target-typed new should trigger diagnostic")]
|
||||
[InlineData("object client = new HttpClient();", true, "Implicit cast construction should trigger diagnostic")]
|
||||
public async Task DiagnosticTriggered_ForVariousHttpClientConstructions(string statement, bool shouldTrigger, string reason)
|
||||
{
|
||||
var source = $$"""
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
{{statement}}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var hasDiagnostic = diagnostics.Any(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
hasDiagnostic.Should().Be(shouldTrigger, reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForHttpClientParameter()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run(HttpClient client)
|
||||
{
|
||||
// Using HttpClient as parameter - not constructing it
|
||||
client.GetStringAsync("https://example.com");
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Using HttpClient as parameter should not trigger diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForHttpClientField()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
private HttpClient? _client;
|
||||
|
||||
public void SetClient(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Declaring HttpClient field should not trigger diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForFactoryMethodReturn()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public interface IHttpClientFactory
|
||||
{
|
||||
HttpClient CreateClient(string name);
|
||||
}
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
private readonly IHttpClientFactory _factory;
|
||||
|
||||
public Demo(IHttpClientFactory factory) => _factory = factory;
|
||||
|
||||
public void Run()
|
||||
{
|
||||
var client = _factory.CreateClient("default");
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Using factory method should not trigger diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_InTestAssembly()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App.Tests;
|
||||
|
||||
public sealed class DemoTests
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App.Tests");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Test assemblies should be exempt from diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_InPolicyAssembly()
|
||||
{
|
||||
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");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Policy assembly itself should be exempt");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Diagnostic_HasCorrectSeverity()
|
||||
{
|
||||
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");
|
||||
var airgapDiagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
airgapDiagnostic.Severity.Should().Be(DiagnosticSeverity.Warning,
|
||||
"Diagnostic should be a warning, not an error");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Diagnostic_HasCorrectLocation()
|
||||
{
|
||||
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");
|
||||
var airgapDiagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
airgapDiagnostic.Location.IsInSource.Should().BeTrue();
|
||||
var lineSpan = airgapDiagnostic.Location.GetLineSpan();
|
||||
lineSpan.StartLinePosition.Line.Should().Be(8, "Diagnostic should point to line 9 (0-indexed: 8)");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MultipleHttpClientUsages_ReportMultipleDiagnostics()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Method1()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
|
||||
public void Method2()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
|
||||
public void Method3()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var airgapDiagnostics = diagnostics.Where(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId).ToList();
|
||||
|
||||
airgapDiagnostics.Should().HaveCount(3, "Each new HttpClient() should trigger a separate diagnostic");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIRGAP-5100-006: Golden Generated Code Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Analyzer_SupportedDiagnostics_ContainsExpectedId()
|
||||
{
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var supportedDiagnostics = analyzer.SupportedDiagnostics;
|
||||
|
||||
supportedDiagnostics.Should().HaveCount(1);
|
||||
supportedDiagnostics[0].Id.Should().Be("AIRGAP001");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
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 IEnumerable<MetadataReference> CreateMetadataReferences()
|
||||
{
|
||||
// Core runtime references
|
||||
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);
|
||||
|
||||
// Add System.Runtime for target-typed new
|
||||
var systemRuntimePath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
|
||||
if (!string.IsNullOrEmpty(systemRuntimePath))
|
||||
{
|
||||
var netstandard = Path.Combine(systemRuntimePath, "netstandard.dll");
|
||||
if (File.Exists(netstandard))
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(netstandard);
|
||||
}
|
||||
|
||||
var systemRuntime = Path.Combine(systemRuntimePath, "System.Runtime.dll");
|
||||
if (File.Exists(systemRuntime))
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(systemRuntime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
# AirGap Policy Analyzers Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0032-M | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
|
||||
| AUDIT-0032-T | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
|
||||
| AUDIT-0032-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Analyzers.Tests/StellaOps.AirGap.Policy.Analyzers.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Operations;
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers;
|
||||
|
||||
public sealed partial class HttpClientUsageAnalyzer
|
||||
{
|
||||
private static void AnalyzeObjectCreation(OperationAnalysisContext context)
|
||||
{
|
||||
if (context.Operation is not IObjectCreationOperation creation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var httpClientSymbol = context.Compilation.GetTypeByMetadataName(HttpClientMetadataName);
|
||||
if (httpClientSymbol is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var createdType = creation.Type;
|
||||
if (createdType is null || !SymbolEqualityComparer.Default.Equals(createdType, httpClientSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsWithinAllowedAssembly(context.ContainingSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.ReportDiagnostic(CreateDiagnostic(creation.Syntax.GetLocation()));
|
||||
}
|
||||
|
||||
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) ||
|
||||
assemblyName.EndsWith(".Test", StringComparison.OrdinalIgnoreCase) ||
|
||||
assemblyName.EndsWith(".Testing", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers;
|
||||
|
||||
public sealed partial class HttpClientUsageAnalyzer
|
||||
{
|
||||
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);
|
||||
|
||||
private static Diagnostic CreateDiagnostic(Location location)
|
||||
=> Diagnostic.Create(_rule, location);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Operations;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
@@ -11,34 +9,21 @@ namespace StellaOps.AirGap.Policy.Analyzers;
|
||||
/// 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
|
||||
public sealed partial 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);
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
|
||||
=> ImmutableArray.Create(_rule);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Initialize(AnalysisContext context)
|
||||
{
|
||||
if (context == null)
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
@@ -47,61 +32,4 @@ public sealed class HttpClientUsageAnalyzer : DiagnosticAnalyzer
|
||||
context.EnableConcurrentExecution();
|
||||
context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation);
|
||||
}
|
||||
|
||||
private static void AnalyzeObjectCreation(OperationAnalysisContext context)
|
||||
{
|
||||
if (context.Operation is not IObjectCreationOperation creation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var httpClientSymbol = context.Compilation.GetTypeByMetadataName(HttpClientMetadataName);
|
||||
if (httpClientSymbol is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var createdType = creation.Type;
|
||||
if (createdType is null || !SymbolEqualityComparer.Default.Equals(createdType, httpClientSymbol))
|
||||
{
|
||||
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) ||
|
||||
assemblyName.EndsWith(".Test", StringComparison.OrdinalIgnoreCase) ||
|
||||
assemblyName.EndsWith(".Testing", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# AirGap Policy Analyzers Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0031-M | DONE | Revalidated 2026-01-06; no new findings. |
|
||||
| AUDIT-0031-T | DONE | Revalidated 2026-01-06; test coverage tracked in AUDIT-0032. |
|
||||
| AUDIT-0031-A | DONE | Applied analyzer symbol match, test assembly exemptions, and code-fix preservation. |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Analyzers/StellaOps.AirGap.Policy.Analyzers.md. |
|
||||
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using StellaOps.AirGap.Policy;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
private sealed class RecordingPolicy : IEgressPolicy
|
||||
{
|
||||
public bool EnsureAllowedCalled { get; private set; }
|
||||
|
||||
public bool IsSealed => true;
|
||||
|
||||
public EgressPolicyMode Mode => EgressPolicyMode.Sealed;
|
||||
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
EnsureAllowedCalled = true;
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
public ValueTask<EgressDecision> EvaluateAsync(
|
||||
EgressRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new ValueTask<EgressDecision>(Evaluate(request));
|
||||
}
|
||||
|
||||
public void EnsureAllowed(EgressRequest request)
|
||||
{
|
||||
EnsureAllowedCalled = true;
|
||||
}
|
||||
|
||||
public ValueTask EnsureAllowedAsync(
|
||||
EgressRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureAllowed(request);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceCollection_AddAirGapEgressPolicy_RegistersService()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAirGapEgressPolicy(options =>
|
||||
{
|
||||
options.Mode = EgressPolicyMode.Sealed;
|
||||
options.AddAllowRule("mirror.internal", transport: EgressTransport.Https);
|
||||
});
|
||||
|
||||
var descriptor = services.Single(service => service.ServiceType == typeof(IEgressPolicy));
|
||||
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
|
||||
Assert.Equal(typeof(EgressPolicy), descriptor.ImplementationType);
|
||||
|
||||
var configured = BuildConfiguredOptions(services);
|
||||
var policy = new EgressPolicy(configured);
|
||||
policy.EnsureAllowed(new EgressRequest("PolicyEngine", new Uri("https://mirror.internal"), "mirror-sync"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
|
||||
var configured = BuildConfiguredOptions(services);
|
||||
Assert.Equal(EgressPolicyMode.Sealed, configured.Mode);
|
||||
Assert.Equal("https://docs.example/airgap", configured.RemediationDocumentationUrl);
|
||||
Assert.Equal("airgap@example.org", configured.SupportContact);
|
||||
|
||||
var policy = new EgressPolicy(configured);
|
||||
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);
|
||||
}
|
||||
|
||||
private static EgressPolicyOptions BuildConfiguredOptions(IServiceCollection services)
|
||||
{
|
||||
var options = new EgressPolicyOptions();
|
||||
var configurators = services
|
||||
.Where(descriptor => descriptor.ServiceType == typeof(IConfigureOptions<EgressPolicyOptions>))
|
||||
.Select(descriptor => descriptor.ImplementationInstance)
|
||||
.OfType<IConfigureOptions<EgressPolicyOptions>>()
|
||||
.ToArray();
|
||||
|
||||
Assert.NotEmpty(configurators);
|
||||
|
||||
foreach (var configurator in configurators)
|
||||
{
|
||||
configurator.Configure(options);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,14 @@
|
||||
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 StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed class EgressPolicyTests
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Evaluate_UnsealedEnvironment_AllowsRequest()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
@@ -33,7 +26,7 @@ public sealed class EgressPolicyTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironmentWithMatchingRule_Allows()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
@@ -49,7 +42,7 @@ public sealed class EgressPolicyTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironmentWithoutRule_ThrowsWithGuidance()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
@@ -71,171 +64,4 @@ public sealed class EgressPolicyTests
|
||||
Assert.Equal(options.RemediationDocumentationUrl, exception.DocumentationUrl);
|
||||
Assert.Equal(options.SupportContact, exception.SupportContact);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# AirGap Policy Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0033-M | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
|
||||
| AUDIT-0033-T | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
|
||||
| AUDIT-0033-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Tests/StellaOps.AirGap.Policy.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class AirGapEgressBlockedException
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
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
|
||||
public sealed partial class AirGapEgressBlockedException : InvalidOperationException
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code surfaced to callers when egress is blocked.
|
||||
@@ -60,41 +59,4 @@ public sealed class AirGapEgressBlockedException : InvalidOperationException
|
||||
/// Gets an optional support contact (for example, an on-call alias).
|
||||
/// </summary>
|
||||
public string? SupportContact { get; }
|
||||
|
||||
private static string BuildMessage(EgressRequest request, string reason, string remediation, string? documentationUrl, string? supportContact)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(ErrorCode)
|
||||
.Append(": component '")
|
||||
.Append(request.Component)
|
||||
.Append("' attempted to reach '")
|
||||
.Append(request.Destination)
|
||||
.Append("' (intent: ")
|
||||
.Append(request.Intent);
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Operation))
|
||||
{
|
||||
builder.Append(", operation: ")
|
||||
.Append(request.Operation);
|
||||
}
|
||||
|
||||
builder.Append("). Reason: ")
|
||||
.Append(reason)
|
||||
.Append(". Remediation: ")
|
||||
.Append(remediation);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(documentationUrl))
|
||||
{
|
||||
builder.Append(" Documentation: ")
|
||||
.Append(documentationUrl);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(supportContact))
|
||||
{
|
||||
builder.Append(" Contact: ")
|
||||
.Append(supportContact);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressPolicy
|
||||
{
|
||||
/// <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))
|
||||
{
|
||||
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 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);
|
||||
}
|
||||
|
||||
/// <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, Volatile.Read(ref _rules)),
|
||||
Volatile.Read(ref _options).RemediationDocumentationUrl,
|
||||
Volatile.Read(ref _options).SupportContact);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressPolicy
|
||||
{
|
||||
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))
|
||||
{
|
||||
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)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
var isUniqueLocal = bytes.Length > 0 && (bytes[0] & 0xFE) == 0xFC; // fc00::/7
|
||||
return address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || isUniqueLocal;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressPolicy
|
||||
{
|
||||
private void ApplyOptions(EgressPolicyOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var rules = options.BuildRuleSet();
|
||||
Volatile.Write(ref _rules, rules);
|
||||
Volatile.Write(ref _options, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressPolicy
|
||||
{
|
||||
private string BuildRemediation(EgressRequest request, EgressRule[] rules)
|
||||
{
|
||||
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 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 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.";
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
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
|
||||
public sealed partial class EgressPolicy : IEgressPolicy
|
||||
{
|
||||
private readonly IDisposable? _optionsSubscription;
|
||||
private EgressRule[] _rules = Array.Empty<EgressRule>();
|
||||
@@ -27,7 +23,8 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EgressPolicy"/> class with reload support.
|
||||
/// 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)
|
||||
@@ -43,202 +40,4 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
|
||||
/// <inheritdoc />
|
||||
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))
|
||||
{
|
||||
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 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);
|
||||
}
|
||||
|
||||
/// <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, Volatile.Read(ref _rules)),
|
||||
Volatile.Read(ref _options).RemediationDocumentationUrl,
|
||||
Volatile.Read(ref _options).SupportContact);
|
||||
|
||||
private string BuildRemediation(EgressRequest request, EgressRule[] rules)
|
||||
{
|
||||
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();
|
||||
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 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))
|
||||
{
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public static partial class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public static partial class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
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>();
|
||||
var seenRules = new HashSet<RuleKey>();
|
||||
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();
|
||||
|
||||
var ruleKey = RuleKey.Create(hostPattern, port, transport);
|
||||
if (seenRules.Add(ruleKey))
|
||||
{
|
||||
rules.Add(new EgressRule(hostPattern, port, transport, description));
|
||||
}
|
||||
}
|
||||
|
||||
options.SetAllowRules(rules);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public static partial class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public static partial class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
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 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection helpers for configuring the air-gap egress policy.
|
||||
/// </summary>
|
||||
public static class EgressPolicyServiceCollectionExtensions
|
||||
public static partial class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers <see cref="IEgressPolicy"/> using the provided configuration delegate.
|
||||
@@ -20,7 +16,9 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
/// <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)
|
||||
public static IServiceCollection AddAirGapEgressPolicy(
|
||||
this IServiceCollection services,
|
||||
Action<EgressPolicyOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
@@ -33,11 +31,7 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
services.AddOptions<EgressPolicyOptions>().Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IEgressPolicy>(sp =>
|
||||
{
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<EgressPolicyOptions>>();
|
||||
return new EgressPolicy(optionsMonitor);
|
||||
});
|
||||
services.TryAddSingleton<IEgressPolicy, EgressPolicy>();
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -67,228 +61,4 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
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>();
|
||||
var seenRules = new HashSet<RuleKey>();
|
||||
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();
|
||||
|
||||
var ruleKey = RuleKey.Create(hostPattern, port, transport);
|
||||
if (seenRules.Add(ruleKey))
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressRule
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
=> Port is null
|
||||
? $"{_hostPattern} ({Transport})"
|
||||
: $"{_hostPattern}:{Port} ({Transport})";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressRule
|
||||
{
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace StellaOps.AirGap.Policy;
|
||||
/// <summary>
|
||||
/// Represents a single allow entry used when sealed mode is active.
|
||||
/// </summary>
|
||||
public sealed class EgressRule
|
||||
public sealed partial class EgressRule
|
||||
{
|
||||
private readonly string _hostPattern;
|
||||
private readonly string? _wildcardSuffix;
|
||||
@@ -59,69 +59,4 @@ public sealed class EgressRule
|
||||
/// 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})";
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# AirGap Policy Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0030-M | DONE | Revalidated 2026-01-06; new findings recorded in audit report. |
|
||||
| AUDIT-0030-T | DONE | Revalidated 2026-01-06; test coverage tracked in AUDIT-0033. |
|
||||
| AUDIT-0030-A | TODO | Replace direct new HttpClient usage in EgressHttpClientFactory. |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.md. |
|
||||
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |
|
||||
|
||||
@@ -29,19 +29,19 @@ public class TimeStatusController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<TimeStatusDto>> GetStatus([FromQuery] string tenantId)
|
||||
public async Task<ActionResult<TimeStatusDto>> GetStatusAsync([FromQuery] string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return BadRequest("tenantId-required");
|
||||
}
|
||||
|
||||
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted);
|
||||
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
return Ok(TimeStatusDto.FromStatus(status));
|
||||
}
|
||||
|
||||
[HttpPost("anchor")]
|
||||
public async Task<ActionResult<TimeStatusDto>> SetAnchor([FromBody] SetAnchorRequest request)
|
||||
public async Task<ActionResult<TimeStatusDto>> SetAnchorAsync([FromBody] SetAnchorRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
@@ -78,9 +78,9 @@ public class TimeStatusController : ControllerBase
|
||||
request.WarningSeconds ?? StalenessBudget.Default.WarningSeconds,
|
||||
request.BreachSeconds ?? StalenessBudget.Default.BreachSeconds);
|
||||
|
||||
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted);
|
||||
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
_logger.LogInformation("Time anchor set for tenant {Tenant} format={Format} digest={Digest} warning={Warning}s breach={Breach}s", request.TenantId, anchor.Format, anchor.TokenDigest, budget.WarningSeconds, budget.BreachSeconds);
|
||||
var status = await _statusService.GetStatusAsync(request.TenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted);
|
||||
var status = await _statusService.GetStatusAsync(request.TenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
return Ok(TimeStatusDto.FromStatus(status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class TimeAnchorHealthCheck : IHealthCheck
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
var status = await _statusService.GetStatusAsync(opts.TenantId, _timeProvider.GetUtcNow(), cancellationToken);
|
||||
var status = await _statusService.GetStatusAsync(opts.TenantId, _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (status.Anchor == TimeAnchor.Unknown)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Hooks;
|
||||
|
||||
public sealed class SealedStartupHostedService : IHostedService
|
||||
{
|
||||
private readonly SealedStartupValidator _validator;
|
||||
private readonly IOptions<AirGapOptions> _options;
|
||||
private readonly ILogger<SealedStartupHostedService> _logger;
|
||||
|
||||
public SealedStartupHostedService(
|
||||
SealedStartupValidator validator,
|
||||
IOptions<AirGapOptions> options,
|
||||
ILogger<SealedStartupHostedService> logger)
|
||||
{
|
||||
_validator = validator;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
var tenantId = opts.TenantId;
|
||||
var budget = new StalenessBudget(opts.Staleness.WarningSeconds, opts.Staleness.BreachSeconds);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AirGap Time starting for tenant {Tenant} with budgets warning={Warning}s breach={Breach}s",
|
||||
tenantId,
|
||||
budget.WarningSeconds,
|
||||
budget.BreachSeconds);
|
||||
|
||||
var result = await _validator.ValidateAsync(tenantId, budget, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
_logger.LogCritical(
|
||||
"AirGap time validation failed: {Reason} (tenant {TenantId})",
|
||||
result.Reason,
|
||||
tenantId);
|
||||
throw new InvalidOperationException($"sealed-startup-blocked:{result.Reason}");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AirGap time validation passed: anchor={Anchor} age={Age}s tenant={Tenant}",
|
||||
result.Status?.Anchor.TokenDigest,
|
||||
result.Status?.Staleness.AgeSeconds,
|
||||
tenantId);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Hooks;
|
||||
|
||||
public static class StartupValidationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs sealed-mode time anchor validation during app startup; aborts if missing or stale.
|
||||
/// </summary>
|
||||
public static IHost ValidateTimeAnchorOnStart(this IHost host, string tenantId, StalenessBudget budget)
|
||||
{
|
||||
using var scope = host.Services.CreateScope();
|
||||
var validator = scope.ServiceProvider.GetRequiredService<SealedStartupValidator>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("AirGap.Time.Startup");
|
||||
|
||||
var result = validator.ValidateAsync(tenantId, budget, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (!result.IsValid)
|
||||
{
|
||||
logger.LogCritical("AirGap time validation failed: {Reason} (tenant {TenantId})", result.Reason, tenantId);
|
||||
throw new InvalidOperationException($"sealed-startup-blocked:{result.Reason}");
|
||||
}
|
||||
|
||||
logger.LogInformation("AirGap time validation passed: anchor={Anchor} age={Age}s tenant={Tenant}", result.Status?.Anchor.TokenDigest, result.Status?.Staleness.AgeSeconds, tenantId);
|
||||
return host;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ builder.Services.AddSingleton<TimeAnchorLoader>();
|
||||
builder.Services.AddSingleton<TimeTokenParser>();
|
||||
builder.Services.AddSingleton<SealedStartupValidator>();
|
||||
builder.Services.AddSingleton<TrustRootProvider>();
|
||||
builder.Services.AddHostedService<SealedStartupHostedService>();
|
||||
|
||||
// AIRGAP-TIME-57-001: Time-anchor policy service
|
||||
builder.Services.Configure<TimeAnchorPolicyOptions>(builder.Configuration.GetSection("AirGap:Policy"));
|
||||
@@ -44,13 +45,4 @@ app.LogStellaOpsLocalHostname("airgap-time");
|
||||
app.UseStellaOpsCors();
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/healthz/ready");
|
||||
|
||||
var opts = app.Services.GetRequiredService<IOptions<AirGapOptions>>().Value;
|
||||
var tenantId = opts.TenantId;
|
||||
var budget = new StalenessBudget(opts.Staleness.WarningSeconds, opts.Staleness.BreachSeconds);
|
||||
|
||||
app.Services.GetRequiredService<ILogger<Program>>()
|
||||
.LogInformation("AirGap Time starting for tenant {Tenant} with budgets warning={Warning}s breach={Breach}s", tenantId, budget.WarningSeconds, budget.BreachSeconds);
|
||||
|
||||
app.ValidateTimeAnchorOnStart(tenantId, budget);
|
||||
app.Run();
|
||||
|
||||
29
src/AirGap/StellaOps.AirGap.Time/Services/Ed25519.cs
Normal file
29
src/AirGap/StellaOps.AirGap.Time/Services/Ed25519.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
internal static class Ed25519
|
||||
{
|
||||
public static bool Verify(byte[] publicKey, byte[] message, byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.CreateFromValue("1.3.101.112"));
|
||||
ecdsa.ImportSubjectPublicKeyInfo(CreateEd25519Spki(publicKey), out _);
|
||||
return ecdsa.VerifyData(message, signature, HashAlgorithmName.SHA512);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreateEd25519Spki(byte[] publicKey)
|
||||
{
|
||||
var spki = new byte[44];
|
||||
new byte[] { 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00 }
|
||||
.CopyTo(spki, 0);
|
||||
publicKey.CopyTo(spki, 12);
|
||||
return spki;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Policy enforcement service for time anchors.
|
||||
/// </summary>
|
||||
public interface ITimeAnchorPolicyService
|
||||
{
|
||||
Task<TimeAnchorPolicyResult> ValidateTimeAnchorAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TimeAnchorPolicyResult> EnforceBundleImportPolicyAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
DateTimeOffset? bundleTimestamp,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TimeAnchorPolicyResult> EnforceOperationPolicyAsync(
|
||||
string tenantId,
|
||||
string operation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TimeAnchorDriftResult> CalculateDriftAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset targetTime,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class Rfc3161Verifier
|
||||
{
|
||||
private static readonly Oid _tstInfoOid = new("1.2.840.113549.1.9.16.1.4");
|
||||
private static readonly Oid _signingTimeOid = new("1.2.840.113549.1.9.5");
|
||||
|
||||
private static DateTimeOffset? ExtractSigningTime(SignedCms signedCms, SignerInfo signerInfo)
|
||||
{
|
||||
foreach (var attr in signerInfo.SignedAttributes)
|
||||
{
|
||||
if (attr.Oid.Value == _signingTimeOid.Value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(attr.Values[0].RawData, AsnEncodingRules.DER);
|
||||
return reader.ReadUtcTime();
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = signedCms.ContentInfo;
|
||||
if (content.ContentType.Value == _tstInfoOid.Value)
|
||||
{
|
||||
var tstInfo = ParseTstInfo(content.Content);
|
||||
if (tstInfo.HasValue)
|
||||
{
|
||||
return tstInfo.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTstInfo(ReadOnlyMemory<byte> tstInfoBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(tstInfoBytes, AsnEncodingRules.DER);
|
||||
var sequenceReader = reader.ReadSequence();
|
||||
|
||||
sequenceReader.ReadInteger();
|
||||
sequenceReader.ReadObjectIdentifier();
|
||||
sequenceReader.ReadSequence();
|
||||
sequenceReader.ReadInteger();
|
||||
|
||||
return sequenceReader.ReadGeneralizedTime();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Formats.Asn1;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class Rfc3161Verifier
|
||||
{
|
||||
private static bool TryVerifyOfflineRevocation(
|
||||
TimeTokenVerificationOptions options,
|
||||
out string reason)
|
||||
{
|
||||
var hasOcsp = options.OcspResponses.Count > 0;
|
||||
var hasCrl = options.Crls.Count > 0;
|
||||
|
||||
if (!hasOcsp && !hasCrl)
|
||||
{
|
||||
reason = "rfc3161-revocation-missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasOcsp && options.OcspResponses.Any(IsOcspSuccess))
|
||||
{
|
||||
reason = "rfc3161-revocation-ocsp";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasCrl && options.Crls.Any(IsCrlParseable))
|
||||
{
|
||||
reason = "rfc3161-revocation-crl";
|
||||
return true;
|
||||
}
|
||||
|
||||
reason = "rfc3161-revocation-invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsOcspSuccess(byte[] response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(response, AsnEncodingRules.DER);
|
||||
var sequence = reader.ReadSequence();
|
||||
var status = sequence.ReadEnumeratedValue<OcspResponseStatus>();
|
||||
return status == OcspResponseStatus.Successful;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCrlParseable(byte[] crl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(crl, AsnEncodingRules.DER);
|
||||
reader.ReadSequence();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private enum OcspResponseStatus
|
||||
{
|
||||
Successful = 0,
|
||||
MalformedRequest = 1,
|
||||
InternalError = 2,
|
||||
TryLater = 3,
|
||||
SigRequired = 5,
|
||||
Unauthorized = 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class Rfc3161Verifier
|
||||
{
|
||||
private static TimeTrustRoot? ValidateAgainstTrustRoots(
|
||||
X509Certificate2 signerCert,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
IReadOnlyList<X509Certificate2> extraCertificates,
|
||||
DateTimeOffset verificationTime)
|
||||
{
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rootCert = X509CertificateLoader.LoadCertificate(root.PublicKey);
|
||||
if (signerCert.Thumbprint.Equals(rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return root;
|
||||
}
|
||||
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(rootCert);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
chain.ChainPolicy.VerificationTime = verificationTime.UtcDateTime;
|
||||
|
||||
foreach (var cert in extraCertificates)
|
||||
{
|
||||
if (!string.Equals(cert.Thumbprint, rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.Build(signerCert))
|
||||
{
|
||||
return root;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<X509Certificate2> BuildExtraCertificates(
|
||||
SignedCms signedCms,
|
||||
TimeTokenVerificationOptions? options)
|
||||
{
|
||||
var extra = new List<X509Certificate2>();
|
||||
if (options?.CertificateChain is { Count: > 0 })
|
||||
{
|
||||
extra.AddRange(options.CertificateChain);
|
||||
}
|
||||
|
||||
foreach (var cert in signedCms.Certificates.Cast<X509Certificate2>())
|
||||
{
|
||||
if (!extra.Any(existing => existing.Thumbprint.Equals(cert.Thumbprint, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
extra.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
return extra;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class Rfc3161Verifier
|
||||
{
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-trust-roots-required");
|
||||
}
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-token-empty");
|
||||
}
|
||||
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
var signedCms = new SignedCms();
|
||||
signedCms.Decode(tokenBytes.ToArray());
|
||||
|
||||
try
|
||||
{
|
||||
signedCms.CheckSignature(verifySignatureOnly: true);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-signature-invalid:{ex.Message}");
|
||||
}
|
||||
|
||||
if (signedCms.SignerInfos.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer");
|
||||
}
|
||||
|
||||
var signerInfo = signedCms.SignerInfos[0];
|
||||
var signerCert = signerInfo.Certificate;
|
||||
if (signerCert is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer-certificate");
|
||||
}
|
||||
|
||||
var signingTime = ExtractSigningTime(signedCms, signerInfo);
|
||||
if (signingTime is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signing-time");
|
||||
}
|
||||
|
||||
var extraCertificates = BuildExtraCertificates(signedCms, options);
|
||||
var verificationTime = options?.VerificationTime ?? signingTime.Value;
|
||||
var validRoot = ValidateAgainstTrustRoots(
|
||||
signerCert,
|
||||
trustRoots,
|
||||
extraCertificates,
|
||||
verificationTime);
|
||||
if (validRoot is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-certificate-not-trusted");
|
||||
}
|
||||
|
||||
if (options?.Offline == true)
|
||||
{
|
||||
if (!TryVerifyOfflineRevocation(options, out var revocationReason))
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure(revocationReason);
|
||||
}
|
||||
}
|
||||
|
||||
var certFingerprint = Convert.ToHexString(SHA256.HashData(signerCert.RawData)).ToLowerInvariant()[..16];
|
||||
|
||||
anchor = new TimeAnchor(
|
||||
signingTime.Value,
|
||||
$"rfc3161:{validRoot.KeyId}",
|
||||
"RFC3161",
|
||||
certFingerprint,
|
||||
tokenDigest);
|
||||
|
||||
return TimeAnchorValidationResult.Success("rfc3161-verified");
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-decode-error:{ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-error:{ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
@@ -12,329 +6,7 @@ namespace StellaOps.AirGap.Time.Services;
|
||||
/// Verifies RFC 3161 timestamp tokens using SignedCms and X509 certificate chain validation.
|
||||
/// Per AIRGAP-TIME-57-001: Provides trusted time-anchor service with real crypto verification.
|
||||
/// </summary>
|
||||
public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
public sealed partial class Rfc3161Verifier : ITimeTokenVerifier
|
||||
{
|
||||
// RFC 3161 OIDs
|
||||
private static readonly Oid TstInfoOid = new("1.2.840.113549.1.9.16.1.4"); // id-ct-TSTInfo
|
||||
private static readonly Oid SigningTimeOid = new("1.2.840.113549.1.9.5");
|
||||
|
||||
public TimeTokenFormat Format => TimeTokenFormat.Rfc3161;
|
||||
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-trust-roots-required");
|
||||
}
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-token-empty");
|
||||
}
|
||||
|
||||
// Compute token digest for reference
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
// Parse the SignedCms structure
|
||||
var signedCms = new SignedCms();
|
||||
signedCms.Decode(tokenBytes.ToArray());
|
||||
|
||||
// Verify signature (basic check without chain building)
|
||||
try
|
||||
{
|
||||
signedCms.CheckSignature(verifySignatureOnly: true);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-signature-invalid:{ex.Message}");
|
||||
}
|
||||
|
||||
// Extract the signing certificate
|
||||
if (signedCms.SignerInfos.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer");
|
||||
}
|
||||
|
||||
var signerInfo = signedCms.SignerInfos[0];
|
||||
var signerCert = signerInfo.Certificate;
|
||||
|
||||
if (signerCert is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer-certificate");
|
||||
}
|
||||
|
||||
// Extract signing time from the TSTInfo or signed attributes
|
||||
var signingTime = ExtractSigningTime(signedCms, signerInfo);
|
||||
if (signingTime is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signing-time");
|
||||
}
|
||||
|
||||
// Validate signer certificate against trust roots
|
||||
var extraCertificates = BuildExtraCertificates(signedCms, options);
|
||||
var verificationTime = options?.VerificationTime ?? signingTime.Value;
|
||||
var validRoot = ValidateAgainstTrustRoots(
|
||||
signerCert,
|
||||
trustRoots,
|
||||
extraCertificates,
|
||||
verificationTime);
|
||||
if (validRoot is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-certificate-not-trusted");
|
||||
}
|
||||
|
||||
if (options?.Offline == true)
|
||||
{
|
||||
if (!TryVerifyOfflineRevocation(options, out var revocationReason))
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure(revocationReason);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute certificate fingerprint
|
||||
var certFingerprint = Convert.ToHexString(SHA256.HashData(signerCert.RawData)).ToLowerInvariant()[..16];
|
||||
|
||||
anchor = new TimeAnchor(
|
||||
signingTime.Value,
|
||||
$"rfc3161:{validRoot.KeyId}",
|
||||
"RFC3161",
|
||||
certFingerprint,
|
||||
tokenDigest);
|
||||
|
||||
return TimeAnchorValidationResult.Success("rfc3161-verified");
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-decode-error:{ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-error:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeTrustRoot? ValidateAgainstTrustRoots(
|
||||
X509Certificate2 signerCert,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
IReadOnlyList<X509Certificate2> extraCertificates,
|
||||
DateTimeOffset verificationTime)
|
||||
{
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
// Match by certificate thumbprint or subject key identifier
|
||||
try
|
||||
{
|
||||
// Try direct certificate match
|
||||
var rootCert = X509CertificateLoader.LoadCertificate(root.PublicKey);
|
||||
if (signerCert.Thumbprint.Equals(rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return root;
|
||||
}
|
||||
|
||||
// Try chain validation against root
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(rootCert);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // Offline mode
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
chain.ChainPolicy.VerificationTime = verificationTime.UtcDateTime;
|
||||
|
||||
foreach (var cert in extraCertificates)
|
||||
{
|
||||
if (!string.Equals(cert.Thumbprint, rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.Build(signerCert))
|
||||
{
|
||||
return root;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid root certificate format, try next
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<X509Certificate2> BuildExtraCertificates(
|
||||
SignedCms signedCms,
|
||||
TimeTokenVerificationOptions? options)
|
||||
{
|
||||
var extra = new List<X509Certificate2>();
|
||||
if (options?.CertificateChain is { Count: > 0 })
|
||||
{
|
||||
extra.AddRange(options.CertificateChain);
|
||||
}
|
||||
|
||||
foreach (var cert in signedCms.Certificates.Cast<X509Certificate2>())
|
||||
{
|
||||
if (!extra.Any(existing =>
|
||||
existing.Thumbprint.Equals(cert.Thumbprint, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
extra.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
return extra;
|
||||
}
|
||||
|
||||
private static bool TryVerifyOfflineRevocation(
|
||||
TimeTokenVerificationOptions options,
|
||||
out string reason)
|
||||
{
|
||||
var hasOcsp = options.OcspResponses.Count > 0;
|
||||
var hasCrl = options.Crls.Count > 0;
|
||||
|
||||
if (!hasOcsp && !hasCrl)
|
||||
{
|
||||
reason = "rfc3161-revocation-missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasOcsp && options.OcspResponses.Any(IsOcspSuccess))
|
||||
{
|
||||
reason = "rfc3161-revocation-ocsp";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasCrl && options.Crls.Any(IsCrlParseable))
|
||||
{
|
||||
reason = "rfc3161-revocation-crl";
|
||||
return true;
|
||||
}
|
||||
|
||||
reason = "rfc3161-revocation-invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsOcspSuccess(byte[] response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(response, AsnEncodingRules.DER);
|
||||
var sequence = reader.ReadSequence();
|
||||
var status = sequence.ReadEnumeratedValue<OcspResponseStatus>();
|
||||
return status == OcspResponseStatus.Successful;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCrlParseable(byte[] crl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(crl, AsnEncodingRules.DER);
|
||||
reader.ReadSequence();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ExtractSigningTime(SignedCms signedCms, SignerInfo signerInfo)
|
||||
{
|
||||
// Try to get signing time from signed attributes
|
||||
foreach (var attr in signerInfo.SignedAttributes)
|
||||
{
|
||||
if (attr.Oid.Value == SigningTimeOid.Value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(attr.Values[0].RawData, AsnEncodingRules.DER);
|
||||
var time = reader.ReadUtcTime();
|
||||
return time;
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from TSTInfo content
|
||||
try
|
||||
{
|
||||
var content = signedCms.ContentInfo;
|
||||
if (content.ContentType.Value == TstInfoOid.Value)
|
||||
{
|
||||
var tstInfo = ParseTstInfo(content.Content);
|
||||
if (tstInfo.HasValue)
|
||||
{
|
||||
return tstInfo.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTstInfo(ReadOnlyMemory<byte> tstInfoBytes)
|
||||
{
|
||||
// TSTInfo ::= SEQUENCE {
|
||||
// version INTEGER,
|
||||
// policy OBJECT IDENTIFIER,
|
||||
// messageImprint MessageImprint,
|
||||
// serialNumber INTEGER,
|
||||
// genTime GeneralizedTime,
|
||||
// ...
|
||||
// }
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(tstInfoBytes, AsnEncodingRules.DER);
|
||||
var sequenceReader = reader.ReadSequence();
|
||||
|
||||
// Skip version
|
||||
sequenceReader.ReadInteger();
|
||||
|
||||
// Skip policy OID
|
||||
sequenceReader.ReadObjectIdentifier();
|
||||
|
||||
// Skip messageImprint (SEQUENCE)
|
||||
sequenceReader.ReadSequence();
|
||||
|
||||
// Skip serialNumber
|
||||
sequenceReader.ReadInteger();
|
||||
|
||||
// Read genTime (GeneralizedTime)
|
||||
var genTime = sequenceReader.ReadGeneralizedTime();
|
||||
return genTime;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private enum OcspResponseStatus
|
||||
{
|
||||
Successful = 0,
|
||||
MalformedRequest = 1,
|
||||
InternalError = 2,
|
||||
TryLater = 3,
|
||||
SigRequired = 5,
|
||||
Unauthorized = 6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class RoughtimeVerifier
|
||||
{
|
||||
private static TimeAnchorValidationResult ParseRoughtimeResponse(
|
||||
ReadOnlySpan<byte> data,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> signature,
|
||||
out ReadOnlySpan<byte> signedMessage)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
signature = ReadOnlySpan<byte>.Empty;
|
||||
signedMessage = ReadOnlySpan<byte>.Empty;
|
||||
|
||||
if (data.Length < 8)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-message-too-short");
|
||||
}
|
||||
|
||||
var numTags = BinaryPrimitives.ReadUInt32LittleEndian(data);
|
||||
if (numTags == 0 || numTags > 100)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-tag-count");
|
||||
}
|
||||
|
||||
var headerSize = 4 + (4 * ((int)numTags - 1)) + (4 * (int)numTags);
|
||||
if (data.Length < headerSize)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-header-incomplete");
|
||||
}
|
||||
|
||||
var offsetsStart = 4;
|
||||
var tagsStart = offsetsStart + (4 * ((int)numTags - 1));
|
||||
var valuesStart = headerSize;
|
||||
|
||||
ReadOnlySpan<byte> sigBytes = ReadOnlySpan<byte>.Empty;
|
||||
ReadOnlySpan<byte> srepBytes = ReadOnlySpan<byte>.Empty;
|
||||
|
||||
for (var i = 0; i < (int)numTags; i++)
|
||||
{
|
||||
var tag = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(tagsStart + (i * 4)));
|
||||
var valueStart = valuesStart;
|
||||
var valueEnd = data.Length;
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
valueStart = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
data.Slice(offsetsStart + ((i - 1) * 4)));
|
||||
}
|
||||
|
||||
if (i < (int)numTags - 1)
|
||||
{
|
||||
valueEnd = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
data.Slice(offsetsStart + (i * 4)));
|
||||
}
|
||||
|
||||
if (valueStart < 0 || valueEnd > data.Length || valueStart > valueEnd)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-value-bounds");
|
||||
}
|
||||
|
||||
var value = data.Slice(valueStart, valueEnd - valueStart);
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case TagSig:
|
||||
if (value.Length != Ed25519SignatureLength)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-signature-length");
|
||||
}
|
||||
sigBytes = value;
|
||||
break;
|
||||
case TagSrep:
|
||||
srepBytes = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sigBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-signature");
|
||||
}
|
||||
|
||||
if (srepBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-srep");
|
||||
}
|
||||
|
||||
var srepResult = ParseSignedResponse(srepBytes, out midpointMicros, out radiusMicros, out _, out _, out _);
|
||||
if (!srepResult.IsValid)
|
||||
{
|
||||
return srepResult;
|
||||
}
|
||||
|
||||
signature = sigBytes;
|
||||
signedMessage = srepBytes;
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-parsed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class RoughtimeVerifier
|
||||
{
|
||||
private static TimeAnchorValidationResult ReadSignedResponseTags(
|
||||
ReadOnlySpan<byte> data,
|
||||
uint numTags,
|
||||
int offsetsStart,
|
||||
int tagsStart,
|
||||
int valuesStart,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> rootBytes,
|
||||
out ReadOnlySpan<byte> pathBytes,
|
||||
out uint index,
|
||||
out bool hasMidp,
|
||||
out bool hasRadi,
|
||||
out bool hasRoot,
|
||||
out bool hasPath,
|
||||
out bool hasIndex)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
rootBytes = ReadOnlySpan<byte>.Empty;
|
||||
pathBytes = ReadOnlySpan<byte>.Empty;
|
||||
index = 0;
|
||||
hasMidp = false;
|
||||
hasRadi = false;
|
||||
hasRoot = false;
|
||||
hasPath = false;
|
||||
hasIndex = false;
|
||||
|
||||
for (var i = 0; i < (int)numTags; i++)
|
||||
{
|
||||
var tag = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(tagsStart + (i * 4)));
|
||||
var valueStart = valuesStart;
|
||||
var valueEnd = data.Length;
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
valueStart = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
data.Slice(offsetsStart + ((i - 1) * 4)));
|
||||
}
|
||||
|
||||
if (i < (int)numTags - 1)
|
||||
{
|
||||
valueEnd = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
data.Slice(offsetsStart + (i * 4)));
|
||||
}
|
||||
|
||||
if (valueStart < 0 || valueEnd > data.Length || valueStart > valueEnd)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = data.Slice(valueStart, valueEnd - valueStart);
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case TagMidp:
|
||||
if (value.Length == 8)
|
||||
{
|
||||
midpointMicros = BinaryPrimitives.ReadInt64LittleEndian(value);
|
||||
hasMidp = true;
|
||||
}
|
||||
break;
|
||||
case TagRadi:
|
||||
if (value.Length == 4)
|
||||
{
|
||||
radiusMicros = BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
hasRadi = true;
|
||||
}
|
||||
break;
|
||||
case TagRoot:
|
||||
if (value.Length == MerkleNodeLength)
|
||||
{
|
||||
rootBytes = value;
|
||||
hasRoot = true;
|
||||
}
|
||||
break;
|
||||
case TagPath:
|
||||
if (!value.IsEmpty && value.Length % MerkleNodeLength == 0)
|
||||
{
|
||||
pathBytes = value;
|
||||
hasPath = true;
|
||||
}
|
||||
break;
|
||||
case TagIndx:
|
||||
if (value.Length == MerkleIndexLength)
|
||||
{
|
||||
index = BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
hasIndex = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-srep-tags-read");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class RoughtimeVerifier
|
||||
{
|
||||
private static TimeAnchorValidationResult ParseSignedResponse(
|
||||
ReadOnlySpan<byte> data,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> rootBytes,
|
||||
out ReadOnlySpan<byte> pathBytes,
|
||||
out uint index)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
rootBytes = ReadOnlySpan<byte>.Empty;
|
||||
pathBytes = ReadOnlySpan<byte>.Empty;
|
||||
index = 0;
|
||||
|
||||
if (data.Length < 8)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-too-short");
|
||||
}
|
||||
|
||||
var numTags = BinaryPrimitives.ReadUInt32LittleEndian(data);
|
||||
if (numTags == 0 || numTags > 50)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-invalid-tag-count");
|
||||
}
|
||||
|
||||
var headerSize = 4 + (4 * ((int)numTags - 1)) + (4 * (int)numTags);
|
||||
if (data.Length < headerSize)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-header-incomplete");
|
||||
}
|
||||
|
||||
var offsetsStart = 4;
|
||||
var tagsStart = offsetsStart + (4 * ((int)numTags - 1));
|
||||
var valuesStart = headerSize;
|
||||
|
||||
var readResult = ReadSignedResponseTags(
|
||||
data,
|
||||
numTags,
|
||||
offsetsStart,
|
||||
tagsStart,
|
||||
valuesStart,
|
||||
out midpointMicros,
|
||||
out radiusMicros,
|
||||
out rootBytes,
|
||||
out pathBytes,
|
||||
out index,
|
||||
out var hasMidp,
|
||||
out var hasRadi,
|
||||
out var hasRoot,
|
||||
out var hasPath,
|
||||
out var hasIndex);
|
||||
if (!readResult.IsValid)
|
||||
{
|
||||
return readResult;
|
||||
}
|
||||
|
||||
if (!hasMidp)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-midpoint");
|
||||
}
|
||||
|
||||
if (!hasRoot)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-root");
|
||||
}
|
||||
|
||||
if (!hasPath)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-path");
|
||||
}
|
||||
|
||||
if (!hasIndex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-index");
|
||||
}
|
||||
|
||||
var pathValidation = ValidateMerklePathStructure(rootBytes, pathBytes, index);
|
||||
if (!pathValidation.IsValid)
|
||||
{
|
||||
return pathValidation;
|
||||
}
|
||||
|
||||
if (!hasRadi)
|
||||
{
|
||||
radiusMicros = 1_000_000;
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-srep-parsed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class RoughtimeVerifier
|
||||
{
|
||||
private static TimeAnchorValidationResult ValidateMerklePathStructure(
|
||||
ReadOnlySpan<byte> rootBytes,
|
||||
ReadOnlySpan<byte> pathBytes,
|
||||
uint index)
|
||||
{
|
||||
if (rootBytes.Length != MerkleNodeLength)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-root-length");
|
||||
}
|
||||
|
||||
if (pathBytes.IsEmpty || pathBytes.Length % MerkleNodeLength != 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-path-length");
|
||||
}
|
||||
|
||||
var depth = pathBytes.Length / MerkleNodeLength;
|
||||
if (depth <= 31)
|
||||
{
|
||||
var maxIndex = 1u << depth;
|
||||
if (index >= maxIndex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-index");
|
||||
}
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-merkle-structure-valid");
|
||||
}
|
||||
|
||||
private static bool VerifyEd25519Signature(ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature, byte[] publicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
const string ContextPrefix = "RoughTime v1 response signature\0";
|
||||
var prefixBytes = System.Text.Encoding.ASCII.GetBytes(ContextPrefix);
|
||||
var signedData = new byte[prefixBytes.Length + message.Length];
|
||||
prefixBytes.CopyTo(signedData, 0);
|
||||
message.CopyTo(signedData.AsSpan(prefixBytes.Length));
|
||||
|
||||
return Ed25519.Verify(publicKey, signedData, signature.ToArray());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class RoughtimeVerifier
|
||||
{
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-trust-roots-required");
|
||||
}
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-token-empty");
|
||||
}
|
||||
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
|
||||
var parseResult = ParseRoughtimeResponse(
|
||||
tokenBytes,
|
||||
out var midpointMicros,
|
||||
out var radiusMicros,
|
||||
out var signature,
|
||||
out var signedMessage);
|
||||
if (!parseResult.IsValid)
|
||||
{
|
||||
return parseResult;
|
||||
}
|
||||
|
||||
TimeTrustRoot? validRoot = null;
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
if (!string.Equals(root.Algorithm, "ed25519", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root.PublicKey.Length != Ed25519PublicKeyLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VerifyEd25519Signature(signedMessage, signature, root.PublicKey))
|
||||
{
|
||||
validRoot = root;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (validRoot is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-signature-invalid");
|
||||
}
|
||||
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddMicroseconds(midpointMicros);
|
||||
var keyFingerprint = Convert.ToHexString(SHA256.HashData(validRoot.PublicKey)).ToLowerInvariant()[..16];
|
||||
|
||||
anchor = new TimeAnchor(
|
||||
anchorTime,
|
||||
$"roughtime:{validRoot.KeyId}",
|
||||
"Roughtime",
|
||||
keyFingerprint,
|
||||
tokenDigest);
|
||||
|
||||
return TimeAnchorValidationResult.Success($"roughtime-verified:radius={radiusMicros}us");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
@@ -10,422 +6,20 @@ namespace StellaOps.AirGap.Time.Services;
|
||||
/// Verifies Roughtime tokens using Ed25519 signature verification.
|
||||
/// Per AIRGAP-TIME-57-001: Provides trusted time-anchor service with real crypto verification.
|
||||
/// </summary>
|
||||
public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
public sealed partial class RoughtimeVerifier : ITimeTokenVerifier
|
||||
{
|
||||
// Roughtime wire format tag constants (32-bit little-endian ASCII codes)
|
||||
private const uint TagSig = 0x00474953; // "SIG\0" - Signature
|
||||
private const uint TagMidp = 0x5044494D; // "MIDP" - Midpoint
|
||||
private const uint TagRadi = 0x49444152; // "RADI" - Radius
|
||||
private const uint TagRoot = 0x544F4F52; // "ROOT" - Merkle root
|
||||
private const uint TagPath = 0x48544150; // "PATH" - Merkle path
|
||||
private const uint TagIndx = 0x58444E49; // "INDX" - Index
|
||||
private const uint TagSrep = 0x50455253; // "SREP" - Signed response
|
||||
private const uint TagSig = 0x00474953;
|
||||
private const uint TagMidp = 0x5044494D;
|
||||
private const uint TagRadi = 0x49444152;
|
||||
private const uint TagRoot = 0x544F4F52;
|
||||
private const uint TagPath = 0x48544150;
|
||||
private const uint TagIndx = 0x58444E49;
|
||||
private const uint TagSrep = 0x50455253;
|
||||
|
||||
// Ed25519 constants
|
||||
private const int Ed25519SignatureLength = 64;
|
||||
private const int Ed25519PublicKeyLength = 32;
|
||||
private const int MerkleNodeLength = 32;
|
||||
private const int MerkleIndexLength = 4;
|
||||
|
||||
public TimeTokenFormat Format => TimeTokenFormat.Roughtime;
|
||||
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-trust-roots-required");
|
||||
}
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-token-empty");
|
||||
}
|
||||
|
||||
// Compute token digest for reference
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
|
||||
// Parse Roughtime wire format
|
||||
var parseResult = ParseRoughtimeResponse(tokenBytes, out var midpointMicros, out var radiusMicros, out var signature, out var signedMessage);
|
||||
|
||||
if (!parseResult.IsValid)
|
||||
{
|
||||
return parseResult;
|
||||
}
|
||||
|
||||
// Find a valid trust root with Ed25519 key
|
||||
TimeTrustRoot? validRoot = null;
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
if (!string.Equals(root.Algorithm, "ed25519", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root.PublicKey.Length != Ed25519PublicKeyLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify Ed25519 signature
|
||||
if (VerifyEd25519Signature(signedMessage, signature, root.PublicKey))
|
||||
{
|
||||
validRoot = root;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (validRoot is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-signature-invalid");
|
||||
}
|
||||
|
||||
// Convert midpoint from microseconds to DateTimeOffset
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddMicroseconds(midpointMicros);
|
||||
|
||||
// Compute signature fingerprint from the public key
|
||||
var keyFingerprint = Convert.ToHexString(SHA256.HashData(validRoot.PublicKey)).ToLowerInvariant()[..16];
|
||||
|
||||
anchor = new TimeAnchor(
|
||||
anchorTime,
|
||||
$"roughtime:{validRoot.KeyId}",
|
||||
"Roughtime",
|
||||
keyFingerprint,
|
||||
tokenDigest);
|
||||
|
||||
return TimeAnchorValidationResult.Success($"roughtime-verified:radius={radiusMicros}us");
|
||||
}
|
||||
|
||||
private static TimeAnchorValidationResult ParseRoughtimeResponse(
|
||||
ReadOnlySpan<byte> data,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> signature,
|
||||
out ReadOnlySpan<byte> signedMessage)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
signature = ReadOnlySpan<byte>.Empty;
|
||||
signedMessage = ReadOnlySpan<byte>.Empty;
|
||||
|
||||
// Roughtime wire format: [num_tags:u32] [offsets:u32[]] [tags:u32[]] [values...]
|
||||
// Minimum size: 4 (num_tags) + at least one tag
|
||||
if (data.Length < 8)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-message-too-short");
|
||||
}
|
||||
|
||||
var numTags = BinaryPrimitives.ReadUInt32LittleEndian(data);
|
||||
|
||||
if (numTags == 0 || numTags > 100)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-tag-count");
|
||||
}
|
||||
|
||||
// Header size: 4 + 4*(numTags-1) offsets + 4*numTags tags
|
||||
var headerSize = 4 + (4 * ((int)numTags - 1)) + (4 * (int)numTags);
|
||||
|
||||
if (data.Length < headerSize)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-header-incomplete");
|
||||
}
|
||||
|
||||
// Parse tags and extract required fields
|
||||
var offsetsStart = 4;
|
||||
var tagsStart = offsetsStart + (4 * ((int)numTags - 1));
|
||||
var valuesStart = headerSize;
|
||||
|
||||
ReadOnlySpan<byte> sigBytes = ReadOnlySpan<byte>.Empty;
|
||||
ReadOnlySpan<byte> srepBytes = ReadOnlySpan<byte>.Empty;
|
||||
|
||||
for (var i = 0; i < (int)numTags; i++)
|
||||
{
|
||||
var tag = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(tagsStart + (i * 4)));
|
||||
|
||||
// Calculate value bounds
|
||||
var valueStart = valuesStart;
|
||||
var valueEnd = data.Length;
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
valueStart = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + ((i - 1) * 4)));
|
||||
}
|
||||
|
||||
if (i < (int)numTags - 1)
|
||||
{
|
||||
valueEnd = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + (i * 4)));
|
||||
}
|
||||
|
||||
if (valueStart < 0 || valueEnd > data.Length || valueStart > valueEnd)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-value-bounds");
|
||||
}
|
||||
|
||||
var value = data.Slice(valueStart, valueEnd - valueStart);
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case TagSig:
|
||||
if (value.Length != Ed25519SignatureLength)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-signature-length");
|
||||
}
|
||||
sigBytes = value;
|
||||
break;
|
||||
case TagSrep:
|
||||
srepBytes = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sigBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-signature");
|
||||
}
|
||||
|
||||
if (srepBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-srep");
|
||||
}
|
||||
|
||||
// Parse SREP (signed response) for MIDP and RADI
|
||||
var srepResult = ParseSignedResponse(srepBytes, out midpointMicros, out radiusMicros, out _, out _, out _);
|
||||
if (!srepResult.IsValid)
|
||||
{
|
||||
return srepResult;
|
||||
}
|
||||
|
||||
signature = sigBytes;
|
||||
signedMessage = srepBytes;
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-parsed");
|
||||
}
|
||||
|
||||
private static TimeAnchorValidationResult ParseSignedResponse(
|
||||
ReadOnlySpan<byte> data,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> rootBytes,
|
||||
out ReadOnlySpan<byte> pathBytes,
|
||||
out uint index)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
rootBytes = ReadOnlySpan<byte>.Empty;
|
||||
pathBytes = ReadOnlySpan<byte>.Empty;
|
||||
index = 0;
|
||||
|
||||
if (data.Length < 8)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-too-short");
|
||||
}
|
||||
|
||||
var numTags = BinaryPrimitives.ReadUInt32LittleEndian(data);
|
||||
|
||||
if (numTags == 0 || numTags > 50)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-invalid-tag-count");
|
||||
}
|
||||
|
||||
var headerSize = 4 + (4 * ((int)numTags - 1)) + (4 * (int)numTags);
|
||||
|
||||
if (data.Length < headerSize)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-header-incomplete");
|
||||
}
|
||||
|
||||
var offsetsStart = 4;
|
||||
var tagsStart = offsetsStart + (4 * ((int)numTags - 1));
|
||||
var valuesStart = headerSize;
|
||||
|
||||
var hasMidp = false;
|
||||
var hasRadi = false;
|
||||
var hasRoot = false;
|
||||
var hasPath = false;
|
||||
var hasIndex = false;
|
||||
|
||||
for (var i = 0; i < (int)numTags; i++)
|
||||
{
|
||||
var tag = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(tagsStart + (i * 4)));
|
||||
|
||||
var valueStart = valuesStart;
|
||||
var valueEnd = data.Length;
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
valueStart = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + ((i - 1) * 4)));
|
||||
}
|
||||
|
||||
if (i < (int)numTags - 1)
|
||||
{
|
||||
valueEnd = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + (i * 4)));
|
||||
}
|
||||
|
||||
if (valueStart < 0 || valueEnd > data.Length || valueStart > valueEnd)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = data.Slice(valueStart, valueEnd - valueStart);
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case TagMidp:
|
||||
if (value.Length == 8)
|
||||
{
|
||||
midpointMicros = BinaryPrimitives.ReadInt64LittleEndian(value);
|
||||
hasMidp = true;
|
||||
}
|
||||
break;
|
||||
case TagRadi:
|
||||
if (value.Length == 4)
|
||||
{
|
||||
radiusMicros = BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
hasRadi = true;
|
||||
}
|
||||
break;
|
||||
case TagRoot:
|
||||
if (value.Length == MerkleNodeLength)
|
||||
{
|
||||
rootBytes = value;
|
||||
hasRoot = true;
|
||||
}
|
||||
break;
|
||||
case TagPath:
|
||||
if (!value.IsEmpty && value.Length % MerkleNodeLength == 0)
|
||||
{
|
||||
pathBytes = value;
|
||||
hasPath = true;
|
||||
}
|
||||
break;
|
||||
case TagIndx:
|
||||
if (value.Length == MerkleIndexLength)
|
||||
{
|
||||
index = BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
hasIndex = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMidp)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-midpoint");
|
||||
}
|
||||
|
||||
if (!hasRoot)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-root");
|
||||
}
|
||||
|
||||
if (!hasPath)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-path");
|
||||
}
|
||||
|
||||
if (!hasIndex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-index");
|
||||
}
|
||||
|
||||
var pathValidation = ValidateMerklePathStructure(rootBytes, pathBytes, index);
|
||||
if (!pathValidation.IsValid)
|
||||
{
|
||||
return pathValidation;
|
||||
}
|
||||
|
||||
if (!hasRadi)
|
||||
{
|
||||
// RADI is optional, default to 1 second uncertainty
|
||||
radiusMicros = 1_000_000;
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-srep-parsed");
|
||||
}
|
||||
|
||||
private static TimeAnchorValidationResult ValidateMerklePathStructure(ReadOnlySpan<byte> rootBytes, ReadOnlySpan<byte> pathBytes, uint index)
|
||||
{
|
||||
if (rootBytes.Length != MerkleNodeLength)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-root-length");
|
||||
}
|
||||
|
||||
if (pathBytes.IsEmpty || pathBytes.Length % MerkleNodeLength != 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-path-length");
|
||||
}
|
||||
|
||||
var depth = pathBytes.Length / MerkleNodeLength;
|
||||
if (depth <= 31)
|
||||
{
|
||||
var maxIndex = 1u << depth;
|
||||
if (index >= maxIndex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-index");
|
||||
}
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-merkle-structure-valid");
|
||||
}
|
||||
|
||||
private static bool VerifyEd25519Signature(ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature, byte[] publicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Roughtime signs the context-prefixed message: "RoughTime v1 response signature\0" || SREP
|
||||
const string ContextPrefix = "RoughTime v1 response signature\0";
|
||||
var prefixBytes = System.Text.Encoding.ASCII.GetBytes(ContextPrefix);
|
||||
var signedData = new byte[prefixBytes.Length + message.Length];
|
||||
prefixBytes.CopyTo(signedData, 0);
|
||||
message.CopyTo(signedData.AsSpan(prefixBytes.Length));
|
||||
|
||||
// Use .NET's Ed25519 verification
|
||||
// Note: .NET 10 supports Ed25519 natively via ECDsa with curve Ed25519
|
||||
return Ed25519.Verify(publicKey, signedData, signature.ToArray());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 signature verification helper using .NET cryptography.
|
||||
/// </summary>
|
||||
internal static class Ed25519
|
||||
{
|
||||
public static bool Verify(byte[] publicKey, byte[] message, byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
// .NET 10 has native Ed25519 support via ECDsa
|
||||
using var ecdsa = ECDsa.Create(ECCurve.CreateFromValue("1.3.101.112")); // Ed25519 OID
|
||||
ecdsa.ImportSubjectPublicKeyInfo(CreateEd25519Spki(publicKey), out _);
|
||||
return ecdsa.VerifyData(message, signature, HashAlgorithmName.SHA512);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback: if Ed25519 curve not available, return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreateEd25519Spki(byte[] publicKey)
|
||||
{
|
||||
// Ed25519 SPKI format:
|
||||
// 30 2a - SEQUENCE (42 bytes)
|
||||
// 30 05 - SEQUENCE (5 bytes)
|
||||
// 06 03 2b 65 70 - OID 1.3.101.112 (Ed25519)
|
||||
// 03 21 00 [32 bytes public key]
|
||||
var spki = new byte[44];
|
||||
new byte[] { 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00 }.CopyTo(spki, 0);
|
||||
publicKey.CopyTo(spki, 12);
|
||||
return spki;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class SealedStartupValidator
|
||||
|
||||
public async Task<StartupValidationResult> ValidateAsync(string tenantId, StalenessBudget budget, CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), cancellationToken);
|
||||
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (status.Anchor == TimeAnchor.Unknown)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of time drift calculation.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorDriftResult(
|
||||
bool HasAnchor,
|
||||
TimeSpan Drift,
|
||||
bool DriftExceedsThreshold,
|
||||
DateTimeOffset? AnchorTime);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for time-anchor policy violations.
|
||||
/// </summary>
|
||||
public static class TimeAnchorPolicyErrorCodes
|
||||
{
|
||||
public const string AnchorMissing = "TIME_ANCHOR_MISSING";
|
||||
public const string AnchorStale = "TIME_ANCHOR_STALE";
|
||||
public const string AnchorBreached = "TIME_ANCHOR_BREACHED";
|
||||
public const string DriftExceeded = "TIME_ANCHOR_DRIFT_EXCEEDED";
|
||||
public const string PolicyViolation = "TIME_ANCHOR_POLICY_VIOLATION";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Policy configuration for time anchors.
|
||||
/// </summary>
|
||||
public sealed class TimeAnchorPolicyOptions
|
||||
{
|
||||
public bool StrictEnforcement { get; set; } = true;
|
||||
|
||||
public int MaxDriftSeconds { get; set; } = 86400;
|
||||
|
||||
public bool AllowMissingAnchorInUnsealedMode { get; set; } = true;
|
||||
|
||||
public IReadOnlyList<string> StrictOperations { get; set; } = new[]
|
||||
{
|
||||
"bundle.import",
|
||||
"attestation.sign",
|
||||
"audit.record"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of time-anchor policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorPolicyResult(
|
||||
bool Allowed,
|
||||
string? ErrorCode,
|
||||
string? Reason,
|
||||
string? Remediation,
|
||||
StalenessEvaluation? Staleness);
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class TimeAnchorPolicyService
|
||||
{
|
||||
public async Task<TimeAnchorPolicyResult> EnforceBundleImportPolicyAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
DateTimeOffset? bundleTimestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
|
||||
var baseResult = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!baseResult.Allowed)
|
||||
{
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
if (bundleTimestamp.HasValue)
|
||||
{
|
||||
var driftResult = await CalculateDriftAsync(tenantId, bundleTimestamp.Value, cancellationToken).ConfigureAwait(false);
|
||||
if (driftResult.DriftExceedsThreshold)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle {BundleId} timestamp drift exceeds threshold for tenant {TenantId}: drift={DriftSeconds}s > max={MaxDriftSeconds}s [{ErrorCode}]",
|
||||
bundleId, tenantId, driftResult.Drift.TotalSeconds, _options.MaxDriftSeconds, TimeAnchorPolicyErrorCodes.DriftExceeded);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.DriftExceeded,
|
||||
Reason: $"Bundle timestamp drift exceeds maximum ({driftResult.Drift.TotalSeconds:F0}s > {_options.MaxDriftSeconds}s)",
|
||||
Remediation: "Bundle is too old or time anchor is significantly out of sync. Refresh the time anchor or use a more recent bundle.",
|
||||
Staleness: baseResult.Staleness);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Bundle import policy passed for tenant {TenantId}, bundle {BundleId}", tenantId, bundleId);
|
||||
return baseResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class TimeAnchorPolicyService
|
||||
{
|
||||
public async Task<TimeAnchorDriftResult> CalculateDriftAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset targetTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var status = await _statusService.GetStatusAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!status.HasAnchor)
|
||||
{
|
||||
return new TimeAnchorDriftResult(
|
||||
HasAnchor: false,
|
||||
Drift: TimeSpan.Zero,
|
||||
DriftExceedsThreshold: false,
|
||||
AnchorTime: null);
|
||||
}
|
||||
|
||||
var drift = targetTime - status.Anchor!.AnchorTime;
|
||||
var absDriftSeconds = Math.Abs(drift.TotalSeconds);
|
||||
var exceedsThreshold = absDriftSeconds > _options.MaxDriftSeconds;
|
||||
|
||||
return new TimeAnchorDriftResult(
|
||||
HasAnchor: true,
|
||||
Drift: drift,
|
||||
DriftExceedsThreshold: exceedsThreshold,
|
||||
AnchorTime: status.Anchor.AnchorTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class TimeAnchorPolicyService
|
||||
{
|
||||
public async Task<TimeAnchorPolicyResult> EnforceOperationPolicyAsync(
|
||||
string tenantId,
|
||||
string operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(operation);
|
||||
|
||||
var isStrictOperation = _options.StrictOperations.Contains(operation, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (isStrictOperation)
|
||||
{
|
||||
var result = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.Allowed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Strict operation {Operation} blocked for tenant {TenantId}: {Reason} [{ErrorCode}]",
|
||||
operation, tenantId, result.Reason, result.ErrorCode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var baseResult = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!baseResult.Allowed && !_options.StrictEnforcement)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Non-strict operation {Operation} allowed for tenant {TenantId} despite policy issue: {Reason}",
|
||||
operation, tenantId, baseResult.Reason);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: baseResult.ErrorCode,
|
||||
Reason: $"operation-allowed-with-warning:{baseResult.Reason}",
|
||||
Remediation: baseResult.Remediation,
|
||||
Staleness: baseResult.Staleness);
|
||||
}
|
||||
|
||||
return baseResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class TimeAnchorPolicyService
|
||||
{
|
||||
public async Task<TimeAnchorPolicyResult> ValidateTimeAnchorAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var status = await _statusService.GetStatusAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!status.HasAnchor)
|
||||
{
|
||||
if (_options.AllowMissingAnchorInUnsealedMode && !_options.StrictEnforcement)
|
||||
{
|
||||
_logger.LogDebug("Time anchor missing for tenant {TenantId}, allowed in non-strict mode", tenantId);
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: null,
|
||||
Reason: "time-anchor-missing-allowed",
|
||||
Remediation: null,
|
||||
Staleness: null);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Time anchor missing for tenant {TenantId} [{ErrorCode}]",
|
||||
tenantId, TimeAnchorPolicyErrorCodes.AnchorMissing);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.AnchorMissing,
|
||||
Reason: "No time anchor configured for tenant",
|
||||
Remediation: "Set a time anchor using POST /api/v1/time/anchor with a valid Roughtime or RFC3161 token",
|
||||
Staleness: null);
|
||||
}
|
||||
|
||||
var staleness = status.Staleness;
|
||||
|
||||
if (staleness.IsBreach)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Time anchor staleness breached for tenant {TenantId}: age={AgeSeconds}s > breach={BreachSeconds}s [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, TimeAnchorPolicyErrorCodes.AnchorBreached);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.AnchorBreached,
|
||||
Reason: $"Time anchor staleness breached ({staleness.AgeSeconds}s > {staleness.BreachSeconds}s)",
|
||||
Remediation: "Refresh time anchor with a new token to continue operations",
|
||||
Staleness: staleness);
|
||||
}
|
||||
|
||||
if (staleness.IsWarning)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Time anchor staleness warning for tenant {TenantId}: age={AgeSeconds}s approaching breach at {BreachSeconds}s [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, TimeAnchorPolicyErrorCodes.AnchorStale);
|
||||
}
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: null,
|
||||
Reason: staleness.IsWarning ? "time-anchor-warning" : "time-anchor-valid",
|
||||
Remediation: staleness.IsWarning ? "Consider refreshing time anchor soon" : null,
|
||||
Staleness: staleness);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +1,13 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Policy enforcement service for time anchors.
|
||||
/// Per AIRGAP-TIME-57-001: Enforces time-anchor requirements in sealed-mode operations.
|
||||
/// </summary>
|
||||
public interface ITimeAnchorPolicyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that a valid time anchor exists and is not stale.
|
||||
/// </summary>
|
||||
Task<TimeAnchorPolicyResult> ValidateTimeAnchorAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enforces time-anchor requirements before bundle import.
|
||||
/// </summary>
|
||||
Task<TimeAnchorPolicyResult> EnforceBundleImportPolicyAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
DateTimeOffset? bundleTimestamp,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enforces time-anchor requirements before operations that require trusted time.
|
||||
/// </summary>
|
||||
Task<TimeAnchorPolicyResult> EnforceOperationPolicyAsync(
|
||||
string tenantId,
|
||||
string operation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time drift between the anchor and a given timestamp.
|
||||
/// </summary>
|
||||
Task<TimeAnchorDriftResult> CalculateDriftAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset targetTime,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of time-anchor policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorPolicyResult(
|
||||
bool Allowed,
|
||||
string? ErrorCode,
|
||||
string? Reason,
|
||||
string? Remediation,
|
||||
StalenessEvaluation? Staleness);
|
||||
|
||||
/// <summary>
|
||||
/// Result of time drift calculation.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorDriftResult(
|
||||
bool HasAnchor,
|
||||
TimeSpan Drift,
|
||||
bool DriftExceedsThreshold,
|
||||
DateTimeOffset? AnchorTime);
|
||||
|
||||
/// <summary>
|
||||
/// Policy configuration for time anchors.
|
||||
/// </summary>
|
||||
public sealed class TimeAnchorPolicyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to enforce strict time-anchor requirements.
|
||||
/// When true, operations fail if time anchor is missing or stale.
|
||||
/// </summary>
|
||||
public bool StrictEnforcement { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed drift between anchor time and operation time in seconds.
|
||||
/// </summary>
|
||||
public int MaxDriftSeconds { get; set; } = 86400; // 24 hours
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow operations when no time anchor exists (unsealed mode only).
|
||||
/// </summary>
|
||||
public bool AllowMissingAnchorInUnsealedMode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Operations that require strict time-anchor enforcement regardless of mode.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> StrictOperations { get; set; } = new[]
|
||||
{
|
||||
"bundle.import",
|
||||
"attestation.sign",
|
||||
"audit.record"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for time-anchor policy violations.
|
||||
/// </summary>
|
||||
public static class TimeAnchorPolicyErrorCodes
|
||||
{
|
||||
public const string AnchorMissing = "TIME_ANCHOR_MISSING";
|
||||
public const string AnchorStale = "TIME_ANCHOR_STALE";
|
||||
public const string AnchorBreached = "TIME_ANCHOR_BREACHED";
|
||||
public const string DriftExceeded = "TIME_ANCHOR_DRIFT_EXCEEDED";
|
||||
public const string PolicyViolation = "TIME_ANCHOR_POLICY_VIOLATION";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of time-anchor policy service.
|
||||
/// </summary>
|
||||
public sealed class TimeAnchorPolicyService : ITimeAnchorPolicyService
|
||||
public sealed partial class TimeAnchorPolicyService : ITimeAnchorPolicyService
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly TimeAnchorPolicyOptions _options;
|
||||
@@ -125,182 +25,4 @@ public sealed class TimeAnchorPolicyService : ITimeAnchorPolicyService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<TimeAnchorPolicyResult> ValidateTimeAnchorAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var status = await _statusService.GetStatusAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check if anchor exists
|
||||
if (!status.HasAnchor)
|
||||
{
|
||||
if (_options.AllowMissingAnchorInUnsealedMode && !_options.StrictEnforcement)
|
||||
{
|
||||
_logger.LogDebug("Time anchor missing for tenant {TenantId}, allowed in non-strict mode", tenantId);
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: null,
|
||||
Reason: "time-anchor-missing-allowed",
|
||||
Remediation: null,
|
||||
Staleness: null);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Time anchor missing for tenant {TenantId} [{ErrorCode}]",
|
||||
tenantId, TimeAnchorPolicyErrorCodes.AnchorMissing);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.AnchorMissing,
|
||||
Reason: "No time anchor configured for tenant",
|
||||
Remediation: "Set a time anchor using POST /api/v1/time/anchor with a valid Roughtime or RFC3161 token",
|
||||
Staleness: null);
|
||||
}
|
||||
|
||||
// Evaluate staleness
|
||||
var staleness = status.Staleness;
|
||||
|
||||
// Check for breach
|
||||
if (staleness.IsBreach)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Time anchor staleness breached for tenant {TenantId}: age={AgeSeconds}s > breach={BreachSeconds}s [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, TimeAnchorPolicyErrorCodes.AnchorBreached);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.AnchorBreached,
|
||||
Reason: $"Time anchor staleness breached ({staleness.AgeSeconds}s > {staleness.BreachSeconds}s)",
|
||||
Remediation: "Refresh time anchor with a new token to continue operations",
|
||||
Staleness: staleness);
|
||||
}
|
||||
|
||||
// Check for warning (allowed but logged)
|
||||
if (staleness.IsWarning)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Time anchor staleness warning for tenant {TenantId}: age={AgeSeconds}s approaching breach at {BreachSeconds}s [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, TimeAnchorPolicyErrorCodes.AnchorStale);
|
||||
}
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: null,
|
||||
Reason: staleness.IsWarning ? "time-anchor-warning" : "time-anchor-valid",
|
||||
Remediation: staleness.IsWarning ? "Consider refreshing time anchor soon" : null,
|
||||
Staleness: staleness);
|
||||
}
|
||||
|
||||
public async Task<TimeAnchorPolicyResult> EnforceBundleImportPolicyAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
DateTimeOffset? bundleTimestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
|
||||
// First validate basic time anchor requirements
|
||||
var baseResult = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!baseResult.Allowed)
|
||||
{
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
// If bundle has a timestamp, check drift
|
||||
if (bundleTimestamp.HasValue)
|
||||
{
|
||||
var driftResult = await CalculateDriftAsync(tenantId, bundleTimestamp.Value, cancellationToken).ConfigureAwait(false);
|
||||
if (driftResult.DriftExceedsThreshold)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle {BundleId} timestamp drift exceeds threshold for tenant {TenantId}: drift={DriftSeconds}s > max={MaxDriftSeconds}s [{ErrorCode}]",
|
||||
bundleId, tenantId, driftResult.Drift.TotalSeconds, _options.MaxDriftSeconds, TimeAnchorPolicyErrorCodes.DriftExceeded);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.DriftExceeded,
|
||||
Reason: $"Bundle timestamp drift exceeds maximum ({driftResult.Drift.TotalSeconds:F0}s > {_options.MaxDriftSeconds}s)",
|
||||
Remediation: "Bundle is too old or time anchor is significantly out of sync. Refresh the time anchor or use a more recent bundle.",
|
||||
Staleness: baseResult.Staleness);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Bundle import policy passed for tenant {TenantId}, bundle {BundleId}", tenantId, bundleId);
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
public async Task<TimeAnchorPolicyResult> EnforceOperationPolicyAsync(
|
||||
string tenantId,
|
||||
string operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(operation);
|
||||
|
||||
var isStrictOperation = _options.StrictOperations.Contains(operation, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// For strict operations, always require valid time anchor
|
||||
if (isStrictOperation)
|
||||
{
|
||||
var result = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.Allowed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Strict operation {Operation} blocked for tenant {TenantId}: {Reason} [{ErrorCode}]",
|
||||
operation, tenantId, result.Reason, result.ErrorCode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// For non-strict operations, allow with warning if anchor is missing/stale
|
||||
var baseResult = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!baseResult.Allowed && !_options.StrictEnforcement)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Non-strict operation {Operation} allowed for tenant {TenantId} despite policy issue: {Reason}",
|
||||
operation, tenantId, baseResult.Reason);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: baseResult.ErrorCode,
|
||||
Reason: $"operation-allowed-with-warning:{baseResult.Reason}",
|
||||
Remediation: baseResult.Remediation,
|
||||
Staleness: baseResult.Staleness);
|
||||
}
|
||||
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
public async Task<TimeAnchorDriftResult> CalculateDriftAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset targetTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var status = await _statusService.GetStatusAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!status.HasAnchor)
|
||||
{
|
||||
return new TimeAnchorDriftResult(
|
||||
HasAnchor: false,
|
||||
Drift: TimeSpan.Zero,
|
||||
DriftExceedsThreshold: false,
|
||||
AnchorTime: null);
|
||||
}
|
||||
|
||||
var drift = targetTime - status.Anchor!.AnchorTime;
|
||||
var absDriftSeconds = Math.Abs(drift.TotalSeconds);
|
||||
var exceedsThreshold = absDriftSeconds > _options.MaxDriftSeconds;
|
||||
|
||||
return new TimeAnchorDriftResult(
|
||||
HasAnchor: true,
|
||||
Drift: drift,
|
||||
DriftExceedsThreshold: exceedsThreshold,
|
||||
AnchorTime: status.Anchor.AnchorTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ public sealed class TimeStatusService
|
||||
public async Task SetAnchorAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken = default)
|
||||
{
|
||||
budget.Validate();
|
||||
await _store.SetAsync(tenantId, anchor, budget, cancellationToken);
|
||||
await _store.SetAsync(tenantId, anchor, budget, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<TimeStatus> GetStatusAsync(string tenantId, DateTimeOffset nowUtc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (anchor, budget) = await _store.GetAsync(tenantId, cancellationToken);
|
||||
var (anchor, budget) = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var eval = _calculator.Evaluate(anchor, budget, nowUtc);
|
||||
var content = _calculator.EvaluateContent(anchor, _contentBudgets, nowUtc);
|
||||
var status = new TimeStatus(anchor, eval, budget, content, nowUtc);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed class TimeTelemetry
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.AirGap.Time", "1.0.0");
|
||||
private static readonly Meter _meter = new("StellaOps.AirGap.Time", "1.0.0");
|
||||
private const int MaxEntries = 1024;
|
||||
// Bound eviction queue to 3x max entries to prevent unbounded memory growth
|
||||
private const int MaxEvictionQueueSize = MaxEntries * 3;
|
||||
@@ -16,13 +16,13 @@ public sealed class TimeTelemetry
|
||||
|
||||
private readonly ObservableGauge<long> _anchorAgeGauge;
|
||||
|
||||
private static readonly Counter<long> StatusCounter = Meter.CreateCounter<long>("airgap_time_anchor_status_total");
|
||||
private static readonly Counter<long> WarningCounter = Meter.CreateCounter<long>("airgap_time_anchor_warning_total");
|
||||
private static readonly Counter<long> BreachCounter = Meter.CreateCounter<long>("airgap_time_anchor_breach_total");
|
||||
private static readonly Counter<long> _statusCounter = _meter.CreateCounter<long>("airgap_time_anchor_status_total");
|
||||
private static readonly Counter<long> _warningCounter = _meter.CreateCounter<long>("airgap_time_anchor_warning_total");
|
||||
private static readonly Counter<long> _breachCounter = _meter.CreateCounter<long>("airgap_time_anchor_breach_total");
|
||||
|
||||
public TimeTelemetry()
|
||||
{
|
||||
_anchorAgeGauge = Meter.CreateObservableGauge(
|
||||
_anchorAgeGauge = _meter.CreateObservableGauge(
|
||||
"airgap_time_anchor_age_seconds",
|
||||
() => _latest.Select(kvp => new Measurement<long>(kvp.Value.AgeSeconds, new KeyValuePair<string, object?>("tenant", kvp.Key))));
|
||||
}
|
||||
@@ -47,17 +47,17 @@ public sealed class TimeTelemetry
|
||||
{ "is_breach", status.Staleness.IsBreach }
|
||||
};
|
||||
|
||||
StatusCounter.Add(1, tags);
|
||||
_statusCounter.Add(1, tags);
|
||||
|
||||
if (status.Staleness.IsWarning)
|
||||
{
|
||||
WarningCounter.Add(1, tags);
|
||||
_warningCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
if (status.Staleness.IsBreach)
|
||||
{
|
||||
BreachCounter.Add(1, tags);
|
||||
}
|
||||
_breachCounter.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
public Snapshot? GetLatest(string tenantId)
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# AirGap Time Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -9,3 +9,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0034-T | DONE | Revalidated 2026-01-06; test coverage tracked in AUDIT-0035. |
|
||||
| AUDIT-0034-A | TODO | Address TimeTelemetry queue growth, TimeTokenParser endianness, and default store wiring. |
|
||||
| TASK-029-002 | DONE | Offline RFC3161 verification using bundled TSA chain/OCSP/CRL. |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/StellaOps.AirGap.Time/StellaOps.AirGap.Time.md. |
|
||||
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class AdvisorySnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts advisories from all configured feeds.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryExtractionResult> ExtractAllAsync(
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var contents = new List<AdvisoryContent>();
|
||||
var errors = new List<string>();
|
||||
var totalRecords = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var feeds = await _dataSource.GetAvailableFeedsAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Sort feeds for deterministic output.
|
||||
var sortedFeeds = feeds.OrderBy(f => f.FeedId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var feed in sortedFeeds)
|
||||
{
|
||||
if (request.FeedIds is { Count: > 0 } && !request.FeedIds.Contains(feed.FeedId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var feedResult = await ExtractFeedAsync(feed.FeedId, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (feedResult.Success && feedResult.Content is not null)
|
||||
{
|
||||
contents.Add(feedResult.Content);
|
||||
totalRecords += feedResult.RecordCount;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(feedResult.Error))
|
||||
{
|
||||
errors.Add($"{feed.FeedId}: {feedResult.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{feed.FeedId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AdvisoryExtractionResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
Advisories = contents,
|
||||
TotalRecordCount = totalRecords,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AdvisoryExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Advisories = [],
|
||||
Errors = [$"Extraction failed: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class AdvisorySnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts advisories from a specific feed.
|
||||
/// </summary>
|
||||
public async Task<FeedExtractionResult> ExtractFeedAsync(
|
||||
string feedId,
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(feedId);
|
||||
|
||||
try
|
||||
{
|
||||
var advisories = await _dataSource.GetAdvisoriesAsync(
|
||||
feedId,
|
||||
request.Since,
|
||||
request.MaxRecords,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (advisories.Count == 0)
|
||||
{
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
RecordCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Serialize advisories to NDJSON format for deterministic output.
|
||||
var contentBuilder = new StringBuilder();
|
||||
foreach (var advisory in advisories.OrderBy(a => a.Id, StringComparer.Ordinal))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(advisory, _jsonOptions);
|
||||
contentBuilder.AppendLine(json);
|
||||
}
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes(contentBuilder.ToString());
|
||||
var fileName = $"{feedId}-{snapshotAt.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}.ndjson";
|
||||
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
RecordCount = advisories.Count,
|
||||
Content = new AdvisoryContent
|
||||
{
|
||||
FeedId = feedId,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
SnapshotAt = snapshotAt,
|
||||
RecordCount = advisories.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
@@ -16,9 +14,9 @@ namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
/// <summary>
|
||||
/// Extracts advisory data from Concelier database for inclusion in knowledge snapshot bundles.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
public sealed partial class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
@@ -38,233 +36,4 @@ public sealed class AdvisorySnapshotExtractor : IAdvisorySnapshotExtractor
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts advisories from all configured feeds.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryExtractionResult> ExtractAllAsync(
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var contents = new List<AdvisoryContent>();
|
||||
var errors = new List<string>();
|
||||
var totalRecords = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var feeds = await _dataSource.GetAvailableFeedsAsync(cancellationToken);
|
||||
|
||||
// Sort feeds for deterministic output
|
||||
var sortedFeeds = feeds.OrderBy(f => f.FeedId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var feed in sortedFeeds)
|
||||
{
|
||||
// Skip if specific feeds are requested and this isn't one of them
|
||||
if (request.FeedIds is { Count: > 0 } && !request.FeedIds.Contains(feed.FeedId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var feedResult = await ExtractFeedAsync(feed.FeedId, request, cancellationToken);
|
||||
if (feedResult.Success && feedResult.Content is not null)
|
||||
{
|
||||
contents.Add(feedResult.Content);
|
||||
totalRecords += feedResult.RecordCount;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(feedResult.Error))
|
||||
{
|
||||
errors.Add($"{feed.FeedId}: {feedResult.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{feed.FeedId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AdvisoryExtractionResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
Advisories = contents,
|
||||
TotalRecordCount = totalRecords,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AdvisoryExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Advisories = [],
|
||||
Errors = [$"Extraction failed: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts advisories from a specific feed.
|
||||
/// </summary>
|
||||
public async Task<FeedExtractionResult> ExtractFeedAsync(
|
||||
string feedId,
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(feedId);
|
||||
|
||||
try
|
||||
{
|
||||
var advisories = await _dataSource.GetAdvisoriesAsync(
|
||||
feedId,
|
||||
request.Since,
|
||||
request.MaxRecords,
|
||||
cancellationToken);
|
||||
|
||||
if (advisories.Count == 0)
|
||||
{
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
RecordCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Serialize advisories to NDJSON format for deterministic output
|
||||
var contentBuilder = new StringBuilder();
|
||||
foreach (var advisory in advisories.OrderBy(a => a.Id, StringComparer.Ordinal))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(advisory, JsonOptions);
|
||||
contentBuilder.AppendLine(json);
|
||||
}
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes(contentBuilder.ToString());
|
||||
// Use invariant culture for deterministic filename formatting
|
||||
var fileName = $"{feedId}-{snapshotAt.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}.ndjson";
|
||||
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
RecordCount = advisories.Count,
|
||||
Content = new AdvisoryContent
|
||||
{
|
||||
FeedId = feedId,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
SnapshotAt = snapshotAt,
|
||||
RecordCount = advisories.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new FeedExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for advisory snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IAdvisorySnapshotExtractor
|
||||
{
|
||||
Task<AdvisoryExtractionResult> ExtractAllAsync(
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<FeedExtractionResult> ExtractFeedAsync(
|
||||
string feedId,
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for advisory data access.
|
||||
/// This should be implemented by Concelier to provide advisory data.
|
||||
/// </summary>
|
||||
public interface IAdvisoryDataSource
|
||||
{
|
||||
Task<IReadOnlyList<FeedInfo>> GetAvailableFeedsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AdvisoryRecord>> GetAdvisoriesAsync(
|
||||
string feedId,
|
||||
DateTimeOffset? since = null,
|
||||
int? maxRecords = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Data Models
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available feed.
|
||||
/// </summary>
|
||||
public sealed record FeedInfo(string FeedId, string Name, string? Ecosystem);
|
||||
|
||||
/// <summary>
|
||||
/// A single advisory record.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string FeedId { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public double? CvssScore { get; init; }
|
||||
public DateTimeOffset? PublishedAt { get; init; }
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
public IReadOnlyList<string>? AffectedPackages { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? RawData { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting advisories.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific feed IDs to extract. Empty means all feeds.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? FeedIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Only extract advisories modified since this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum records per feed.
|
||||
/// </summary>
|
||||
public int? MaxRecords { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting advisories from all feeds.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<AdvisoryContent> Advisories { get; init; } = [];
|
||||
public int TotalRecordCount { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single feed.
|
||||
/// </summary>
|
||||
public sealed record FeedExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int RecordCount { get; init; }
|
||||
public AdvisoryContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available feed.
|
||||
/// </summary>
|
||||
public sealed record FeedInfo(string FeedId, string Name, string? Ecosystem);
|
||||
|
||||
/// <summary>
|
||||
/// A single advisory record.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string FeedId { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public double? CvssScore { get; init; }
|
||||
public DateTimeOffset? PublishedAt { get; init; }
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
public IReadOnlyList<string>? AffectedPackages { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? RawData { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting advisories.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific feed IDs to extract. Empty means all feeds.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? FeedIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Only extract advisories modified since this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum records per feed.
|
||||
/// </summary>
|
||||
public int? MaxRecords { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting advisories from all feeds.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<AdvisoryContent> Advisories { get; init; } = [];
|
||||
public int TotalRecordCount { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single feed.
|
||||
/// </summary>
|
||||
public sealed record FeedExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int RecordCount { get; init; }
|
||||
public AdvisoryContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for advisory data access.
|
||||
/// This should be implemented by Concelier to provide advisory data.
|
||||
/// </summary>
|
||||
public interface IAdvisoryDataSource
|
||||
{
|
||||
Task<IReadOnlyList<FeedInfo>> GetAvailableFeedsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AdvisoryRecord>> GetAdvisoriesAsync(
|
||||
string feedId,
|
||||
DateTimeOffset? since = null,
|
||||
int? maxRecords = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for advisory snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IAdvisorySnapshotExtractor
|
||||
{
|
||||
Task<AdvisoryExtractionResult> ExtractAllAsync(
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<FeedExtractionResult> ExtractFeedAsync(
|
||||
string feedId,
|
||||
AdvisoryExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for policy data access.
|
||||
/// This should be implemented by the Policy module to provide policy data.
|
||||
/// </summary>
|
||||
public interface IPolicyDataSource
|
||||
{
|
||||
Task<IReadOnlyList<PolicyInfo>> GetAvailablePoliciesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicyInfo?> GetPolicyInfoAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<byte[]?> GetPolicyContentAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for policy snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IPolicySnapshotExtractor
|
||||
{
|
||||
Task<PolicyExtractionResult> ExtractAllAsync(
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicySingleExtractionResult> ExtractPolicyAsync(
|
||||
string policyId,
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX data access.
|
||||
/// This should be implemented by Excititor to provide VEX data.
|
||||
/// </summary>
|
||||
public interface IVexDataSource
|
||||
{
|
||||
Task<IReadOnlyList<VexSourceInfo>> GetAvailableSourcesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<VexStatement>> GetStatementsAsync(
|
||||
string sourceId,
|
||||
DateTimeOffset? since = null,
|
||||
int? maxStatements = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IVexSnapshotExtractor
|
||||
{
|
||||
Task<VexExtractionResult> ExtractAllAsync(
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VexSourceExtractionResult> ExtractSourceAsync(
|
||||
string sourceId,
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class PolicySnapshotExtractor
|
||||
{
|
||||
private sealed record OpaBundleManifest
|
||||
{
|
||||
public required string Revision { get; init; }
|
||||
public required string[] Roots { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class PolicySnapshotExtractor
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private static async Task<byte[]> PackageRegoBundleAsync(
|
||||
PolicyInfo policyInfo,
|
||||
byte[] policyContent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false); // Operations below are synchronous
|
||||
|
||||
using var outputStream = new MemoryStream();
|
||||
using var gzipStream = new GZipStream(outputStream, CompressionLevel.Optimal);
|
||||
|
||||
// Write a simple tar with the rego file
|
||||
// Note: This is a minimal implementation; a full implementation would use System.Formats.Tar
|
||||
var header = CreateTarHeader($"{policyInfo.PolicyId}/policy.rego", policyContent.Length);
|
||||
gzipStream.Write(header);
|
||||
gzipStream.Write(policyContent);
|
||||
|
||||
// Pad to 512-byte boundary
|
||||
var padding = 512 - (policyContent.Length % 512);
|
||||
if (padding < 512)
|
||||
{
|
||||
gzipStream.Write(new byte[padding]);
|
||||
}
|
||||
|
||||
// Add manifest.json
|
||||
var manifest = new OpaBundleManifest
|
||||
{
|
||||
Revision = policyInfo.Version,
|
||||
Roots = [policyInfo.PolicyId]
|
||||
};
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, _jsonOptions);
|
||||
|
||||
var manifestHeader = CreateTarHeader(".manifest", manifestBytes.Length);
|
||||
gzipStream.Write(manifestHeader);
|
||||
gzipStream.Write(manifestBytes);
|
||||
|
||||
padding = 512 - (manifestBytes.Length % 512);
|
||||
if (padding < 512)
|
||||
{
|
||||
gzipStream.Write(new byte[padding]);
|
||||
}
|
||||
|
||||
// Write tar end-of-archive marker (two 512-byte zero blocks)
|
||||
gzipStream.Write(new byte[1024]);
|
||||
|
||||
gzipStream.Close();
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class PolicySnapshotExtractor
|
||||
{
|
||||
private static async Task<PolicyContent> BuildPolicyContentAsync(
|
||||
PolicyInfo policyInfo,
|
||||
byte[] policyContent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] contentBytes;
|
||||
string fileName;
|
||||
|
||||
switch (policyInfo.Type)
|
||||
{
|
||||
case "OpaRego":
|
||||
contentBytes = await PackageRegoBundleAsync(policyInfo, policyContent, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.tar.gz";
|
||||
break;
|
||||
|
||||
case "LatticeRules":
|
||||
case "UnknownBudgets":
|
||||
case "ScoringWeights":
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.json";
|
||||
break;
|
||||
|
||||
default:
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.bin";
|
||||
break;
|
||||
}
|
||||
|
||||
return new PolicyContent
|
||||
{
|
||||
PolicyId = policyInfo.PolicyId,
|
||||
Name = policyInfo.Name,
|
||||
Version = policyInfo.Version,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
Type = policyInfo.Type
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class PolicySnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts a specific policy.
|
||||
/// </summary>
|
||||
public async Task<PolicySingleExtractionResult> ExtractPolicyAsync(
|
||||
string policyId,
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
try
|
||||
{
|
||||
var policyInfo = await _dataSource.GetPolicyInfoAsync(policyId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (policyInfo is null)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy not found"
|
||||
};
|
||||
}
|
||||
|
||||
var policyContent = await _dataSource.GetPolicyContentAsync(policyId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (policyContent is null || policyContent.Length == 0)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy content is empty"
|
||||
};
|
||||
}
|
||||
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
Content = await BuildPolicyContentAsync(policyInfo, policyContent, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class PolicySnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Fixed mtime for deterministic tar headers (2024-01-01 00:00:00 UTC).
|
||||
/// </summary>
|
||||
private const long DeterministicMtime = 1704067200;
|
||||
|
||||
private static byte[] CreateTarHeader(string fileName, long fileSize)
|
||||
{
|
||||
var header = new byte[512];
|
||||
var nameBytes = Encoding.ASCII.GetBytes(fileName);
|
||||
Array.Copy(nameBytes, header, Math.Min(nameBytes.Length, 100));
|
||||
|
||||
// Mode (100-107) - 0644
|
||||
Encoding.ASCII.GetBytes("0000644").CopyTo(header, 100);
|
||||
|
||||
// Owner/group UID/GID (108-123) - zeros
|
||||
Encoding.ASCII.GetBytes("0000000").CopyTo(header, 108);
|
||||
Encoding.ASCII.GetBytes("0000000").CopyTo(header, 116);
|
||||
|
||||
// File size in octal (124-135)
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(fileSize, 8).PadLeft(11, '0')).CopyTo(header, 124);
|
||||
|
||||
// Modification time (136-147) - use deterministic mtime for reproducible output
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(DeterministicMtime, 8).PadLeft(11, '0')).CopyTo(header, 136);
|
||||
|
||||
// Checksum placeholder (148-155) - spaces
|
||||
for (var i = 148; i < 156; i++)
|
||||
{
|
||||
header[i] = 0x20;
|
||||
}
|
||||
|
||||
// Type flag (156) - regular file
|
||||
header[156] = (byte)'0';
|
||||
|
||||
// USTAR magic (257-264)
|
||||
Encoding.ASCII.GetBytes("ustar\0").CopyTo(header, 257);
|
||||
Encoding.ASCII.GetBytes("00").CopyTo(header, 263);
|
||||
|
||||
// Calculate and set checksum
|
||||
var checksum = 0;
|
||||
foreach (var b in header)
|
||||
{
|
||||
checksum += b;
|
||||
}
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(checksum, 8).PadLeft(6, '0') + "\0 ").CopyTo(header, 148);
|
||||
|
||||
return header;
|
||||
}
|
||||
}
|
||||
@@ -4,31 +4,15 @@
|
||||
// Task: SEAL-008 - Implement policy bundle extractor
|
||||
// Description: Extracts policy bundle data for knowledge snapshot bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts policy bundles from the Policy registry for inclusion in knowledge snapshot bundles.
|
||||
/// </summary>
|
||||
public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
public sealed partial class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fixed mtime for deterministic tar headers (2024-01-01 00:00:00 UTC).
|
||||
/// </summary>
|
||||
private const long DeterministicMtime = 1704067200;
|
||||
|
||||
private readonly IPolicyDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -57,7 +41,8 @@ public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
|
||||
try
|
||||
{
|
||||
var policies = await _dataSource.GetAvailablePoliciesAsync(cancellationToken);
|
||||
var policies = await _dataSource.GetAvailablePoliciesAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Sort policies for deterministic output
|
||||
var sortedPolicies = policies.OrderBy(p => p.PolicyId, StringComparer.Ordinal).ToList();
|
||||
@@ -72,7 +57,8 @@ public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
|
||||
try
|
||||
{
|
||||
var policyResult = await ExtractPolicyAsync(policy.PolicyId, request, cancellationToken);
|
||||
var policyResult = await ExtractPolicyAsync(policy.PolicyId, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (policyResult.Success && policyResult.Content is not null)
|
||||
{
|
||||
contents.Add(policyResult.Content);
|
||||
@@ -105,271 +91,4 @@ public sealed class PolicySnapshotExtractor : IPolicySnapshotExtractor
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a specific policy.
|
||||
/// </summary>
|
||||
public async Task<PolicySingleExtractionResult> ExtractPolicyAsync(
|
||||
string policyId,
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
try
|
||||
{
|
||||
var policyInfo = await _dataSource.GetPolicyInfoAsync(policyId, cancellationToken);
|
||||
if (policyInfo is null)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy not found"
|
||||
};
|
||||
}
|
||||
|
||||
var policyContent = await _dataSource.GetPolicyContentAsync(policyId, cancellationToken);
|
||||
if (policyContent is null || policyContent.Length == 0)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy content is empty"
|
||||
};
|
||||
}
|
||||
|
||||
// Package policy based on type
|
||||
byte[] contentBytes;
|
||||
string fileName;
|
||||
|
||||
switch (policyInfo.Type)
|
||||
{
|
||||
case "OpaRego":
|
||||
// Package Rego files as a tar.gz bundle
|
||||
contentBytes = await PackageRegoBundle(policyInfo, policyContent, cancellationToken);
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.tar.gz";
|
||||
break;
|
||||
|
||||
case "LatticeRules":
|
||||
// LatticeRules are JSON files
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.json";
|
||||
break;
|
||||
|
||||
case "UnknownBudgets":
|
||||
// Unknown budgets are JSON files
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.json";
|
||||
break;
|
||||
|
||||
case "ScoringWeights":
|
||||
// Scoring weights are JSON files
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.json";
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown types are passed through as-is
|
||||
contentBytes = policyContent;
|
||||
fileName = $"{policyInfo.PolicyId}-{policyInfo.Version}.bin";
|
||||
break;
|
||||
}
|
||||
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
Content = new PolicyContent
|
||||
{
|
||||
PolicyId = policyInfo.PolicyId,
|
||||
Name = policyInfo.Name,
|
||||
Version = policyInfo.Version,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
Type = policyInfo.Type
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PolicySingleExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> PackageRegoBundle(
|
||||
PolicyInfo policyInfo,
|
||||
byte[] policyContent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask; // Operations below are synchronous
|
||||
|
||||
using var outputStream = new MemoryStream();
|
||||
using var gzipStream = new GZipStream(outputStream, CompressionLevel.Optimal);
|
||||
|
||||
// Write a simple tar with the rego file
|
||||
// Note: This is a minimal implementation; a full implementation would use System.Formats.Tar
|
||||
var header = CreateTarHeader($"{policyInfo.PolicyId}/policy.rego", policyContent.Length);
|
||||
gzipStream.Write(header);
|
||||
gzipStream.Write(policyContent);
|
||||
|
||||
// Pad to 512-byte boundary
|
||||
var padding = 512 - (policyContent.Length % 512);
|
||||
if (padding < 512)
|
||||
{
|
||||
gzipStream.Write(new byte[padding]);
|
||||
}
|
||||
|
||||
// Add manifest.json
|
||||
var manifest = new OpaBundleManifest
|
||||
{
|
||||
Revision = policyInfo.Version,
|
||||
Roots = [policyInfo.PolicyId]
|
||||
};
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions);
|
||||
|
||||
var manifestHeader = CreateTarHeader(".manifest", manifestBytes.Length);
|
||||
gzipStream.Write(manifestHeader);
|
||||
gzipStream.Write(manifestBytes);
|
||||
|
||||
padding = 512 - (manifestBytes.Length % 512);
|
||||
if (padding < 512)
|
||||
{
|
||||
gzipStream.Write(new byte[padding]);
|
||||
}
|
||||
|
||||
// Write tar end-of-archive marker (two 512-byte zero blocks)
|
||||
gzipStream.Write(new byte[1024]);
|
||||
|
||||
gzipStream.Close();
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CreateTarHeader(string fileName, long fileSize)
|
||||
{
|
||||
var header = new byte[512];
|
||||
var nameBytes = Encoding.ASCII.GetBytes(fileName);
|
||||
Array.Copy(nameBytes, header, Math.Min(nameBytes.Length, 100));
|
||||
|
||||
// Mode (100-107) - 0644
|
||||
Encoding.ASCII.GetBytes("0000644").CopyTo(header, 100);
|
||||
|
||||
// Owner/group UID/GID (108-123) - zeros
|
||||
Encoding.ASCII.GetBytes("0000000").CopyTo(header, 108);
|
||||
Encoding.ASCII.GetBytes("0000000").CopyTo(header, 116);
|
||||
|
||||
// File size in octal (124-135)
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(fileSize, 8).PadLeft(11, '0')).CopyTo(header, 124);
|
||||
|
||||
// Modification time (136-147) - use deterministic mtime for reproducible output
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(DeterministicMtime, 8).PadLeft(11, '0')).CopyTo(header, 136);
|
||||
|
||||
// Checksum placeholder (148-155) - spaces
|
||||
for (var i = 148; i < 156; i++)
|
||||
{
|
||||
header[i] = 0x20;
|
||||
}
|
||||
|
||||
// Type flag (156) - regular file
|
||||
header[156] = (byte)'0';
|
||||
|
||||
// USTAR magic (257-264)
|
||||
Encoding.ASCII.GetBytes("ustar\0").CopyTo(header, 257);
|
||||
Encoding.ASCII.GetBytes("00").CopyTo(header, 263);
|
||||
|
||||
// Calculate and set checksum
|
||||
var checksum = 0;
|
||||
foreach (var b in header)
|
||||
{
|
||||
checksum += b;
|
||||
}
|
||||
Encoding.ASCII.GetBytes(Convert.ToString(checksum, 8).PadLeft(6, '0') + "\0 ").CopyTo(header, 148);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
private sealed record OpaBundleManifest
|
||||
{
|
||||
public required string Revision { get; init; }
|
||||
public required string[] Roots { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for policy snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IPolicySnapshotExtractor
|
||||
{
|
||||
Task<PolicyExtractionResult> ExtractAllAsync(
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicySingleExtractionResult> ExtractPolicyAsync(
|
||||
string policyId,
|
||||
PolicyExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for policy data access.
|
||||
/// This should be implemented by the Policy module to provide policy data.
|
||||
/// </summary>
|
||||
public interface IPolicyDataSource
|
||||
{
|
||||
Task<IReadOnlyList<PolicyInfo>> GetAvailablePoliciesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PolicyInfo?> GetPolicyInfoAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<byte[]?> GetPolicyContentAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Data Models
|
||||
|
||||
/// <summary>
|
||||
/// Information about a policy.
|
||||
/// </summary>
|
||||
public sealed record PolicyInfo
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting policies.
|
||||
/// </summary>
|
||||
public sealed record PolicyExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific policy types to extract. Empty means all types.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Types { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting policies.
|
||||
/// </summary>
|
||||
public sealed record PolicyExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<PolicyContent> Policies { get; init; } = [];
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single policy.
|
||||
/// </summary>
|
||||
public sealed record PolicySingleExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public PolicyContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Information about a policy.
|
||||
/// </summary>
|
||||
public sealed record PolicyInfo
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting policies.
|
||||
/// </summary>
|
||||
public sealed record PolicyExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific policy types to extract. Empty means all types.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Types { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting policies.
|
||||
/// </summary>
|
||||
public sealed record PolicyExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<PolicyContent> Policies { get; init; } = [];
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single policy.
|
||||
/// </summary>
|
||||
public sealed record PolicySingleExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public PolicyContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class VexSnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts VEX statements from all configured sources.
|
||||
/// </summary>
|
||||
public async Task<VexExtractionResult> ExtractAllAsync(
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var contents = new List<VexContent>();
|
||||
var errors = new List<string>();
|
||||
var totalStatements = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var sources = await _dataSource.GetAvailableSourcesAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Sort sources for deterministic output.
|
||||
var sortedSources = sources.OrderBy(s => s.SourceId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var source in sortedSources)
|
||||
{
|
||||
if (request.SourceIds is { Count: > 0 } && !request.SourceIds.Contains(source.SourceId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sourceResult = await ExtractSourceAsync(source.SourceId, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (sourceResult.Success && sourceResult.Content is not null)
|
||||
{
|
||||
contents.Add(sourceResult.Content);
|
||||
totalStatements += sourceResult.StatementCount;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(sourceResult.Error))
|
||||
{
|
||||
errors.Add($"{source.SourceId}: {sourceResult.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{source.SourceId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new VexExtractionResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
VexStatements = contents,
|
||||
TotalStatementCount = totalStatements,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new VexExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
VexStatements = [],
|
||||
Errors = [$"Extraction failed: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Globalization;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
public sealed partial class VexSnapshotExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts VEX statements from a specific source.
|
||||
/// </summary>
|
||||
public async Task<VexSourceExtractionResult> ExtractSourceAsync(
|
||||
string sourceId,
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
|
||||
try
|
||||
{
|
||||
var statements = await _dataSource.GetStatementsAsync(
|
||||
sourceId,
|
||||
request.Since,
|
||||
request.MaxStatements,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
StatementCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
var timestampStr = snapshotAt.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
|
||||
|
||||
// Serialize statements to OpenVEX format.
|
||||
var document = new OpenVexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns",
|
||||
Id = $"urn:stellaops:vex:{sourceId}:{timestampStr}",
|
||||
Author = sourceId,
|
||||
Timestamp = snapshotAt,
|
||||
Version = 1,
|
||||
Statements = statements.OrderBy(s => s.VulnerabilityId, StringComparer.Ordinal).ToList()
|
||||
};
|
||||
|
||||
var contentBytes = JsonSerializer.SerializeToUtf8Bytes(document, _jsonOptions);
|
||||
var fileName = $"{sourceId}-{timestampStr}.json";
|
||||
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
StatementCount = statements.Count,
|
||||
Content = new VexContent
|
||||
{
|
||||
SourceId = sourceId,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
SnapshotAt = snapshotAt,
|
||||
StatementCount = statements.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,6 @@
|
||||
// Task: SEAL-007 - Implement VEX snapshot extractor
|
||||
// Description: Extracts VEX statement data from Excititor for knowledge snapshot bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
@@ -17,9 +12,9 @@ namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
/// Extracts VEX (Vulnerability Exploitability eXchange) statements from Excititor
|
||||
/// database for inclusion in knowledge snapshot bundles.
|
||||
/// </summary>
|
||||
public sealed class VexSnapshotExtractor : IVexSnapshotExtractor
|
||||
public sealed partial class VexSnapshotExtractor : IVexSnapshotExtractor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
@@ -39,258 +34,4 @@ public sealed class VexSnapshotExtractor : IVexSnapshotExtractor
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts VEX statements from all configured sources.
|
||||
/// </summary>
|
||||
public async Task<VexExtractionResult> ExtractAllAsync(
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var contents = new List<VexContent>();
|
||||
var errors = new List<string>();
|
||||
var totalStatements = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var sources = await _dataSource.GetAvailableSourcesAsync(cancellationToken);
|
||||
|
||||
// Sort sources for deterministic output
|
||||
var sortedSources = sources.OrderBy(s => s.SourceId, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var source in sortedSources)
|
||||
{
|
||||
// Skip if specific sources are requested and this isn't one of them
|
||||
if (request.SourceIds is { Count: > 0 } && !request.SourceIds.Contains(source.SourceId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sourceResult = await ExtractSourceAsync(source.SourceId, request, cancellationToken);
|
||||
if (sourceResult.Success && sourceResult.Content is not null)
|
||||
{
|
||||
contents.Add(sourceResult.Content);
|
||||
totalStatements += sourceResult.StatementCount;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(sourceResult.Error))
|
||||
{
|
||||
errors.Add($"{source.SourceId}: {sourceResult.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{source.SourceId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new VexExtractionResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
VexStatements = contents,
|
||||
TotalStatementCount = totalStatements,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new VexExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
VexStatements = [],
|
||||
Errors = [$"Extraction failed: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts VEX statements from a specific source.
|
||||
/// </summary>
|
||||
public async Task<VexSourceExtractionResult> ExtractSourceAsync(
|
||||
string sourceId,
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
|
||||
try
|
||||
{
|
||||
var statements = await _dataSource.GetStatementsAsync(
|
||||
sourceId,
|
||||
request.Since,
|
||||
request.MaxStatements,
|
||||
cancellationToken);
|
||||
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
StatementCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
var timestampStr = snapshotAt.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
|
||||
|
||||
// Serialize statements to OpenVEX format
|
||||
var document = new OpenVexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns",
|
||||
Id = $"urn:stellaops:vex:{sourceId}:{timestampStr}",
|
||||
Author = sourceId,
|
||||
Timestamp = snapshotAt,
|
||||
Version = 1,
|
||||
Statements = statements.OrderBy(s => s.VulnerabilityId, StringComparer.Ordinal).ToList()
|
||||
};
|
||||
|
||||
var contentBytes = JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions);
|
||||
var fileName = $"{sourceId}-{timestampStr}.json";
|
||||
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
StatementCount = statements.Count,
|
||||
Content = new VexContent
|
||||
{
|
||||
SourceId = sourceId,
|
||||
FileName = fileName,
|
||||
Content = contentBytes,
|
||||
SnapshotAt = snapshotAt,
|
||||
StatementCount = statements.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new VexSourceExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX snapshot extraction.
|
||||
/// </summary>
|
||||
public interface IVexSnapshotExtractor
|
||||
{
|
||||
Task<VexExtractionResult> ExtractAllAsync(
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VexSourceExtractionResult> ExtractSourceAsync(
|
||||
string sourceId,
|
||||
VexExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX data access.
|
||||
/// This should be implemented by Excititor to provide VEX data.
|
||||
/// </summary>
|
||||
public interface IVexDataSource
|
||||
{
|
||||
Task<IReadOnlyList<VexSourceInfo>> GetAvailableSourcesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<VexStatement>> GetStatementsAsync(
|
||||
string sourceId,
|
||||
DateTimeOffset? since = null,
|
||||
int? maxStatements = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Data Models
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available VEX source.
|
||||
/// </summary>
|
||||
public sealed record VexSourceInfo(string SourceId, string Name, string? Publisher);
|
||||
|
||||
/// <summary>
|
||||
/// A VEX statement following OpenVEX format.
|
||||
/// </summary>
|
||||
public sealed record VexStatement
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string? ImpactStatement { get; init; }
|
||||
public string? ActionStatement { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public IReadOnlyList<VexProduct>? Products { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A product reference in a VEX statement.
|
||||
/// </summary>
|
||||
public sealed record VexProduct
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public IReadOnlyList<string>? Hashes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX document format.
|
||||
/// </summary>
|
||||
public sealed record OpenVexDocument
|
||||
{
|
||||
public required string Context { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public required string Author { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required int Version { get; init; }
|
||||
public required IReadOnlyList<VexStatement> Statements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting VEX statements.
|
||||
/// </summary>
|
||||
public sealed record VexExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific source IDs to extract. Empty means all sources.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? SourceIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Only extract statements modified since this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum statements per source.
|
||||
/// </summary>
|
||||
public int? MaxStatements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting VEX statements from all sources.
|
||||
/// </summary>
|
||||
public sealed record VexExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<VexContent> VexStatements { get; init; } = [];
|
||||
public int TotalStatementCount { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single VEX source.
|
||||
/// </summary>
|
||||
public sealed record VexSourceExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int StatementCount { get; init; }
|
||||
public VexContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Extractors;
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available VEX source.
|
||||
/// </summary>
|
||||
public sealed record VexSourceInfo(string SourceId, string Name, string? Publisher);
|
||||
|
||||
/// <summary>
|
||||
/// A VEX statement following OpenVEX format.
|
||||
/// </summary>
|
||||
public sealed record VexStatement
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string? ImpactStatement { get; init; }
|
||||
public string? ActionStatement { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public IReadOnlyList<VexProduct>? Products { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A product reference in a VEX statement.
|
||||
/// </summary>
|
||||
public sealed record VexProduct
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public IReadOnlyList<string>? Hashes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX document format.
|
||||
/// </summary>
|
||||
public sealed record OpenVexDocument
|
||||
{
|
||||
public required string Context { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public required string Author { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required int Version { get; init; }
|
||||
public required IReadOnlyList<VexStatement> Statements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for extracting VEX statements.
|
||||
/// </summary>
|
||||
public sealed record VexExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific source IDs to extract. Empty means all sources.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? SourceIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Only extract statements modified since this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum statements per source.
|
||||
/// </summary>
|
||||
public int? MaxStatements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting VEX statements from all sources.
|
||||
/// </summary>
|
||||
public sealed record VexExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<VexContent> VexStatements { get; init; } = [];
|
||||
public int TotalStatementCount { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of extracting a single VEX source.
|
||||
/// </summary>
|
||||
public sealed record VexSourceExtractionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int StatementCount { get; init; }
|
||||
public VexContent? Content { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.FunctionMap;
|
||||
|
||||
public static partial class FunctionMapBundleIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a function map predicate file.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the function map JSON file on disk.</param>
|
||||
/// <param name="serviceName">Service name for the function map (used in bundle path).</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateFunctionMapConfig(string sourcePath, string serviceName)
|
||||
{
|
||||
var fileName = $"{SanitizeName(serviceName)}-function-map.json";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.FunctionMap,
|
||||
ContentType = MediaTypes.FunctionMap,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.FunctionMapsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a DSSE-signed function map.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the DSSE envelope JSON file on disk.</param>
|
||||
/// <param name="serviceName">Service name for the function map (used in bundle path).</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateFunctionMapDsseConfig(string sourcePath, string serviceName)
|
||||
{
|
||||
var fileName = $"{SanitizeName(serviceName)}-function-map.dsse.json";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.FunctionMapDsse,
|
||||
ContentType = MediaTypes.FunctionMapDsse,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.FunctionMapsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a runtime observations file.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the NDJSON observations file on disk.</param>
|
||||
/// <param name="dateLabel">Date label for the observations file (e.g., "2026-01-22").</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateObservationsConfig(string sourcePath, string dateLabel)
|
||||
{
|
||||
var fileName = $"observations-{SanitizeName(dateLabel)}.ndjson";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.Observations,
|
||||
ContentType = MediaTypes.Observations,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.ObservationsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a verification report.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the verification report JSON file on disk.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateVerificationReportConfig(string sourcePath)
|
||||
{
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.VerificationReport,
|
||||
ContentType = MediaTypes.VerificationReport,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.VerificationDir}/verification-report.json"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a DSSE-signed verification report.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the DSSE envelope JSON file on disk.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateVerificationReportDsseConfig(string sourcePath)
|
||||
{
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.VerificationReportDsse,
|
||||
ContentType = MediaTypes.FunctionMapDsse,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.VerificationDir}/verification-report.dsse.json"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.FunctionMap;
|
||||
|
||||
public static partial class FunctionMapBundleIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config from in-memory function map content.
|
||||
/// </summary>
|
||||
/// <param name="content">Function map predicate JSON bytes.</param>
|
||||
/// <param name="serviceName">Service name for the function map.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateFunctionMapFromContent(byte[] content, string serviceName)
|
||||
{
|
||||
var fileName = $"{SanitizeName(serviceName)}-function-map.json";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.FunctionMap,
|
||||
ContentType = MediaTypes.FunctionMap,
|
||||
Content = content,
|
||||
RelativePath = $"{BundlePaths.FunctionMapsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config from in-memory observations content.
|
||||
/// </summary>
|
||||
/// <param name="content">Observations NDJSON bytes.</param>
|
||||
/// <param name="dateLabel">Date label for the observations file.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateObservationsFromContent(byte[] content, string dateLabel)
|
||||
{
|
||||
var fileName = $"observations-{SanitizeName(dateLabel)}.ndjson";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.Observations,
|
||||
ContentType = MediaTypes.Observations,
|
||||
Content = content,
|
||||
RelativePath = $"{BundlePaths.ObservationsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.AirGap.Bundle.FunctionMap;
|
||||
|
||||
public static partial class FunctionMapBundleIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if the given artifact type string represents a function-map related artifact.
|
||||
/// </summary>
|
||||
public static bool IsFunctionMapArtifact(string? artifactType)
|
||||
{
|
||||
return artifactType is ArtifactTypes.FunctionMap
|
||||
or ArtifactTypes.FunctionMapDsse
|
||||
or ArtifactTypes.Observations
|
||||
or ArtifactTypes.VerificationReport
|
||||
or ArtifactTypes.VerificationReportDsse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given artifact type is a DSSE-signed artifact that should be verified.
|
||||
/// </summary>
|
||||
public static bool IsDsseArtifact(string? artifactType)
|
||||
{
|
||||
return artifactType is ArtifactTypes.FunctionMapDsse
|
||||
or ArtifactTypes.VerificationReportDsse;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,6 @@
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
|
||||
// Task: RLV-011 - Bundle Integration: function_map Artifact Type
|
||||
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.FunctionMap;
|
||||
|
||||
/// <summary>
|
||||
@@ -13,7 +9,7 @@ namespace StellaOps.AirGap.Bundle.FunctionMap;
|
||||
/// Provides standardized artifact type strings, media types, and factory methods
|
||||
/// for building function-map bundle configurations.
|
||||
/// </summary>
|
||||
public static class FunctionMapBundleIntegration
|
||||
public static partial class FunctionMapBundleIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact type strings for bundle manifest entries.
|
||||
@@ -69,149 +65,6 @@ public static class FunctionMapBundleIntegration
|
||||
public const string VerificationDir = "verification";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a function map predicate file.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the function map JSON file on disk.</param>
|
||||
/// <param name="serviceName">Service name for the function map (used in bundle path).</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateFunctionMapConfig(string sourcePath, string serviceName)
|
||||
{
|
||||
var fileName = $"{SanitizeName(serviceName)}-function-map.json";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.FunctionMap,
|
||||
ContentType = MediaTypes.FunctionMap,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.FunctionMapsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a DSSE-signed function map.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the DSSE envelope JSON file on disk.</param>
|
||||
/// <param name="serviceName">Service name for the function map (used in bundle path).</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateFunctionMapDsseConfig(string sourcePath, string serviceName)
|
||||
{
|
||||
var fileName = $"{SanitizeName(serviceName)}-function-map.dsse.json";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.FunctionMapDsse,
|
||||
ContentType = MediaTypes.FunctionMapDsse,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.FunctionMapsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a runtime observations file.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the NDJSON observations file on disk.</param>
|
||||
/// <param name="dateLabel">Date label for the observations file (e.g., "2026-01-22").</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateObservationsConfig(string sourcePath, string dateLabel)
|
||||
{
|
||||
var fileName = $"observations-{SanitizeName(dateLabel)}.ndjson";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.Observations,
|
||||
ContentType = MediaTypes.Observations,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.ObservationsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a verification report.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the verification report JSON file on disk.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateVerificationReportConfig(string sourcePath)
|
||||
{
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.VerificationReport,
|
||||
ContentType = MediaTypes.VerificationReport,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.VerificationDir}/verification-report.json"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config for a DSSE-signed verification report.
|
||||
/// </summary>
|
||||
/// <param name="sourcePath">Path to the DSSE envelope JSON file on disk.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateVerificationReportDsseConfig(string sourcePath)
|
||||
{
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.VerificationReportDsse,
|
||||
ContentType = MediaTypes.FunctionMapDsse,
|
||||
SourcePath = sourcePath,
|
||||
RelativePath = $"{BundlePaths.VerificationDir}/verification-report.dsse.json"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config from in-memory function map content.
|
||||
/// </summary>
|
||||
/// <param name="content">Function map predicate JSON bytes.</param>
|
||||
/// <param name="serviceName">Service name for the function map.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateFunctionMapFromContent(byte[] content, string serviceName)
|
||||
{
|
||||
var fileName = $"{SanitizeName(serviceName)}-function-map.json";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.FunctionMap,
|
||||
ContentType = MediaTypes.FunctionMap,
|
||||
Content = content,
|
||||
RelativePath = $"{BundlePaths.FunctionMapsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bundle artifact build config from in-memory observations content.
|
||||
/// </summary>
|
||||
/// <param name="content">Observations NDJSON bytes.</param>
|
||||
/// <param name="dateLabel">Date label for the observations file.</param>
|
||||
/// <returns>A configured <see cref="BundleArtifactBuildConfig"/>.</returns>
|
||||
public static BundleArtifactBuildConfig CreateObservationsFromContent(byte[] content, string dateLabel)
|
||||
{
|
||||
var fileName = $"observations-{SanitizeName(dateLabel)}.ndjson";
|
||||
return new BundleArtifactBuildConfig
|
||||
{
|
||||
Type = ArtifactTypes.Observations,
|
||||
ContentType = MediaTypes.Observations,
|
||||
Content = content,
|
||||
RelativePath = $"{BundlePaths.ObservationsDir}/{fileName}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given artifact type string represents a function-map related artifact.
|
||||
/// </summary>
|
||||
public static bool IsFunctionMapArtifact(string? artifactType)
|
||||
{
|
||||
return artifactType is ArtifactTypes.FunctionMap
|
||||
or ArtifactTypes.FunctionMapDsse
|
||||
or ArtifactTypes.Observations
|
||||
or ArtifactTypes.VerificationReport
|
||||
or ArtifactTypes.VerificationReportDsse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given artifact type is a DSSE-signed artifact that should be verified.
|
||||
/// </summary>
|
||||
public static bool IsDsseArtifact(string? artifactType)
|
||||
{
|
||||
return artifactType is ArtifactTypes.FunctionMapDsse
|
||||
or ArtifactTypes.VerificationReportDsse;
|
||||
}
|
||||
|
||||
private static string SanitizeName(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entry for an advisory feed in the snapshot.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySnapshotEntry
|
||||
{
|
||||
public required string FeedId { get; init; }
|
||||
public required string RelativePath { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
public DateTimeOffset SnapshotAt { get; init; }
|
||||
public int RecordCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.AirGap.Bundle.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Artifact entry in a bundle (v2.0.0).
|
||||
/// Sprint: SPRINT_20260118_018 (TASK-018-001)
|
||||
/// </summary>
|
||||
public sealed record BundleArtifact(
|
||||
/// <summary>Relative path within the bundle.</summary>
|
||||
string? Path,
|
||||
/// <summary>Artifact type: sbom, vex, dsse, rekor-proof, oci-referrers, etc.</summary>
|
||||
string Type,
|
||||
/// <summary>Content type (MIME).</summary>
|
||||
string? ContentType,
|
||||
/// <summary>SHA-256 digest of the artifact.</summary>
|
||||
string? Digest,
|
||||
/// <summary>Size in bytes.</summary>
|
||||
long? SizeBytes);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user