save progress
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
extern alias AirGapController;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public sealed class AirGapEndpointTests : IClassFixture<WebApplicationFactory<AirGapController::Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<AirGapController::Program> _factory;
|
||||
|
||||
public AirGapEndpointTests(WebApplicationFactory<AirGapController::Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Status_requires_scope_header()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/system/airgap/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Status_requires_tenant_header_or_claim()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:status:read");
|
||||
var response = await client.GetAsync("/system/airgap/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var payload = await ReadErrorAsync(response);
|
||||
Assert.Equal("tenant_required", payload);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Seal_validates_staleness_budget()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:seal", tenantId: "tenant-a");
|
||||
var response = await client.PostAsJsonAsync("/system/airgap/seal", new
|
||||
{
|
||||
policyHash = "policy-1",
|
||||
stalenessBudget = new { warningSeconds = 120, breachSeconds = 60 }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Verify_rejects_missing_hashes()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:verify", tenantId: "tenant-a");
|
||||
var response = await client.PostAsJsonAsync("/system/airgap/verify", new
|
||||
{
|
||||
manifestCreatedAt = DateTimeOffset.Parse("2025-12-01T00:00:00Z")
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Seal_and_status_round_trip()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:seal airgap:status:read", tenantId: "tenant-ops");
|
||||
var response = await client.PostAsJsonAsync("/system/airgap/seal", new
|
||||
{
|
||||
policyHash = "policy-ops",
|
||||
timeAnchor = new TimeAnchor(DateTimeOffset.Parse("2025-12-10T12:00:00Z"), "rough", "rough", "fp", "digest"),
|
||||
stalenessBudget = new { warningSeconds = 60, breachSeconds = 120 }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var statusResponse = await client.GetAsync("/system/airgap/status");
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(string scopes, string? tenantId = null)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("scope", scopes);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("x-tenant-id", tenantId);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<string?> ReadErrorAsync(HttpResponseMessage response)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
return doc.RootElement.TryGetProperty("error", out var error)
|
||||
? error.GetString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using Xunit;
|
||||
using OptionsFactory = Microsoft.Extensions.Options.Options;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
@@ -15,11 +16,13 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 20, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Blocks_when_allowlist_missing_for_sealed_state()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -30,8 +33,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(60, 120)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir);
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path);
|
||||
options.EgressAllowlist = null; // simulate missing config section
|
||||
|
||||
var service = CreateService(store, options, now);
|
||||
@@ -44,7 +47,7 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
[Fact]
|
||||
public async Task Passes_when_materials_present_and_anchor_fresh()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -55,8 +58,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(300, 600)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir, new[] { "127.0.0.1/32" });
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path, new[] { "127.0.0.1/32" });
|
||||
|
||||
var service = CreateService(store, options, now);
|
||||
|
||||
@@ -67,7 +70,7 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
[Fact]
|
||||
public async Task Blocks_when_anchor_is_stale()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -78,8 +81,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(60, 90)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir, new[] { "10.0.0.0/24" });
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path, new[] { "10.0.0.0/24" });
|
||||
|
||||
var service = CreateService(store, options, now);
|
||||
|
||||
@@ -91,7 +94,7 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
[Fact]
|
||||
public async Task Blocks_when_rotation_pending_without_dual_approval()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -102,8 +105,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(120, 240)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir, new[] { "10.10.0.0/16" });
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path, new[] { "10.10.0.0/16" });
|
||||
options.Rotation.PendingKeys["k-new"] = Convert.ToBase64String(new byte[] { 1, 2, 3 });
|
||||
options.Rotation.ActiveKeys["k-old"] = Convert.ToBase64String(new byte[] { 9, 9, 9 });
|
||||
options.Rotation.ApproverIds.Add("approver-1");
|
||||
@@ -135,22 +138,22 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
store,
|
||||
new StalenessCalculator(),
|
||||
new FixedTimeProvider(now),
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
OptionsFactory.Create(options),
|
||||
NullLogger<AirGapStartupDiagnosticsHostedService>.Instance,
|
||||
new AirGapTelemetry(NullLogger<AirGapTelemetry>.Instance),
|
||||
new AirGapTelemetry(OptionsFactory.Create(new AirGapTelemetryOptions()), NullLogger<AirGapTelemetry>.Instance),
|
||||
new TufMetadataValidator(),
|
||||
new RootRotationPolicy());
|
||||
}
|
||||
|
||||
private static string CreateTrustMaterial()
|
||||
private static TempDirectory CreateTrustMaterial(DateTimeOffset now)
|
||||
{
|
||||
var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "airgap-trust-" + Guid.NewGuid().ToString("N"))).FullName;
|
||||
var expires = DateTimeOffset.UtcNow.AddDays(1).ToString("O");
|
||||
var dir = new TempDirectory("airgap-trust");
|
||||
var expires = now.AddDays(1).ToString("O");
|
||||
const string hash = "abc123";
|
||||
|
||||
File.WriteAllText(Path.Combine(dir, "root.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\"}}");
|
||||
File.WriteAllText(Path.Combine(dir, "snapshot.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"meta\":{{\"snapshot\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
File.WriteAllText(Path.Combine(dir, "timestamp.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"snapshot\":{{\"meta\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
File.WriteAllText(Path.Combine(dir.Path, "root.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\"}}");
|
||||
File.WriteAllText(Path.Combine(dir.Path, "snapshot.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"meta\":{{\"snapshot\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
File.WriteAllText(Path.Combine(dir.Path, "timestamp.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"snapshot\":{{\"meta\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStateServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 10, 9, 0, 0, TimeSpan.Zero);
|
||||
private readonly AirGapStateService _service;
|
||||
private readonly InMemoryAirGapStateStore _store = new();
|
||||
private readonly StalenessCalculator _calculator = new();
|
||||
@@ -23,7 +24,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_sets_state_and_computes_staleness()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-2), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(60, 120);
|
||||
|
||||
@@ -42,7 +43,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Unseal_clears_sealed_flag_and_updates_timestamp()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
await _service.SealAsync("default", "hash", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var later = now.AddMinutes(1);
|
||||
@@ -57,7 +58,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_persists_drift_baseline_seconds()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-5), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
|
||||
@@ -70,7 +71,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_creates_default_content_budgets_when_not_provided()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(120, 240);
|
||||
|
||||
@@ -86,7 +87,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_uses_provided_content_budgets()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
@@ -106,7 +107,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task GetStatus_returns_per_content_staleness()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddSeconds(-45), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
@@ -125,4 +126,20 @@ public class AirGapStateServiceTests
|
||||
Assert.False(status.ContentStaleness["vex"].IsWarning); // 45s < 60s warning
|
||||
Assert.False(status.ContentStaleness["policy"].IsWarning); // 45s < 100s warning
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_rejects_invalid_content_budgets()
|
||||
{
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
{
|
||||
{ "advisories", new StalenessBudget(120, 60) }
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
_service.SealAsync("tenant-invalid", "policy", anchor, budget, now, contentBudgets));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Controller.Options;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using Xunit;
|
||||
using OptionsFactory = Microsoft.Extensions.Options.Options;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public sealed class AirGapTelemetryTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 12, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evicts_oldest_tenants_when_over_limit()
|
||||
{
|
||||
var options = OptionsFactory.Create(new AirGapTelemetryOptions { MaxTenantEntries = 2 });
|
||||
var telemetry = new AirGapTelemetry(options, NullLogger<AirGapTelemetry>.Instance);
|
||||
|
||||
telemetry.RecordStatus("tenant-1", BuildStatus("tenant-1"));
|
||||
telemetry.RecordStatus("tenant-2", BuildStatus("tenant-2"));
|
||||
telemetry.RecordStatus("tenant-3", BuildStatus("tenant-3"));
|
||||
|
||||
Assert.Equal(2, telemetry.TenantCacheCount);
|
||||
}
|
||||
|
||||
private static AirGapStatus BuildStatus(string tenantId)
|
||||
{
|
||||
var state = new AirGapState
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Sealed = true,
|
||||
PolicyHash = "policy",
|
||||
TimeAnchor = TimeAnchor.Unknown,
|
||||
StalenessBudget = StalenessBudget.Default,
|
||||
LastTransitionAt = FixedNow
|
||||
};
|
||||
|
||||
var empty = new Dictionary<string, StalenessEvaluation>(StringComparer.OrdinalIgnoreCase);
|
||||
return new AirGapStatus(state, StalenessEvaluation.Unknown, empty, FixedNow);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class InMemoryAirGapStateStoreTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 5, 13, 0, 0, TimeSpan.Zero);
|
||||
private readonly InMemoryAirGapStateStore _store = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -20,9 +21,9 @@ public class InMemoryAirGapStateStoreTests
|
||||
TenantId = "tenant-x",
|
||||
Sealed = true,
|
||||
PolicyHash = "hash-1",
|
||||
TimeAnchor = new TimeAnchor(DateTimeOffset.UtcNow, "roughtime", "roughtime", "fp", "digest"),
|
||||
TimeAnchor = new TimeAnchor(FixedNow, "roughtime", "roughtime", "fp", "digest"),
|
||||
StalenessBudget = new StalenessBudget(10, 20),
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
LastTransitionAt = FixedNow
|
||||
};
|
||||
|
||||
await _store.SetAsync(state);
|
||||
@@ -106,7 +107,7 @@ public class InMemoryAirGapStateStoreTests
|
||||
[Fact]
|
||||
public async Task Staleness_round_trip_matches_budget()
|
||||
{
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UtcNow.AddMinutes(-3), "roughtime", "roughtime", "fp", "digest");
|
||||
var anchor = new TimeAnchor(FixedNow.AddMinutes(-3), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(60, 600);
|
||||
await _store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -115,7 +116,7 @@ public class InMemoryAirGapStateStoreTests
|
||||
PolicyHash = "hash-s",
|
||||
TimeAnchor = anchor,
|
||||
StalenessBudget = budget,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
LastTransitionAt = FixedNow
|
||||
});
|
||||
|
||||
var stored = await _store.GetAsync("tenant-staleness");
|
||||
@@ -129,7 +130,7 @@ public class InMemoryAirGapStateStoreTests
|
||||
public async Task Multi_tenant_states_preserve_transition_times()
|
||||
{
|
||||
var tenants = new[] { "a", "b", "c" };
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
|
||||
foreach (var t in tenants)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class ReplayVerificationServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 2, 1, 0, 0, TimeSpan.Zero);
|
||||
private readonly ReplayVerificationService _service;
|
||||
private readonly AirGapStateService _stateService;
|
||||
private readonly StalenessCalculator _staleness = new();
|
||||
@@ -28,7 +29,7 @@ public class ReplayVerificationServiceTests
|
||||
[Fact]
|
||||
public async Task Passes_full_recompute_when_hashes_match()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-12-02T01:00:00Z");
|
||||
var now = FixedNow;
|
||||
await _stateService.SealAsync("tenant-a", "policy-x", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var request = new VerifyRequest
|
||||
@@ -53,7 +54,7 @@ public class ReplayVerificationServiceTests
|
||||
[Fact]
|
||||
public async Task Detects_stale_manifest()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var request = new VerifyRequest
|
||||
{
|
||||
Depth = ReplayDepth.HashOnly,
|
||||
@@ -75,7 +76,7 @@ public class ReplayVerificationServiceTests
|
||||
[Fact]
|
||||
public async Task Policy_freeze_requires_matching_policy()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
await _stateService.SealAsync("tenant-b", "sealed-policy", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var request = new VerifyRequest
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" Aliases="global,AirGapController" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<Compile Include="../../shared/*.cs" Link="Shared/%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
private static int _counter;
|
||||
|
||||
public TempDirectory(string? prefix = null)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _counter);
|
||||
var name = $"{prefix ?? "airgap-test"}-{id:D4}";
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), name);
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, true);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Importer.Reconciliation;
|
||||
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
|
||||
|
||||
public sealed class EvidenceReconcilerVexTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_MergesVexStatements_BySourcePrecedence()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
|
||||
var input = Path.Combine(root, "input");
|
||||
var output = Path.Combine(root, "output");
|
||||
Directory.CreateDirectory(Path.Combine(input, "attestations"));
|
||||
Directory.CreateDirectory(Path.Combine(input, "sboms"));
|
||||
|
||||
var digest = "sha256:" + new string('a', 64);
|
||||
|
||||
try
|
||||
{
|
||||
var vendorVex = BuildOpenVexDocument("VendorA", "CVE-2023-99997", "not_affected");
|
||||
var researcherVex = BuildOpenVexDocument("Researcher", "CVE-2023-99997", "affected");
|
||||
|
||||
var vendorEnvelope = BuildDsseEnvelope(vendorVex, digest);
|
||||
var researcherEnvelope = BuildDsseEnvelope(researcherVex, digest);
|
||||
|
||||
var attestations = Path.Combine(input, "attestations");
|
||||
await File.WriteAllTextAsync(Path.Combine(attestations, "vendor.dsse.json"), vendorEnvelope);
|
||||
await File.WriteAllTextAsync(Path.Combine(attestations, "researcher.dsse.json"), researcherEnvelope);
|
||||
|
||||
var reconciler = new EvidenceReconciler();
|
||||
var options = new ReconciliationOptions
|
||||
{
|
||||
VerifySignatures = false,
|
||||
Lattice = new LatticeConfiguration
|
||||
{
|
||||
SourceMappings = new Dictionary<string, SourcePrecedence>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["VendorA"] = SourcePrecedence.Vendor,
|
||||
["Researcher"] = SourcePrecedence.ThirdParty
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var graph = await reconciler.ReconcileAsync(input, output, options);
|
||||
|
||||
graph.Metadata.VexStatementCount.Should().Be(1);
|
||||
graph.Metadata.ConflictCount.Should().Be(0);
|
||||
|
||||
var node = graph.Nodes.Single(n => n.Digest == digest);
|
||||
node.VexStatements.Should().NotBeNull();
|
||||
node.VexStatements!.Should().HaveCount(1);
|
||||
node.VexStatements[0].VulnerabilityId.Should().Be("CVE-2023-99997");
|
||||
node.VexStatements[0].Status.Should().Be(VexStatus.NotAffected.ToString());
|
||||
node.VexStatements[0].Source.Should().Be(SourcePrecedence.Vendor.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildOpenVexDocument(string author, string vulnerabilityId, string status)
|
||||
{
|
||||
var statement = new Dictionary<string, object?>
|
||||
{
|
||||
["vulnerability"] = new Dictionary<string, object?>
|
||||
{
|
||||
["@id"] = vulnerabilityId,
|
||||
["name"] = vulnerabilityId
|
||||
},
|
||||
["products"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["@id"] = "pkg:nuget/Example@1.0.0"
|
||||
}
|
||||
},
|
||||
["status"] = status
|
||||
};
|
||||
|
||||
var document = new Dictionary<string, object?>
|
||||
{
|
||||
["@context"] = "https://openvex.dev/ns/v0.2.0",
|
||||
["@id"] = $"urn:stellaops:vex:{author}:{vulnerabilityId}",
|
||||
["author"] = author,
|
||||
["timestamp"] = "2025-01-15T00:00:00Z",
|
||||
["version"] = 1,
|
||||
["statements"] = new[] { statement }
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(document);
|
||||
}
|
||||
|
||||
private static string BuildDsseEnvelope(string predicateJson, string subjectDigest)
|
||||
{
|
||||
using var predicateDoc = JsonDocument.Parse(predicateJson);
|
||||
var predicateElement = predicateDoc.RootElement.Clone();
|
||||
var digest = subjectDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? subjectDigest["sha256:".Length..]
|
||||
: subjectDigest;
|
||||
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
predicateType = PredicateTypes.OpenVex,
|
||||
subject = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "artifact",
|
||||
digest = new Dictionary<string, string> { ["sha256"] = digest }
|
||||
}
|
||||
},
|
||||
predicate = predicateElement
|
||||
};
|
||||
|
||||
var statementJson = JsonSerializer.Serialize(statement);
|
||||
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
||||
var signature = Convert.ToBase64String(Encoding.UTF8.GetBytes("sig"));
|
||||
|
||||
var envelope = new
|
||||
{
|
||||
payloadType = "application/vnd.in-toto+json",
|
||||
payload,
|
||||
signatures = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyid = "test",
|
||||
sig = signature
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(envelope);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user