consolidate the tests locations

This commit is contained in:
StellaOps Bot
2025-12-26 01:48:24 +02:00
parent 17613acf57
commit 39359da171
2031 changed files with 2607 additions and 476 deletions

184
src/__Tests/AGENTS.md Normal file
View File

@@ -0,0 +1,184 @@
# src/__Tests/AGENTS.md
## Purpose & Scope
This directory contains all global test infrastructure, benchmarks, datasets, and shared testing libraries for the StellaOps platform.
- **Working directory:** `src/__Tests/`
- **Roles:** QA engineer, performance/bench engineer, integration test developer, docs contributor
## Directory Structure
```
src/__Tests/
├── __Libraries/ # Shared testing libraries
│ ├── StellaOps.Infrastructure.Postgres.Testing/
│ ├── StellaOps.Messaging.Testing/
│ ├── StellaOps.Testing.AirGap/
│ ├── StellaOps.Testing.Determinism/
│ ├── StellaOps.Testing.Manifests/
│ ├── StellaOps.Concelier.Testing/
│ └── StellaOps.Router.Testing/
├── __Benchmarks/ # Golden corpus, CVE findings, determinism fixtures
│ ├── golden-corpus/ # Canonical test cases (severity, VEX, reachability)
│ ├── findings/ # CVE bundles with reachability evidence
│ ├── reachability-benchmark/ # Public multi-language benchmark
│ ├── determinism/ # Determinism test fixtures
│ └── tools/ # Verification utilities
├── __Datasets/ # Ground truth samples, schemas
│ └── reachability/ # Reachability ground truth
├── Integration/ # Cross-module integration tests
├── acceptance/ # Acceptance test packs
├── load/ # k6 load tests
├── security/ # OWASP security tests
├── chaos/ # Chaos engineering tests
├── AirGap/ # Offline operation tests
├── reachability/ # Reachability analysis tests
├── fixtures/ # Shared test fixtures (offline-bundle, images, sboms)
└── ... # Other test categories
```
## Required Reading
Before working in this directory:
- `docs/README.md`
- `docs/19_TEST_SUITE_OVERVIEW.md`
- `src/__Tests/__Benchmarks/README.md`
- Sprint-specific guidance for corpus/bench artifacts
## Test Categories
When writing tests, use appropriate xUnit traits:
```csharp
[Trait("Category", "Unit")] // Fast, isolated unit tests
[Trait("Category", "Integration")] // Tests requiring infrastructure
[Trait("Category", "E2E")] // Full end-to-end workflows
[Trait("Category", "AirGap")] // Must work without network
[Trait("Category", "Interop")] // Third-party tool compatibility
[Trait("Category", "Performance")] // Performance benchmarks
[Trait("Category", "Chaos")] // Failure injection tests
[Trait("Category", "Security")] // Security-focused tests
```
## Key Patterns
### 1. PostgreSQL Integration Tests
Use the shared fixture from `__Libraries/StellaOps.Infrastructure.Postgres.Testing`:
```csharp
public class MyIntegrationTests : IClassFixture<MyPostgresFixture>
{
private readonly MyPostgresFixture _fixture;
public MyIntegrationTests(MyPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task MyTest()
{
// _fixture.ConnectionString is available
// _fixture.TruncateAllTablesAsync() for cleanup
}
}
```
### 2. Air-Gap Tests
Inherit from `NetworkIsolatedTestBase` for network-free tests:
```csharp
[Trait("Category", "AirGap")]
public class OfflineTests : NetworkIsolatedTestBase
{
[Fact]
public async Task Test_WorksOffline()
{
AssertNoNetworkCalls(); // Fails if network accessed
}
protected string GetOfflineBundlePath() =>
Path.Combine(AppContext.BaseDirectory, "fixtures", "offline-bundle");
}
```
### 3. Determinism Tests
Use `DeterminismVerifier` to ensure reproducibility:
```csharp
[Fact]
public void Output_IsDeterministic()
{
var verifier = new DeterminismVerifier();
var result = verifier.Verify(myObject, iterations: 10);
result.IsDeterministic.Should().BeTrue();
}
```
### 4. Golden Corpus Tests
Reference cases from `__Benchmarks/golden-corpus/`:
```csharp
[Theory]
[MemberData(nameof(GetCorpusCases))]
public async Task Corpus_Case_Passes(string caseId)
{
var testCase = CorpusLoader.Load(caseId);
var result = await ProcessAsync(testCase.Input);
result.Should().BeEquivalentTo(testCase.Expected);
}
```
## Working Agreements
1. **Determinism:** Stable ordering, fixed seeds, UTC timestamps
2. **Offline-first:** No network dependencies unless explicitly required
3. **Testcontainers:** Use PostgreSQL fixtures from `__Libraries/`
4. **Air-gap validation:** Inherit from `NetworkIsolatedTestBase`
5. **Golden corpus:** Reference cases from `__Benchmarks/golden-corpus/`
6. **Fixtures:** Keep ASCII and reproducible; avoid oversized binaries
## Module Tests vs Global Tests
- **Module tests:** Stay in `src/<Module>/__Tests/` - component-specific testing
- **Global tests:** Go in `src/__Tests/` - cross-cutting, infrastructure, benchmarks, integration
## Rules for Test Development
### DO:
1. Tag tests with appropriate categories for filtering
2. Use Testcontainers for infrastructure dependencies
3. Inherit from shared fixtures to avoid duplication
4. Assert no network calls in air-gap tests
5. Verify determinism for any serialization output
6. Use property-based tests (FsCheck) for invariants
7. Document test purpose in method names
### DON'T:
1. Don't skip tests without documenting why
2. Don't use `Thread.Sleep` - use proper async waits
3. Don't hardcode paths - use `AppContext.BaseDirectory`
4. Don't make network calls in non-interop tests
5. Don't depend on test execution order
6. Don't leave test data in shared databases
## Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `STELLAOPS_OFFLINE_MODE` | Enable offline mode | `false` |
| `STELLAOPS_OFFLINE_BUNDLE` | Path to offline bundle | - |
| `STELLAOPS_TEST_POSTGRES` | PostgreSQL connection | Testcontainers |
| `STELLAOPS_TEST_VALKEY` | Valkey connection | Testcontainers |
## Related Documentation
- `docs/19_TEST_SUITE_OVERVIEW.md` - Comprehensive test taxonomy
- `docs/testing/webservice-test-discipline.md` - WebService test patterns
- `docs/testing/SPRINT_EXECUTION_PLAYBOOK.md` - Sprint execution guide
- `docs/dev/fixtures.md` - Fixture maintenance patterns

View File

@@ -0,0 +1,6 @@
# AirGap Tests
## Notes
- Tests now run entirely against in-memory stores (no MongoDB or external services required).
- Keep fixtures deterministic: stable ordering, UTC timestamps, fixed seeds where applicable.
- Sealed-mode and staleness tests rely on local fixture bundles only; no network access is needed.

View File

@@ -0,0 +1,163 @@
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.Controller.Stores;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using Xunit;
namespace StellaOps.AirGap.Controller.Tests;
public class AirGapStartupDiagnosticsHostedServiceTests
{
[Fact]
public async Task Blocks_when_allowlist_missing_for_sealed_state()
{
var now = DateTimeOffset.UtcNow;
var store = new InMemoryAirGapStateStore();
await store.SetAsync(new AirGapState
{
TenantId = "default",
Sealed = true,
PolicyHash = "policy-x",
TimeAnchor = new TimeAnchor(now, "rough", "rough", "fp", "digest"),
StalenessBudget = new StalenessBudget(60, 120)
});
var trustDir = CreateTrustMaterial();
var options = BuildOptions(trustDir);
options.EgressAllowlist = null; // simulate missing config section
var service = CreateService(store, options, now);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.StartAsync(CancellationToken.None));
Assert.Contains("egress-allowlist-missing", ex.Message);
}
[Fact]
public async Task Passes_when_materials_present_and_anchor_fresh()
{
var now = DateTimeOffset.UtcNow;
var store = new InMemoryAirGapStateStore();
await store.SetAsync(new AirGapState
{
TenantId = "default",
Sealed = true,
PolicyHash = "policy-ok",
TimeAnchor = new TimeAnchor(now.AddMinutes(-1), "rough", "rough", "fp", "digest"),
StalenessBudget = new StalenessBudget(300, 600)
});
var trustDir = CreateTrustMaterial();
var options = BuildOptions(trustDir, new[] { "127.0.0.1/32" });
var service = CreateService(store, options, now);
await service.StartAsync(CancellationToken.None); // should not throw
}
[Fact]
public async Task Blocks_when_anchor_is_stale()
{
var now = DateTimeOffset.UtcNow;
var store = new InMemoryAirGapStateStore();
await store.SetAsync(new AirGapState
{
TenantId = "default",
Sealed = true,
PolicyHash = "policy-stale",
TimeAnchor = new TimeAnchor(now.AddHours(-2), "rough", "rough", "fp", "digest"),
StalenessBudget = new StalenessBudget(60, 90)
});
var trustDir = CreateTrustMaterial();
var options = BuildOptions(trustDir, new[] { "10.0.0.0/24" });
var service = CreateService(store, options, now);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.StartAsync(CancellationToken.None));
Assert.Contains("time-anchor-stale", ex.Message);
}
[Fact]
public async Task Blocks_when_rotation_pending_without_dual_approval()
{
var now = DateTimeOffset.UtcNow;
var store = new InMemoryAirGapStateStore();
await store.SetAsync(new AirGapState
{
TenantId = "default",
Sealed = true,
PolicyHash = "policy-rot",
TimeAnchor = new TimeAnchor(now, "rough", "rough", "fp", "digest"),
StalenessBudget = new StalenessBudget(120, 240)
});
var trustDir = CreateTrustMaterial();
var options = BuildOptions(trustDir, 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");
var service = CreateService(store, options, now);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.StartAsync(CancellationToken.None));
Assert.Contains("rotation:rotation-dual-approval-required", ex.Message);
}
private static AirGapStartupOptions BuildOptions(string trustDir, string[]? allowlist = null)
{
return new AirGapStartupOptions
{
TenantId = "default",
EgressAllowlist = allowlist,
Trust = new TrustMaterialOptions
{
RootJsonPath = Path.Combine(trustDir, "root.json"),
SnapshotJsonPath = Path.Combine(trustDir, "snapshot.json"),
TimestampJsonPath = Path.Combine(trustDir, "timestamp.json")
}
};
}
private static AirGapStartupDiagnosticsHostedService CreateService(IAirGapStateStore store, AirGapStartupOptions options, DateTimeOffset now)
{
return new AirGapStartupDiagnosticsHostedService(
store,
new StalenessCalculator(),
new FixedTimeProvider(now),
Microsoft.Extensions.Options.Options.Create(options),
NullLogger<AirGapStartupDiagnosticsHostedService>.Instance,
new AirGapTelemetry(NullLogger<AirGapTelemetry>.Instance),
new TufMetadataValidator(),
new RootRotationPolicy());
}
private static string CreateTrustMaterial()
{
var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "airgap-trust-" + Guid.NewGuid().ToString("N"))).FullName;
var expires = DateTimeOffset.UtcNow.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}\"}}}}}}}}");
return dir;
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,120 @@
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using Xunit;
namespace StellaOps.AirGap.Controller.Tests;
public class AirGapStateServiceTests
{
private readonly AirGapStateService _service;
private readonly InMemoryAirGapStateStore _store = new();
private readonly StalenessCalculator _calculator = new();
public AirGapStateServiceTests()
{
_service = new AirGapStateService(_store, _calculator);
}
[Fact]
public async Task Seal_sets_state_and_computes_staleness()
{
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now.AddMinutes(-2), "roughtime", "roughtime", "fp", "digest");
var budget = new StalenessBudget(60, 120);
await _service.SealAsync("tenant-a", "policy-1", anchor, budget, now);
var status = await _service.GetStatusAsync("tenant-a", now);
Assert.True(status.State.Sealed);
Assert.Equal("policy-1", status.State.PolicyHash);
Assert.Equal("tenant-a", status.State.TenantId);
Assert.True(status.Staleness.AgeSeconds > 0);
Assert.True(status.Staleness.IsWarning);
Assert.Equal(120 - status.Staleness.AgeSeconds, status.Staleness.SecondsRemaining);
}
[Fact]
public async Task Unseal_clears_sealed_flag_and_updates_timestamp()
{
var now = DateTimeOffset.UtcNow;
await _service.SealAsync("default", "hash", TimeAnchor.Unknown, StalenessBudget.Default, now);
var later = now.AddMinutes(1);
await _service.UnsealAsync("default", later);
var status = await _service.GetStatusAsync("default", later);
Assert.False(status.State.Sealed);
Assert.Equal(later, status.State.LastTransitionAt);
}
[Fact]
public async Task Seal_persists_drift_baseline_seconds()
{
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now.AddMinutes(-5), "roughtime", "roughtime", "fp", "digest");
var budget = StalenessBudget.Default;
var state = await _service.SealAsync("tenant-drift", "policy-drift", anchor, budget, now);
Assert.Equal(300, state.DriftBaselineSeconds); // 5 minutes = 300 seconds
}
[Fact]
public async Task Seal_creates_default_content_budgets_when_not_provided()
{
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
var budget = new StalenessBudget(120, 240);
var state = await _service.SealAsync("tenant-content", "policy-content", anchor, budget, now);
Assert.Contains("advisories", state.ContentBudgets.Keys);
Assert.Contains("vex", state.ContentBudgets.Keys);
Assert.Contains("policy", state.ContentBudgets.Keys);
Assert.Equal(budget, state.ContentBudgets["advisories"]);
}
[Fact]
public async Task Seal_uses_provided_content_budgets()
{
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
var budget = StalenessBudget.Default;
var contentBudgets = new Dictionary<string, StalenessBudget>
{
{ "advisories", new StalenessBudget(30, 60) },
{ "vex", new StalenessBudget(60, 120) }
};
var state = await _service.SealAsync("tenant-custom", "policy-custom", anchor, budget, now, contentBudgets);
Assert.Equal(new StalenessBudget(30, 60), state.ContentBudgets["advisories"]);
Assert.Equal(new StalenessBudget(60, 120), state.ContentBudgets["vex"]);
Assert.Equal(budget, state.ContentBudgets["policy"]); // Falls back to default
}
[Fact]
public async Task GetStatus_returns_per_content_staleness()
{
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now.AddSeconds(-45), "roughtime", "roughtime", "fp", "digest");
var budget = StalenessBudget.Default;
var contentBudgets = new Dictionary<string, StalenessBudget>
{
{ "advisories", new StalenessBudget(30, 60) },
{ "vex", new StalenessBudget(60, 120) },
{ "policy", new StalenessBudget(100, 200) }
};
await _service.SealAsync("tenant-content-status", "policy-content-status", anchor, budget, now, contentBudgets);
var status = await _service.GetStatusAsync("tenant-content-status", now);
Assert.NotEmpty(status.ContentStaleness);
Assert.True(status.ContentStaleness["advisories"].IsWarning); // 45s >= 30s warning
Assert.False(status.ContentStaleness["advisories"].IsBreach); // 45s < 60s breach
Assert.False(status.ContentStaleness["vex"].IsWarning); // 45s < 60s warning
Assert.False(status.ContentStaleness["policy"].IsWarning); // 45s < 100s warning
}
}

View File

@@ -0,0 +1,143 @@
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Time.Models;
using Xunit;
namespace StellaOps.AirGap.Controller.Tests;
public class InMemoryAirGapStateStoreTests
{
private readonly InMemoryAirGapStateStore _store = new();
[Fact]
public async Task Upsert_and_read_state_by_tenant()
{
var state = new AirGapState
{
TenantId = "tenant-x",
Sealed = true,
PolicyHash = "hash-1",
TimeAnchor = new TimeAnchor(DateTimeOffset.UtcNow, "roughtime", "roughtime", "fp", "digest"),
StalenessBudget = new StalenessBudget(10, 20),
LastTransitionAt = DateTimeOffset.UtcNow
};
await _store.SetAsync(state);
var stored = await _store.GetAsync("tenant-x");
Assert.True(stored.Sealed);
Assert.Equal("hash-1", stored.PolicyHash);
Assert.Equal("tenant-x", stored.TenantId);
Assert.Equal(state.TimeAnchor.TokenDigest, stored.TimeAnchor.TokenDigest);
Assert.Equal(10, stored.StalenessBudget.WarningSeconds);
}
[Fact]
public async Task Enforces_singleton_per_tenant()
{
var first = new AirGapState { TenantId = "tenant-y", Sealed = true, PolicyHash = "h1" };
var second = new AirGapState { TenantId = "tenant-y", Sealed = false, PolicyHash = "h2" };
await _store.SetAsync(first);
await _store.SetAsync(second);
var stored = await _store.GetAsync("tenant-y");
Assert.Equal("h2", stored.PolicyHash);
Assert.False(stored.Sealed);
}
[Fact]
public async Task Defaults_to_unknown_when_missing()
{
var stored = await _store.GetAsync("absent");
Assert.False(stored.Sealed);
Assert.Equal("absent", stored.TenantId);
}
[Fact]
public async Task Parallel_upserts_keep_single_document()
{
var tasks = Enumerable.Range(0, 20).Select(i =>
{
var state = new AirGapState
{
TenantId = "tenant-parallel",
Sealed = i % 2 == 0,
PolicyHash = $"hash-{i}"
};
return _store.SetAsync(state);
});
await Task.WhenAll(tasks);
var stored = await _store.GetAsync("tenant-parallel");
Assert.StartsWith("hash-", stored.PolicyHash);
}
[Fact]
public async Task Multi_tenant_updates_do_not_collide()
{
var tenants = Enumerable.Range(0, 5).Select(i => $"t-{i}").ToArray();
var tasks = tenants.Select(t => _store.SetAsync(new AirGapState
{
TenantId = t,
Sealed = true,
PolicyHash = $"hash-{t}"
}));
await Task.WhenAll(tasks);
foreach (var t in tenants)
{
var stored = await _store.GetAsync(t);
Assert.Equal($"hash-{t}", stored.PolicyHash);
}
}
[Fact]
public async Task Staleness_round_trip_matches_budget()
{
var anchor = new TimeAnchor(DateTimeOffset.UtcNow.AddMinutes(-3), "roughtime", "roughtime", "fp", "digest");
var budget = new StalenessBudget(60, 600);
await _store.SetAsync(new AirGapState
{
TenantId = "tenant-staleness",
Sealed = true,
PolicyHash = "hash-s",
TimeAnchor = anchor,
StalenessBudget = budget,
LastTransitionAt = DateTimeOffset.UtcNow
});
var stored = await _store.GetAsync("tenant-staleness");
Assert.Equal(anchor.TokenDigest, stored.TimeAnchor.TokenDigest);
Assert.Equal(budget.WarningSeconds, stored.StalenessBudget.WarningSeconds);
Assert.Equal(budget.BreachSeconds, stored.StalenessBudget.BreachSeconds);
}
[Fact]
public async Task Multi_tenant_states_preserve_transition_times()
{
var tenants = new[] { "a", "b", "c" };
var now = DateTimeOffset.UtcNow;
foreach (var t in tenants)
{
await _store.SetAsync(new AirGapState
{
TenantId = t,
Sealed = true,
PolicyHash = $"ph-{t}",
LastTransitionAt = now
});
}
foreach (var t in tenants)
{
var state = await _store.GetAsync(t);
Assert.Equal(now, state.LastTransitionAt);
Assert.Equal($"ph-{t}", state.PolicyHash);
}
}
}

View File

@@ -0,0 +1,93 @@
using StellaOps.AirGap.Controller.Endpoints.Contracts;
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using Xunit;
namespace StellaOps.AirGap.Controller.Tests;
public class ReplayVerificationServiceTests
{
private readonly ReplayVerificationService _service;
private readonly AirGapStateService _stateService;
private readonly StalenessCalculator _staleness = new();
private readonly InMemoryAirGapStateStore _store = new();
public ReplayVerificationServiceTests()
{
_stateService = new AirGapStateService(_store, _staleness);
_service = new ReplayVerificationService(_stateService, new ReplayVerifier());
}
[Fact]
public async Task Passes_full_recompute_when_hashes_match()
{
var now = DateTimeOffset.Parse("2025-12-02T01:00:00Z");
await _stateService.SealAsync("tenant-a", "policy-x", TimeAnchor.Unknown, StalenessBudget.Default, now);
var request = new VerifyRequest
{
Depth = ReplayDepth.FullRecompute,
ManifestSha256 = new string('a', 64),
BundleSha256 = new string('b', 64),
ComputedManifestSha256 = new string('a', 64),
ComputedBundleSha256 = new string('b', 64),
ManifestCreatedAt = now.AddHours(-2),
StalenessWindowHours = 24,
BundlePolicyHash = "policy-x"
};
var result = await _service.VerifyAsync("tenant-a", request, now);
Assert.True(result.IsValid);
Assert.Equal("full-recompute-passed", result.Reason);
}
[Fact]
public async Task Detects_stale_manifest()
{
var now = DateTimeOffset.UtcNow;
var request = new VerifyRequest
{
Depth = ReplayDepth.HashOnly,
ManifestSha256 = new string('a', 64),
BundleSha256 = new string('b', 64),
ComputedManifestSha256 = new string('a', 64),
ComputedBundleSha256 = new string('b', 64),
ManifestCreatedAt = now.AddHours(-30),
StalenessWindowHours = 12
};
var result = await _service.VerifyAsync("default", request, now);
Assert.False(result.IsValid);
Assert.Equal("manifest-stale", result.Reason);
}
[Fact]
public async Task Policy_freeze_requires_matching_policy()
{
var now = DateTimeOffset.UtcNow;
await _stateService.SealAsync("tenant-b", "sealed-policy", TimeAnchor.Unknown, StalenessBudget.Default, now);
var request = new VerifyRequest
{
Depth = ReplayDepth.PolicyFreeze,
ManifestSha256 = new string('a', 64),
BundleSha256 = new string('b', 64),
ComputedManifestSha256 = new string('a', 64),
ComputedBundleSha256 = new string('b', 64),
ManifestCreatedAt = now,
StalenessWindowHours = 48,
BundlePolicyHash = "bundle-policy"
};
var result = await _service.VerifyAsync("tenant-b", request, now);
Assert.False(result.IsValid);
Assert.Equal("policy-hash-drift", result.Reason);
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" />
<Compile Include="../../shared/*.cs" Link="Shared/%(Filename)%(Extension)" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Planning;
namespace StellaOps.AirGap.Importer.Tests;
public class BundleImportPlannerTests
{
[Fact]
public void ReturnsFailureWhenBundlePathMissing()
{
var planner = new BundleImportPlanner();
var result = planner.CreatePlan(string.Empty, TrustRootConfig.Empty("/tmp"));
Assert.False(result.InitialState.IsValid);
Assert.Equal("bundle-path-required", result.InitialState.Reason);
}
[Fact]
public void ReturnsFailureWhenTrustRootsMissing()
{
var planner = new BundleImportPlanner();
var result = planner.CreatePlan("bundle.tar", TrustRootConfig.Empty("/tmp"));
Assert.False(result.InitialState.IsValid);
Assert.Equal("trust-roots-required", result.InitialState.Reason);
}
[Fact]
public void ReturnsDefaultPlanWhenInputsProvided()
{
var planner = new BundleImportPlanner();
var trust = new TrustRootConfig("/tmp/trust.json", new[] { "abc" }, new[] { "ed25519" }, null, null, new Dictionary<string, byte[]>());
var result = planner.CreatePlan("bundle.tar", trust);
Assert.True(result.InitialState.IsValid);
Assert.Contains("verify-dsse-signature", result.Steps);
Assert.Equal("bundle.tar", result.Inputs["bundlePath"]);
}
}

View File

@@ -0,0 +1,71 @@
using System.Security.Cryptography;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Tests;
public class DsseVerifierTests
{
[Fact]
public void FailsWhenUntrustedKey()
{
var verifier = new DsseVerifier();
var envelope = new DsseEnvelope("text/plain", Convert.ToBase64String("hi"u8), new[] { new DsseSignature("k1", "sig") });
var trust = TrustRootConfig.Empty("/tmp");
var result = verifier.Verify(envelope, trust);
Assert.False(result.IsValid);
}
[Fact]
public void VerifiesRsaPssSignature()
{
using var rsa = RSA.Create(2048);
var pub = rsa.ExportSubjectPublicKeyInfo();
var payload = "hello-world";
var payloadType = "application/vnd.stella.bundle";
var pae = BuildPae(payloadType, payload);
var sig = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
var envelope = new DsseEnvelope(payloadType, Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)), new[]
{
new DsseSignature("k1", Convert.ToBase64String(sig))
});
var trust = new TrustRootConfig(
"/tmp/root.json",
new[] { Fingerprint(pub) },
new[] { "rsassa-pss-sha256" },
null,
null,
new Dictionary<string, byte[]> { ["k1"] = pub });
var result = new DsseVerifier().Verify(envelope, trust);
Assert.True(result.IsValid);
Assert.Equal("dsse-signature-verified", result.Reason);
}
private static byte[] BuildPae(string payloadType, string payload)
{
var parts = new[] { "DSSEv1", payloadType, payload };
var paeBuilder = new System.Text.StringBuilder();
paeBuilder.Append("PAE:");
paeBuilder.Append(parts.Length);
foreach (var part in parts)
{
paeBuilder.Append(' ');
paeBuilder.Append(part.Length);
paeBuilder.Append(' ');
paeBuilder.Append(part);
}
return System.Text.Encoding.UTF8.GetBytes(paeBuilder.ToString());
}
private static string Fingerprint(byte[] pub)
{
return Convert.ToHexString(SHA256.HashData(pub)).ToLowerInvariant();
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,238 @@
using System.Security.Cryptography;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Quarantine;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.AirGap.Importer.Versioning;
namespace StellaOps.AirGap.Importer.Tests;
public sealed class ImportValidatorTests
{
[Fact]
public async Task ValidateAsync_WhenTufInvalid_ShouldFailAndQuarantine()
{
var quarantine = new CapturingQuarantineService();
var monotonicity = new CapturingMonotonicityChecker();
var validator = new ImportValidator(
new DsseVerifier(),
new TufMetadataValidator(),
new MerkleRootCalculator(),
new RootRotationPolicy(),
monotonicity,
quarantine,
NullLogger<ImportValidator>.Instance);
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
try
{
var request = BuildRequest(bundlePath, rootJson: "{}", snapshotJson: "{}", timestampJson: "{}");
var result = await validator.ValidateAsync(request);
result.IsValid.Should().BeFalse();
result.Reason.Should().StartWith("tuf:");
quarantine.Requests.Should().HaveCount(1);
quarantine.Requests[0].TenantId.Should().Be("tenant-a");
}
finally
{
try
{
Directory.Delete(tempRoot, recursive: true);
}
catch
{
// best-effort cleanup
}
}
}
[Fact]
public async Task ValidateAsync_WhenAllChecksPass_ShouldSucceedAndRecordActivation()
{
var root = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\"}";
var snapshot = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"meta\":{\"snapshot\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
var timestamp = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"snapshot\":{\"meta\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
using var rsa = RSA.Create(2048);
var pub = rsa.ExportSubjectPublicKeyInfo();
var payload = "bundle-body";
var payloadType = "application/vnd.stella.bundle";
var pae = BuildPae(payloadType, payload);
var sig = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
var envelope = new DsseEnvelope(payloadType, Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)), new[]
{
new DsseSignature("k1", Convert.ToBase64String(sig))
});
var trustStore = new TrustStore();
trustStore.LoadActive(new Dictionary<string, byte[]> { ["k1"] = pub });
trustStore.StagePending(new Dictionary<string, byte[]> { ["k2"] = pub });
var quarantine = new CapturingQuarantineService();
var monotonicity = new CapturingMonotonicityChecker();
var validator = new ImportValidator(
new DsseVerifier(),
new TufMetadataValidator(),
new MerkleRootCalculator(),
new RootRotationPolicy(),
monotonicity,
quarantine,
NullLogger<ImportValidator>.Instance);
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
try
{
var request = new ImportValidationRequest(
TenantId: "tenant-a",
BundleType: "offline-kit",
BundleDigest: "sha256:bundle",
BundlePath: bundlePath,
ManifestJson: "{\"version\":\"1.0.0\"}",
ManifestVersion: "1.0.0",
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
ForceActivate: false,
ForceActivateReason: null,
Envelope: envelope,
TrustRoots: new TrustRootConfig("/tmp/root.json", new[] { Fingerprint(pub) }, new[] { "rsassa-pss-sha256" }, null, null, new Dictionary<string, byte[]> { ["k1"] = pub }),
RootJson: root,
SnapshotJson: snapshot,
TimestampJson: timestamp,
PayloadEntries: new List<NamedStream> { new("a.txt", new MemoryStream("data"u8.ToArray())) },
TrustStore: trustStore,
ApproverIds: new[] { "approver-1", "approver-2" });
var result = await validator.ValidateAsync(request);
result.IsValid.Should().BeTrue();
result.Reason.Should().Be("import-validated");
monotonicity.RecordedActivations.Should().HaveCount(1);
monotonicity.RecordedActivations[0].BundleDigest.Should().Be("sha256:bundle");
monotonicity.RecordedActivations[0].Version.SemVer.Should().Be("1.0.0");
quarantine.Requests.Should().BeEmpty();
}
finally
{
try
{
Directory.Delete(tempRoot, recursive: true);
}
catch
{
// best-effort cleanup
}
}
}
private static byte[] BuildPae(string payloadType, string payload)
{
var parts = new[] { "DSSEv1", payloadType, payload };
var paeBuilder = new System.Text.StringBuilder();
paeBuilder.Append("PAE:");
paeBuilder.Append(parts.Length);
foreach (var part in parts)
{
paeBuilder.Append(' ');
paeBuilder.Append(part.Length);
paeBuilder.Append(' ');
paeBuilder.Append(part);
}
return System.Text.Encoding.UTF8.GetBytes(paeBuilder.ToString());
}
private static string Fingerprint(byte[] pub) => Convert.ToHexString(SHA256.HashData(pub)).ToLowerInvariant();
private static ImportValidationRequest BuildRequest(string bundlePath, string rootJson, string snapshotJson, string timestampJson)
{
var envelope = new DsseEnvelope("text/plain", Convert.ToBase64String("hi"u8), Array.Empty<DsseSignature>());
var trustRoot = TrustRootConfig.Empty("/tmp");
var trustStore = new TrustStore();
return new ImportValidationRequest(
TenantId: "tenant-a",
BundleType: "offline-kit",
BundleDigest: "sha256:bundle",
BundlePath: bundlePath,
ManifestJson: null,
ManifestVersion: "1.0.0",
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
ForceActivate: false,
ForceActivateReason: null,
Envelope: envelope,
TrustRoots: trustRoot,
RootJson: rootJson,
SnapshotJson: snapshotJson,
TimestampJson: timestampJson,
PayloadEntries: Array.Empty<NamedStream>(),
TrustStore: trustStore,
ApproverIds: Array.Empty<string>());
}
private sealed class CapturingMonotonicityChecker : IVersionMonotonicityChecker
{
public List<(BundleVersion Version, string BundleDigest)> RecordedActivations { get; } = new();
public Task<MonotonicityCheckResult> CheckAsync(string tenantId, string bundleType, BundleVersion incomingVersion, CancellationToken cancellationToken = default)
{
return Task.FromResult(new MonotonicityCheckResult(
IsMonotonic: true,
CurrentVersion: null,
CurrentBundleDigest: null,
CurrentActivatedAt: null,
ReasonCode: "FIRST_ACTIVATION"));
}
public Task RecordActivationAsync(
string tenantId,
string bundleType,
BundleVersion version,
string bundleDigest,
bool wasForceActivated = false,
string? forceActivateReason = null,
CancellationToken cancellationToken = default)
{
RecordedActivations.Add((version, bundleDigest));
return Task.CompletedTask;
}
}
private sealed class CapturingQuarantineService : IQuarantineService
{
public List<QuarantineRequest> Requests { get; } = new();
public Task<QuarantineResult> QuarantineAsync(QuarantineRequest request, CancellationToken cancellationToken = default)
{
Requests.Add(request);
return Task.FromResult(new QuarantineResult(
Success: true,
QuarantineId: "test",
QuarantinePath: "(memory)",
QuarantinedAt: DateTimeOffset.UnixEpoch));
}
public Task<IReadOnlyList<QuarantineEntry>> ListAsync(string tenantId, QuarantineListOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<QuarantineEntry>>(Array.Empty<QuarantineEntry>());
public Task<bool> RemoveAsync(string tenantId, string quarantineId, string removalReason, CancellationToken cancellationToken = default) =>
Task.FromResult(false);
public Task<int> CleanupExpiredAsync(TimeSpan retentionPeriod, CancellationToken cancellationToken = default) =>
Task.FromResult(0);
}
}

View File

@@ -0,0 +1,63 @@
using StellaOps.AirGap.Importer.Models;
using StellaOps.AirGap.Importer.Repositories;
namespace StellaOps.AirGap.Importer.Tests;
public class InMemoryBundleRepositoriesTests
{
[Fact]
public async Task CatalogUpsertOverwritesPerTenant()
{
var repo = new InMemoryBundleCatalogRepository();
var entry1 = new BundleCatalogEntry("t1", "b1", "d1", DateTimeOffset.UnixEpoch, new[] { "a" });
var entry2 = new BundleCatalogEntry("t1", "b1", "d2", DateTimeOffset.UnixEpoch.AddMinutes(1), new[] { "b" });
await repo.UpsertAsync(entry1, default);
await repo.UpsertAsync(entry2, default);
var list = await repo.ListAsync("t1", default);
Assert.Single(list);
Assert.Equal("d2", list[0].Digest);
}
[Fact]
public async Task CatalogIsTenantIsolated()
{
var repo = new InMemoryBundleCatalogRepository();
await repo.UpsertAsync(new BundleCatalogEntry("t1", "b1", "d1", DateTimeOffset.UnixEpoch, Array.Empty<string>()), default);
await repo.UpsertAsync(new BundleCatalogEntry("t2", "b1", "d2", DateTimeOffset.UnixEpoch, Array.Empty<string>()), default);
var t1 = await repo.ListAsync("t1", default);
Assert.Single(t1);
Assert.Equal("d1", t1[0].Digest);
}
[Fact]
public async Task ItemsOrderedByPath()
{
var repo = new InMemoryBundleItemRepository();
await repo.UpsertManyAsync(new[]
{
new BundleItem("t1", "b1", "b.txt", "d2", 10),
new BundleItem("t1", "b1", "a.txt", "d1", 5)
}, default);
var list = await repo.ListByBundleAsync("t1", "b1", default);
Assert.Equal(new[] { "a.txt", "b.txt" }, list.Select(i => i.Path).ToArray());
}
[Fact]
public async Task ItemsTenantIsolated()
{
var repo = new InMemoryBundleItemRepository();
await repo.UpsertManyAsync(new[]
{
new BundleItem("t1", "b1", "a.txt", "d1", 1),
new BundleItem("t2", "b1", "a.txt", "d2", 1)
}, default);
var list = await repo.ListByBundleAsync("t1", "b1", default);
Assert.Single(list);
Assert.Equal("d1", list[0].Digest);
}
}

View File

@@ -0,0 +1,28 @@
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Tests;
public class MerkleRootCalculatorTests
{
[Fact]
public void EmptySetProducesEmptyRoot()
{
var calc = new MerkleRootCalculator();
var root = calc.ComputeRoot(Array.Empty<NamedStream>());
Assert.Equal(string.Empty, root);
}
[Fact]
public void DeterministicAcrossOrder()
{
var calc = new MerkleRootCalculator();
var a = new NamedStream("b.txt", new MemoryStream("two"u8.ToArray()));
var b = new NamedStream("a.txt", new MemoryStream("one"u8.ToArray()));
var root1 = calc.ComputeRoot(new[] { a, b });
var root2 = calc.ComputeRoot(new[] { b, a });
Assert.Equal(root1, root2);
Assert.NotEqual(string.Empty, root1);
}
}

View File

@@ -0,0 +1,113 @@
using System.Diagnostics.Metrics;
using StellaOps.AirGap.Importer.Telemetry;
namespace StellaOps.AirGap.Importer.Tests;
public sealed class OfflineKitMetricsTests : IDisposable
{
private readonly MeterListener _listener;
private readonly List<RecordedMeasurement> _measurements = [];
public OfflineKitMetricsTests()
{
_listener = new MeterListener();
_listener.InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == OfflineKitMetrics.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
};
_listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
{
_measurements.Add(new RecordedMeasurement(instrument.Name, measurement, tags.ToArray()));
});
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
_measurements.Add(new RecordedMeasurement(instrument.Name, measurement, tags.ToArray()));
});
_listener.Start();
}
public void Dispose() => _listener.Dispose();
[Fact]
public void RecordImport_EmitsCounterWithLabels()
{
using var metrics = new OfflineKitMetrics();
metrics.RecordImport(status: "success", tenantId: "tenant-a");
Assert.Contains(_measurements, m =>
m.Name == "offlinekit_import_total" &&
m.Value is long v &&
v == 1 &&
m.HasTag(OfflineKitMetrics.TagNames.Status, "success") &&
m.HasTag(OfflineKitMetrics.TagNames.TenantId, "tenant-a"));
}
[Fact]
public void RecordAttestationVerifyLatency_EmitsHistogramWithLabels()
{
using var metrics = new OfflineKitMetrics();
metrics.RecordAttestationVerifyLatency(attestationType: "dsse", seconds: 1.234, success: true);
Assert.Contains(_measurements, m =>
m.Name == "offlinekit_attestation_verify_latency_seconds" &&
m.Value is double v &&
Math.Abs(v - 1.234) < 0.000_001 &&
m.HasTag(OfflineKitMetrics.TagNames.AttestationType, "dsse") &&
m.HasTag(OfflineKitMetrics.TagNames.Success, "true"));
}
[Fact]
public void RecordRekorSuccess_EmitsCounterWithLabels()
{
using var metrics = new OfflineKitMetrics();
metrics.RecordRekorSuccess(mode: "offline");
Assert.Contains(_measurements, m =>
m.Name == "attestor_rekor_success_total" &&
m.Value is long v &&
v == 1 &&
m.HasTag(OfflineKitMetrics.TagNames.Mode, "offline"));
}
[Fact]
public void RecordRekorRetry_EmitsCounterWithLabels()
{
using var metrics = new OfflineKitMetrics();
metrics.RecordRekorRetry(reason: "stale_snapshot");
Assert.Contains(_measurements, m =>
m.Name == "attestor_rekor_retry_total" &&
m.Value is long v &&
v == 1 &&
m.HasTag(OfflineKitMetrics.TagNames.Reason, "stale_snapshot"));
}
[Fact]
public void RecordRekorInclusionLatency_EmitsHistogramWithLabels()
{
using var metrics = new OfflineKitMetrics();
metrics.RecordRekorInclusionLatency(seconds: 0.5, success: false);
Assert.Contains(_measurements, m =>
m.Name == "rekor_inclusion_latency" &&
m.Value is double v &&
Math.Abs(v - 0.5) < 0.000_001 &&
m.HasTag(OfflineKitMetrics.TagNames.Success, "false"));
}
private sealed record RecordedMeasurement(string Name, object Value, IReadOnlyList<KeyValuePair<string, object?>> Tags)
{
public bool HasTag(string key, string expectedValue) =>
Tags.Any(t => t.Key == key && string.Equals(t.Value?.ToString(), expectedValue, StringComparison.Ordinal));
}
}

View File

@@ -0,0 +1,155 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Importer.Quarantine;
namespace StellaOps.AirGap.Importer.Tests.Quarantine;
public sealed class FileSystemQuarantineServiceTests
{
[Fact]
public async Task QuarantineAsync_ShouldCreateExpectedFiles_AndListAsyncShouldReturnEntry()
{
var root = CreateTempDirectory();
try
{
var bundlePath = Path.Combine(root, "bundle.tar.zst");
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
var options = Options.Create(new QuarantineOptions
{
QuarantineRoot = Path.Combine(root, "quarantine"),
RetentionPeriod = TimeSpan.FromDays(30),
MaxQuarantineSizeBytes = 1024 * 1024,
EnableAutomaticCleanup = true
});
var svc = new FileSystemQuarantineService(
options,
NullLogger<FileSystemQuarantineService>.Instance,
TimeProvider.System);
var result = await svc.QuarantineAsync(new QuarantineRequest(
TenantId: "tenant-a",
BundlePath: bundlePath,
ManifestJson: "{\"version\":\"1.0.0\"}",
ReasonCode: "dsse:invalid",
ReasonMessage: "dsse:invalid",
VerificationLog: new[] { "tuf:ok", "dsse:invalid" },
Metadata: new Dictionary<string, string> { ["k"] = "v" }));
result.Success.Should().BeTrue();
Directory.Exists(result.QuarantinePath).Should().BeTrue();
File.Exists(Path.Combine(result.QuarantinePath, "bundle.tar.zst")).Should().BeTrue();
File.Exists(Path.Combine(result.QuarantinePath, "manifest.json")).Should().BeTrue();
File.Exists(Path.Combine(result.QuarantinePath, "verification.log")).Should().BeTrue();
File.Exists(Path.Combine(result.QuarantinePath, "failure-reason.txt")).Should().BeTrue();
File.Exists(Path.Combine(result.QuarantinePath, "quarantine.json")).Should().BeTrue();
var listed = await svc.ListAsync("tenant-a");
listed.Should().ContainSingle(e => e.QuarantineId == result.QuarantineId);
}
finally
{
SafeDeleteDirectory(root);
}
}
[Fact]
public async Task RemoveAsync_ShouldMoveToRemovedFolder()
{
var root = CreateTempDirectory();
try
{
var bundlePath = Path.Combine(root, "bundle.tar.zst");
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
var quarantineRoot = Path.Combine(root, "quarantine");
var options = Options.Create(new QuarantineOptions { QuarantineRoot = quarantineRoot, MaxQuarantineSizeBytes = 1024 * 1024 });
var svc = new FileSystemQuarantineService(options, NullLogger<FileSystemQuarantineService>.Instance, TimeProvider.System);
var result = await svc.QuarantineAsync(new QuarantineRequest(
TenantId: "tenant-a",
BundlePath: bundlePath,
ManifestJson: null,
ReasonCode: "tuf:invalid",
ReasonMessage: "tuf:invalid",
VerificationLog: new[] { "tuf:invalid" }));
var removed = await svc.RemoveAsync("tenant-a", result.QuarantineId, "investigated");
removed.Should().BeTrue();
Directory.Exists(result.QuarantinePath).Should().BeFalse();
Directory.Exists(Path.Combine(quarantineRoot, "tenant-a", ".removed", result.QuarantineId)).Should().BeTrue();
}
finally
{
SafeDeleteDirectory(root);
}
}
[Fact]
public async Task CleanupExpiredAsync_ShouldDeleteOldEntries()
{
var root = CreateTempDirectory();
try
{
var bundlePath = Path.Combine(root, "bundle.tar.zst");
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
var quarantineRoot = Path.Combine(root, "quarantine");
var options = Options.Create(new QuarantineOptions { QuarantineRoot = quarantineRoot, MaxQuarantineSizeBytes = 1024 * 1024 });
var svc = new FileSystemQuarantineService(options, NullLogger<FileSystemQuarantineService>.Instance, TimeProvider.System);
var result = await svc.QuarantineAsync(new QuarantineRequest(
TenantId: "tenant-a",
BundlePath: bundlePath,
ManifestJson: null,
ReasonCode: "tuf:invalid",
ReasonMessage: "tuf:invalid",
VerificationLog: new[] { "tuf:invalid" }));
var jsonPath = Path.Combine(result.QuarantinePath, "quarantine.json");
var json = await File.ReadAllTextAsync(jsonPath);
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };
var entry = JsonSerializer.Deserialize<QuarantineEntry>(json, jsonOptions);
entry.Should().NotBeNull();
var oldEntry = entry! with { QuarantinedAt = DateTimeOffset.Parse("1900-01-01T00:00:00Z") };
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(oldEntry, jsonOptions));
var removed = await svc.CleanupExpiredAsync(TimeSpan.FromDays(30));
removed.Should().BeGreaterThanOrEqualTo(1);
Directory.Exists(result.QuarantinePath).Should().BeFalse();
}
finally
{
SafeDeleteDirectory(root);
}
}
private static string CreateTempDirectory()
{
var dir = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
private static void SafeDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
catch
{
// best-effort cleanup
}
}
}

View File

@@ -0,0 +1,65 @@
using FluentAssertions;
using StellaOps.AirGap.Importer.Reconciliation;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
public sealed class ArtifactIndexTests
{
[Fact]
public void NormalizeDigest_BareHex_AddsPrefixAndLowercases()
{
var hex = new string('A', 64);
ArtifactIndex.NormalizeDigest(hex).Should().Be("sha256:" + new string('a', 64));
}
[Fact]
public void NormalizeDigest_WithSha256Prefix_IsCanonical()
{
var hex = new string('B', 64);
ArtifactIndex.NormalizeDigest("sha256:" + hex).Should().Be("sha256:" + new string('b', 64));
}
[Fact]
public void NormalizeDigest_WithOtherAlgorithm_Throws()
{
var ex = Assert.Throws<FormatException>(() => ArtifactIndex.NormalizeDigest("sha512:" + new string('a', 64)));
ex.Message.Should().Contain("Only sha256");
}
[Fact]
public void AddOrUpdate_MergesEntries_DeduplicatesAndSorts()
{
var digest = new string('c', 64);
var entryA = ArtifactEntry.Empty(digest) with
{
Sboms = new[]
{
new SbomReference("b", "b.json", SbomFormat.CycloneDx, null),
new SbomReference("a", "a.json", SbomFormat.Spdx, null),
}
};
var entryB = ArtifactEntry.Empty("sha256:" + digest.ToUpperInvariant()) with
{
Sboms = new[]
{
new SbomReference("a", "a2.json", SbomFormat.CycloneDx, null),
new SbomReference("c", "c.json", SbomFormat.Spdx, null),
}
};
var index = new ArtifactIndex();
index.AddOrUpdate(entryA);
index.AddOrUpdate(entryB);
var stored = index.Get("sha256:" + digest);
stored.Should().NotBeNull();
stored!.Digest.Should().Be("sha256:" + digest);
stored.Sboms.Select(s => (s.ContentHash, s.FilePath)).Should().Equal(
("a", "a.json"),
("b", "b.json"),
("c", "c.json"));
}
}

View File

@@ -0,0 +1,136 @@
// =============================================================================
// CycloneDxParserTests.cs
// Golden-file tests for CycloneDX SBOM parsing
// Part of Task T24: Golden-file tests for determinism
// =============================================================================
using FluentAssertions;
using StellaOps.AirGap.Importer.Reconciliation;
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
public sealed class CycloneDxParserTests
{
private static readonly string FixturesPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"Reconciliation", "Fixtures");
[Fact]
public async Task ParseAsync_ValidCycloneDx_ExtractsAllSubjects()
{
// Arrange
var parser = new CycloneDxParser();
var filePath = Path.Combine(FixturesPath, "sample.cdx.json");
// Skip if fixtures not available
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.IsSuccess.Should().BeTrue();
result.Format.Should().Be(SbomFormat.CycloneDx);
result.SpecVersion.Should().Be("1.6");
result.SerialNumber.Should().Be("urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79");
result.GeneratorTool.Should().Contain("syft");
// Should have 3 subjects with SHA-256 hashes (primary + 2 components)
result.Subjects.Should().HaveCount(3);
// Verify subjects are sorted by digest
result.Subjects.Should().BeInAscendingOrder(s => s.Digest, StringComparer.Ordinal);
}
[Fact]
public async Task ParseAsync_ExtractsPrimarySubject()
{
// Arrange
var parser = new CycloneDxParser();
var filePath = Path.Combine(FixturesPath, "sample.cdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.PrimarySubject.Should().NotBeNull();
result.PrimarySubject!.Name.Should().Be("test-app");
result.PrimarySubject.Version.Should().Be("1.0.0");
result.PrimarySubject.Digest.Should().StartWith("sha256:");
}
[Fact]
public async Task ParseAsync_SubjectDigestsAreNormalized()
{
// Arrange
var parser = new CycloneDxParser();
var filePath = Path.Combine(FixturesPath, "sample.cdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert - all digests should be normalized sha256:lowercase format
foreach (var subject in result.Subjects)
{
subject.Digest.Should().StartWith("sha256:");
subject.Digest[7..].Should().MatchRegex("^[a-f0-9]{64}$");
}
}
[Fact]
public void DetectFormat_CycloneDxFile_ReturnsCycloneDx()
{
var parser = new CycloneDxParser();
parser.DetectFormat("test.cdx.json").Should().Be(SbomFormat.CycloneDx);
parser.DetectFormat("test.bom.json").Should().Be(SbomFormat.CycloneDx);
}
[Fact]
public void DetectFormat_NonCycloneDxFile_ReturnsUnknown()
{
var parser = new CycloneDxParser();
parser.DetectFormat("test.spdx.json").Should().Be(SbomFormat.Unknown);
parser.DetectFormat("test.json").Should().Be(SbomFormat.Unknown);
}
[Fact]
public async Task ParseAsync_Deterministic_SameOutputForSameInput()
{
// Arrange
var parser = new CycloneDxParser();
var filePath = Path.Combine(FixturesPath, "sample.cdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act - parse twice
var result1 = await parser.ParseAsync(filePath);
var result2 = await parser.ParseAsync(filePath);
// Assert - results should be identical
result1.Subjects.Select(s => s.Digest)
.Should().BeEquivalentTo(result2.Subjects.Select(s => s.Digest));
result1.Subjects.Select(s => s.Name)
.Should().BeEquivalentTo(result2.Subjects.Select(s => s.Name));
// Order should be the same
result1.Subjects.Select(s => s.Digest).Should().Equal(result2.Subjects.Select(s => s.Digest));
}
}

View File

@@ -0,0 +1,141 @@
// =============================================================================
// DsseAttestationParserTests.cs
// Golden-file tests for DSSE attestation parsing
// Part of Task T24: Golden-file tests for determinism
// =============================================================================
using FluentAssertions;
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
public sealed class DsseAttestationParserTests
{
private static readonly string FixturesPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"Reconciliation", "Fixtures");
[Fact]
public async Task ParseAsync_ValidDsse_ExtractsEnvelope()
{
// Arrange
var parser = new DsseAttestationParser();
var filePath = Path.Combine(FixturesPath, "sample.intoto.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.IsSuccess.Should().BeTrue();
result.Envelope.Should().NotBeNull();
result.Envelope!.PayloadType.Should().Be("application/vnd.in-toto+json");
result.Envelope.Signatures.Should().HaveCount(1);
result.Envelope.Signatures[0].KeyId.Should().Be("test-key-id");
}
[Fact]
public async Task ParseAsync_ValidDsse_ExtractsStatement()
{
// Arrange
var parser = new DsseAttestationParser();
var filePath = Path.Combine(FixturesPath, "sample.intoto.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.Statement.Should().NotBeNull();
result.Statement!.Type.Should().Be("https://in-toto.io/Statement/v1");
result.Statement.PredicateType.Should().Be("https://slsa.dev/provenance/v1");
result.Statement.Subjects.Should().HaveCount(1);
}
[Fact]
public async Task ParseAsync_ExtractsSubjectDigests()
{
// Arrange
var parser = new DsseAttestationParser();
var filePath = Path.Combine(FixturesPath, "sample.intoto.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
var subject = result.Statement!.Subjects[0];
subject.Name.Should().Be("test-app");
subject.GetSha256Digest().Should().Be("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
}
[Fact]
public void IsAttestation_DsseFile_ReturnsTrue()
{
var parser = new DsseAttestationParser();
parser.IsAttestation("test.intoto.json").Should().BeTrue();
parser.IsAttestation("test.intoto.jsonl").Should().BeTrue();
parser.IsAttestation("test.dsig").Should().BeTrue();
parser.IsAttestation("test.dsse").Should().BeTrue();
}
[Fact]
public void IsAttestation_NonDsseFile_ReturnsFalse()
{
var parser = new DsseAttestationParser();
parser.IsAttestation("test.json").Should().BeFalse();
parser.IsAttestation("test.cdx.json").Should().BeFalse();
parser.IsAttestation("test.spdx.json").Should().BeFalse();
}
[Fact]
public async Task ParseAsync_Deterministic_SameOutputForSameInput()
{
// Arrange
var parser = new DsseAttestationParser();
var filePath = Path.Combine(FixturesPath, "sample.intoto.json");
if (!File.Exists(filePath))
{
return;
}
// Act - parse twice
var result1 = await parser.ParseAsync(filePath);
var result2 = await parser.ParseAsync(filePath);
// Assert - results should be identical
result1.Statement!.PredicateType.Should().Be(result2.Statement!.PredicateType);
result1.Statement.Subjects.Count.Should().Be(result2.Statement.Subjects.Count);
result1.Statement.Subjects[0].GetSha256Digest()
.Should().Be(result2.Statement.Subjects[0].GetSha256Digest());
}
[Fact]
public async Task ParseAsync_InvalidJson_ReturnsFailure()
{
// Arrange
var parser = new DsseAttestationParser();
var json = "not valid json";
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
// Act
var result = await parser.ParseAsync(stream);
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorMessage.Should().Contain("parsing error");
}
}

View File

@@ -0,0 +1,65 @@
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using StellaOps.AirGap.Importer.Reconciliation;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
public sealed class EvidenceDirectoryDiscoveryTests
{
[Fact]
public void Discover_ReturnsDeterministicRelativePathsAndHashes()
{
var root = Path.Combine(Path.GetTempPath(), "stellaops-evidence-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
WriteUtf8(Path.Combine(root, "sboms", "a.cdx.json"), "{\"bom\":1}");
WriteUtf8(Path.Combine(root, "attestations", "z.intoto.jsonl.dsig"), "dsse");
WriteUtf8(Path.Combine(root, "vex", "v.openvex.json"), "{\"vex\":true}");
var discovered = EvidenceDirectoryDiscovery.Discover(root);
discovered.Should().HaveCount(3);
discovered.Select(d => d.RelativePath).Should().Equal(
"attestations/z.intoto.jsonl.dsig",
"sboms/a.cdx.json",
"vex/v.openvex.json");
discovered[0].Kind.Should().Be(EvidenceFileKind.Attestation);
discovered[1].Kind.Should().Be(EvidenceFileKind.Sbom);
discovered[2].Kind.Should().Be(EvidenceFileKind.Vex);
discovered[0].ContentSha256.Should().Be(HashUtf8("dsse"));
discovered[1].ContentSha256.Should().Be(HashUtf8("{\"bom\":1}"));
discovered[2].ContentSha256.Should().Be(HashUtf8("{\"vex\":true}"));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void Discover_WhenDirectoryMissing_Throws()
{
var missing = Path.Combine(Path.GetTempPath(), "stellaops-missing-" + Guid.NewGuid().ToString("N"));
Action act = () => EvidenceDirectoryDiscovery.Discover(missing);
act.Should().Throw<DirectoryNotFoundException>();
}
private static void WriteUtf8(string path, string content)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
private static string HashUtf8(string content)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(content);
var hash = sha256.ComputeHash(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,56 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
"metadata": {
"timestamp": "2025-01-15T10:00:00Z",
"component": {
"type": "application",
"name": "test-app",
"version": "1.0.0",
"hashes": [
{
"alg": "SHA-256",
"content": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
]
},
"tools": {
"components": [
{
"name": "syft",
"version": "1.0.0"
}
]
}
},
"components": [
{
"type": "library",
"name": "zlib",
"version": "1.2.11",
"bom-ref": "pkg:generic/zlib@1.2.11",
"purl": "pkg:generic/zlib@1.2.11",
"hashes": [
{
"alg": "SHA-256",
"content": "c3e5e9fdd5004dcb542feda5ee4f0ff0744628baf8ed2dd5d66f8ca1197cb1a1"
}
]
},
{
"type": "library",
"name": "openssl",
"version": "3.0.0",
"bom-ref": "pkg:generic/openssl@3.0.0",
"purl": "pkg:generic/openssl@3.0.0",
"hashes": [
{
"alg": "SHA-256",
"content": "919b4a3e65a8deade6b3c94dd44cb98e0f65a1785a787689c23e6b5c0b4edfea"
}
]
}
]
}

View File

@@ -0,0 +1,10 @@
{
"payloadType": "application/vnd.in-toto+json",
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9zbHNhLmRldi9wcm92ZW5hbmNlL3YxIiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QtYXBwIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUifX1dLCJwcmVkaWNhdGUiOnsiYnVpbGRlcklkIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9idWlsZGVyIiwiYnVpbGRUeXBlIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9idWlsZC10eXBlIn19",
"signatures": [
{
"keyid": "test-key-id",
"sig": "MEUCIQDFmJRQSwWMbQGiS8X5mY9CvZxVbVmXJ7JQVGEYIhXEBQIgbqDBJxP2P9N2kGPXDlX7Qx8KPVQjN3P1Y5Z9A8B2C3D="
}
]
}

View File

@@ -0,0 +1,88 @@
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "test-app-sbom",
"documentNamespace": "https://example.com/test-app/1.0.0",
"creationInfo": {
"created": "2025-01-15T10:00:00Z",
"creators": [
"Tool: syft-1.0.0"
]
},
"documentDescribes": [
"SPDXRef-Package-test-app"
],
"packages": [
{
"SPDXID": "SPDXRef-Package-test-app",
"name": "test-app",
"versionInfo": "1.0.0",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"checksums": [
{
"algorithm": "SHA256",
"checksumValue": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
]
},
{
"SPDXID": "SPDXRef-Package-zlib",
"name": "zlib",
"versionInfo": "1.2.11",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"checksums": [
{
"algorithm": "SHA256",
"checksumValue": "c3e5e9fdd5004dcb542feda5ee4f0ff0744628baf8ed2dd5d66f8ca1197cb1a1"
}
],
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:generic/zlib@1.2.11"
}
]
},
{
"SPDXID": "SPDXRef-Package-openssl",
"name": "openssl",
"versionInfo": "3.0.0",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"checksums": [
{
"algorithm": "SHA256",
"checksumValue": "919b4a3e65a8deade6b3c94dd44cb98e0f65a1785a787689c23e6b5c0b4edfea"
}
],
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:generic/openssl@3.0.0"
}
]
}
],
"relationships": [
{
"spdxElementId": "SPDXRef-DOCUMENT",
"relatedSpdxElement": "SPDXRef-Package-test-app",
"relationshipType": "DESCRIBES"
},
{
"spdxElementId": "SPDXRef-Package-test-app",
"relatedSpdxElement": "SPDXRef-Package-zlib",
"relationshipType": "DEPENDS_ON"
},
{
"spdxElementId": "SPDXRef-Package-test-app",
"relatedSpdxElement": "SPDXRef-Package-openssl",
"relationshipType": "DEPENDS_ON"
}
]
}

View File

@@ -0,0 +1,453 @@
// =============================================================================
// SourcePrecedenceLatticePropertyTests.cs
// Property-based tests for lattice properties
// Part of Task T25: Write property-based tests
// =============================================================================
using StellaOps.AirGap.Importer.Reconciliation;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
/// <summary>
/// Property-based tests verifying lattice algebraic properties.
/// A lattice must satisfy: associativity, commutativity, idempotence, and absorption.
/// </summary>
public sealed class SourcePrecedenceLatticePropertyTests
{
private static readonly SourcePrecedence[] AllPrecedences =
[
SourcePrecedence.Unknown,
SourcePrecedence.ThirdParty,
SourcePrecedence.Maintainer,
SourcePrecedence.Vendor
];
#region Lattice Algebraic Properties
/// <summary>
/// Property: Join is commutative - Join(a, b) = Join(b, a)
/// </summary>
[Fact]
public void Join_IsCommutative()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var joinAB = SourcePrecedenceLattice.Join(a, b);
var joinBA = SourcePrecedenceLattice.Join(b, a);
Assert.Equal(joinAB, joinBA);
}
}
}
/// <summary>
/// Property: Meet is commutative - Meet(a, b) = Meet(b, a)
/// </summary>
[Fact]
public void Meet_IsCommutative()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var meetAB = SourcePrecedenceLattice.Meet(a, b);
var meetBA = SourcePrecedenceLattice.Meet(b, a);
Assert.Equal(meetAB, meetBA);
}
}
}
/// <summary>
/// Property: Join is associative - Join(Join(a, b), c) = Join(a, Join(b, c))
/// </summary>
[Fact]
public void Join_IsAssociative()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
foreach (var c in AllPrecedences)
{
var left = SourcePrecedenceLattice.Join(SourcePrecedenceLattice.Join(a, b), c);
var right = SourcePrecedenceLattice.Join(a, SourcePrecedenceLattice.Join(b, c));
Assert.Equal(left, right);
}
}
}
}
/// <summary>
/// Property: Meet is associative - Meet(Meet(a, b), c) = Meet(a, Meet(b, c))
/// </summary>
[Fact]
public void Meet_IsAssociative()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
foreach (var c in AllPrecedences)
{
var left = SourcePrecedenceLattice.Meet(SourcePrecedenceLattice.Meet(a, b), c);
var right = SourcePrecedenceLattice.Meet(a, SourcePrecedenceLattice.Meet(b, c));
Assert.Equal(left, right);
}
}
}
}
/// <summary>
/// Property: Join is idempotent - Join(a, a) = a
/// </summary>
[Fact]
public void Join_IsIdempotent()
{
foreach (var a in AllPrecedences)
{
var result = SourcePrecedenceLattice.Join(a, a);
Assert.Equal(a, result);
}
}
/// <summary>
/// Property: Meet is idempotent - Meet(a, a) = a
/// </summary>
[Fact]
public void Meet_IsIdempotent()
{
foreach (var a in AllPrecedences)
{
var result = SourcePrecedenceLattice.Meet(a, a);
Assert.Equal(a, result);
}
}
/// <summary>
/// Property: Absorption law 1 - Join(a, Meet(a, b)) = a
/// </summary>
[Fact]
public void Absorption_JoinMeet_ReturnsFirst()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var meet = SourcePrecedenceLattice.Meet(a, b);
var result = SourcePrecedenceLattice.Join(a, meet);
Assert.Equal(a, result);
}
}
}
/// <summary>
/// Property: Absorption law 2 - Meet(a, Join(a, b)) = a
/// </summary>
[Fact]
public void Absorption_MeetJoin_ReturnsFirst()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var join = SourcePrecedenceLattice.Join(a, b);
var result = SourcePrecedenceLattice.Meet(a, join);
Assert.Equal(a, result);
}
}
}
#endregion
#region Ordering Properties
/// <summary>
/// Property: Compare is antisymmetric - if Compare(a,b) > 0 then Compare(b,a) < 0
/// </summary>
[Fact]
public void Compare_IsAntisymmetric()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var compareAB = SourcePrecedenceLattice.Compare(a, b);
var compareBA = SourcePrecedenceLattice.Compare(b, a);
if (compareAB > 0)
{
Assert.True(compareBA < 0);
}
else if (compareAB < 0)
{
Assert.True(compareBA > 0);
}
else
{
Assert.Equal(0, compareBA);
}
}
}
}
/// <summary>
/// Property: Compare is transitive - if Compare(a,b) > 0 and Compare(b,c) > 0 then Compare(a,c) > 0
/// </summary>
[Fact]
public void Compare_IsTransitive()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
foreach (var c in AllPrecedences)
{
var ab = SourcePrecedenceLattice.Compare(a, b);
var bc = SourcePrecedenceLattice.Compare(b, c);
var ac = SourcePrecedenceLattice.Compare(a, c);
if (ab > 0 && bc > 0)
{
Assert.True(ac > 0);
}
if (ab < 0 && bc < 0)
{
Assert.True(ac < 0);
}
}
}
}
}
/// <summary>
/// Property: Compare is reflexive - Compare(a, a) = 0
/// </summary>
[Fact]
public void Compare_IsReflexive()
{
foreach (var a in AllPrecedences)
{
Assert.Equal(0, SourcePrecedenceLattice.Compare(a, a));
}
}
#endregion
#region Join/Meet Bound Properties
/// <summary>
/// Property: Join returns an upper bound - Join(a, b) >= a AND Join(a, b) >= b
/// </summary>
[Fact]
public void Join_ReturnsUpperBound()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var join = SourcePrecedenceLattice.Join(a, b);
Assert.True(SourcePrecedenceLattice.Compare(join, a) >= 0);
Assert.True(SourcePrecedenceLattice.Compare(join, b) >= 0);
}
}
}
/// <summary>
/// Property: Meet returns a lower bound - Meet(a, b) <= a AND Meet(a, b) <= b
/// </summary>
[Fact]
public void Meet_ReturnsLowerBound()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var meet = SourcePrecedenceLattice.Meet(a, b);
Assert.True(SourcePrecedenceLattice.Compare(meet, a) <= 0);
Assert.True(SourcePrecedenceLattice.Compare(meet, b) <= 0);
}
}
}
/// <summary>
/// Property: Join is least upper bound - for all c, if c >= a and c >= b then c >= Join(a,b)
/// </summary>
[Fact]
public void Join_IsLeastUpperBound()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var join = SourcePrecedenceLattice.Join(a, b);
foreach (var c in AllPrecedences)
{
var cGeA = SourcePrecedenceLattice.Compare(c, a) >= 0;
var cGeB = SourcePrecedenceLattice.Compare(c, b) >= 0;
if (cGeA && cGeB)
{
Assert.True(SourcePrecedenceLattice.Compare(c, join) >= 0);
}
}
}
}
}
/// <summary>
/// Property: Meet is greatest lower bound - for all c, if c <= a and c <= b then c <= Meet(a,b)
/// </summary>
[Fact]
public void Meet_IsGreatestLowerBound()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var meet = SourcePrecedenceLattice.Meet(a, b);
foreach (var c in AllPrecedences)
{
var cLeA = SourcePrecedenceLattice.Compare(c, a) <= 0;
var cLeB = SourcePrecedenceLattice.Compare(c, b) <= 0;
if (cLeA && cLeB)
{
Assert.True(SourcePrecedenceLattice.Compare(c, meet) <= 0);
}
}
}
}
}
#endregion
#region Bounded Lattice Properties
/// <summary>
/// Property: Unknown is the bottom element - Join(Unknown, a) = a
/// </summary>
[Fact]
public void Unknown_IsBottomElement()
{
foreach (var a in AllPrecedences)
{
var result = SourcePrecedenceLattice.Join(SourcePrecedence.Unknown, a);
Assert.Equal(a, result);
}
}
/// <summary>
/// Property: Vendor is the top element - Meet(Vendor, a) = a
/// </summary>
[Fact]
public void Vendor_IsTopElement()
{
foreach (var a in AllPrecedences)
{
var result = SourcePrecedenceLattice.Meet(SourcePrecedence.Vendor, a);
Assert.Equal(a, result);
}
}
#endregion
#region Merge Determinism
/// <summary>
/// Property: Merge is deterministic - same inputs always produce same output
/// </summary>
[Fact]
public void Merge_IsDeterministic()
{
var lattice = new SourcePrecedenceLattice();
var timestamp = new DateTimeOffset(2025, 12, 4, 12, 0, 0, TimeSpan.Zero);
var statements = new[]
{
CreateStatement("CVE-2024-001", "product-1", VexStatus.Affected, SourcePrecedence.ThirdParty, timestamp),
CreateStatement("CVE-2024-001", "product-1", VexStatus.NotAffected, SourcePrecedence.Vendor, timestamp),
CreateStatement("CVE-2024-001", "product-1", VexStatus.Fixed, SourcePrecedence.Maintainer, timestamp)
};
// Run merge 100 times and verify same result
var firstResult = lattice.Merge(statements);
for (int i = 0; i < 100; i++)
{
var result = lattice.Merge(statements);
Assert.Equal(firstResult.Status, result.Status);
Assert.Equal(firstResult.Source, result.Source);
Assert.Equal(firstResult.VulnerabilityId, result.VulnerabilityId);
}
}
/// <summary>
/// Property: Higher precedence always wins in merge
/// </summary>
[Fact]
public void Merge_HigherPrecedenceWins()
{
var lattice = new SourcePrecedenceLattice();
var timestamp = new DateTimeOffset(2025, 12, 4, 12, 0, 0, TimeSpan.Zero);
// Vendor should win over ThirdParty
var vendorStatement = CreateStatement("CVE-2024-001", "product-1", VexStatus.NotAffected, SourcePrecedence.Vendor, timestamp);
var thirdPartyStatement = CreateStatement("CVE-2024-001", "product-1", VexStatus.Affected, SourcePrecedence.ThirdParty, timestamp);
var result = lattice.Merge(vendorStatement, thirdPartyStatement);
Assert.Equal(SourcePrecedence.Vendor, result.Source);
Assert.Equal(VexStatus.NotAffected, result.Status);
}
/// <summary>
/// Property: More recent timestamp wins when precedence is equal
/// </summary>
[Fact]
public void Merge_MoreRecentTimestampWins_WhenPrecedenceEqual()
{
var lattice = new SourcePrecedenceLattice();
var olderTimestamp = new DateTimeOffset(2025, 12, 1, 12, 0, 0, TimeSpan.Zero);
var newerTimestamp = new DateTimeOffset(2025, 12, 4, 12, 0, 0, TimeSpan.Zero);
var olderStatement = CreateStatement("CVE-2024-001", "product-1", VexStatus.Affected, SourcePrecedence.Maintainer, olderTimestamp);
var newerStatement = CreateStatement("CVE-2024-001", "product-1", VexStatus.Fixed, SourcePrecedence.Maintainer, newerTimestamp);
var result = lattice.Merge(olderStatement, newerStatement);
Assert.Equal(VexStatus.Fixed, result.Status);
Assert.Equal(newerTimestamp, result.Timestamp);
}
private static VexStatement CreateStatement(
string vulnId,
string productId,
VexStatus status,
SourcePrecedence source,
DateTimeOffset? timestamp)
{
return new VexStatement
{
VulnerabilityId = vulnId,
ProductId = productId,
Status = status,
Source = source,
Timestamp = timestamp
};
}
#endregion
}

View File

@@ -0,0 +1,149 @@
// =============================================================================
// SpdxParserTests.cs
// Golden-file tests for SPDX SBOM parsing
// Part of Task T24: Golden-file tests for determinism
// =============================================================================
using FluentAssertions;
using StellaOps.AirGap.Importer.Reconciliation;
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
public sealed class SpdxParserTests
{
private static readonly string FixturesPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"Reconciliation", "Fixtures");
[Fact]
public async Task ParseAsync_ValidSpdx_ExtractsAllSubjects()
{
// Arrange
var parser = new SpdxParser();
var filePath = Path.Combine(FixturesPath, "sample.spdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.IsSuccess.Should().BeTrue();
result.Format.Should().Be(SbomFormat.Spdx);
result.SpecVersion.Should().Be("2.3");
result.SerialNumber.Should().Be("https://example.com/test-app/1.0.0");
result.GeneratorTool.Should().Contain("syft");
// Should have 3 packages with SHA256 checksums
result.Subjects.Should().HaveCount(3);
// Verify subjects are sorted by digest
result.Subjects.Should().BeInAscendingOrder(s => s.Digest, StringComparer.Ordinal);
}
[Fact]
public async Task ParseAsync_ExtractsPrimarySubject()
{
// Arrange
var parser = new SpdxParser();
var filePath = Path.Combine(FixturesPath, "sample.spdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.PrimarySubject.Should().NotBeNull();
result.PrimarySubject!.Name.Should().Be("test-app");
result.PrimarySubject.Version.Should().Be("1.0.0");
result.PrimarySubject.SpdxId.Should().Be("SPDXRef-Package-test-app");
}
[Fact]
public async Task ParseAsync_ExtractsPurls()
{
// Arrange
var parser = new SpdxParser();
var filePath = Path.Combine(FixturesPath, "sample.spdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert - check for components with purls
var zlib = result.Subjects.FirstOrDefault(s => s.Name == "zlib");
zlib.Should().NotBeNull();
zlib!.Purl.Should().Be("pkg:generic/zlib@1.2.11");
}
[Fact]
public async Task ParseAsync_SubjectDigestsAreNormalized()
{
// Arrange
var parser = new SpdxParser();
var filePath = Path.Combine(FixturesPath, "sample.spdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert - all digests should be normalized sha256:lowercase format
foreach (var subject in result.Subjects)
{
subject.Digest.Should().StartWith("sha256:");
subject.Digest[7..].Should().MatchRegex("^[a-f0-9]{64}$");
}
}
[Fact]
public void DetectFormat_SpdxFile_ReturnsSpdx()
{
var parser = new SpdxParser();
parser.DetectFormat("test.spdx.json").Should().Be(SbomFormat.Spdx);
}
[Fact]
public void DetectFormat_NonSpdxFile_ReturnsUnknown()
{
var parser = new SpdxParser();
parser.DetectFormat("test.cdx.json").Should().Be(SbomFormat.Unknown);
parser.DetectFormat("test.json").Should().Be(SbomFormat.Unknown);
}
[Fact]
public async Task ParseAsync_Deterministic_SameOutputForSameInput()
{
// Arrange
var parser = new SpdxParser();
var filePath = Path.Combine(FixturesPath, "sample.spdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act - parse twice
var result1 = await parser.ParseAsync(filePath);
var result2 = await parser.ParseAsync(filePath);
// Assert - results should be identical and in same order
result1.Subjects.Select(s => s.Digest).Should().Equal(result2.Subjects.Select(s => s.Digest));
result1.Subjects.Select(s => s.Name).Should().Equal(result2.Subjects.Select(s => s.Name));
}
}

View File

@@ -0,0 +1,72 @@
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Tests;
public class ReplayVerifierTests
{
private readonly ReplayVerifier _verifier = new();
[Fact]
public void FullRecompute_succeeds_when_hashes_match_and_fresh()
{
var now = DateTimeOffset.Parse("2025-12-02T01:00:00Z");
var request = new ReplayVerificationRequest(
"aa".PadRight(64, 'a'),
"bb".PadRight(64, 'b'),
"aa".PadRight(64, 'a'),
"bb".PadRight(64, 'b'),
now.AddHours(-4),
24,
"cc".PadRight(64, 'c'),
"cc".PadRight(64, 'c'),
ReplayDepth.FullRecompute);
var result = _verifier.Verify(request, now);
Assert.True(result.IsValid);
Assert.Equal("full-recompute-passed", result.Reason);
}
[Fact]
public void Detects_hash_drift()
{
var now = DateTimeOffset.UtcNow;
var request = new ReplayVerificationRequest(
"aa".PadRight(64, 'a'),
"bb".PadRight(64, 'b'),
"00".PadRight(64, '0'),
"bb".PadRight(64, 'b'),
now,
1,
null,
null,
ReplayDepth.HashOnly);
var result = _verifier.Verify(request, now);
Assert.False(result.IsValid);
Assert.Equal("manifest-hash-drift", result.Reason);
}
[Fact]
public void PolicyFreeze_requires_matching_policy_hash()
{
var now = DateTimeOffset.UtcNow;
var request = new ReplayVerificationRequest(
"aa".PadRight(64, 'a'),
"bb".PadRight(64, 'b'),
"aa".PadRight(64, 'a'),
"bb".PadRight(64, 'b'),
now,
12,
"bundle-policy",
"sealed-policy-other",
ReplayDepth.PolicyFreeze);
var result = _verifier.Verify(request, now);
Assert.False(result.IsValid);
Assert.Equal("policy-hash-drift", result.Reason);
}
}

View File

@@ -0,0 +1,40 @@
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Tests;
public class RootRotationPolicyTests
{
[Fact]
public void RequiresTwoApprovers()
{
var policy = new RootRotationPolicy();
var result = policy.Validate(new Dictionary<string, byte[]>(), new Dictionary<string, byte[]> { ["k1"] = new byte[] { 1 } }, new[] { "a" });
Assert.False(result.IsValid);
Assert.Equal("rotation-dual-approval-required", result.Reason);
}
[Fact]
public void RejectsNoChange()
{
var policy = new RootRotationPolicy();
var result = policy.Validate(
new Dictionary<string, byte[]> { ["k1"] = new byte[] { 1 } },
new Dictionary<string, byte[]> { ["k1"] = new byte[] { 1 } },
new[] { "a", "b" });
Assert.False(result.IsValid);
Assert.Equal("rotation-no-change", result.Reason);
}
[Fact]
public void AcceptsRotationWithDualApproval()
{
var policy = new RootRotationPolicy();
var result = policy.Validate(
new Dictionary<string, byte[]> { ["old"] = new byte[] { 1 } },
new Dictionary<string, byte[]> { ["new"] = new byte[] { 2 } },
new[] { "a", "b" });
Assert.True(result.IsValid);
Assert.Equal("rotation-approved", result.Reason);
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Reconciliation/Fixtures/**/*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,42 @@
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Tests;
public class TufMetadataValidatorTests
{
[Fact]
public void RejectsInvalidJson()
{
var validator = new TufMetadataValidator();
var result = validator.Validate("{}", "{}", "{}");
Assert.False(result.IsValid);
}
[Fact]
public void AcceptsConsistentSnapshotHash()
{
var validator = new TufMetadataValidator();
var root = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\"}";
var snapshot = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"meta\":{\"snapshot\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
var timestamp = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"snapshot\":{\"meta\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
var result = validator.Validate(root, snapshot, timestamp);
Assert.True(result.IsValid);
Assert.Equal("tuf-metadata-valid", result.Reason);
}
[Fact]
public void DetectsHashMismatch()
{
var validator = new TufMetadataValidator();
var root = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\"}";
var snapshot = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"meta\":{\"snapshot\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
var timestamp = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"snapshot\":{\"meta\":{\"hashes\":{\"sha256\":\"def\"}}}}";
var result = validator.Validate(root, snapshot, timestamp);
Assert.False(result.IsValid);
Assert.Equal("tuf-snapshot-hash-mismatch", result.Reason);
}
}

View File

@@ -0,0 +1,204 @@
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Quarantine;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.AirGap.Importer.Versioning;
namespace StellaOps.AirGap.Importer.Tests.Validation;
public sealed class ImportValidatorIntegrationTests
{
[Fact]
public async Task ValidateAsync_WhenNonMonotonic_ShouldFailAndQuarantine()
{
var quarantine = new CapturingQuarantineService();
var monotonicity = new FixedMonotonicityChecker(isMonotonic: false);
var validator = new ImportValidator(
new DsseVerifier(),
new TufMetadataValidator(),
new MerkleRootCalculator(),
new RootRotationPolicy(),
monotonicity,
quarantine,
NullLogger<ImportValidator>.Instance);
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
try
{
var (envelope, trustRoots) = CreateValidDsse();
var trustStore = new TrustStore();
trustStore.LoadActive(new Dictionary<string, byte[]>());
trustStore.StagePending(new Dictionary<string, byte[]> { ["pending-key"] = new byte[] { 1, 2, 3 } });
var request = new ImportValidationRequest(
TenantId: "tenant-a",
BundleType: "offline-kit",
BundleDigest: "sha256:bundle",
BundlePath: bundlePath,
ManifestJson: "{\"version\":\"1.0.0\"}",
ManifestVersion: "1.0.0",
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
ForceActivate: false,
ForceActivateReason: null,
Envelope: envelope,
TrustRoots: trustRoots,
RootJson: """
{"version":1,"expiresUtc":"2025-12-31T00:00:00Z"}
""",
SnapshotJson: """
{"version":1,"expiresUtc":"2025-12-31T00:00:00Z","meta":{"snapshot":{"hashes":{"sha256":"abc"}}}}
""",
TimestampJson: """
{"version":1,"expiresUtc":"2025-12-31T00:00:00Z","snapshot":{"meta":{"hashes":{"sha256":"abc"}}}}
""",
PayloadEntries: new[] { new NamedStream("payload.txt", new MemoryStream(Encoding.UTF8.GetBytes("hello"))) },
TrustStore: trustStore,
ApproverIds: new[] { "approver-a", "approver-b" });
var result = await validator.ValidateAsync(request);
result.IsValid.Should().BeFalse();
result.Reason.Should().Contain("version-non-monotonic");
quarantine.Requests.Should().HaveCount(1);
quarantine.Requests[0].TenantId.Should().Be("tenant-a");
quarantine.Requests[0].ReasonCode.Should().Contain("version-non-monotonic");
}
finally
{
try
{
Directory.Delete(tempRoot, recursive: true);
}
catch
{
// best-effort cleanup
}
}
}
private static (DsseEnvelope envelope, TrustRootConfig trustRoots) CreateValidDsse()
{
using var rsa = RSA.Create(2048);
var publicKey = rsa.ExportSubjectPublicKeyInfo();
var fingerprint = Convert.ToHexString(SHA256.HashData(publicKey)).ToLowerInvariant();
var payloadType = "application/vnd.in-toto+json";
var payloadBytes = Encoding.UTF8.GetBytes("{\"hello\":\"world\"}");
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var pae = BuildPae(payloadType, payloadBytes);
var signature = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
var envelope = new DsseEnvelope(
PayloadType: payloadType,
Payload: payloadBase64,
Signatures: new[] { new DsseSignature("key-1", Convert.ToBase64String(signature)) });
var trustRoots = new TrustRootConfig(
RootBundlePath: "(memory)",
TrustedKeyFingerprints: new[] { fingerprint },
AllowedSignatureAlgorithms: new[] { "rsa-pss-sha256" },
NotBeforeUtc: null,
NotAfterUtc: null,
PublicKeys: new Dictionary<string, byte[]> { ["key-1"] = publicKey });
return (envelope, trustRoots);
}
private static byte[] BuildPae(string payloadType, byte[] payloadBytes)
{
const string paePrefix = "DSSEv1";
var payload = Encoding.UTF8.GetString(payloadBytes);
var parts = new[]
{
paePrefix,
payloadType,
payload
};
var paeBuilder = new StringBuilder();
paeBuilder.Append("PAE:");
paeBuilder.Append(parts.Length);
foreach (var part in parts)
{
paeBuilder.Append(' ');
paeBuilder.Append(part.Length);
paeBuilder.Append(' ');
paeBuilder.Append(part);
}
return Encoding.UTF8.GetBytes(paeBuilder.ToString());
}
private sealed class FixedMonotonicityChecker : IVersionMonotonicityChecker
{
private readonly bool _isMonotonic;
public FixedMonotonicityChecker(bool isMonotonic)
{
_isMonotonic = isMonotonic;
}
public Task<MonotonicityCheckResult> CheckAsync(
string tenantId,
string bundleType,
BundleVersion incomingVersion,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new MonotonicityCheckResult(
IsMonotonic: _isMonotonic,
CurrentVersion: new BundleVersion(2, 0, 0, DateTimeOffset.Parse("2025-12-14T00:00:00Z")),
CurrentBundleDigest: "sha256:current",
CurrentActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
ReasonCode: _isMonotonic ? "MONOTONIC_OK" : "VERSION_NON_MONOTONIC"));
}
public Task RecordActivationAsync(
string tenantId,
string bundleType,
BundleVersion version,
string bundleDigest,
bool wasForceActivated = false,
string? forceActivateReason = null,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
private sealed class CapturingQuarantineService : IQuarantineService
{
public List<QuarantineRequest> Requests { get; } = new();
public Task<QuarantineResult> QuarantineAsync(QuarantineRequest request, CancellationToken cancellationToken = default)
{
Requests.Add(request);
return Task.FromResult(new QuarantineResult(
Success: true,
QuarantineId: "test",
QuarantinePath: "(memory)",
QuarantinedAt: DateTimeOffset.UnixEpoch));
}
public Task<IReadOnlyList<QuarantineEntry>> ListAsync(string tenantId, QuarantineListOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<QuarantineEntry>>(Array.Empty<QuarantineEntry>());
public Task<bool> RemoveAsync(string tenantId, string quarantineId, string removalReason, CancellationToken cancellationToken = default) =>
Task.FromResult(false);
public Task<int> CleanupExpiredAsync(TimeSpan retentionPeriod, CancellationToken cancellationToken = default) =>
Task.FromResult(0);
}
}

View File

@@ -0,0 +1,165 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Tests.Validation;
public sealed class RekorOfflineReceiptVerifierTests
{
[Fact]
public async Task VerifyAsync_ValidReceiptAndCheckpoint_Succeeds()
{
var temp = Path.Combine(Path.GetTempPath(), "stellaops-rekor-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temp);
try
{
// Leaf 0 is the DSSE digest we verify for inclusion.
var dsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("dsse-envelope"));
var otherDsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("other-envelope"));
var leaf0 = HashLeaf(dsseSha256);
var leaf1 = HashLeaf(otherDsseSha256);
var root = HashInterior(leaf0, leaf1);
var rootBase64 = Convert.ToBase64String(root);
var treeSize = 2L;
var origin = "rekor.sigstore.dev - 2605736670972794746";
var timestamp = "1700000000";
var canonicalBody = $"{origin}\n{treeSize}\n{rootBase64}\n{timestamp}\n";
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var signature = ecdsa.SignData(Encoding.UTF8.GetBytes(canonicalBody), HashAlgorithmName.SHA256);
var signatureBase64 = Convert.ToBase64String(signature);
var checkpointPath = Path.Combine(temp, "checkpoint.sig");
await File.WriteAllTextAsync(
checkpointPath,
canonicalBody + $"sig {signatureBase64}\n",
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
var publicKeyPath = Path.Combine(temp, "rekor-pub.pem");
await File.WriteAllTextAsync(
publicKeyPath,
WrapPem("PUBLIC KEY", ecdsa.ExportSubjectPublicKeyInfo()),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
var receiptPath = Path.Combine(temp, "rekor-receipt.json");
var receiptJson = JsonSerializer.Serialize(new
{
uuid = "uuid-1",
logIndex = 0,
rootHash = Convert.ToHexString(root).ToLowerInvariant(),
hashes = new[] { Convert.ToHexString(leaf1).ToLowerInvariant() },
checkpoint = "checkpoint.sig"
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await File.WriteAllTextAsync(receiptPath, receiptJson, new UTF8Encoding(false));
var result = await RekorOfflineReceiptVerifier.VerifyAsync(receiptPath, dsseSha256, publicKeyPath, CancellationToken.None);
result.Verified.Should().BeTrue();
result.CheckpointSignatureVerified.Should().BeTrue();
result.RekorUuid.Should().Be("uuid-1");
result.LogIndex.Should().Be(0);
result.TreeSize.Should().Be(2);
result.ExpectedRootHash.Should().Be(Convert.ToHexString(root).ToLowerInvariant());
result.ComputedRootHash.Should().Be(Convert.ToHexString(root).ToLowerInvariant());
}
finally
{
Directory.Delete(temp, recursive: true);
}
}
[Fact]
public async Task VerifyAsync_TamperedCheckpointSignature_Fails()
{
var temp = Path.Combine(Path.GetTempPath(), "stellaops-rekor-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temp);
try
{
var dsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("dsse-envelope"));
var otherDsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("other-envelope"));
var leaf0 = HashLeaf(dsseSha256);
var leaf1 = HashLeaf(otherDsseSha256);
var root = HashInterior(leaf0, leaf1);
var rootBase64 = Convert.ToBase64String(root);
var treeSize = 2L;
var origin = "rekor.sigstore.dev - 2605736670972794746";
var timestamp = "1700000000";
var canonicalBody = $"{origin}\n{treeSize}\n{rootBase64}\n{timestamp}\n";
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var signature = ecdsa.SignData(Encoding.UTF8.GetBytes(canonicalBody), HashAlgorithmName.SHA256);
signature[0] ^= 0xFF; // tamper
var checkpointPath = Path.Combine(temp, "checkpoint.sig");
await File.WriteAllTextAsync(
checkpointPath,
canonicalBody + $"sig {Convert.ToBase64String(signature)}\n",
new UTF8Encoding(false));
var publicKeyPath = Path.Combine(temp, "rekor-pub.pem");
await File.WriteAllTextAsync(
publicKeyPath,
WrapPem("PUBLIC KEY", ecdsa.ExportSubjectPublicKeyInfo()),
new UTF8Encoding(false));
var receiptPath = Path.Combine(temp, "rekor-receipt.json");
var receiptJson = JsonSerializer.Serialize(new
{
uuid = "uuid-1",
logIndex = 0,
rootHash = Convert.ToHexString(root).ToLowerInvariant(),
hashes = new[] { Convert.ToHexString(leaf1).ToLowerInvariant() },
checkpoint = "checkpoint.sig"
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await File.WriteAllTextAsync(receiptPath, receiptJson, new UTF8Encoding(false));
var result = await RekorOfflineReceiptVerifier.VerifyAsync(receiptPath, dsseSha256, publicKeyPath, CancellationToken.None);
result.Verified.Should().BeFalse();
result.FailureReason.Should().Contain("checkpoint signature", because: result.FailureReason);
}
finally
{
Directory.Delete(temp, recursive: true);
}
}
private static byte[] HashLeaf(byte[] leafData)
{
var buffer = new byte[1 + leafData.Length];
buffer[0] = 0x00;
leafData.CopyTo(buffer, 1);
return SHA256.HashData(buffer);
}
private static byte[] HashInterior(byte[] left, byte[] right)
{
var buffer = new byte[1 + left.Length + right.Length];
buffer[0] = 0x01;
left.CopyTo(buffer, 1);
right.CopyTo(buffer, 1 + left.Length);
return SHA256.HashData(buffer);
}
private static string WrapPem(string label, byte[] derBytes)
{
var base64 = Convert.ToBase64String(derBytes);
var sb = new StringBuilder();
sb.AppendLine($"-----BEGIN {label}-----");
for (var i = 0; i < base64.Length; i += 64)
{
sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i)));
}
sb.AppendLine($"-----END {label}-----");
return sb.ToString();
}
}

View File

@@ -0,0 +1,79 @@
using FluentAssertions;
using StellaOps.AirGap.Importer.Versioning;
namespace StellaOps.AirGap.Importer.Tests.Versioning;
public sealed class BundleVersionTests
{
[Fact]
public void Parse_ShouldParseSemVer()
{
var createdAt = new DateTimeOffset(2025, 12, 14, 0, 0, 0, TimeSpan.Zero);
var version = BundleVersion.Parse("1.2.3", createdAt);
version.Major.Should().Be(1);
version.Minor.Should().Be(2);
version.Patch.Should().Be(3);
version.Prerelease.Should().BeNull();
version.CreatedAt.Should().Be(createdAt);
version.SemVer.Should().Be("1.2.3");
}
[Fact]
public void Parse_ShouldParsePrerelease()
{
var createdAt = new DateTimeOffset(2025, 12, 14, 0, 0, 0, TimeSpan.Zero);
var version = BundleVersion.Parse("1.2.3-edge.1", createdAt);
version.SemVer.Should().Be("1.2.3-edge.1");
version.Prerelease.Should().Be("edge.1");
}
[Fact]
public void IsNewerThan_ShouldCompareMajorMinorPatch()
{
var a = new BundleVersion(1, 2, 3, DateTimeOffset.UnixEpoch);
var b = new BundleVersion(2, 0, 0, DateTimeOffset.UnixEpoch);
b.IsNewerThan(a).Should().BeTrue();
a.IsNewerThan(b).Should().BeFalse();
}
[Fact]
public void IsNewerThan_ShouldTreatReleaseAsNewerThanPrerelease()
{
var now = new DateTimeOffset(2025, 12, 14, 0, 0, 0, TimeSpan.Zero);
var prerelease = new BundleVersion(1, 2, 3, now, "alpha");
var release = new BundleVersion(1, 2, 3, now, null);
release.IsNewerThan(prerelease).Should().BeTrue();
prerelease.IsNewerThan(release).Should().BeFalse();
}
[Fact]
public void IsNewerThan_ShouldOrderPrereleaseIdentifiers()
{
var now = new DateTimeOffset(2025, 12, 14, 0, 0, 0, TimeSpan.Zero);
var alpha = new BundleVersion(1, 2, 3, now, "alpha");
var beta = new BundleVersion(1, 2, 3, now, "beta");
var rc1 = new BundleVersion(1, 2, 3, now, "rc.1");
var rc2 = new BundleVersion(1, 2, 3, now, "rc.2");
beta.IsNewerThan(alpha).Should().BeTrue();
rc1.IsNewerThan(beta).Should().BeTrue();
rc2.IsNewerThan(rc1).Should().BeTrue();
}
[Fact]
public void IsNewerThan_ShouldUseCreatedAtAsTiebreaker()
{
var earlier = new DateTimeOffset(2025, 12, 14, 0, 0, 0, TimeSpan.Zero);
var later = earlier.AddMinutes(1);
var a = new BundleVersion(1, 2, 3, earlier, "edge");
var b = new BundleVersion(1, 2, 3, later, "edge");
b.IsNewerThan(a).Should().BeTrue();
a.IsNewerThan(b).Should().BeFalse();
}
}

View File

@@ -0,0 +1,157 @@
using FluentAssertions;
using StellaOps.AirGap.Importer.Versioning;
namespace StellaOps.AirGap.Importer.Tests.Versioning;
public sealed class VersionMonotonicityCheckerTests
{
[Fact]
public async Task CheckAsync_WhenNoCurrent_ShouldBeFirstActivation()
{
var store = new InMemoryBundleVersionStore();
var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-14T00:00:00Z")));
var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-14T00:00:00Z"));
var result = await checker.CheckAsync("tenant-a", "offline-kit", incoming);
result.IsMonotonic.Should().BeTrue();
result.ReasonCode.Should().Be("FIRST_ACTIVATION");
result.CurrentVersion.Should().BeNull();
result.CurrentBundleDigest.Should().BeNull();
}
[Fact]
public async Task CheckAsync_WhenOlder_ShouldBeNonMonotonic()
{
var store = new InMemoryBundleVersionStore();
await store.UpsertAsync(new BundleVersionRecord(
TenantId: "tenant-a",
BundleType: "offline-kit",
VersionString: "2.0.0",
Major: 2,
Minor: 0,
Patch: 0,
Prerelease: null,
BundleCreatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
BundleDigest: "sha256:current",
ActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
WasForceActivated: false,
ForceActivateReason: null));
var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-14T00:00:00Z")));
var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-14T00:00:00Z"));
var result = await checker.CheckAsync("tenant-a", "offline-kit", incoming);
result.IsMonotonic.Should().BeFalse();
result.ReasonCode.Should().Be("VERSION_NON_MONOTONIC");
result.CurrentVersion.Should().NotBeNull();
result.CurrentVersion!.SemVer.Should().Be("2.0.0");
}
[Fact]
public async Task RecordActivationAsync_WhenNonMonotonicWithoutForce_ShouldThrow()
{
var store = new InMemoryBundleVersionStore();
await store.UpsertAsync(new BundleVersionRecord(
TenantId: "tenant-a",
BundleType: "offline-kit",
VersionString: "2.0.0",
Major: 2,
Minor: 0,
Patch: 0,
Prerelease: null,
BundleCreatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
BundleDigest: "sha256:current",
ActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
WasForceActivated: false,
ForceActivateReason: null));
var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-15T00:00:00Z")));
var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-15T00:00:00Z"));
var act = () => checker.RecordActivationAsync("tenant-a", "offline-kit", incoming, "sha256:new");
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task RecordActivationAsync_WhenForced_ShouldWriteForceFields()
{
var store = new InMemoryBundleVersionStore();
await store.UpsertAsync(new BundleVersionRecord(
TenantId: "tenant-a",
BundleType: "offline-kit",
VersionString: "2.0.0",
Major: 2,
Minor: 0,
Patch: 0,
Prerelease: null,
BundleCreatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
BundleDigest: "sha256:current",
ActivatedAt: DateTimeOffset.Parse("2025-12-14T00:00:00Z"),
WasForceActivated: false,
ForceActivateReason: null));
var checker = new VersionMonotonicityChecker(store, new FixedTimeProvider(DateTimeOffset.Parse("2025-12-15T00:00:00Z")));
var incoming = BundleVersion.Parse("1.0.0", DateTimeOffset.Parse("2025-12-15T00:00:00Z"));
await checker.RecordActivationAsync(
"tenant-a",
"offline-kit",
incoming,
"sha256:new",
wasForceActivated: true,
forceActivateReason: "manual rollback permitted");
var current = await store.GetCurrentAsync("tenant-a", "offline-kit");
current.Should().NotBeNull();
current!.WasForceActivated.Should().BeTrue();
current.ForceActivateReason.Should().Be("manual rollback permitted");
current.BundleDigest.Should().Be("sha256:new");
}
private sealed class InMemoryBundleVersionStore : IBundleVersionStore
{
private BundleVersionRecord? _current;
private readonly List<BundleVersionRecord> _history = new();
public Task<BundleVersionRecord?> GetCurrentAsync(string tenantId, string bundleType, CancellationToken ct = default)
{
return Task.FromResult(_current is not null &&
_current.TenantId.Equals(tenantId, StringComparison.Ordinal) &&
_current.BundleType.Equals(bundleType, StringComparison.Ordinal)
? _current
: null);
}
public Task UpsertAsync(BundleVersionRecord record, CancellationToken ct = default)
{
_current = record;
_history.Insert(0, record);
return Task.CompletedTask;
}
public Task<IReadOnlyList<BundleVersionRecord>> GetHistoryAsync(string tenantId, string bundleType, int limit = 10, CancellationToken ct = default)
{
var items = _history
.Where(r => r.TenantId.Equals(tenantId, StringComparison.Ordinal) && r.BundleType.Equals(bundleType, StringComparison.Ordinal))
.Take(limit)
.ToArray();
return Task.FromResult<IReadOnlyList<BundleVersionRecord>>(items);
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Config;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Tests;
public class AirGapOptionsValidatorTests
{
[Fact]
public void FailsWhenTenantMissing()
{
var opts = new AirGapOptions { TenantId = "" };
var validator = new AirGapOptionsValidator();
var result = validator.Validate(null, opts);
Assert.True(result.Failed);
}
[Fact]
public void FailsWhenWarningExceedsBreach()
{
var opts = new AirGapOptions { TenantId = "t", Staleness = new StalenessOptions { WarningSeconds = 20, BreachSeconds = 10 } };
var validator = new AirGapOptionsValidator();
var result = validator.Validate(null, opts);
Assert.True(result.Failed);
}
[Fact]
public void SucceedsForValidOptions()
{
var opts = new AirGapOptions { TenantId = "t", Staleness = new StalenessOptions { WarningSeconds = 10, BreachSeconds = 20 } };
var validator = new AirGapOptionsValidator();
var result = validator.Validate(null, opts);
Assert.True(result.Succeeded);
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,93 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Time.Tests;
/// <summary>
/// Tests for Rfc3161Verifier with real SignedCms verification.
/// Per AIRGAP-TIME-57-001: Trusted time-anchor service.
/// </summary>
public class Rfc3161VerifierTests
{
private readonly Rfc3161Verifier _verifier = new();
[Fact]
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
{
var token = new byte[] { 0x01, 0x02, 0x03 };
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
Assert.False(result.IsValid);
Assert.Equal("rfc3161-trust-roots-required", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Fact]
public void Verify_ReturnsFailure_WhenTokenEmpty()
{
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("rfc3161-token-empty", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Fact]
public void Verify_ReturnsFailure_WhenInvalidAsn1Structure()
{
var token = new byte[] { 0x01, 0x02, 0x03 }; // Invalid ASN.1
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Fact]
public void Verify_ProducesTokenDigest()
{
var token = new byte[] { 0x30, 0x00 }; // Empty SEQUENCE (minimal valid ASN.1)
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out _);
// Should fail on CMS decode but attempt was made
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
}
[Fact]
public void Verify_HandlesExceptionsGracefully()
{
// Create bytes that might cause internal exceptions
var token = new byte[256];
new Random(42).NextBytes(token);
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out var anchor);
// Should not throw, should return failure result
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Fact]
public void Verify_ReportsDecodeErrorForMalformedCms()
{
// Create something that looks like CMS but isn't valid
var token = new byte[] { 0x30, 0x82, 0x00, 0x10, 0x06, 0x09 };
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out _);
Assert.False(result.IsValid);
// Should report either decode or error
Assert.True(result.Reason?.Contains("rfc3161-") ?? false);
}
}

View File

@@ -0,0 +1,150 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Time.Tests;
/// <summary>
/// Tests for RoughtimeVerifier with real Ed25519 signature verification.
/// Per AIRGAP-TIME-57-001: Trusted time-anchor service.
/// </summary>
public class RoughtimeVerifierTests
{
private readonly RoughtimeVerifier _verifier = new();
[Fact]
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
{
var token = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-trust-roots-required", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Fact]
public void Verify_ReturnsFailure_WhenTokenEmpty()
{
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-token-empty", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Fact]
public void Verify_ReturnsFailure_WhenTokenTooShort()
{
var token = new byte[] { 0x01, 0x02, 0x03 };
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-message-too-short", result.Reason);
}
[Fact]
public void Verify_ReturnsFailure_WhenInvalidTagCount()
{
// Create a minimal wire format with invalid tag count
var token = new byte[8];
// Set num_tags to 0 (invalid)
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)0);
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-invalid-tag-count", result.Reason);
}
[Fact]
public void Verify_ReturnsFailure_WhenNonEd25519Algorithm()
{
// Create a minimal valid-looking wire format
var token = CreateMinimalRoughtimeToken();
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "rsa") }; // Wrong algorithm
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
// Should fail either on parsing or signature verification
Assert.Contains("roughtime-", result.Reason);
}
[Fact]
public void Verify_ReturnsFailure_WhenKeyLengthWrong()
{
var token = CreateMinimalRoughtimeToken();
var trust = new[] { new TimeTrustRoot("root1", new byte[16], "ed25519") }; // Wrong key length
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Contains("roughtime-", result.Reason);
}
[Fact]
public void Verify_ProducesTokenDigest()
{
var token = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out _);
// Even on failure, we should get a deterministic result
Assert.False(result.IsValid);
}
/// <summary>
/// Creates a minimal Roughtime wire format token for testing parsing paths.
/// Note: This will fail signature verification but tests the parsing logic.
/// </summary>
private static byte[] CreateMinimalRoughtimeToken()
{
// Roughtime wire format:
// [num_tags:u32] [offsets:u32[n-1]] [tags:u32[n]] [values...]
// We'll create 2 tags: SIG and SREP
const uint TagSig = 0x00474953; // "SIG\0"
const uint TagSrep = 0x50455253; // "SREP"
var sigValue = new byte[64]; // Ed25519 signature
var srepValue = CreateMinimalSrep();
// Header: num_tags=2, offset[0]=64 (sig length), tags=[SIG, SREP]
var headerSize = 4 + 4 + 8; // num_tags + 1 offset + 2 tags = 16 bytes
var token = new byte[headerSize + sigValue.Length + srepValue.Length];
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)2); // num_tags = 2
BitConverter.TryWriteBytes(token.AsSpan(4, 4), (uint)64); // offset[0] = 64 (sig length)
BitConverter.TryWriteBytes(token.AsSpan(8, 4), TagSig);
BitConverter.TryWriteBytes(token.AsSpan(12, 4), TagSrep);
sigValue.CopyTo(token.AsSpan(16));
srepValue.CopyTo(token.AsSpan(16 + 64));
return token;
}
private static byte[] CreateMinimalSrep()
{
// SREP with MIDP tag containing 8-byte timestamp
const uint TagMidp = 0x5044494D; // "MIDP"
// Header: num_tags=1, tags=[MIDP]
var headerSize = 4 + 4; // num_tags + 1 tag = 8 bytes
var srepValue = new byte[headerSize + 8]; // + 8 bytes for MIDP value
BitConverter.TryWriteBytes(srepValue.AsSpan(0, 4), (uint)1); // num_tags = 1
BitConverter.TryWriteBytes(srepValue.AsSpan(4, 4), TagMidp);
// MIDP value: microseconds since Unix epoch (example: 2025-01-01 00:00:00 UTC)
BitConverter.TryWriteBytes(srepValue.AsSpan(8, 8), 1735689600000000L);
return srepValue;
}
}

View File

@@ -0,0 +1,63 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
namespace StellaOps.AirGap.Time.Tests;
public class SealedStartupValidatorTests
{
[Fact]
public async Task FailsWhenAnchorMissing()
{
var validator = Build(out var statusService);
var result = await validator.ValidateAsync("t1", StalenessBudget.Default, default);
Assert.False(result.IsValid);
Assert.Equal("time-anchor-missing", result.Reason);
}
[Fact]
public async Task FailsWhenBreach()
{
var validator = Build(out var statusService);
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "src", "fmt", "fp", "digest");
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
var now = DateTimeOffset.UnixEpoch.AddSeconds(25);
var status = await statusService.GetStatusAsync("t1", now);
var result = status.Staleness.IsBreach;
Assert.True(result);
var validation = await validator.ValidateAsync("t1", new StalenessBudget(10, 20), default);
Assert.False(validation.IsValid);
Assert.Equal("time-anchor-stale", validation.Reason);
}
[Fact]
public async Task SucceedsWhenFresh()
{
var validator = Build(out var statusService);
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now, "src", "fmt", "fp", "digest");
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
var validation = await validator.ValidateAsync("t1", new StalenessBudget(10, 20), default);
Assert.True(validation.IsValid);
}
[Fact]
public async Task FailsOnBudgetMismatch()
{
var validator = Build(out var statusService);
var anchor = new TimeAnchor(DateTimeOffset.UtcNow, "src", "fmt", "fp", "digest");
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
var validation = await validator.ValidateAsync("t1", new StalenessBudget(5, 15), default);
Assert.False(validation.IsValid);
Assert.Equal("time-anchor-budget-mismatch", validation.Reason);
}
private static SealedStartupValidator Build(out TimeStatusService statusService)
{
var store = new InMemoryTimeAnchorStore();
statusService = new TimeStatusService(store, new StalenessCalculator(), new TimeTelemetry(), Microsoft.Extensions.Options.Options.Create(new AirGapOptions()));
return new SealedStartupValidator(statusService);
}
}

View File

@@ -0,0 +1,43 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Time.Tests;
public class StalenessCalculatorTests
{
[Fact]
public void UnknownWhenNoAnchor()
{
var calc = new StalenessCalculator();
var result = calc.Evaluate(TimeAnchor.Unknown, StalenessBudget.Default, DateTimeOffset.UnixEpoch);
Assert.False(result.IsWarning);
Assert.False(result.IsBreach);
}
[Fact]
public void BreachWhenBeyondBudget()
{
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
var budget = new StalenessBudget(10, 20);
var calc = new StalenessCalculator();
var result = calc.Evaluate(anchor, budget, DateTimeOffset.UnixEpoch.AddSeconds(25));
Assert.True(result.IsBreach);
Assert.True(result.IsWarning);
Assert.Equal(25, result.AgeSeconds);
}
[Fact]
public void WarningWhenBetweenWarningAndBreach()
{
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
var budget = new StalenessBudget(10, 20);
var calc = new StalenessCalculator();
var result = calc.Evaluate(anchor, budget, DateTimeOffset.UnixEpoch.AddSeconds(15));
Assert.True(result.IsWarning);
Assert.False(result.IsBreach);
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Time.Tests;
public class TimeAnchorLoaderTests
{
[Fact]
public void RejectsInvalidHex()
{
var loader = Build();
var trust = new[] { new TimeTrustRoot("k1", new byte[32], "ed25519") };
var result = loader.TryLoadHex("not-hex", TimeTokenFormat.Roughtime, trust, out _);
Assert.False(result.IsValid);
Assert.Equal("token-hex-invalid", result.Reason);
}
[Fact]
public void LoadsHexToken()
{
var loader = Build();
var hex = "01020304";
var trust = new[] { new TimeTrustRoot("k1", new byte[32], "ed25519") };
var result = loader.TryLoadHex(hex, TimeTokenFormat.Roughtime, trust, out var anchor);
Assert.True(result.IsValid);
Assert.Equal("Roughtime", anchor.Format);
}
[Fact]
public void RejectsIncompatibleTrustRoots()
{
var loader = Build();
var hex = "010203";
var rsaKey = new byte[128];
var trust = new[] { new TimeTrustRoot("k1", rsaKey, "rsa") };
var result = loader.TryLoadHex(hex, TimeTokenFormat.Roughtime, trust, out _);
Assert.False(result.IsValid);
Assert.Equal("trust-roots-incompatible-format", result.Reason);
}
[Fact]
public void RejectsWhenTrustRootsMissing()
{
var loader = Build();
var result = loader.TryLoadHex("010203", TimeTokenFormat.Roughtime, Array.Empty<TimeTrustRoot>(), out _);
Assert.False(result.IsValid);
Assert.Equal("trust-roots-required", result.Reason);
}
private static TimeAnchorLoader Build()
{
var options = Options.Create(new AirGapOptions { AllowUntrustedAnchors = false });
return new TimeAnchorLoader(new TimeVerificationService(), new TimeTokenParser(), options);
}
}

View File

@@ -0,0 +1,261 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
namespace StellaOps.AirGap.Time.Tests;
/// <summary>
/// Tests for TimeAnchorPolicyService.
/// Per AIRGAP-TIME-57-001: Time-anchor policy enforcement.
/// </summary>
public class TimeAnchorPolicyServiceTests
{
private readonly TimeProvider _fixedTimeProvider;
private readonly InMemoryTimeAnchorStore _store;
private readonly StalenessCalculator _calculator;
private readonly TimeTelemetry _telemetry;
private readonly TimeStatusService _statusService;
private readonly AirGapOptions _airGapOptions;
public TimeAnchorPolicyServiceTests()
{
_fixedTimeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
_store = new InMemoryTimeAnchorStore();
_calculator = new StalenessCalculator();
_telemetry = new TimeTelemetry();
_airGapOptions = new AirGapOptions
{
Staleness = new AirGapOptions.StalenessOptions { WarningSeconds = 3600, BreachSeconds = 7200 },
ContentBudgets = new Dictionary<string, AirGapOptions.StalenessOptions>()
};
_statusService = new TimeStatusService(_store, _calculator, _telemetry, Options.Create(_airGapOptions));
}
private TimeAnchorPolicyService CreateService(TimeAnchorPolicyOptions? options = null)
{
return new TimeAnchorPolicyService(
_statusService,
Options.Create(options ?? new TimeAnchorPolicyOptions()),
NullLogger<TimeAnchorPolicyService>.Instance,
_fixedTimeProvider);
}
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenNoAnchor()
{
var service = CreateService();
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
Assert.NotNull(result.Remediation);
}
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsSuccess_WhenAnchorValid()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.True(result.Allowed);
Assert.Null(result.ErrorCode);
Assert.NotNull(result.Staleness);
Assert.False(result.Staleness.IsBreach);
}
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsWarning_WhenAnchorStale()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddSeconds(-5000), // Past warning threshold
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.True(result.Allowed); // Allowed but with warning
Assert.NotNull(result.Staleness);
Assert.True(result.Staleness.IsWarning);
Assert.Contains("warning", result.Reason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenAnchorBreached()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddSeconds(-8000), // Past breach threshold
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorBreached, result.ErrorCode);
Assert.NotNull(result.Staleness);
Assert.True(result.Staleness.IsBreach);
}
[Fact]
public async Task EnforceBundleImportPolicyAsync_AllowsImport_WhenAnchorValid()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.EnforceBundleImportPolicyAsync(
"tenant-1",
"bundle-123",
_fixedTimeProvider.GetUtcNow().AddMinutes(-15));
Assert.True(result.Allowed);
}
[Fact]
public async Task EnforceBundleImportPolicyAsync_BlocksImport_WhenDriftExceeded()
{
var options = new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 }; // 1 hour max
var service = CreateService(options);
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(86400, 172800); // Large budget
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var bundleTimestamp = _fixedTimeProvider.GetUtcNow().AddDays(-2); // 2 days ago
var result = await service.EnforceBundleImportPolicyAsync(
"tenant-1",
"bundle-123",
bundleTimestamp);
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.DriftExceeded, result.ErrorCode);
}
[Fact]
public async Task EnforceOperationPolicyAsync_BlocksStrictOperations_WhenNoAnchor()
{
var options = new TimeAnchorPolicyOptions
{
StrictOperations = new[] { "attestation.sign" }
};
var service = CreateService(options);
var result = await service.EnforceOperationPolicyAsync("tenant-1", "attestation.sign");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
}
[Fact]
public async Task EnforceOperationPolicyAsync_AllowsNonStrictOperations_InNonStrictMode()
{
var options = new TimeAnchorPolicyOptions
{
StrictEnforcement = false,
StrictOperations = new[] { "attestation.sign" }
};
var service = CreateService(options);
var result = await service.EnforceOperationPolicyAsync("tenant-1", "some.other.operation");
Assert.True(result.Allowed);
}
[Fact]
public async Task CalculateDriftAsync_ReturnsNoDrift_WhenNoAnchor()
{
var service = CreateService();
var result = await service.CalculateDriftAsync("tenant-1", _fixedTimeProvider.GetUtcNow());
Assert.False(result.HasAnchor);
Assert.Equal(TimeSpan.Zero, result.Drift);
Assert.Null(result.AnchorTime);
}
[Fact]
public async Task CalculateDriftAsync_ReturnsDrift_WhenAnchorExists()
{
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 });
var anchorTime = _fixedTimeProvider.GetUtcNow().AddMinutes(-30);
var anchor = new TimeAnchor(anchorTime, "test", "Roughtime", "fp", "digest");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(15);
var result = await service.CalculateDriftAsync("tenant-1", targetTime);
Assert.True(result.HasAnchor);
Assert.Equal(anchorTime, result.AnchorTime);
Assert.Equal(45, (int)result.Drift.TotalMinutes); // 30 min + 15 min
Assert.False(result.DriftExceedsThreshold);
}
[Fact]
public async Task CalculateDriftAsync_DetectsExcessiveDrift()
{
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 60 }); // 1 minute max
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow(),
"test",
"Roughtime",
"fp",
"digest");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(5); // 5 minutes drift
var result = await service.CalculateDriftAsync("tenant-1", targetTime);
Assert.True(result.HasAnchor);
Assert.True(result.DriftExceedsThreshold);
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,24 @@
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Tests;
public class TimeStatusDtoTests
{
[Fact]
public void SerializesDeterministically()
{
var status = new TimeStatus(
new TimeAnchor(DateTimeOffset.Parse("2025-01-01T00:00:00Z"), "source", "fmt", "fp", "digest"),
new StalenessEvaluation(42, 10, 20, true, false),
new StalenessBudget(10, 20),
new Dictionary<string, StalenessEvaluation>
{
{ "advisories", new StalenessEvaluation(42, 10, 20, true, false) }
},
DateTimeOffset.Parse("2025-01-02T00:00:00Z"));
var json = TimeStatusDto.FromStatus(status).ToJson();
Assert.Contains("\"contentStaleness\":{\"advisories\":{", json);
Assert.Contains("\"ageSeconds\":42", json);
}
}

View File

@@ -0,0 +1,45 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
namespace StellaOps.AirGap.Time.Tests;
public class TimeStatusServiceTests
{
[Fact]
public async Task ReturnsUnknownWhenNoAnchor()
{
var svc = Build(out var telemetry);
var status = await svc.GetStatusAsync("t1", DateTimeOffset.UnixEpoch);
Assert.Equal(TimeAnchor.Unknown, status.Anchor);
Assert.False(status.Staleness.IsWarning);
Assert.Equal(0, telemetry.GetLatest("t1")?.AgeSeconds ?? 0);
}
[Fact]
public async Task PersistsAnchorAndBudget()
{
var svc = Build(out var telemetry);
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
var budget = new StalenessBudget(10, 20);
await svc.SetAnchorAsync("t1", anchor, budget);
var status = await svc.GetStatusAsync("t1", DateTimeOffset.UnixEpoch.AddSeconds(15));
Assert.Equal(anchor, status.Anchor);
Assert.True(status.Staleness.IsWarning);
Assert.False(status.Staleness.IsBreach);
Assert.Equal(15, status.Staleness.AgeSeconds);
var snap = telemetry.GetLatest("t1");
Assert.NotNull(snap);
Assert.Equal(status.Staleness.AgeSeconds, snap!.AgeSeconds);
Assert.True(snap.IsWarning);
}
private static TimeStatusService Build(out TimeTelemetry telemetry)
{
telemetry = new TimeTelemetry();
var options = Microsoft.Extensions.Options.Options.Create(new AirGapOptions());
return new TimeStatusService(new InMemoryTimeAnchorStore(), new StalenessCalculator(), telemetry, options);
}
}

View File

@@ -0,0 +1,27 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Time.Tests;
public class TimeTelemetryTests
{
[Fact]
public void Records_latest_snapshot_per_tenant()
{
var telemetry = new TimeTelemetry();
var status = new TimeStatus(
new TimeAnchor(DateTimeOffset.UnixEpoch, "src", "fmt", "fp", "digest"),
new StalenessEvaluation(90, 60, 120, true, false),
StalenessBudget.Default,
new Dictionary<string, StalenessEvaluation>{{"advisories", new StalenessEvaluation(90,60,120,true,false)}},
DateTimeOffset.UtcNow);
telemetry.Record("t1", status);
var snap = telemetry.GetLatest("t1");
Assert.NotNull(snap);
Assert.Equal(90, snap!.AgeSeconds);
Assert.True(snap.IsWarning);
Assert.False(snap.IsBreach);
}
}

View File

@@ -0,0 +1,34 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Tests;
public class TimeTokenParserTests
{
[Fact]
public void EmptyTokenFails()
{
var parser = new TimeTokenParser();
var result = parser.TryParse(Array.Empty<byte>(), TimeTokenFormat.Roughtime, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("token-empty", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Fact]
public void RoughtimeTokenProducesDigest()
{
var parser = new TimeTokenParser();
var token = new byte[] { 0x01, 0x02, 0x03 };
var result = parser.TryParse(token, TimeTokenFormat.Roughtime, out var anchor);
Assert.True(result.IsValid);
Assert.Equal("Roughtime", anchor.Format);
Assert.Equal("roughtime-token", anchor.Source);
Assert.Equal("structure-stubbed", result.Reason);
Assert.Matches("^[0-9a-f]{64}$", anchor.TokenDigest);
Assert.NotEqual(DateTimeOffset.UnixEpoch, anchor.AnchorTime); // deterministic derivation
}
}

View File

@@ -0,0 +1,28 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Time.Tests;
public class TimeVerificationServiceTests
{
[Fact]
public void FailsWithoutTrustRoots()
{
var svc = new TimeVerificationService();
var result = svc.Verify(new byte[] { 0x01 }, TimeTokenFormat.Roughtime, Array.Empty<TimeTrustRoot>(), out _);
Assert.False(result.IsValid);
Assert.Equal("trust-roots-required", result.Reason);
}
[Fact]
public void SucceedsForRoughtimeWithTrustRoot()
{
var svc = new TimeVerificationService();
var trust = new[] { new TimeTrustRoot("k1", new byte[] { 0x01 }, "rsassa-pss-sha256") };
var result = svc.Verify(new byte[] { 0x01, 0x02 }, TimeTokenFormat.Roughtime, trust, out var anchor);
Assert.True(result.IsValid);
Assert.Equal("Roughtime", anchor.Format);
Assert.Equal("k1", anchor.SignatureFingerprint);
}
}

View File

@@ -0,0 +1,20 @@
# Evidence Locker Golden Fixtures (EB10)
Purpose: reference bundles and replay records used by CI to prove deterministic packaging, DSSE subject stability, and portable redaction behaviour.
## Layout
- `sealed/` sealed bundle ingredients (`manifest.json`, `checksums.txt`, DSSE `signature.json`, `bundle.json`, evidence ndjson) plus `expected.json`.
- `portable/` redacted bundle ingredients and `expected.json` noting masked fields and tenant token.
- `replay/` `replay.ndjson` with `expected.json` (recordDigest, sequence, ledger URI); ordering is canonical (recordedAtUtc, scanId).
## Expectations
- Gzip timestamp pinned to `2025-01-01T00:00:00Z`; tar entries use `0644` perms and fixed mtime.
- `checksums.txt` sorted lexicographically by `canonicalPath`; Merkle root equals `sha256sum checksums.txt`.
- DSSE subject ties to the Merkle root; manifest validates against `schemas/bundle.manifest.schema.json`.
- Portable bundles must exclude tenant identifiers and include redaction metadata in the manifest.
## How to (re)generate
1. Set `TZ=UTC` and ensure deterministic tool versions.
2. Run EvidenceLocker pipeline to produce sealed bundle; copy outputs here with expected hash values.
3. Produce portable bundle and replay records using the same input set; write `expected.json` capturing root hashes and replay digests.
4. Update xUnit tests in `StellaOps.EvidenceLocker.Tests` to consume these fixtures without network calls.

View File

@@ -0,0 +1,7 @@
{
"bundleId": "11111111111111111111111111111111",
"tenant": "redacted",
"kind": "evaluation",
"createdAt": "2025-12-04T00:00:00Z",
"portable": true
}

View File

@@ -0,0 +1,14 @@
{
"algorithm": "sha256",
"root": "72c82a7a3d114164d491e2ecd7098bc015b115ee1ec7c42d648f0348e573cfcf",
"generatedAt": "2025-12-04T00:00:00Z",
"bundleId": "11111111111111111111111111111111",
"tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"entries": [
{ "canonicalPath": "bundle.json", "sha256": "10695174db1b549d77be583e529a249713e9bd23e46cc5e73250db5dfc92c4a9", "sizeBytes": 160 },
{ "canonicalPath": "instructions-portable.txt", "sha256": "dd2a3b62857cf331b423e7dc3b869ad2dc9bfa852109a20bcbecc7bcef9bdcb7", "sizeBytes": 180 },
{ "canonicalPath": "linksets.ndjson", "sha256": "a4d84bbc3262190fd3e1f5dbc15915c97e464326a56534483ce810c905288b9d", "sizeBytes": 151 },
{ "canonicalPath": "observations.ndjson", "sha256": "c523f82e71c8a1bd9be0650883faf00ec39a792023066105d7cda544ad6ef5fd", "sizeBytes": 149 }
],
"chunking": { "strategy": "none" }
}

View File

@@ -0,0 +1,18 @@
{
"bundleId": "11111111111111111111111111111111",
"tenantRedacted": true,
"merkleRoot": "72c82a7a3d114164d491e2ecd7098bc015b115ee1ec7c42d648f0348e573cfcf",
"subject": "sha256:72c82a7a3d114164d491e2ecd7098bc015b115ee1ec7c42d648f0348e573cfcf",
"entries": [
"bundle.json",
"instructions-portable.txt",
"linksets.ndjson",
"observations.ndjson"
],
"dsseKeyId": "demo-ed25519",
"logPolicy": "skip-offline",
"redaction": {
"maskedFields": ["tenantId"],
"tenantToken": "portable-tenant-01"
}
}

View File

@@ -0,0 +1,4 @@
Portable bundle verification:
1) sha256sum -c checksums.txt
2) expect no tenant identifiers in manifest or bundle.json
3) merkle_root=$(sha256sum checksums.txt | awk '{print $1}')

View File

@@ -0,0 +1 @@
{"linksetId":"lnk-demo-001","advisoryId":"CVE-2025-0001","components":["pkg:deb/openssl@1.1.1w"],"normalized":true,"createdAt":"2025-11-30T00:05:00Z"}

View File

@@ -0,0 +1,58 @@
{
"bundleId": "11111111111111111111111111111111",
"tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"kind": "evaluation",
"createdAt": "2025-12-04T00:00:00Z",
"metadata": {
"scope": "demo",
"portable": "true"
},
"redaction": {
"portable": true,
"maskedFields": ["tenantId"],
"tenantToken": "portable-tenant-01"
},
"entries": [
{
"section": "manifest",
"canonicalPath": "bundle.json",
"sha256": "10695174db1b549d77be583e529a249713e9bd23e46cc5e73250db5dfc92c4a9",
"sizeBytes": 160,
"mediaType": "application/json",
"attributes": {
"role": "bundle",
"portable": "true"
}
},
{
"section": "evidence",
"canonicalPath": "observations.ndjson",
"sha256": "c523f82e71c8a1bd9be0650883faf00ec39a792023066105d7cda544ad6ef5fd",
"sizeBytes": 149,
"mediaType": "application/x-ndjson",
"attributes": {
"dataset": "observations"
}
},
{
"section": "evidence",
"canonicalPath": "linksets.ndjson",
"sha256": "a4d84bbc3262190fd3e1f5dbc15915c97e464326a56534483ce810c905288b9d",
"sizeBytes": 151,
"mediaType": "application/x-ndjson",
"attributes": {
"dataset": "linksets"
}
},
{
"section": "docs",
"canonicalPath": "instructions-portable.txt",
"sha256": "dd2a3b62857cf331b423e7dc3b869ad2dc9bfa852109a20bcbecc7bcef9bdcb7",
"sizeBytes": 180,
"mediaType": "text/plain",
"attributes": {
"purpose": "verification"
}
}
]
}

View File

@@ -0,0 +1 @@
{"observationId":"obs-demo-001","advisoryId":"CVE-2025-0001","component":"pkg:deb/openssl@1.1.1w","source":"nvd","fetchedAt":"2025-11-30T00:00:00Z"}

View File

@@ -0,0 +1,15 @@
{
"payloadType": "application/vnd.stellaops.evidence+json",
"payload": "ewogICJidW5kbGVJZCI6ICIxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsCiAgInRlbmFudElkIjogImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwKICAia2luZCI6ICJldmFsdWF0aW9uIiwKICAiY3JlYXRlZEF0IjogIjIwMjUtMTItMDRUMDA6MDA6MDBaIiwKICAibWV0YWRhdGEiOiB7CiAgICAic2NvcGUiOiAiZGVtbyIsCiAgICAicG9ydGFibGUiOiAidHJ1ZSIKICB9LAogICJyZWRhY3Rpb24iOiB7CiAgICAicG9ydGFibGUiOiB0cnVlLAogICAgIm1hc2tlZEZpZWxkcyI6IFsidGVuYW50SWQiXSwKICAgICJ0ZW5hbnRUb2tlbiI6ICJwb3J0YWJsZS10ZW5hbnQtMDEiCiAgfSwKICAiZW50cmllcyI6IFsKICAgIHsKICAgICAgInNlY3Rpb24iOiAibWFuaWZlc3QiLAogICAgICAiY2Fub25pY2FsUGF0aCI6ICJidW5kbGUuanNvbiIsCiAgICAgICJzaGEyNTYiOiAiMTA2OTUxNzRkYjFiNTQ5ZDc3YmU1ODNlNTI5YTI0OTcxM2U5YmQyM2U0NmNjNWU3MzI1MGRiNWRmYzkyYzRhOSIsCiAgICAgICJzaXplQnl0ZXMiOiAxNjAsCiAgICAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24vanNvbiIsCiAgICAgICJhdHRyaWJ1dGVzIjogewogICAgICAgICJyb2xlIjogImJ1bmRsZSIsCiAgICAgICAgInBvcnRhYmxlIjogInRydWUiCiAgICAgIH0KICAgIH0sCiAgICB7CiAgICAgICJzZWN0aW9uIjogImV2aWRlbmNlIiwKICAgICAgImNhbm9uaWNhbFBhdGgiOiAib2JzZXJ2YXRpb25zLm5kanNvbiIsCiAgICAgICJzaGEyNTYiOiAiYzUyM2Y4MmU3MWM4YTFiZDliZTA2NTA4ODNmYWYwMGVjMzlhNzkyMDIzMDY2MTA1ZDdjZGE1NDRhZDZlZjVmZCIsCiAgICAgICJzaXplQnl0ZXMiOiAxNDksCiAgICAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24veC1uZGpzb24iLAogICAgICAiYXR0cmlidXRlcyI6IHsKICAgICAgICAiZGF0YXNldCI6ICJvYnNlcnZhdGlvbnMiCiAgICAgIH0KICAgIH0sCiAgICB7CiAgICAgICJzZWN0aW9uIjogImV2aWRlbmNlIiwKICAgICAgImNhbm9uaWNhbFBhdGgiOiAibGlua3NldHMubmRqc29uIiwKICAgICAgInNoYTI1NiI6ICJhNGQ4NGJiYzMyNjIxOTBmZDNlMWY1ZGJjMTU5MTVjOTdlNDY0MzI2YTU2NTM0NDgzY2U4MTBjOTA1Mjg4YjlkIiwKICAgICAgInNpemVCeXRlcyI6IDE1MSwKICAgICAgIm1lZGlhVHlwZSI6ICJhcHBsaWNhdGlvbi94LW5kanNvbiIsCiAgICAgICJhdHRyaWJ1dGVzIjogewogICAgICAgICJkYXRhc2V0IjogImxpbmtzZXRzIgogICAgICB9CiAgICB9LAogICAgewogICAgICAic2VjdGlvbiI6ICJkb2NzIiwKICAgICAgImNhbm9uaWNhbFBhdGgiOiAiaW5zdHJ1Y3Rpb25zLXBvcnRhYmxlLnR4dCIsCiAgICAgICJzaGEyNTYiOiAiZGQyYTNiNjI4NTdjZjMzMWI0MjNlN2RjM2I4NjlhZDJkYzliZmE4NTIxMDlhMjBiY2JlY2M3YmNlZjliZGNiNyIsCiAgICAgICJzaXplQnl0ZXMiOiAxODAsCiAgICAgICJtZWRpYVR5cGUiOiAidGV4dC9wbGFpbiIsCiAgICAgICJhdHRyaWJ1dGVzIjogewogICAgICAgICJwdXJwb3NlIjogInZlcmlmaWNhdGlvbiIKICAgICAgfQogICAgfQogIF0KfQo=",
"signatures": [
{
"keyid": "demo-ed25519",
"sig": "MEQCIGZkZGVtb3NpZw==",
"algorithm": "ed25519",
"provider": "sovereign-default",
"subjectMerkleRoot": "72c82a7a3d114164d491e2ecd7098bc015b115ee1ec7c42d648f0348e573cfcf",
"transparency": null,
"log_policy": "skip-offline"
}
]
}

View File

@@ -0,0 +1,7 @@
{
"recordDigest": "sha256:8765b4a8411e76b36a2d2d43eba4c2197b4dcf0c5c0a11685ce46780a7c54222",
"sequence": 0,
"ledgerUri": "offline://demo-ledger",
"dsseEnvelope": "ZHNzZV9lbmNfZGVtbyIs",
"ordering": "recordedAtUtc, scanId"
}

View File

@@ -0,0 +1 @@
{"scanId":"22222222-2222-4222-8222-222222222222","tenantId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","subjectDigest":"sha256:c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596","scanKind":"sbom","startedAtUtc":"2025-12-03T00:00:00Z","completedAtUtc":"2025-12-03T00:10:00Z","recordedAtUtc":"2025-12-03T00:10:01Z","artifacts":[{"type":"sbom","digest":"sha256:aaaa","uri":"s3://demo/sbom"}],"provenance":{"dsseEnvelope":"ZHNzZV9lbmNfZGVtbyIs"},"summary":{"findings":1,"advisories":1,"policies":0}}

View File

@@ -0,0 +1 @@
8765b4a8411e76b36a2d2d43eba4c2197b4dcf0c5c0a11685ce46780a7c54222 replay.ndjson

View File

@@ -0,0 +1,7 @@
{
"bundleId": "11111111111111111111111111111111",
"tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"kind": "evaluation",
"createdAt": "2025-12-04T00:00:00Z",
"portable": false
}

View File

@@ -0,0 +1,14 @@
{
"algorithm": "sha256",
"root": "c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596",
"generatedAt": "2025-12-04T00:00:00Z",
"bundleId": "11111111111111111111111111111111",
"tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"entries": [
{ "canonicalPath": "bundle.json", "sha256": "86872809b585f9b43f53b12a8fb27dbb0a3b9c4f74e41c38118877ebcff1c273", "sizeBytes": 187 },
{ "canonicalPath": "instructions.txt", "sha256": "39a5880af850121919a540dd4528e49a3b5687cb922195b07db2c56f9e90dd1b", "sizeBytes": 160 },
{ "canonicalPath": "linksets.ndjson", "sha256": "a4d84bbc3262190fd3e1f5dbc15915c97e464326a56534483ce810c905288b9d", "sizeBytes": 151 },
{ "canonicalPath": "observations.ndjson", "sha256": "c523f82e71c8a1bd9be0650883faf00ec39a792023066105d7cda544ad6ef5fd", "sizeBytes": 149 }
],
"chunking": { "strategy": "none" }
}

View File

@@ -0,0 +1,14 @@
{
"bundleId": "11111111111111111111111111111111",
"tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"merkleRoot": "c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596",
"subject": "sha256:c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596",
"entries": [
"bundle.json",
"instructions.txt",
"linksets.ndjson",
"observations.ndjson"
],
"dsseKeyId": "demo-ed25519",
"logPolicy": "skip-offline"
}

View File

@@ -0,0 +1,4 @@
Offline verification steps:
1) sha256sum -c checksums.txt
2) merkle_root=$(sha256sum checksums.txt | awk '{print $1}')
3) compare merkle_root with DSSE subject

View File

@@ -0,0 +1 @@
{"linksetId":"lnk-demo-001","advisoryId":"CVE-2025-0001","components":["pkg:deb/openssl@1.1.1w"],"normalized":true,"createdAt":"2025-11-30T00:05:00Z"}

View File

@@ -0,0 +1,52 @@
{
"bundleId": "11111111111111111111111111111111",
"tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"kind": "evaluation",
"createdAt": "2025-12-04T00:00:00Z",
"metadata": {
"scope": "demo",
"advisory": "CVE-2025-0001"
},
"entries": [
{
"section": "manifest",
"canonicalPath": "bundle.json",
"sha256": "86872809b585f9b43f53b12a8fb27dbb0a3b9c4f74e41c38118877ebcff1c273",
"sizeBytes": 187,
"mediaType": "application/json",
"attributes": {
"role": "bundle"
}
},
{
"section": "evidence",
"canonicalPath": "observations.ndjson",
"sha256": "c523f82e71c8a1bd9be0650883faf00ec39a792023066105d7cda544ad6ef5fd",
"sizeBytes": 149,
"mediaType": "application/x-ndjson",
"attributes": {
"dataset": "observations"
}
},
{
"section": "evidence",
"canonicalPath": "linksets.ndjson",
"sha256": "a4d84bbc3262190fd3e1f5dbc15915c97e464326a56534483ce810c905288b9d",
"sizeBytes": 151,
"mediaType": "application/x-ndjson",
"attributes": {
"dataset": "linksets"
}
},
{
"section": "docs",
"canonicalPath": "instructions.txt",
"sha256": "39a5880af850121919a540dd4528e49a3b5687cb922195b07db2c56f9e90dd1b",
"sizeBytes": 160,
"mediaType": "text/plain",
"attributes": {
"purpose": "verification"
}
}
]
}

View File

@@ -0,0 +1 @@
{"observationId":"obs-demo-001","advisoryId":"CVE-2025-0001","component":"pkg:deb/openssl@1.1.1w","source":"nvd","fetchedAt":"2025-11-30T00:00:00Z"}

View File

@@ -0,0 +1,15 @@
{
"payloadType": "application/vnd.stellaops.evidence+json",
"payload": "ewogICJidW5kbGVJZCI6ICIxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsCiAgInRlbmFudElkIjogImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwKICAia2luZCI6ICJldmFsdWF0aW9uIiwKICAiY3JlYXRlZEF0IjogIjIwMjUtMTItMDRUMDA6MDA6MDBaIiwKICAibWV0YWRhdGEiOiB7CiAgICAic2NvcGUiOiAiZGVtbyIsCiAgICAiYWR2aXNvcnkiOiAiQ1ZFLTIwMjUtMDAwMSIKICB9LAogICJlbnRyaWVzIjogWwogICAgewogICAgICAic2VjdGlvbiI6ICJtYW5pZmVzdCIsCiAgICAgICJjYW5vbmljYWxQYXRoIjogImJ1bmRsZS5qc29uIiwKICAgICAgInNoYTI1NiI6ICI4Njg3MjgwOWI1ODVmOWI0M2Y1M2IxMmE4ZmIyN2RiYjBhM2I5YzRmNzRlNDFjMzgxMTg4NzdlYmNmZjFjMjczIiwKICAgICAgInNpemVCeXRlcyI6IDE4NywKICAgICAgIm1lZGlhVHlwZSI6ICJhcHBsaWNhdGlvbi9qc29uIiwKICAgICAgImF0dHJpYnV0ZXMiOiB7CiAgICAgICAgInJvbGUiOiAiYnVuZGxlIgogICAgICB9CiAgICB9LAogICAgewogICAgICAic2VjdGlvbiI6ICJldmlkZW5jZSIsCiAgICAgICJjYW5vbmljYWxQYXRoIjogIm9ic2VydmF0aW9ucy5uZGpzb24iLAogICAgICAic2hhMjU2IjogImM1MjNmODJlNzFjOGExYmQ5YmUwNjUwODgzZmFmMDBlYzM5YTc5MjAyMzA2NjEwNWQ3Y2RhNTQ0YWQ2ZWY1ZmQiLAogICAgICAic2l6ZUJ5dGVzIjogMTQ5LAogICAgICAibWVkaWFUeXBlIjogImFwcGxpY2F0aW9uL3gtbmRqc29uIiwKICAgICAgImF0dHJpYnV0ZXMiOiB7CiAgICAgICAgImRhdGFzZXQiOiAib2JzZXJ2YXRpb25zIgogICAgICB9CiAgICB9LAogICAgewogICAgICAic2VjdGlvbiI6ICJldmlkZW5jZSIsCiAgICAgICJjYW5vbmljYWxQYXRoIjogImxpbmtzZXRzLm5kanNvbiIsCiAgICAgICJzaGEyNTYiOiAiYTRkODRiYmMzMjYyMTkwZmQzZTFmNWRiYzE1OTE1Yzk3ZTQ2NDMyNmE1NjUzNDQ4M2NlODEwYzkwNTI4OGI5ZCIsCiAgICAgICJzaXplQnl0ZXMiOiAxNTEsCiAgICAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24veC1uZGpzb24iLAogICAgICAiYXR0cmlidXRlcyI6IHsKICAgICAgICAiZGF0YXNldCI6ICJsaW5rc2V0cyIKICAgICAgfQogICAgfSwKICAgIHsKICAgICAgInNlY3Rpb24iOiAiZG9jcyIsCiAgICAgICJjYW5vbmljYWxQYXRoIjogImluc3RydWN0aW9ucy50eHQiLAogICAgICAic2hhMjU2IjogIjM5YTU4ODBhZjg1MDEyMTkxOWE1NDBkZDQ1MjhlNDlhM2I1Njg3Y2I5MjIxOTViMDdkYjJjNTZmOWU5MGRkMWIiLAogICAgICAic2l6ZUJ5dGVzIjogMTYwLAogICAgICAibWVkaWFUeXBlIjogInRleHQvcGxhaW4iLAogICAgICAiYXR0cmlidXRlcyI6IHsKICAgICAgICAicHVycG9zZSI6ICJ2ZXJpZmljYXRpb24iCiAgICAgIH0KICAgIH0KICBdCn0K",
"signatures": [
{
"keyid": "demo-ed25519",
"sig": "MEQCIGZkZGVtb3NpZw==",
"algorithm": "ed25519",
"provider": "sovereign-default",
"subjectMerkleRoot": "c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596",
"transparency": null,
"log_policy": "skip-offline"
}
]
}

View File

@@ -0,0 +1,148 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Graph.Indexer.Ingestion.Advisory;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
using Xunit;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class AdvisoryLinksetProcessorTests
{
[Fact]
public async Task ProcessAsync_persists_batch_and_records_success()
{
var snapshot = CreateSnapshot();
var transformer = new AdvisoryLinksetTransformer();
var writer = new CaptureWriter();
var metrics = new CaptureMetrics();
var processor = new AdvisoryLinksetProcessor(
transformer,
writer,
metrics,
NullLogger<AdvisoryLinksetProcessor>.Instance);
await processor.ProcessAsync(snapshot, CancellationToken.None);
writer.LastBatch.Should().NotBeNull();
writer.LastBatch!.Edges.Length.Should().Be(1, "duplicate impacts should collapse into one edge");
metrics.LastRecord.Should().NotBeNull();
metrics.LastRecord!.Success.Should().BeTrue();
metrics.LastRecord.NodeCount.Should().Be(writer.LastBatch!.Nodes.Length);
metrics.LastRecord.EdgeCount.Should().Be(writer.LastBatch!.Edges.Length);
}
[Fact]
public async Task ProcessAsync_records_failure_when_writer_throws()
{
var snapshot = CreateSnapshot();
var transformer = new AdvisoryLinksetTransformer();
var writer = new CaptureWriter(shouldThrow: true);
var metrics = new CaptureMetrics();
var processor = new AdvisoryLinksetProcessor(
transformer,
writer,
metrics,
NullLogger<AdvisoryLinksetProcessor>.Instance);
var act = () => processor.ProcessAsync(snapshot, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
metrics.LastRecord.Should().NotBeNull();
metrics.LastRecord!.Success.Should().BeFalse();
}
private static AdvisoryLinksetSnapshot CreateSnapshot()
{
return new AdvisoryLinksetSnapshot
{
Tenant = "tenant-alpha",
Source = "concelier.overlay.v1",
LinksetDigest = "sha256:linkset001",
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:05:00Z"),
EventOffset = 2201,
Advisory = new AdvisoryDetails
{
Source = "concelier.linkset.v1",
AdvisorySource = "ghsa",
AdvisoryId = "GHSA-1234-5678-90AB",
Severity = "HIGH",
PublishedAt = DateTimeOffset.Parse("2025-10-25T09:00:00Z"),
ContentHash = "sha256:ddd444"
},
Components = new[]
{
new AdvisoryComponentImpact
{
ComponentPurl = "pkg:nuget/Newtonsoft.Json@13.0.3",
ComponentSourceType = "inventory",
EvidenceDigest = "sha256:evidence004",
MatchedVersions = new[] { "13.0.3" },
Cvss = 8.1,
Confidence = 0.9,
Source = "concelier.overlay.v1",
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:05:10Z"),
EventOffset = 3100,
SbomDigest = "sha256:sbom111"
},
new AdvisoryComponentImpact
{
ComponentPurl = "pkg:nuget/Newtonsoft.Json@13.0.3",
ComponentSourceType = "inventory",
EvidenceDigest = "sha256:evidence004",
MatchedVersions = new[] { "13.0.3" },
Cvss = 8.1,
Confidence = 0.9,
Source = "concelier.overlay.v1",
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:05:10Z"),
EventOffset = 3100,
SbomDigest = "sha256:sbom111"
}
}
};
}
private sealed class CaptureWriter : IGraphDocumentWriter
{
private readonly bool _shouldThrow;
public CaptureWriter(bool shouldThrow = false)
{
_shouldThrow = shouldThrow;
}
public GraphBuildBatch? LastBatch { get; private set; }
public Task WriteAsync(GraphBuildBatch batch, CancellationToken cancellationToken)
{
LastBatch = batch;
if (_shouldThrow)
{
throw new InvalidOperationException("Simulated write failure");
}
return Task.CompletedTask;
}
}
private sealed class CaptureMetrics : IAdvisoryLinksetMetrics
{
public BatchRecord? LastRecord { get; private set; }
public void RecordBatch(string source, string tenant, int nodeCount, int edgeCount, TimeSpan duration, bool success)
{
LastRecord = new BatchRecord(source, tenant, nodeCount, edgeCount, duration, success);
}
}
private sealed record BatchRecord(
string Source,
string Tenant,
int NodeCount,
int EdgeCount,
TimeSpan Duration,
bool Success);
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using StellaOps.Graph.Indexer.Ingestion.Advisory;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class AdvisoryLinksetTransformerTests
{
private readonly ITestOutputHelper _output;
public AdvisoryLinksetTransformerTests(ITestOutputHelper output)
{
_output = output;
}
private static readonly string FixturesRoot =
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
private static readonly HashSet<string> ExpectedNodeKinds = new(StringComparer.Ordinal)
{
"advisory"
};
private static readonly HashSet<string> ExpectedEdgeKinds = new(StringComparer.Ordinal)
{
"AFFECTED_BY"
};
[Fact]
public void Transform_projects_advisory_nodes_and_affected_by_edges()
{
var snapshot = LoadSnapshot("concelier-linkset.json");
var transformer = new AdvisoryLinksetTransformer();
var batch = transformer.Transform(snapshot);
var expectedNodes = LoadArray("nodes.json")
.Cast<JsonObject>()
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var expectedEdges = LoadArray("edges.json")
.Cast<JsonObject>()
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var actualNodes = batch.Nodes
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var actualEdges = batch.Edges
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
actualNodes.Length.Should().Be(expectedNodes.Length);
actualEdges.Length.Should().Be(expectedEdges.Length);
for (var i = 0; i < expectedNodes.Length; i++)
{
if (!JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]))
{
_output.WriteLine($"Expected Node: {expectedNodes[i]}");
_output.WriteLine($"Actual Node: {actualNodes[i]}");
}
JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]).Should().BeTrue();
}
for (var i = 0; i < expectedEdges.Length; i++)
{
if (!JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]))
{
_output.WriteLine($"Expected Edge: {expectedEdges[i]}");
_output.WriteLine($"Actual Edge: {actualEdges[i]}");
}
JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]).Should().BeTrue();
}
}
private static AdvisoryLinksetSnapshot LoadSnapshot(string fileName)
{
var path = Path.Combine(FixturesRoot, fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<AdvisoryLinksetSnapshot>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
}
private static JsonArray LoadArray(string fileName)
{
var path = Path.Combine(FixturesRoot, fileName);
return (JsonArray)JsonNode.Parse(File.ReadAllText(path))!;
}
}

View File

@@ -0,0 +1,54 @@
using System.IO;
using System.Text.Json.Nodes;
using FluentAssertions;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
using Xunit;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class FileSystemSnapshotFileWriterTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"graph-snapshots-{Guid.NewGuid():N}");
[Fact]
public async Task WriteJsonAsync_writes_canonical_json()
{
var writer = new FileSystemSnapshotFileWriter(_root);
var json = new JsonObject
{
["b"] = "value2",
["a"] = "value1"
};
await writer.WriteJsonAsync("manifest.json", json, CancellationToken.None);
var content = await File.ReadAllTextAsync(Path.Combine(_root, "manifest.json"));
content.Should().Be("{\"a\":\"value1\",\"b\":\"value2\"}");
}
[Fact]
public async Task WriteJsonLinesAsync_writes_each_object_on_new_line()
{
var writer = new FileSystemSnapshotFileWriter(_root);
var items = new[]
{
new JsonObject { ["id"] = "1", ["kind"] = "component" },
new JsonObject { ["id"] = "2", ["kind"] = "artifact" }
};
await writer.WriteJsonLinesAsync("nodes.jsonl", items, CancellationToken.None);
var lines = await File.ReadAllLinesAsync(Path.Combine(_root, "nodes.jsonl"));
lines.Should().HaveCount(2);
lines[0].Should().Be("{\"id\":\"1\",\"kind\":\"component\"}");
lines[1].Should().Be("{\"id\":\"2\",\"kind\":\"artifact\"}");
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
}

View File

@@ -0,0 +1,32 @@
{
"tenant": "tenant-alpha",
"source": "concelier.overlay.v1",
"linksetDigest": "sha256:linkset001",
"collectedAt": "2025-10-30T12:05:10Z",
"eventOffset": 3100,
"advisory": {
"source": "concelier.linkset.v1",
"advisorySource": "ghsa",
"advisoryId": "GHSA-1234-5678-90AB",
"severity": "HIGH",
"publishedAt": "2025-10-25T09:00:00Z",
"contentHash": "sha256:ddd444",
"linksetDigest": "sha256:linkset001"
},
"components": [
{
"purl": "pkg:nuget/Newtonsoft.Json@13.0.3",
"sourceType": "inventory",
"sbomDigest": "sha256:sbom111",
"evidenceDigest": "sha256:evidence004",
"matchedVersions": [
"13.0.3"
],
"cvss": 8.1,
"confidence": 0.9,
"source": "concelier.overlay.v1",
"collectedAt": "2025-10-30T12:05:10Z",
"eventOffset": 3100
}
]
}

View File

@@ -0,0 +1,209 @@
[
{
"kind": "CONTAINS",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"artifact_node_id": "gn:tenant-alpha:artifact:RX033HH7S6JXMY66QM51S89SX76B3JXJHWHPXPPBJCD05BR3GVXG",
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
"sbom_digest": "sha256:sbom111"
},
"attributes": {
"detected_by": "sbom.analyzer.nuget",
"layer_digest": "sha256:layer123",
"scope": "runtime",
"evidence_digest": "sha256:evidence001"
},
"provenance": {
"source": "scanner.sbom.v1",
"collected_at": "2025-10-30T12:00:02Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 2100
},
"valid_from": "2025-10-30T12:00:02Z",
"valid_to": null,
"id": "ge:tenant-alpha:CONTAINS:EVA5N7P029VYV9W8Q7XJC0JFTEQYFSAQ6381SNVM3T1G5290XHTG",
"hash": "139e534be32f666cbd8e4fb0daee629b7b133ef8d10e98413ffc33fde59f7935"
},
{
"kind": "DEPENDS_ON",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
"dependency_purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
"sbom_digest": "sha256:sbom111"
},
"attributes": {
"dependency_purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
"dependency_version": "4.7.0",
"relationship": "direct",
"evidence_digest": "sha256:evidence002"
},
"provenance": {
"source": "scanner.sbom.v1",
"collected_at": "2025-10-30T12:00:02Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 2101
},
"valid_from": "2025-10-30T12:00:02Z",
"valid_to": null,
"id": "ge:tenant-alpha:DEPENDS_ON:FJ7GZ9RHPKPR30XVKECD702QG20PGT3V75DY1GST8AAW9SR8TBB0",
"hash": "4caae0dff840dee840d413005f1b493936446322e8cfcecd393983184cc399c1"
},
{
"kind": "DECLARED_IN",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
"file_node_id": "gn:tenant-alpha:file:M1MWHCXA66MQE8FZMPK3RNRMN7Z18H4VGWX6QTNNBKABFKRACKDG",
"sbom_digest": "sha256:sbom111"
},
"attributes": {
"detected_by": "sbom.analyzer.nuget",
"scope": "runtime",
"evidence_digest": "sha256:evidence003"
},
"provenance": {
"source": "scanner.layer.v1",
"collected_at": "2025-10-30T12:00:03Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 2102
},
"valid_from": "2025-10-30T12:00:03Z",
"valid_to": null,
"id": "ge:tenant-alpha:DECLARED_IN:T7E8NQEMKXPZ3T1SWT8HXKWAHJVS9QKD87XBKAQAAQ29CDHEA47G",
"hash": "2a2e7ba8785d75eb11feebc2df99a6a04d05ee609b36cbe0b15fa142e4c4f184"
},
{
"kind": "BUILT_FROM",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"parent_artifact_node_id": "gn:tenant-alpha:artifact:RX033HH7S6JXMY66QM51S89SX76B3JXJHWHPXPPBJCD05BR3GVXG",
"child_artifact_digest": "sha256:base000"
},
"attributes": {
"build_type": "https://slsa.dev/provenance/v1",
"builder_id": "builder://tekton/pipeline/default",
"attestation_digest": "sha256:attestation001"
},
"provenance": {
"source": "scanner.provenance.v1",
"collected_at": "2025-10-30T12:00:05Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 2103
},
"valid_from": "2025-10-30T12:00:05Z",
"valid_to": null,
"id": "ge:tenant-alpha:BUILT_FROM:HJNKVFSDSA44HRY0XAJ0GBEVPD2S82JFF58BZVRT9QF6HB2EGPJG",
"hash": "17bdb166f4ba05406ed17ec38d460fb83bd72cec60095f0966b1d79c2a55f1de"
},
{
"kind": "AFFECTED_BY",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
"advisory_node_id": "gn:tenant-alpha:advisory:RFGYXZ2TG0BF117T3HCX3XYAZFXPD72991QD0JZWDVY7FXYY87R0",
"linkset_digest": "sha256:linkset001"
},
"attributes": {
"evidence_digest": "sha256:evidence004",
"matched_versions": [
"13.0.3"
],
"cvss": 8.1,
"confidence": 0.9
},
"provenance": {
"source": "concelier.overlay.v1",
"collected_at": "2025-10-30T12:05:10Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 3100
},
"valid_from": "2025-10-30T12:05:10Z",
"valid_to": null,
"id": "ge:tenant-alpha:AFFECTED_BY:1V3NRKAR6KMXAWZ89R69G8JAY3HV7DXNB16YY9X25X1TAFW9VGYG",
"hash": "45e845ee51dc2e8e8990707906bddcd3ecedf209de10b87ce8eed604dcc51ff5"
},
{
"kind": "VEX_EXEMPTS",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
"vex_node_id": "gn:tenant-alpha:vex_statement:BVRF35CX6TZTHPD7YFHYTJJACPYJD86JP7C74SH07QT9JT82NDSG",
"statement_hash": "sha256:eee555"
},
"attributes": {
"status": "not_affected",
"justification": "component not present",
"impact_statement": "Library not loaded at runtime",
"evidence_digest": "sha256:evidence005"
},
"provenance": {
"source": "excititor.overlay.v1",
"collected_at": "2025-10-30T12:06:10Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 3200
},
"valid_from": "2025-10-30T12:06:10Z",
"valid_to": null,
"id": "ge:tenant-alpha:VEX_EXEMPTS:DT0BBCM9S0KJVF61KVR7D2W8DVFTKK03F3TFD4DR9DRS0T5CWZM0",
"hash": "0ae4085e510898e68ad5cb48b7385a1ae9af68fcfea9bd5c22c47d78bb1c2f2e"
},
{
"kind": "GOVERNS_WITH",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"policy_node_id": "gn:tenant-alpha:policy_version:YZSMWHHR6Y5XR1HFRBV3H5TR6GMZVN9BPDAAVQEACV7XRYP06390",
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
"finding_explain_hash": "sha256:explain001"
},
"attributes": {
"verdict": "fail",
"explain_hash": "sha256:explain001",
"policy_rule_id": "rule:runtime/critical-dependency",
"evaluation_timestamp": "2025-10-30T12:07:00Z"
},
"provenance": {
"source": "policy.engine.v1",
"collected_at": "2025-10-30T12:07:00Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 4200
},
"valid_from": "2025-10-30T12:07:00Z",
"valid_to": null,
"id": "ge:tenant-alpha:GOVERNS_WITH:XG3KQTYT8D4NY0BTFXWGBQY6TXR2MRYDWZBQT07T0200NQ72AFG0",
"hash": "38a05081a9b046bfd391505d47da6b7c6e3a74e114999b38a4e4e9341f2dc279"
},
{
"kind": "OBSERVED_RUNTIME",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"runtime_node_id": "gn:tenant-alpha:runtime_context:EFVARD7VM4710F8554Q3NGH0X8W7XRF3RDARE8YJWK1H3GABX8A0",
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
"runtime_fingerprint": "pod-abc123"
},
"attributes": {
"process_name": "dotnet",
"entrypoint_kind": "container",
"runtime_evidence_digest": "sha256:evidence006",
"confidence": 0.8
},
"provenance": {
"source": "signals.runtime.v1",
"collected_at": "2025-10-30T12:15:10Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 5200
},
"valid_from": "2025-10-30T12:15:10Z",
"valid_to": null,
"id": "ge:tenant-alpha:OBSERVED_RUNTIME:CVV4ACPPJVHWX2NRZATB8H045F71HXT59TQHEZE2QBAQGJDK1FY0",
"hash": "15d24ebdf126b6f8947d3041f8cbb291bb66e8f595737a7c7dd2683215568367"
}
]

View File

@@ -0,0 +1,34 @@
{
"tenant": "tenant-alpha",
"source": "excititor.overlay.v1",
"collectedAt": "2025-10-30T12:06:10Z",
"eventOffset": 3200,
"statement": {
"vexSource": "vendor-x",
"statementId": "statement-789",
"status": "not_affected",
"justification": "component not present",
"impactStatement": "Library not loaded at runtime",
"issuedAt": "2025-10-27T14:30:00Z",
"expiresAt": "2026-10-27T14:30:00Z",
"contentHash": "sha256:eee555",
"provenanceSource": "excititor.vex.v1",
"collectedAt": "2025-10-30T12:06:00Z",
"eventOffset": 3302
},
"exemptions": [
{
"componentPurl": "pkg:nuget/Newtonsoft.Json@13.0.3",
"componentSourceType": "inventory",
"sbomDigest": "sha256:sbom111",
"statementHash": "sha256:eee555",
"status": "not_affected",
"justification": "component not present",
"impactStatement": "Library not loaded at runtime",
"evidenceDigest": "sha256:evidence005",
"provenanceSource": "excititor.overlay.v1",
"collectedAt": "2025-10-30T12:06:10Z",
"eventOffset": 3200
}
]
}

View File

@@ -0,0 +1,29 @@
{
tenant: tenant-alpha,
source: concelier.overlay.v1,
linksetDigest: sha256:linkset001,
collectedAt: 2025-10-30T12:05:00Z,
eventOffset: 2201,
advisory: {
source: concelier.linkset.v1,
advisorySource: ghsa,
advisoryId: GHSA-1234-5678-90AB,
contentHash: sha256:ddd444,
severity: HIGH,
publishedAt: 2025-10-25T09:00:00Z
},
components: [
{
purl: pkg:nuget/Newtonsoft.Json@13.0.3,
sourceType: inventory,
sbomDigest: sha256:sbom111,
evidenceDigest: sha256:evidence004,
matchedVersions: [13.0.3],
cvss: 8.1,
confidence: 0.9,
collectedAt: 2025-10-30T12:05:10Z,
eventOffset: 3100,
source: concelier.overlay.v1
}
]
}

View File

@@ -0,0 +1,280 @@
[
{
"kind": "artifact",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"artifact_digest": "sha256:aaa111",
"sbom_digest": "sha256:sbom111"
},
"attributes": {
"display_name": "registry.example.com/team/app:1.2.3",
"artifact_digest": "sha256:aaa111",
"sbom_digest": "sha256:sbom111",
"environment": "prod",
"labels": [
"critical",
"payments"
],
"origin_registry": "registry.example.com",
"supply_chain_stage": "deploy"
},
"provenance": {
"source": "scanner.sbom.v1",
"collected_at": "2025-10-30T12:00:00Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 1182
},
"valid_from": "2025-10-30T12:00:00Z",
"valid_to": null,
"id": "gn:tenant-alpha:artifact:RX033HH7S6JXMY66QM51S89SX76B3JXJHWHPXPPBJCD05BR3GVXG",
"hash": "891601471f7dea636ec2988966b3aee3721a1faedb7e1c8e2834355eb4e31cfd"
},
{
"kind": "artifact",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"artifact_digest": "sha256:base000",
"sbom_digest": "sha256:sbom-base"
},
"attributes": {
"display_name": "registry.example.com/base/runtime:2025.09",
"artifact_digest": "sha256:base000",
"sbom_digest": "sha256:sbom-base",
"environment": "prod",
"labels": [
"base-image"
],
"origin_registry": "registry.example.com",
"supply_chain_stage": "build"
},
"provenance": {
"source": "scanner.sbom.v1",
"collected_at": "2025-10-22T08:00:00Z",
"sbom_digest": "sha256:sbom-base",
"event_offset": 800
},
"valid_from": "2025-10-22T08:00:00Z",
"valid_to": null,
"id": "gn:tenant-alpha:artifact:KD207PSJ36Q0B19CT8K8H2FQCV0HGQRNK8QWHFXE1VWAKPF9XH00",
"hash": "11593184fe6aa37a0e1d1909d4a401084a9ca452959a369590ac20d4dff77bd8"
},
{
"kind": "component",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"purl": "pkg:nuget/Newtonsoft.Json@13.0.3",
"source_type": "inventory"
},
"attributes": {
"purl": "pkg:nuget/Newtonsoft.Json@13.0.3",
"version": "13.0.3",
"ecosystem": "nuget",
"scope": "runtime",
"license_spdx": "MIT",
"usage": "direct"
},
"provenance": {
"source": "scanner.sbom.v1",
"collected_at": "2025-10-30T12:00:01Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 1183
},
"valid_from": "2025-10-30T12:00:01Z",
"valid_to": null,
"id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
"hash": "e4c22e7522573b746c654bb6bdd05d01db1bcd34db8b22e5e12d2e8528268786"
},
{
"kind": "component",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
"source_type": "inventory"
},
"attributes": {
"purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
"version": "4.7.0",
"ecosystem": "nuget",
"scope": "runtime",
"license_spdx": "MIT",
"usage": "transitive"
},
"provenance": {
"source": "scanner.sbom.v1",
"collected_at": "2025-10-30T12:00:01Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 1184
},
"valid_from": "2025-10-30T12:00:01Z",
"valid_to": null,
"id": "gn:tenant-alpha:component:FZ9EHXFFGPDQAEKAPWZ4JX5X6KYS467PJ5D1Y4T9NFFQG2SG0DV0",
"hash": "b941ff7178451b7a0403357d08ed8996e8aea1bf40032660e18406787e57ce3f"
},
{
"kind": "file",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"artifact_digest": "sha256:aaa111",
"normalized_path": "/src/app/Program.cs",
"content_sha256": "sha256:bbb222"
},
"attributes": {
"normalized_path": "/src/app/Program.cs",
"content_sha256": "sha256:bbb222",
"language_hint": "csharp",
"size_bytes": 3472,
"scope": "build"
},
"provenance": {
"source": "scanner.layer.v1",
"collected_at": "2025-10-30T12:00:02Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 1185
},
"valid_from": "2025-10-30T12:00:02Z",
"valid_to": null,
"id": "gn:tenant-alpha:file:M1MWHCXA66MQE8FZMPK3RNRMN7Z18H4VGWX6QTNNBKABFKRACKDG",
"hash": "a0a7e7b6ff4a8357bea3273e38b3a3d801531a4f6b716513b7d4972026db3a76"
},
{
"kind": "license",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"license_spdx": "Apache-2.0",
"source_digest": "sha256:ccc333"
},
"attributes": {
"license_spdx": "Apache-2.0",
"name": "Apache License 2.0",
"classification": "permissive",
"notice_uri": "https://www.apache.org/licenses/LICENSE-2.0"
},
"provenance": {
"source": "scanner.sbom.v1",
"collected_at": "2025-10-30T12:00:03Z",
"sbom_digest": "sha256:sbom111",
"event_offset": 1186
},
"valid_from": "2025-10-30T12:00:03Z",
"valid_to": null,
"id": "gn:tenant-alpha:license:7SDDWTRKXYG9MBK89X7JFMAQRBEZHV1NFZNSN2PBRZT5H0FHZB90",
"hash": "790f1d803dd35d9f77b08977e4dd3fc9145218ee7c68524881ee13b7a2e9ede8"
},
{
"tenant": "tenant-alpha",
"kind": "advisory",
"canonical_key": {
"advisory_id": "GHSA-1234-5678-90AB",
"advisory_source": "ghsa",
"content_hash": "sha256:ddd444",
"tenant": "tenant-alpha"
},
"attributes": {
"advisory_source": "ghsa",
"advisory_id": "GHSA-1234-5678-90AB",
"severity": "HIGH",
"published_at": "2025-10-25T09:00:00Z",
"content_hash": "sha256:ddd444",
"linkset_digest": "sha256:linkset001"
},
"provenance": {
"source": "concelier.linkset.v1",
"collected_at": "2025-10-30T12:05:10Z",
"sbom_digest": null,
"event_offset": 3100
},
"valid_from": "2025-10-25T09:00:00Z",
"valid_to": null,
"id": "gn:tenant-alpha:advisory:RFGYXZ2TG0BF117T3HCX3XYAZFXPD72991QD0JZWDVY7FXYY87R0",
"hash": "df4b4087dc6bf4c8b071ce808b97025036a6d33d30ea538a279a4f55ed7ffb8e"
},
{
"tenant": "tenant-alpha",
"kind": "vex_statement",
"canonical_key": {
"content_hash": "sha256:eee555",
"statement_id": "statement-789",
"tenant": "tenant-alpha",
"vex_source": "vendor-x"
},
"attributes": {
"status": "not_affected",
"statement_id": "statement-789",
"justification": "component not present",
"issued_at": "2025-10-27T14:30:00Z",
"expires_at": "2026-10-27T14:30:00Z",
"content_hash": "sha256:eee555"
},
"provenance": {
"source": "excititor.vex.v1",
"collected_at": "2025-10-30T12:06:00Z",
"sbom_digest": null,
"event_offset": 3302
},
"valid_from": "2025-10-27T14:30:00Z",
"valid_to": null,
"id": "gn:tenant-alpha:vex_statement:BVRF35CX6TZTHPD7YFHYTJJACPYJD86JP7C74SH07QT9JT82NDSG",
"hash": "4b613e2b8460c542597bbc70b8ba3e6796c3e1d261d0c74ce30fba42f7681f25"
},
{
"kind": "policy_version",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"policy_pack_digest": "sha256:fff666",
"effective_from": "2025-10-28T00:00:00Z"
},
"attributes": {
"policy_pack_digest": "sha256:fff666",
"policy_name": "Default Runtime Policy",
"effective_from": "2025-10-28T00:00:00Z",
"expires_at": "2026-01-01T00:00:00Z",
"explain_hash": "sha256:explain001"
},
"provenance": {
"source": "policy.engine.v1",
"collected_at": "2025-10-28T00:00:05Z",
"sbom_digest": null,
"event_offset": 4100
},
"valid_from": "2025-10-28T00:00:00Z",
"valid_to": "2026-01-01T00:00:00Z",
"id": "gn:tenant-alpha:policy_version:YZSMWHHR6Y5XR1HFRBV3H5TR6GMZVN9BPDAAVQEACV7XRYP06390",
"hash": "a8539c4d611535c3afcfd406a08208ab3bbfc81f6e31f87dd727b7d8bd9c4209"
},
{
"kind": "runtime_context",
"tenant": "tenant-alpha",
"canonical_key": {
"tenant": "tenant-alpha",
"runtime_fingerprint": "pod-abc123",
"collector": "zastava.v1",
"observed_at": "2025-10-30T12:15:00Z"
},
"attributes": {
"runtime_fingerprint": "pod-abc123",
"collector": "zastava.v1",
"observed_at": "2025-10-30T12:15:00Z",
"cluster": "prod-cluster-1",
"namespace": "payments",
"workload_kind": "deployment",
"runtime_state": "Running"
},
"provenance": {
"source": "signals.runtime.v1",
"collected_at": "2025-10-30T12:15:05Z",
"sbom_digest": null,
"event_offset": 5109
},
"valid_from": "2025-10-30T12:15:00Z",
"valid_to": null,
"id": "gn:tenant-alpha:runtime_context:EFVARD7VM4710F8554Q3NGH0X8W7XRF3RDARE8YJWK1H3GABX8A0",
"hash": "0294c4131ba98d52674ca31a409488b73f47a193cf3a13cede8671e6112a5a29"
}
]

View File

@@ -0,0 +1,31 @@
{
"tenant": "tenant-alpha",
"source": "policy.engine.v1",
"collectedAt": "2025-10-30T12:07:00Z",
"eventOffset": 4200,
"policy": {
"source": "policy.engine.v1",
"policyPackDigest": "sha256:fff666",
"policyName": "Default Runtime Policy",
"effectiveFrom": "2025-10-28T00:00:00Z",
"expiresAt": "2026-01-01T00:00:00Z",
"explainHash": "sha256:explain001",
"collectedAt": "2025-10-28T00:00:05Z",
"eventOffset": 4100
},
"evaluations": [
{
"componentPurl": "pkg:nuget/Newtonsoft.Json@13.0.3",
"componentSourceType": "inventory",
"findingExplainHash": "sha256:explain001",
"explainHash": "sha256:explain001",
"policyRuleId": "rule:runtime/critical-dependency",
"verdict": "fail",
"evaluationTimestamp": "2025-10-30T12:07:00Z",
"sbomDigest": "sha256:sbom111",
"source": "policy.engine.v1",
"collectedAt": "2025-10-30T12:07:00Z",
"eventOffset": 4200
}
]
}

View File

@@ -0,0 +1,110 @@
{
"tenant": "tenant-alpha",
"source": "scanner.sbom.v1",
"artifactDigest": "sha256:aaa111",
"sbomDigest": "sha256:sbom111",
"collectedAt": "2025-10-30T12:00:00Z",
"eventOffset": 1182,
"artifact": {
"displayName": "registry.example.com/team/app:1.2.3",
"environment": "prod",
"labels": [
"critical",
"payments"
],
"originRegistry": "registry.example.com",
"supplyChainStage": "deploy"
},
"build": {
"builderId": "builder://tekton/pipeline/default",
"buildType": "https://slsa.dev/provenance/v1",
"attestationDigest": "sha256:attestation001",
"source": "scanner.provenance.v1",
"collectedAt": "2025-10-30T12:00:05Z",
"eventOffset": 2103
},
"components": [
{
"purl": "pkg:nuget/Newtonsoft.Json@13.0.3",
"version": "13.0.3",
"ecosystem": "nuget",
"scope": "runtime",
"license": {
"spdx": "MIT",
"name": "MIT License",
"classification": "permissive",
"noticeUri": "https://opensource.org/licenses/MIT",
"sourceDigest": "sha256:ccc333"
},
"usage": "direct",
"detectedBy": "sbom.analyzer.nuget",
"layerDigest": "sha256:layer123",
"evidenceDigest": "sha256:evidence001",
"collectedAt": "2025-10-30T12:00:01Z",
"eventOffset": 1183,
"source": "scanner.sbom.v1",
"files": [
{
"path": "/src/app/Program.cs",
"contentSha256": "sha256:bbb222",
"languageHint": "csharp",
"sizeBytes": 3472,
"scope": "build",
"detectedBy": "sbom.analyzer.nuget",
"evidenceDigest": "sha256:evidence003",
"collectedAt": "2025-10-30T12:00:02Z",
"eventOffset": 1185,
"source": "scanner.layer.v1"
}
],
"dependencies": [
{
"purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
"version": "4.7.0",
"relationship": "direct",
"evidenceDigest": "sha256:evidence002",
"collectedAt": "2025-10-30T12:00:01Z",
"eventOffset": 1183
}
]
},
{
"purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
"version": "4.7.0",
"ecosystem": "nuget",
"scope": "runtime",
"license": {
"spdx": "MIT",
"name": "MIT License",
"classification": "permissive",
"noticeUri": "https://opensource.org/licenses/MIT",
"sourceDigest": "sha256:ccc333"
},
"usage": "transitive",
"detectedBy": "sbom.analyzer.nuget",
"layerDigest": "sha256:layer123",
"evidenceDigest": "sha256:evidence001",
"collectedAt": "2025-10-30T12:00:01Z",
"eventOffset": 1184,
"source": "scanner.sbom.v1",
"files": [],
"dependencies": []
}
],
"baseArtifacts": [
{
"artifactDigest": "sha256:base000",
"sbomDigest": "sha256:sbom-base",
"displayName": "registry.example.com/base/runtime:2025.09",
"environment": "prod",
"labels": [
"base-image"
],
"originRegistry": "registry.example.com",
"supplyChainStage": "build",
"collectedAt": "2025-10-22T08:00:00Z",
"eventOffset": 800,
"source": "scanner.sbom.v1"
}
]
}

View File

@@ -0,0 +1,115 @@
{
"version": "v1",
"nodes": {
"artifact": [
"display_name",
"artifact_digest",
"sbom_digest",
"environment",
"labels",
"origin_registry",
"supply_chain_stage"
],
"component": [
"purl",
"version",
"ecosystem",
"scope",
"license_spdx",
"usage"
],
"file": [
"normalized_path",
"content_sha256",
"language_hint",
"size_bytes",
"scope"
],
"license": [
"license_spdx",
"name",
"classification",
"notice_uri"
],
"advisory": [
"advisory_source",
"advisory_id",
"severity",
"published_at",
"content_hash",
"linkset_digest"
],
"vex_statement": [
"status",
"statement_id",
"justification",
"issued_at",
"expires_at",
"content_hash"
],
"policy_version": [
"policy_pack_digest",
"policy_name",
"effective_from",
"expires_at",
"explain_hash"
],
"runtime_context": [
"runtime_fingerprint",
"collector",
"observed_at",
"cluster",
"namespace",
"workload_kind",
"runtime_state"
]
},
"edges": {
"CONTAINS": [
"detected_by",
"layer_digest",
"scope",
"evidence_digest"
],
"DEPENDS_ON": [
"dependency_purl",
"dependency_version",
"relationship",
"evidence_digest"
],
"DECLARED_IN": [
"detected_by",
"scope",
"evidence_digest"
],
"BUILT_FROM": [
"build_type",
"builder_id",
"attestation_digest"
],
"AFFECTED_BY": [
"evidence_digest",
"matched_versions",
"cvss",
"confidence"
],
"VEX_EXEMPTS": [
"status",
"justification",
"impact_statement",
"evidence_digest"
],
"GOVERNS_WITH": [
"verdict",
"explain_hash",
"policy_rule_id",
"evaluation_timestamp"
],
"OBSERVED_RUNTIME": [
"process_name",
"entrypoint_kind",
"runtime_evidence_digest",
"confidence"
]
}
}

View File

@@ -0,0 +1,110 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using StellaOps.Graph.Indexer.Schema;
using Xunit;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class GraphIdentityTests
{
private static readonly string FixturesRoot =
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
[Fact]
public void NodeIds_are_stable()
{
var nodes = LoadArray("nodes.json");
foreach (var node in nodes.Cast<JsonObject>())
{
var tenant = node["tenant"]!.GetValue<string>();
var kind = node["kind"]!.GetValue<string>();
var canonicalKey = (JsonObject)node["canonical_key"]!;
var tuple = GraphIdentity.ExtractIdentityTuple(canonicalKey);
var expectedId = node["id"]!.GetValue<string>();
var actualId = GraphIdentity.ComputeNodeId(tenant, kind, tuple);
actualId.Should()
.Be(expectedId, $"node {kind} with canonical tuple {canonicalKey.ToJsonString()} must have deterministic id");
var documentClone = JsonNode.Parse(node.ToJsonString())!.AsObject();
documentClone.Remove("hash");
var expectedHash = node["hash"]!.GetValue<string>();
var actualHash = GraphIdentity.ComputeDocumentHash(documentClone);
actualHash.Should()
.Be(expectedHash, $"node {kind}:{expectedId} must have deterministic document hash");
}
}
[Fact]
public void EdgeIds_are_stable()
{
var edges = LoadArray("edges.json");
foreach (var edge in edges.Cast<JsonObject>())
{
var tenant = edge["tenant"]!.GetValue<string>();
var kind = edge["kind"]!.GetValue<string>();
var canonicalKey = (JsonObject)edge["canonical_key"]!;
var tuple = GraphIdentity.ExtractIdentityTuple(canonicalKey);
var expectedId = edge["id"]!.GetValue<string>();
var actualId = GraphIdentity.ComputeEdgeId(tenant, kind, tuple);
actualId.Should()
.Be(expectedId, $"edge {kind} with canonical tuple {canonicalKey.ToJsonString()} must have deterministic id");
var documentClone = JsonNode.Parse(edge.ToJsonString())!.AsObject();
documentClone.Remove("hash");
var expectedHash = edge["hash"]!.GetValue<string>();
var actualHash = GraphIdentity.ComputeDocumentHash(documentClone);
actualHash.Should()
.Be(expectedHash, $"edge {kind}:{expectedId} must have deterministic document hash");
}
}
[Fact]
public void AttributeCoverage_matches_matrix()
{
var matrix = LoadObject("schema-matrix.json");
var nodeExpectations = (JsonObject)matrix["nodes"]!;
var edgeExpectations = (JsonObject)matrix["edges"]!;
var nodes = LoadArray("nodes.json");
foreach (var node in nodes.Cast<JsonObject>())
{
var kind = node["kind"]!.GetValue<string>();
var expectedAttributes = nodeExpectations[kind]!.AsArray().Select(x => x!.GetValue<string>()).OrderBy(x => x, StringComparer.Ordinal).ToArray();
var actualAttributes = ((JsonObject)node["attributes"]!).Select(pair => pair.Key).OrderBy(x => x, StringComparer.Ordinal).ToArray();
actualAttributes.Should()
.Equal(expectedAttributes, $"node kind {kind} must align with schema matrix");
}
var edges = LoadArray("edges.json");
foreach (var edge in edges.Cast<JsonObject>())
{
var kind = edge["kind"]!.GetValue<string>();
var expectedAttributes = edgeExpectations[kind]!.AsArray().Select(x => x!.GetValue<string>()).OrderBy(x => x, StringComparer.Ordinal).ToArray();
var actualAttributes = ((JsonObject)edge["attributes"]!).Select(pair => pair.Key).OrderBy(x => x, StringComparer.Ordinal).ToArray();
actualAttributes.Should()
.Equal(expectedAttributes, $"edge kind {kind} must align with schema matrix");
}
}
private static JsonArray LoadArray(string fileName)
=> (JsonArray)JsonNode.Parse(File.ReadAllText(GetFixturePath(fileName)))!;
private static JsonObject LoadObject(string fileName)
=> (JsonObject)JsonNode.Parse(File.ReadAllText(GetFixturePath(fileName)))!;
private static string GetFixturePath(string fileName)
=> Path.Combine(FixturesRoot, fileName);
}

View File

@@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using StellaOps.Graph.Indexer.Documents;
using StellaOps.Graph.Indexer.Ingestion.Advisory;
using StellaOps.Graph.Indexer.Ingestion.Policy;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
using StellaOps.Graph.Indexer.Ingestion.Vex;
using StellaOps.Graph.Indexer.Schema;
using Xunit;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class GraphSnapshotBuilderTests
{
private static readonly string FixturesRoot =
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
[Fact]
public void Build_creates_manifest_and_adjacency_with_lineage()
{
var sbomSnapshot = Load<SbomSnapshot>("sbom-snapshot.json");
var linksetSnapshot = Load<AdvisoryLinksetSnapshot>("concelier-linkset.json");
var vexSnapshot = Load<VexOverlaySnapshot>("excititor-vex.json");
var policySnapshot = Load<PolicyOverlaySnapshot>("policy-overlay.json");
var sbomBatch = new SbomIngestTransformer().Transform(sbomSnapshot);
var advisoryBatch = new AdvisoryLinksetTransformer().Transform(linksetSnapshot);
var vexBatch = new VexOverlayTransformer().Transform(vexSnapshot);
var policyBatch = new PolicyOverlayTransformer().Transform(policySnapshot);
var combinedBatch = MergeBatches(sbomBatch, advisoryBatch, vexBatch, policyBatch);
var builder = new GraphSnapshotBuilder();
var generatedAt = DateTimeOffset.Parse("2025-10-30T12:06:30Z");
var snapshot = builder.Build(sbomSnapshot, combinedBatch, generatedAt);
snapshot.Manifest.Tenant.Should().Be("tenant-alpha");
snapshot.Manifest.ArtifactDigest.Should().Be("sha256:aaa111");
snapshot.Manifest.SbomDigest.Should().Be("sha256:sbom111");
snapshot.Manifest.GeneratedAt.Should().Be(generatedAt);
snapshot.Manifest.NodeCount.Should().Be(combinedBatch.Nodes.Length);
snapshot.Manifest.EdgeCount.Should().Be(combinedBatch.Edges.Length);
snapshot.Manifest.Files.Nodes.Should().Be("nodes.jsonl");
snapshot.Manifest.Files.Edges.Should().Be("edges.jsonl");
snapshot.Manifest.Files.Adjacency.Should().Be("adjacency.json");
snapshot.Manifest.Lineage.DerivedFromSbomDigests.Should().BeEquivalentTo(new[] { "sha256:sbom-base" }, options => options.WithStrictOrdering());
snapshot.Manifest.Lineage.BaseArtifactDigests.Should().BeEquivalentTo(new[] { "sha256:base000" }, options => options.WithStrictOrdering());
snapshot.Manifest.Lineage.SourceSnapshotId.Should().BeNull();
var manifestJson = snapshot.Manifest.ToJson();
manifestJson.Should().NotBeNull();
manifestJson["hash"]!.GetValue<string>().Should().Be(snapshot.Manifest.Hash);
var manifestWithoutHash = (JsonObject)manifestJson.DeepClone();
manifestWithoutHash.Remove("hash");
var expectedManifestHash = GraphIdentity.ComputeDocumentHash(manifestWithoutHash);
snapshot.Manifest.Hash.Should().Be(expectedManifestHash);
var adjacency = snapshot.Adjacency;
adjacency.Tenant.Should().Be("tenant-alpha");
adjacency.SnapshotId.Should().Be(snapshot.Manifest.SnapshotId);
adjacency.GeneratedAt.Should().Be(generatedAt);
var adjacencyNodes = adjacency.Nodes.ToDictionary(node => node.NodeId, StringComparer.Ordinal);
adjacencyNodes.Should().ContainKey("gn:tenant-alpha:artifact:RX033HH7S6JXMY66QM51S89SX76B3JXJHWHPXPPBJCD05BR3GVXG");
var artifactAdjacency = adjacencyNodes["gn:tenant-alpha:artifact:RX033HH7S6JXMY66QM51S89SX76B3JXJHWHPXPPBJCD05BR3GVXG"];
artifactAdjacency.OutgoingEdges.Should().BeEquivalentTo(new[]
{
"ge:tenant-alpha:BUILT_FROM:HJNKVFSDSA44HRY0XAJ0GBEVPD2S82JFF58BZVRT9QF6HB2EGPJG",
"ge:tenant-alpha:CONTAINS:EVA5N7P029VYV9W8Q7XJC0JFTEQYFSAQ6381SNVM3T1G5290XHTG"
}, options => options.WithStrictOrdering());
artifactAdjacency.IncomingEdges.Should().BeEmpty();
var componentAdjacency = adjacencyNodes["gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0"];
componentAdjacency.IncomingEdges.Should().BeEquivalentTo(new[]
{
"ge:tenant-alpha:CONTAINS:EVA5N7P029VYV9W8Q7XJC0JFTEQYFSAQ6381SNVM3T1G5290XHTG",
"ge:tenant-alpha:GOVERNS_WITH:XG3KQTYT8D4NY0BTFXWGBQY6TXR2MRYDWZBQT07T0200NQ72AFG0"
});
componentAdjacency.OutgoingEdges.Should().BeEquivalentTo(new[]
{
"ge:tenant-alpha:DEPENDS_ON:FJ7GZ9RHPKPR30XVKECD702QG20PGT3V75DY1GST8AAW9SR8TBB0",
"ge:tenant-alpha:DECLARED_IN:T7E8NQEMKXPZ3T1SWT8HXKWAHJVS9QKD87XBKAQAAQ29CDHEA47G",
"ge:tenant-alpha:AFFECTED_BY:1V3NRKAR6KMXAWZ89R69G8JAY3HV7DXNB16YY9X25X1TAFW9VGYG",
"ge:tenant-alpha:VEX_EXEMPTS:DT0BBCM9S0KJVF61KVR7D2W8DVFTKK03F3TFD4DR9DRS0T5CWZM0"
});
var dependencyComponent = adjacencyNodes["gn:tenant-alpha:component:FZ9EHXFFGPDQAEKAPWZ4JX5X6KYS467PJ5D1Y4T9NFFQG2SG0DV0"];
dependencyComponent.IncomingEdges.Should().BeEquivalentTo(new[]
{
"ge:tenant-alpha:DEPENDS_ON:FJ7GZ9RHPKPR30XVKECD702QG20PGT3V75DY1GST8AAW9SR8TBB0"
});
dependencyComponent.OutgoingEdges.Should().BeEmpty();
adjacency.Nodes.Length.Should().Be(combinedBatch.Nodes.Length);
}
private static GraphBuildBatch MergeBatches(params GraphBuildBatch[] batches)
{
var nodes = new Dictionary<string, JsonObject>(StringComparer.Ordinal);
var edges = new Dictionary<string, JsonObject>(StringComparer.Ordinal);
foreach (var batch in batches)
{
foreach (var node in batch.Nodes)
{
nodes[node["id"]!.GetValue<string>()] = node;
}
foreach (var edge in batch.Edges)
{
edges[edge["id"]!.GetValue<string>()] = edge;
}
}
var orderedNodes = nodes.Values
.OrderBy(node => node["kind"]!.GetValue<string>(), StringComparer.Ordinal)
.ThenBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToImmutableArray();
var orderedEdges = edges.Values
.OrderBy(edge => edge["kind"]!.GetValue<string>(), StringComparer.Ordinal)
.ThenBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToImmutableArray();
return new GraphBuildBatch(orderedNodes, orderedEdges);
}
private static T Load<T>(string fixtureFile)
{
var path = Path.Combine(FixturesRoot, fixtureFile);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
}
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Graph.Indexer.Ingestion.Policy;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
using Xunit;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class PolicyOverlayProcessorTests
{
[Fact]
public async Task ProcessAsync_persists_overlay_and_records_success_metrics()
{
var snapshot = CreateSnapshot();
var transformer = new PolicyOverlayTransformer();
var writer = new CaptureWriter();
var metrics = new CaptureMetrics();
var processor = new PolicyOverlayProcessor(
transformer,
writer,
metrics,
NullLogger<PolicyOverlayProcessor>.Instance);
await processor.ProcessAsync(snapshot, CancellationToken.None);
writer.LastBatch.Should().NotBeNull();
metrics.LastRecord.Should().NotBeNull();
metrics.LastRecord!.Success.Should().BeTrue();
metrics.LastRecord.NodeCount.Should().Be(writer.LastBatch!.Nodes.Length);
metrics.LastRecord.EdgeCount.Should().Be(writer.LastBatch!.Edges.Length);
}
[Fact]
public async Task ProcessAsync_records_failure_when_writer_throws()
{
var snapshot = CreateSnapshot();
var transformer = new PolicyOverlayTransformer();
var writer = new CaptureWriter(shouldThrow: true);
var metrics = new CaptureMetrics();
var processor = new PolicyOverlayProcessor(
transformer,
writer,
metrics,
NullLogger<PolicyOverlayProcessor>.Instance);
var act = () => processor.ProcessAsync(snapshot, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
metrics.LastRecord.Should().NotBeNull();
metrics.LastRecord!.Success.Should().BeFalse();
}
private static PolicyOverlaySnapshot CreateSnapshot()
{
return new PolicyOverlaySnapshot
{
Tenant = "tenant-alpha",
Source = "policy.engine.v1",
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:07:00Z"),
EventOffset = 4200,
Policy = new PolicyVersionDetails
{
Source = "policy.engine.v1",
PolicyPackDigest = "sha256:fff666",
PolicyName = "Default Runtime Policy",
EffectiveFrom = DateTimeOffset.Parse("2025-10-28T00:00:00Z"),
ExpiresAt = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
ExplainHash = "sha256:explain001",
CollectedAt = DateTimeOffset.Parse("2025-10-28T00:00:05Z"),
EventOffset = 4100
},
Evaluations = new[]
{
new PolicyEvaluation
{
ComponentPurl = "pkg:nuget/Newtonsoft.Json@13.0.3",
ComponentSourceType = "inventory",
FindingExplainHash = "sha256:explain001",
ExplainHash = "sha256:explain001",
PolicyRuleId = "rule:runtime/critical-dependency",
Verdict = "fail",
EvaluationTimestamp = DateTimeOffset.Parse("2025-10-30T12:07:00Z"),
SbomDigest = "sha256:sbom111",
Source = "policy.engine.v1",
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:07:00Z"),
EventOffset = 4200
}
}
};
}
private sealed class CaptureWriter : IGraphDocumentWriter
{
private readonly bool _shouldThrow;
public CaptureWriter(bool shouldThrow = false)
{
_shouldThrow = shouldThrow;
}
public GraphBuildBatch? LastBatch { get; private set; }
public Task WriteAsync(GraphBuildBatch batch, CancellationToken cancellationToken)
{
LastBatch = batch;
if (_shouldThrow)
{
throw new InvalidOperationException("Simulated persistence failure");
}
return Task.CompletedTask;
}
}
private sealed class CaptureMetrics : IPolicyOverlayMetrics
{
public MetricRecord? LastRecord { get; private set; }
public void RecordBatch(string source, string tenant, int nodeCount, int edgeCount, TimeSpan duration, bool success)
{
LastRecord = new MetricRecord(source, tenant, nodeCount, edgeCount, duration, success);
}
}
private sealed record MetricRecord(
string Source,
string Tenant,
int NodeCount,
int EdgeCount,
TimeSpan Duration,
bool Success);
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using StellaOps.Graph.Indexer.Ingestion.Policy;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class PolicyOverlayTransformerTests
{
private readonly ITestOutputHelper _output;
public PolicyOverlayTransformerTests(ITestOutputHelper output)
{
_output = output;
}
private static readonly string FixturesRoot =
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
private static readonly HashSet<string> ExpectedNodeKinds = new(StringComparer.Ordinal)
{
"policy_version"
};
private static readonly HashSet<string> ExpectedEdgeKinds = new(StringComparer.Ordinal)
{
"GOVERNS_WITH"
};
[Fact]
public void Transform_projects_policy_nodes_and_governs_with_edges()
{
var snapshot = LoadSnapshot("policy-overlay.json");
var transformer = new PolicyOverlayTransformer();
var batch = transformer.Transform(snapshot);
var expectedNodes = LoadArray("nodes.json")
.Cast<JsonObject>()
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var expectedEdges = LoadArray("edges.json")
.Cast<JsonObject>()
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var actualNodes = batch.Nodes
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var actualEdges = batch.Edges
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
actualNodes.Length.Should().Be(expectedNodes.Length);
actualEdges.Length.Should().Be(expectedEdges.Length);
for (var i = 0; i < expectedNodes.Length; i++)
{
if (!JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]))
{
_output.WriteLine($"Expected Node: {expectedNodes[i]}");
_output.WriteLine($"Actual Node: {actualNodes[i]}");
}
JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]).Should().BeTrue();
}
for (var i = 0; i < expectedEdges.Length; i++)
{
if (!JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]))
{
_output.WriteLine($"Expected Edge: {expectedEdges[i]}");
_output.WriteLine($"Actual Edge: {actualEdges[i]}");
}
JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]).Should().BeTrue();
}
}
private static PolicyOverlaySnapshot LoadSnapshot(string fileName)
{
var path = Path.Combine(FixturesRoot, fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<PolicyOverlaySnapshot>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
}
private static JsonArray LoadArray(string fileName)
{
var path = Path.Combine(FixturesRoot, fileName);
return (JsonArray)JsonNode.Parse(File.ReadAllText(path))!;
}
}

View File

@@ -0,0 +1,4 @@
# StellaOps Graph Indexer Tests
The Graph Indexer tests now run entirely in-memory and no longer require MongoDB.
No special environment variables are needed to execute the suite locally or in CI.

View File

@@ -0,0 +1,194 @@
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
using Xunit;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class SbomIngestProcessorTests
{
[Fact]
public async Task ProcessAsync_writes_batch_and_records_success_metrics()
{
var snapshot = CreateSnapshot();
var transformer = new SbomIngestTransformer();
var writer = new CaptureWriter();
var metrics = new CaptureMetrics();
var snapshotExporter = new CaptureSnapshotExporter();
var processor = new SbomIngestProcessor(transformer, writer, metrics, snapshotExporter, NullLogger<SbomIngestProcessor>.Instance);
await processor.ProcessAsync(snapshot, CancellationToken.None);
writer.LastBatch.Should().NotBeNull();
metrics.LastRecord.Should().NotBeNull();
metrics.LastRecord!.Success.Should().BeTrue();
metrics.LastRecord.NodeCount.Should().Be(writer.LastBatch!.Nodes.Length);
metrics.LastRecord.EdgeCount.Should().Be(writer.LastBatch!.Edges.Length);
snapshotExporter.LastSnapshot.Should().BeSameAs(snapshot);
snapshotExporter.LastBatch.Should().BeSameAs(writer.LastBatch);
}
[Fact]
public async Task ProcessAsync_records_failure_when_writer_throws()
{
var snapshot = CreateSnapshot();
var transformer = new SbomIngestTransformer();
var writer = new CaptureWriter(shouldThrow: true);
var metrics = new CaptureMetrics();
var snapshotExporter = new CaptureSnapshotExporter();
var processor = new SbomIngestProcessor(transformer, writer, metrics, snapshotExporter, NullLogger<SbomIngestProcessor>.Instance);
var act = () => processor.ProcessAsync(snapshot, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
metrics.LastRecord.Should().NotBeNull();
metrics.LastRecord!.Success.Should().BeFalse();
snapshotExporter.LastSnapshot.Should().BeNull();
snapshotExporter.LastBatch.Should().BeNull();
}
private static SbomSnapshot CreateSnapshot()
{
return new SbomSnapshot
{
Tenant = "tenant-alpha",
Source = "scanner.sbom.v1",
ArtifactDigest = "sha256:test-artifact",
SbomDigest = "sha256:test-sbom",
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:00Z"),
EventOffset = 1000,
Artifact = new SbomArtifactMetadata
{
DisplayName = "registry.example.com/app:latest",
Environment = "prod",
Labels = new[] { "demo" },
OriginRegistry = "registry.example.com",
SupplyChainStage = "deploy"
},
Build = new SbomBuildMetadata
{
BuilderId = "builder://tekton/default",
BuildType = "https://slsa.dev/provenance/v1",
AttestationDigest = "sha256:attestation",
Source = "scanner.build.v1",
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:05Z"),
EventOffset = 2000
},
Components = new[]
{
new SbomComponent
{
Purl = "pkg:nuget/Example.Primary@1.0.0",
Version = "1.0.0",
Ecosystem = "nuget",
Scope = "runtime",
License = new SbomLicense
{
Spdx = "MIT",
Name = "MIT License",
Classification = "permissive",
SourceDigest = "sha256:license001"
},
Usage = "direct",
DetectedBy = "sbom.analyzer.transformer",
LayerDigest = "sha256:layer",
EvidenceDigest = "sha256:evidence",
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:01Z"),
EventOffset = 1201,
Source = "scanner.component.v1",
Files = new[]
{
new SbomComponentFile
{
Path = "/src/app/Program.cs",
ContentSha256 = "sha256:file",
LanguageHint = "csharp",
SizeBytes = 1024,
Scope = "build",
DetectedBy = "sbom.analyzer.transformer",
EvidenceDigest = "sha256:file-evidence",
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:02Z"),
EventOffset = 1202,
Source = "scanner.layer.v1"
}
},
Dependencies = Array.Empty<SbomDependency>(),
SourceType = "inventory"
}
},
BaseArtifacts = new[]
{
new SbomBaseArtifact
{
ArtifactDigest = "sha256:base",
SbomDigest = "sha256:base-sbom",
DisplayName = "registry.example.com/base:2025.09",
Environment = "prod",
Labels = new[] { "base-image" },
OriginRegistry = "registry.example.com",
SupplyChainStage = "build",
CollectedAt = DateTimeOffset.Parse("2025-10-22T08:00:00Z"),
EventOffset = 800,
Source = "scanner.sbom.v1"
}
}
};
}
private sealed class CaptureWriter : IGraphDocumentWriter
{
private readonly bool _shouldThrow;
public CaptureWriter(bool shouldThrow = false)
{
_shouldThrow = shouldThrow;
}
public GraphBuildBatch? LastBatch { get; private set; }
public Task WriteAsync(GraphBuildBatch batch, CancellationToken cancellationToken)
{
LastBatch = batch;
if (_shouldThrow)
{
throw new InvalidOperationException("Simulated persistence failure");
}
return Task.CompletedTask;
}
}
private sealed class CaptureMetrics : ISbomIngestMetrics
{
public MetricRecord? LastRecord { get; private set; }
public void RecordBatch(string source, string tenant, int nodeCount, int edgeCount, TimeSpan duration, bool success)
{
LastRecord = new MetricRecord(source, tenant, nodeCount, edgeCount, duration, success);
}
}
private sealed class CaptureSnapshotExporter : ISbomSnapshotExporter
{
public SbomSnapshot? LastSnapshot { get; private set; }
public GraphBuildBatch? LastBatch { get; private set; }
public Task ExportAsync(SbomSnapshot snapshot, GraphBuildBatch batch, CancellationToken cancellationToken)
{
LastSnapshot = snapshot;
LastBatch = batch;
return Task.CompletedTask;
}
}
private sealed record MetricRecord(
string Source,
string Tenant,
int NodeCount,
int EdgeCount,
TimeSpan Duration,
bool Success);
}

View File

@@ -0,0 +1,125 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
using Xunit;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class SbomIngestServiceCollectionExtensionsTests : IDisposable
{
private static readonly string FixturesRoot =
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
private readonly string _tempDirectory;
public SbomIngestServiceCollectionExtensionsTests()
{
_tempDirectory = Path.Combine(Path.GetTempPath(), $"graph-indexer-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDirectory);
}
[Fact]
public async Task AddSbomIngestPipeline_exports_snapshots_to_configured_directory()
{
var services = new ServiceCollection();
services.AddSingleton<IGraphDocumentWriter, CaptureWriter>();
services.AddSbomIngestPipeline(options => options.SnapshotRootDirectory = _tempDirectory);
using var provider = services.BuildServiceProvider();
var processor = provider.GetRequiredService<SbomIngestProcessor>();
var snapshot = LoadSnapshot();
await processor.ProcessAsync(snapshot, CancellationToken.None);
AssertSnapshotOutputs(_tempDirectory);
var writer = provider.GetRequiredService<IGraphDocumentWriter>() as CaptureWriter;
writer!.LastBatch.Should().NotBeNull();
}
[Fact]
public async Task AddSbomIngestPipeline_uses_environment_variable_when_not_configured()
{
var previous = Environment.GetEnvironmentVariable("STELLAOPS_GRAPH_SNAPSHOT_DIR");
try
{
Environment.SetEnvironmentVariable("STELLAOPS_GRAPH_SNAPSHOT_DIR", _tempDirectory);
var services = new ServiceCollection();
services.AddSingleton<IGraphDocumentWriter, CaptureWriter>();
services.AddSbomIngestPipeline();
using var provider = services.BuildServiceProvider();
var processor = provider.GetRequiredService<SbomIngestProcessor>();
var snapshot = LoadSnapshot();
await processor.ProcessAsync(snapshot, CancellationToken.None);
AssertSnapshotOutputs(_tempDirectory);
}
finally
{
Environment.SetEnvironmentVariable("STELLAOPS_GRAPH_SNAPSHOT_DIR", previous);
}
}
private static SbomSnapshot LoadSnapshot()
{
var path = Path.Combine(FixturesRoot, "sbom-snapshot.json");
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<SbomSnapshot>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
}
private static void AssertSnapshotOutputs(string root)
{
var manifestPath = Path.Combine(root, "manifest.json");
var adjacencyPath = Path.Combine(root, "adjacency.json");
var nodesPath = Path.Combine(root, "nodes.jsonl");
var edgesPath = Path.Combine(root, "edges.jsonl");
File.Exists(manifestPath).Should().BeTrue("manifest should be exported");
File.Exists(adjacencyPath).Should().BeTrue("adjacency manifest should be exported");
File.Exists(nodesPath).Should().BeTrue("node stream should be exported");
File.Exists(edgesPath).Should().BeTrue("edge stream should be exported");
new FileInfo(manifestPath).Length.Should().BeGreaterThan(0);
new FileInfo(adjacencyPath).Length.Should().BeGreaterThan(0);
new FileInfo(nodesPath).Length.Should().BeGreaterThan(0);
new FileInfo(edgesPath).Length.Should().BeGreaterThan(0);
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempDirectory))
{
Directory.Delete(_tempDirectory, recursive: true);
}
}
catch
{
// Ignore cleanup failures in CI environments.
}
}
private sealed class CaptureWriter : IGraphDocumentWriter
{
public GraphBuildBatch? LastBatch { get; private set; }
public Task WriteAsync(GraphBuildBatch batch, CancellationToken cancellationToken)
{
LastBatch = batch;
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,283 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class SbomIngestTransformerTests
{
private readonly ITestOutputHelper _output;
public SbomIngestTransformerTests(ITestOutputHelper output)
{
_output = output;
}
private static readonly string FixturesRoot =
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
private static readonly HashSet<string> ExpectedNodeKinds = new(StringComparer.Ordinal)
{
"artifact",
"component",
"file"
};
private static readonly HashSet<string> ExpectedEdgeKinds = new(StringComparer.Ordinal)
{
"CONTAINS",
"DEPENDS_ON",
"DECLARED_IN",
"BUILT_FROM"
};
[Fact]
public void Transform_produces_expected_nodes_and_edges()
{
var snapshot = LoadSnapshot("sbom-snapshot.json");
var transformer = new SbomIngestTransformer();
var batch = transformer.Transform(snapshot);
var expectedNodes = LoadArray("nodes.json")
.Cast<JsonObject>()
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var expectedEdges = LoadArray("edges.json")
.Cast<JsonObject>()
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var actualNodes = batch.Nodes
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var actualEdges = batch.Edges
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
actualNodes.Length.Should().Be(expectedNodes.Length);
actualEdges.Length.Should().Be(expectedEdges.Length);
for (var i = 0; i < expectedNodes.Length; i++)
{
if (!JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]))
{
_output.WriteLine($"Expected Node: {expectedNodes[i]}");
_output.WriteLine($"Actual Node: {actualNodes[i]}");
}
JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]).Should().BeTrue();
}
for (var i = 0; i < expectedEdges.Length; i++)
{
if (!JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]))
{
_output.WriteLine($"Expected Edge: {expectedEdges[i]}");
_output.WriteLine($"Actual Edge: {actualEdges[i]}");
}
JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]).Should().BeTrue();
}
}
[Fact]
public void Transform_deduplicates_license_nodes_case_insensitive()
{
var baseCollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:00Z");
var components = new[]
{
CreateComponent(
purl: "pkg:nuget/Example.Primary@1.0.0",
spdx: "MIT",
sourceDigest: "sha256:license001",
collectedAt: baseCollectedAt.AddSeconds(1),
eventOffset: 1201,
source: "scanner.component.v1"),
CreateComponent(
purl: "pkg:nuget/Example.Secondary@2.0.0",
spdx: "mit",
sourceDigest: "SHA256:LICENSE001",
collectedAt: baseCollectedAt.AddSeconds(2),
eventOffset: 1202,
usage: "transitive",
source: "scanner.component.v1")
};
var snapshot = CreateSnapshot(components: components);
var transformer = new SbomIngestTransformer();
var batch = transformer.Transform(snapshot);
var licenseNodes = batch.Nodes
.Where(node => string.Equals(node["kind"]!.GetValue<string>(), "license", StringComparison.Ordinal))
.ToArray();
licenseNodes.Should().HaveCount(1);
var canonicalKey = licenseNodes[0]["canonical_key"]!.AsObject();
canonicalKey["license_spdx"]!.GetValue<string>().Should().Be("MIT");
canonicalKey["source_digest"]!.GetValue<string>().Should().Be("sha256:license001");
}
[Fact]
public void Transform_emits_built_from_edge_with_provenance()
{
var snapshot = LoadSnapshot("sbom-snapshot.json");
var transformer = new SbomIngestTransformer();
var batch = transformer.Transform(snapshot);
var builtFrom = batch.Edges.Single(edge => edge["kind"]!.GetValue<string>() == "BUILT_FROM");
var attributes = builtFrom["attributes"]!.AsObject();
attributes["build_type"]!.GetValue<string>().Should().Be(snapshot.Build.BuildType);
attributes["builder_id"]!.GetValue<string>().Should().Be(snapshot.Build.BuilderId);
attributes["attestation_digest"]!.GetValue<string>().Should().Be(snapshot.Build.AttestationDigest);
var provenance = builtFrom["provenance"]!.AsObject();
provenance["source"]!.GetValue<string>().Should().Be(snapshot.Build.Source);
provenance["collected_at"]!.GetValue<string>()
.Should().Be(snapshot.Build.CollectedAt.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"));
var canonicalKey = builtFrom["canonical_key"]!.AsObject();
canonicalKey.ContainsKey("parent_artifact_node_id").Should().BeTrue();
canonicalKey.ContainsKey("child_artifact_digest").Should().BeTrue();
}
[Fact]
public void Transform_normalizes_valid_from_to_utc()
{
var componentCollectedAt = new DateTimeOffset(2025, 11, 1, 15, 30, 45, TimeSpan.FromHours(2));
var components = new[]
{
CreateComponent(
purl: "pkg:nuget/Example.Primary@1.0.0",
spdx: "Apache-2.0",
sourceDigest: "sha256:license002",
collectedAt: componentCollectedAt,
eventOffset: 2101,
source: "scanner.component.v1")
};
var snapshot = CreateSnapshot(
components: components,
collectedAt: componentCollectedAt.AddSeconds(-1),
eventOffset: 2000);
var transformer = new SbomIngestTransformer();
var batch = transformer.Transform(snapshot);
var componentNode = batch.Nodes.Single(node => node["kind"]!.GetValue<string>() == "component");
componentNode["valid_from"]!.GetValue<string>().Should().Be("2025-11-01T13:30:45Z");
var containsEdge = batch.Edges.Single(edge => edge["kind"]!.GetValue<string>() == "CONTAINS");
containsEdge["valid_from"]!.GetValue<string>().Should().Be("2025-11-01T13:30:46Z");
}
private static SbomSnapshot CreateSnapshot(
IEnumerable<SbomComponent>? components = null,
IEnumerable<SbomBaseArtifact>? baseArtifacts = null,
DateTimeOffset? collectedAt = null,
long eventOffset = 1000,
string? source = null,
SbomArtifactMetadata? artifact = null,
SbomBuildMetadata? build = null)
{
return new SbomSnapshot
{
Tenant = "tenant-alpha",
Source = source ?? "scanner.sbom.v1",
ArtifactDigest = "sha256:test-artifact",
SbomDigest = "sha256:test-sbom",
CollectedAt = collectedAt ?? DateTimeOffset.Parse("2025-10-30T12:00:00Z"),
EventOffset = eventOffset,
Artifact = artifact ?? new SbomArtifactMetadata
{
DisplayName = "registry.example.com/app:latest",
Environment = "prod",
Labels = new[] { "critical" },
OriginRegistry = "registry.example.com",
SupplyChainStage = "deploy"
},
Build = build ?? new SbomBuildMetadata
{
BuilderId = "builder://tekton/default",
BuildType = "https://slsa.dev/provenance/v1",
AttestationDigest = "sha256:attestation",
Source = "scanner.build.v1",
CollectedAt = (collectedAt ?? DateTimeOffset.Parse("2025-10-30T12:00:00Z")).AddSeconds(5),
EventOffset = eventOffset + 100
},
Components = (components ?? Array.Empty<SbomComponent>()).ToArray(),
BaseArtifacts = (baseArtifacts ?? Array.Empty<SbomBaseArtifact>()).ToArray()
};
}
private static SbomComponent CreateComponent(
string purl,
string spdx,
string sourceDigest,
DateTimeOffset collectedAt,
long eventOffset,
string version = "1.0.0",
string usage = "direct",
string? source = null,
string detectedBy = "sbom.analyzer.transformer",
string scope = "runtime",
IEnumerable<SbomComponentFile>? files = null,
IEnumerable<SbomDependency>? dependencies = null)
{
return new SbomComponent
{
Purl = purl,
Version = version,
Ecosystem = "nuget",
Scope = scope,
License = new SbomLicense
{
Spdx = spdx,
Name = $"{spdx} License",
Classification = "permissive",
SourceDigest = sourceDigest,
NoticeUri = null
},
Usage = usage,
DetectedBy = detectedBy,
LayerDigest = "sha256:layer",
EvidenceDigest = "sha256:evidence",
CollectedAt = collectedAt,
EventOffset = eventOffset,
Source = source ?? "scanner.component.v1",
Files = (files ?? Array.Empty<SbomComponentFile>()).ToArray(),
Dependencies = (dependencies ?? Array.Empty<SbomDependency>()).ToArray(),
SourceType = "inventory"
};
}
private static SbomSnapshot LoadSnapshot(string fileName)
{
var path = Path.Combine(FixturesRoot, fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<SbomSnapshot>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
}
private static JsonArray LoadArray(string fileName)
{
var path = Path.Combine(FixturesRoot, fileName);
return (JsonArray)JsonNode.Parse(File.ReadAllText(path))!;
}
}

View File

@@ -0,0 +1,125 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Graph.Indexer.Documents;
using StellaOps.Graph.Indexer.Ingestion.Advisory;
using StellaOps.Graph.Indexer.Ingestion.Policy;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
using StellaOps.Graph.Indexer.Ingestion.Vex;
using Xunit;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class SbomSnapshotExporterTests
{
private static readonly string FixturesRoot =
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
[Fact]
public async Task ExportAsync_writes_manifest_adjacency_nodes_and_edges()
{
var sbomSnapshot = Load<SbomSnapshot>("sbom-snapshot.json");
var linksetSnapshot = Load<AdvisoryLinksetSnapshot>("concelier-linkset.json");
var vexSnapshot = Load<VexOverlaySnapshot>("excititor-vex.json");
var policySnapshot = Load<PolicyOverlaySnapshot>("policy-overlay.json");
var sbomBatch = new SbomIngestTransformer().Transform(sbomSnapshot);
var advisoryBatch = new AdvisoryLinksetTransformer().Transform(linksetSnapshot);
var vexBatch = new VexOverlayTransformer().Transform(vexSnapshot);
var policyBatch = new PolicyOverlayTransformer().Transform(policySnapshot);
var combinedBatch = MergeBatches(sbomBatch, advisoryBatch, vexBatch, policyBatch);
var builder = new GraphSnapshotBuilder();
var writer = new InMemorySnapshotFileWriter();
var exporter = new SbomSnapshotExporter(builder, writer);
await exporter.ExportAsync(sbomSnapshot, combinedBatch, CancellationToken.None);
writer.JsonFiles.Should().ContainKey("manifest.json");
writer.JsonFiles.Should().ContainKey("adjacency.json");
writer.JsonLinesFiles.Should().ContainKey("nodes.jsonl");
writer.JsonLinesFiles.Should().ContainKey("edges.jsonl");
var manifest = writer.JsonFiles["manifest.json"];
manifest["tenant"]!.GetValue<string>().Should().Be("tenant-alpha");
manifest["node_count"]!.GetValue<int>().Should().Be(combinedBatch.Nodes.Length);
manifest["edge_count"]!.GetValue<int>().Should().Be(combinedBatch.Edges.Length);
manifest["hash"]!.GetValue<string>().Should().NotBeNullOrEmpty();
var adjacency = writer.JsonFiles["adjacency.json"];
adjacency["tenant"]!.GetValue<string>().Should().Be("tenant-alpha");
adjacency["nodes"]!.AsArray().Should().HaveCount(combinedBatch.Nodes.Length);
writer.JsonLinesFiles["nodes.jsonl"].Should().HaveCount(combinedBatch.Nodes.Length);
writer.JsonLinesFiles["edges.jsonl"].Should().HaveCount(combinedBatch.Edges.Length);
}
private static GraphBuildBatch MergeBatches(params GraphBuildBatch[] batches)
{
var nodes = new Dictionary<string, JsonObject>(StringComparer.Ordinal);
var edges = new Dictionary<string, JsonObject>(StringComparer.Ordinal);
foreach (var batch in batches)
{
foreach (var node in batch.Nodes)
{
nodes[node["id"]!.GetValue<string>()] = node;
}
foreach (var edge in batch.Edges)
{
edges[edge["id"]!.GetValue<string>()] = edge;
}
}
var orderedNodes = nodes.Values
.OrderBy(node => node["kind"]!.GetValue<string>(), StringComparer.Ordinal)
.ThenBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToImmutableArray();
var orderedEdges = edges.Values
.OrderBy(edge => edge["kind"]!.GetValue<string>(), StringComparer.Ordinal)
.ThenBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToImmutableArray();
return new GraphBuildBatch(orderedNodes, orderedEdges);
}
private static T Load<T>(string fixtureFile)
{
var path = Path.Combine(FixturesRoot, fixtureFile);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
}
private sealed class InMemorySnapshotFileWriter : ISnapshotFileWriter
{
public Dictionary<string, JsonObject> JsonFiles { get; } = new(StringComparer.Ordinal);
public Dictionary<string, List<JsonObject>> JsonLinesFiles { get; } = new(StringComparer.Ordinal);
public Task WriteJsonAsync(string relativePath, JsonObject content, CancellationToken cancellationToken)
{
JsonFiles[relativePath] = (JsonObject)content.DeepClone();
return Task.CompletedTask;
}
public Task WriteJsonLinesAsync(string relativePath, IEnumerable<JsonObject> items, CancellationToken cancellationToken)
{
JsonLinesFiles[relativePath] = items
.Select(item => (JsonObject)item.DeepClone())
.ToList();
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\Graph\\StellaOps.Graph.Indexer\\StellaOps.Graph.Indexer.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\**\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using StellaOps.Graph.Indexer.Ingestion.Vex;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class VexOverlayTransformerTests
{
private readonly ITestOutputHelper _output;
public VexOverlayTransformerTests(ITestOutputHelper output)
{
_output = output;
}
private static readonly string FixturesRoot =
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
private static readonly HashSet<string> ExpectedNodeKinds = new(StringComparer.Ordinal)
{
"vex_statement"
};
private static readonly HashSet<string> ExpectedEdgeKinds = new(StringComparer.Ordinal)
{
"VEX_EXEMPTS"
};
[Fact]
public void Transform_projects_vex_nodes_and_exempt_edges()
{
var snapshot = LoadSnapshot("excititor-vex.json");
var transformer = new VexOverlayTransformer();
var batch = transformer.Transform(snapshot);
var expectedNodes = LoadArray("nodes.json")
.Cast<JsonObject>()
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var expectedEdges = LoadArray("edges.json")
.Cast<JsonObject>()
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var actualNodes = batch.Nodes
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
var actualEdges = batch.Edges
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToArray();
actualNodes.Length.Should().Be(expectedNodes.Length);
actualEdges.Length.Should().Be(expectedEdges.Length);
for (var i = 0; i < expectedNodes.Length; i++)
{
if (!JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]))
{
_output.WriteLine($"Expected Node: {expectedNodes[i]}");
_output.WriteLine($"Actual Node: {actualNodes[i]}");
}
JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]).Should().BeTrue();
}
for (var i = 0; i < expectedEdges.Length; i++)
{
if (!JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]))
{
_output.WriteLine($"Expected Edge: {expectedEdges[i]}");
_output.WriteLine($"Actual Edge: {actualEdges[i]}");
}
JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]).Should().BeTrue();
}
}
private static VexOverlaySnapshot LoadSnapshot(string fileName)
{
var path = Path.Combine(FixturesRoot, fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<VexOverlaySnapshot>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
}
private static JsonArray LoadArray(string fileName)
{
var path = Path.Combine(FixturesRoot, fileName);
return (JsonArray)JsonNode.Parse(File.ReadAllText(path))!;
}
}

View File

@@ -0,0 +1,384 @@
// =============================================================================
// StellaOps.Integration.AirGap - Air-Gap Integration Tests
// Sprint 3500.0004.0003 - T8: Air-Gap Integration Tests
// =============================================================================
using FluentAssertions;
using System.Net;
using System.Net.Sockets;
using Moq;
using Xunit;
namespace StellaOps.Integration.AirGap;
/// <summary>
/// Integration tests for air-gapped (offline) operation.
/// Validates that StellaOps functions correctly without network access.
/// </summary>
/// <remarks>
/// T8-AC1: Offline kit installation test
/// T8-AC2: Offline scan test
/// T8-AC3: Offline score replay test
/// T8-AC4: Offline proof verification test
/// T8-AC5: No network calls during offline operation
/// </remarks>
[Trait("Category", "AirGap")]
[Trait("Category", "Integration")]
[Trait("Category", "Offline")]
public class AirGapIntegrationTests : IClassFixture<AirGapTestFixture>
{
private readonly AirGapTestFixture _fixture;
public AirGapIntegrationTests(AirGapTestFixture fixture)
{
_fixture = fixture;
}
#region T8-AC1: Offline Kit Installation
[Fact(DisplayName = "T8-AC1.1: Offline kit manifest is valid")]
public void OfflineKitManifest_IsValid()
{
// Arrange & Act
var manifest = _fixture.GetOfflineKitManifest();
// Assert
manifest.Should().NotBeNull();
manifest.Version.Should().NotBeNullOrEmpty();
manifest.Components.Should().NotBeEmpty();
manifest.CreatedAt.Should().BeBefore(DateTime.UtcNow);
}
[Fact(DisplayName = "T8-AC1.2: All required components present")]
public void OfflineKit_HasRequiredComponents()
{
// Arrange
var requiredComponents = new[]
{
"vulnerability-database",
"advisory-feeds",
"trust-bundles",
"signing-keys"
};
// Act
var manifest = _fixture.GetOfflineKitManifest();
// Assert
foreach (var component in requiredComponents)
{
manifest.Components.Should().ContainKey(component,
$"Offline kit missing required component: {component}");
}
}
[Fact(DisplayName = "T8-AC1.3: Component hashes are valid")]
public async Task OfflineKitComponents_HaveValidHashes()
{
// Arrange
var manifest = _fixture.GetOfflineKitManifest();
var invalidComponents = new List<string>();
// Act
foreach (var (name, component) in manifest.Components)
{
var actualHash = await _fixture.ComputeComponentHashAsync(name);
if (actualHash != component.Hash)
{
invalidComponents.Add($"{name}: expected {component.Hash}, got {actualHash}");
}
}
// Assert
invalidComponents.Should().BeEmpty(
$"Components with invalid hashes:\n{string.Join("\n", invalidComponents)}");
}
[Fact(DisplayName = "T8-AC1.4: Offline kit installation succeeds")]
public async Task OfflineKitInstallation_Succeeds()
{
// Arrange
var targetPath = _fixture.GetTempDirectory();
// Act
var result = await _fixture.InstallOfflineKitAsync(targetPath);
// Assert
result.Success.Should().BeTrue();
result.InstalledComponents.Should().NotBeEmpty();
Directory.Exists(targetPath).Should().BeTrue();
}
#endregion
#region T8-AC2: Offline Scan
[Fact(DisplayName = "T8-AC2.1: Scan completes without network")]
public async Task OfflineScan_CompletesWithoutNetwork()
{
// Arrange
await _fixture.DisableNetworkAsync();
var targetImage = _fixture.GetLocalTestImage();
try
{
// Act
var result = await _fixture.RunOfflineScanAsync(targetImage);
// Assert
result.Success.Should().BeTrue();
result.Findings.Should().NotBeNull();
}
finally
{
await _fixture.EnableNetworkAsync();
}
}
[Fact(DisplayName = "T8-AC2.2: Scan uses local vulnerability database")]
public async Task OfflineScan_UsesLocalVulnDatabase()
{
// Arrange
var targetImage = _fixture.GetLocalTestImage();
_fixture.SetOfflineMode(true);
// Act
var result = await _fixture.RunOfflineScanAsync(targetImage);
// Assert
result.Success.Should().BeTrue();
result.DataSource.Should().Be("offline-kit");
result.DataSourcePath.Should().Contain("offline");
}
[Fact(DisplayName = "T8-AC2.3: Scan produces deterministic results offline")]
public async Task OfflineScan_ProducesDeterministicResults()
{
// Arrange
var targetImage = _fixture.GetLocalTestImage();
_fixture.SetOfflineMode(true);
// Act - run twice
var result1 = await _fixture.RunOfflineScanAsync(targetImage);
var result2 = await _fixture.RunOfflineScanAsync(targetImage);
// Assert
result1.ManifestHash.Should().Be(result2.ManifestHash,
"Offline scan should produce identical results");
result1.Findings.Count.Should().Be(result2.Findings.Count);
}
#endregion
#region T8-AC3: Offline Score Replay
[Fact(DisplayName = "T8-AC3.1: Score replay works offline")]
public async Task ScoreReplay_WorksOffline()
{
// Arrange
var proofBundle = _fixture.GetSampleProofBundle();
_fixture.SetOfflineMode(true);
// Act
var result = await _fixture.ReplayScoreOfflineAsync(proofBundle);
// Assert
result.Success.Should().BeTrue();
result.Score.Should().BeGreaterThanOrEqualTo(0);
result.ReplayedAt.Should().BeBefore(DateTime.UtcNow);
}
[Fact(DisplayName = "T8-AC3.2: Score replay produces identical score")]
public async Task ScoreReplay_ProducesIdenticalScore()
{
// Arrange
var proofBundle = _fixture.GetSampleProofBundle();
var originalScore = proofBundle.OriginalScore;
_fixture.SetOfflineMode(true);
// Act
var result = await _fixture.ReplayScoreOfflineAsync(proofBundle);
// Assert
result.Score.Should().Be(originalScore,
"Replay score should match original");
result.ScoreHash.Should().Be(proofBundle.OriginalScoreHash,
"Replay score hash should match original");
}
[Fact(DisplayName = "T8-AC3.3: Score replay includes audit trail")]
public async Task ScoreReplay_IncludesAuditTrail()
{
// Arrange
var proofBundle = _fixture.GetSampleProofBundle();
_fixture.SetOfflineMode(true);
// Act
var result = await _fixture.ReplayScoreOfflineAsync(proofBundle);
// Assert
result.AuditTrail.Should().NotBeEmpty();
result.AuditTrail.Should().Contain(a => a.Type == "replay_started");
result.AuditTrail.Should().Contain(a => a.Type == "replay_completed");
}
#endregion
#region T8-AC4: Offline Proof Verification
[Fact(DisplayName = "T8-AC4.1: Proof verification works offline")]
public async Task ProofVerification_WorksOffline()
{
// Arrange
var proofBundle = _fixture.GetSampleProofBundle();
_fixture.SetOfflineMode(true);
// Act
var result = await _fixture.VerifyProofOfflineAsync(proofBundle);
// Assert
result.Valid.Should().BeTrue();
result.VerifiedAt.Should().BeBefore(DateTime.UtcNow);
}
[Fact(DisplayName = "T8-AC4.2: Verification uses offline trust store")]
public async Task ProofVerification_UsesOfflineTrustStore()
{
// Arrange
var proofBundle = _fixture.GetSampleProofBundle();
_fixture.SetOfflineMode(true);
// Act
var result = await _fixture.VerifyProofOfflineAsync(proofBundle);
// Assert
result.TrustSource.Should().Be("offline-trust-store");
result.CertificateChain.Should().NotBeEmpty();
}
[Fact(DisplayName = "T8-AC4.3: Tampered proof fails verification")]
public async Task TamperedProof_FailsVerification()
{
// Arrange
var proofBundle = _fixture.GetSampleProofBundle();
var tamperedBundle = _fixture.TamperWithProof(proofBundle);
_fixture.SetOfflineMode(true);
// Act
var result = await _fixture.VerifyProofOfflineAsync(tamperedBundle);
// Assert
result.Valid.Should().BeFalse();
result.FailureReason.Should().Contain("signature");
}
[Fact(DisplayName = "T8-AC4.4: Expired certificate handling offline")]
public async Task ExpiredCertificate_HandledCorrectly()
{
// Arrange
var proofBundle = _fixture.GetProofBundleWithExpiredCert();
_fixture.SetOfflineMode(true);
// Act
var result = await _fixture.VerifyProofOfflineAsync(proofBundle);
// Assert
result.Valid.Should().BeFalse();
result.FailureReason.Should().Contain("expired");
result.Warnings.Should().ContainSingle(w => w.Contains("certificate"));
}
#endregion
#region T8-AC5: No Network Calls
[Fact(DisplayName = "T8-AC5.1: No outbound connections during scan")]
public async Task OfflineScan_NoOutboundConnections()
{
// Arrange
var connectionAttempts = new List<string>();
_fixture.SetConnectionMonitor(endpoint => connectionAttempts.Add(endpoint));
_fixture.SetOfflineMode(true);
var targetImage = _fixture.GetLocalTestImage();
// Act
await _fixture.RunOfflineScanAsync(targetImage);
// Assert
connectionAttempts.Should().BeEmpty(
$"Unexpected network connections:\n{string.Join("\n", connectionAttempts)}");
}
[Fact(DisplayName = "T8-AC5.2: No outbound connections during verification")]
public async Task OfflineVerification_NoOutboundConnections()
{
// Arrange
var connectionAttempts = new List<string>();
_fixture.SetConnectionMonitor(endpoint => connectionAttempts.Add(endpoint));
_fixture.SetOfflineMode(true);
var proofBundle = _fixture.GetSampleProofBundle();
// Act
await _fixture.VerifyProofOfflineAsync(proofBundle);
// Assert
connectionAttempts.Should().BeEmpty(
$"Unexpected network connections:\n{string.Join("\n", connectionAttempts)}");
}
[Fact(DisplayName = "T8-AC5.3: No DNS lookups in offline mode")]
public async Task OfflineMode_NoDnsLookups()
{
// Arrange
var dnsLookups = new List<string>();
_fixture.SetDnsMonitor(hostname => dnsLookups.Add(hostname));
_fixture.SetOfflineMode(true);
// Act
var targetImage = _fixture.GetLocalTestImage();
await _fixture.RunOfflineScanAsync(targetImage);
// Assert
dnsLookups.Should().BeEmpty(
$"Unexpected DNS lookups:\n{string.Join("\n", dnsLookups)}");
}
[Fact(DisplayName = "T8-AC5.4: Telemetry disabled in offline mode")]
public async Task OfflineMode_TelemetryDisabled()
{
// Arrange
_fixture.SetOfflineMode(true);
var targetImage = _fixture.GetLocalTestImage();
// Act
var result = await _fixture.RunOfflineScanAsync(targetImage);
// Assert
result.TelemetrySent.Should().BeFalse();
result.Configuration.TelemetryEnabled.Should().BeFalse();
}
[Fact(DisplayName = "T8-AC5.5: Network operations gracefully fail")]
public async Task NetworkOperations_GracefullyFail()
{
// Arrange
await _fixture.DisableNetworkAsync();
try
{
// Act - attempt online operation
var result = await _fixture.AttemptOnlineUpdateAsync();
// Assert
result.Success.Should().BeFalse();
result.FailureReason.Should().Contain("offline");
result.SuggestedAction.Should().Contain("offline-kit");
}
finally
{
await _fixture.EnableNetworkAsync();
}
}
#endregion
}

View File

@@ -0,0 +1,418 @@
// =============================================================================
// StellaOps.Integration.AirGap - Air-Gap Test Fixture
// Sprint 3500.0004.0003 - T8: Air-Gap Integration Tests
// =============================================================================
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Integration.AirGap;
/// <summary>
/// Test fixture for air-gap integration tests.
/// Manages offline kit, network simulation, and test artifacts.
/// </summary>
public sealed class AirGapTestFixture : IDisposable
{
private readonly string _offlineKitPath;
private readonly string _tempDir;
private bool _offlineMode;
private Action<string>? _connectionMonitor;
private Action<string>? _dnsMonitor;
public AirGapTestFixture()
{
_offlineKitPath = Path.Combine(AppContext.BaseDirectory, "offline-kit");
_tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-airgap-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
#region Offline Kit
public OfflineKitManifest GetOfflineKitManifest()
{
var manifestPath = Path.Combine(_offlineKitPath, "manifest.json");
if (File.Exists(manifestPath))
{
var json = File.ReadAllText(manifestPath);
return JsonSerializer.Deserialize<OfflineKitManifest>(json) ?? GetDefaultManifest();
}
return GetDefaultManifest();
}
public async Task<string> ComputeComponentHashAsync(string componentName)
{
var componentPath = Path.Combine(_offlineKitPath, componentName);
if (!Directory.Exists(componentPath) && !File.Exists(componentPath))
{
return "MISSING";
}
using var sha256 = SHA256.Create();
if (File.Exists(componentPath))
{
await using var stream = File.OpenRead(componentPath);
var hash = await sha256.ComputeHashAsync(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
// Directory - hash all files
var files = Directory.GetFiles(componentPath, "*", SearchOption.AllDirectories)
.OrderBy(f => f)
.ToList();
using var combinedStream = new MemoryStream();
foreach (var file in files)
{
await using var fileStream = File.OpenRead(file);
await fileStream.CopyToAsync(combinedStream);
}
combinedStream.Position = 0;
var dirHash = await sha256.ComputeHashAsync(combinedStream);
return Convert.ToHexString(dirHash).ToLowerInvariant();
}
public async Task<InstallationResult> InstallOfflineKitAsync(string targetPath)
{
await Task.Delay(10); // Simulate installation
var manifest = GetOfflineKitManifest();
var installed = new List<string>();
foreach (var (name, _) in manifest.Components)
{
var sourcePath = Path.Combine(_offlineKitPath, name);
var destPath = Path.Combine(targetPath, name);
if (Directory.Exists(sourcePath))
{
Directory.CreateDirectory(destPath);
// Simulate copy
}
else if (File.Exists(sourcePath))
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
// Simulate copy
}
installed.Add(name);
}
return new InstallationResult
{
Success = true,
InstalledComponents = installed
};
}
#endregion
#region Test Images
public string GetLocalTestImage()
{
return "localhost/test-image:v1.0.0";
}
#endregion
#region Scanning
public async Task<ScanResult> RunOfflineScanAsync(string targetImage)
{
await Task.Delay(50); // Simulate scan
if (!_offlineMode)
{
_connectionMonitor?.Invoke("nvd.nist.gov:443");
}
return new ScanResult
{
Success = true,
Findings = GenerateSampleFindings(),
ManifestHash = "sha256:abc123def456",
DataSource = _offlineMode ? "offline-kit" : "online",
DataSourcePath = _offlineMode ? _offlineKitPath : "https://feeds.stellaops.io",
TelemetrySent = !_offlineMode,
Configuration = new ScanConfiguration
{
TelemetryEnabled = !_offlineMode
}
};
}
#endregion
#region Score Replay
public ProofBundle GetSampleProofBundle()
{
return new ProofBundle
{
Id = Guid.NewGuid().ToString(),
CreatedAt = DateTime.UtcNow.AddDays(-1),
OriginalScore = 7.5,
OriginalScoreHash = "sha256:score123",
Signature = Convert.ToBase64String(new byte[64]),
CertificateChain = new[] { "cert1", "cert2", "root" }
};
}
public async Task<ReplayResult> ReplayScoreOfflineAsync(ProofBundle bundle)
{
await Task.Delay(20); // Simulate replay
return new ReplayResult
{
Success = true,
Score = bundle.OriginalScore,
ScoreHash = bundle.OriginalScoreHash,
ReplayedAt = DateTime.UtcNow,
AuditTrail = new[]
{
new AuditEntry { Type = "replay_started", Timestamp = DateTime.UtcNow.AddMilliseconds(-20) },
new AuditEntry { Type = "data_loaded", Timestamp = DateTime.UtcNow.AddMilliseconds(-15) },
new AuditEntry { Type = "score_computed", Timestamp = DateTime.UtcNow.AddMilliseconds(-5) },
new AuditEntry { Type = "replay_completed", Timestamp = DateTime.UtcNow }
}
};
}
#endregion
#region Proof Verification
public async Task<VerificationResult> VerifyProofOfflineAsync(ProofBundle bundle)
{
await Task.Delay(10); // Simulate verification
var isTampered = bundle.Signature.Contains("TAMPERED");
var isExpired = bundle.CertificateChain.Any(c => c.Contains("EXPIRED"));
return new VerificationResult
{
Valid = !isTampered && !isExpired,
VerifiedAt = DateTime.UtcNow,
TrustSource = "offline-trust-store",
CertificateChain = bundle.CertificateChain,
FailureReason = isTampered ? "Invalid signature" : (isExpired ? "Certificate expired" : null),
Warnings = isExpired ? new[] { "certificate chain contains expired certificate" } : Array.Empty<string>()
};
}
public ProofBundle TamperWithProof(ProofBundle original)
{
return original with
{
Signature = "TAMPERED_" + original.Signature
};
}
public ProofBundle GetProofBundleWithExpiredCert()
{
return new ProofBundle
{
Id = Guid.NewGuid().ToString(),
CreatedAt = DateTime.UtcNow.AddYears(-2),
OriginalScore = 5.0,
OriginalScoreHash = "sha256:expired123",
Signature = Convert.ToBase64String(new byte[64]),
CertificateChain = new[] { "cert1", "EXPIRED_cert2", "root" }
};
}
#endregion
#region Network Control
public void SetOfflineMode(bool offline)
{
_offlineMode = offline;
}
public async Task DisableNetworkAsync()
{
_offlineMode = true;
await Task.CompletedTask;
}
public async Task EnableNetworkAsync()
{
_offlineMode = false;
await Task.CompletedTask;
}
public void SetConnectionMonitor(Action<string> monitor)
{
_connectionMonitor = monitor;
}
public void SetDnsMonitor(Action<string> monitor)
{
_dnsMonitor = monitor;
}
public async Task<OnlineUpdateResult> AttemptOnlineUpdateAsync()
{
if (_offlineMode)
{
return new OnlineUpdateResult
{
Success = false,
FailureReason = "System is in offline mode",
SuggestedAction = "Use offline-kit update mechanism"
};
}
await Task.Delay(100);
return new OnlineUpdateResult { Success = true };
}
#endregion
#region Helpers
public string GetTempDirectory()
{
var path = Path.Combine(_tempDir, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static List<Finding> GenerateSampleFindings()
{
return new List<Finding>
{
new() { CveId = "CVE-2024-00001", Severity = "HIGH", Score = 8.0 },
new() { CveId = "CVE-2024-00002", Severity = "MEDIUM", Score = 5.5 },
new() { CveId = "CVE-2024-00003", Severity = "LOW", Score = 3.2 }
};
}
private static OfflineKitManifest GetDefaultManifest()
{
return new OfflineKitManifest
{
Version = "1.0.0",
CreatedAt = DateTime.UtcNow.AddDays(-7),
Components = new Dictionary<string, OfflineComponent>
{
["vulnerability-database"] = new() { Hash = "sha256:vulndb123", Size = 1024 * 1024 },
["advisory-feeds"] = new() { Hash = "sha256:feeds456", Size = 512 * 1024 },
["trust-bundles"] = new() { Hash = "sha256:trust789", Size = 64 * 1024 },
["signing-keys"] = new() { Hash = "sha256:keys012", Size = 16 * 1024 }
}
};
}
#endregion
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
try
{
Directory.Delete(_tempDir, true);
}
catch
{
// Best effort cleanup
}
}
}
}
#region Record Types
public record OfflineKitManifest
{
public string Version { get; init; } = "";
public DateTime CreatedAt { get; init; }
public Dictionary<string, OfflineComponent> Components { get; init; } = new();
}
public record OfflineComponent
{
public string Hash { get; init; } = "";
public long Size { get; init; }
}
public record InstallationResult
{
public bool Success { get; init; }
public List<string> InstalledComponents { get; init; } = new();
}
public record ScanResult
{
public bool Success { get; init; }
public List<Finding> Findings { get; init; } = new();
public string ManifestHash { get; init; } = "";
public string DataSource { get; init; } = "";
public string DataSourcePath { get; init; } = "";
public bool TelemetrySent { get; init; }
public ScanConfiguration Configuration { get; init; } = new();
}
public record ScanConfiguration
{
public bool TelemetryEnabled { get; init; }
}
public record Finding
{
public string CveId { get; init; } = "";
public string Severity { get; init; } = "";
public double Score { get; init; }
}
public record ProofBundle
{
public string Id { get; init; } = "";
public DateTime CreatedAt { get; init; }
public double OriginalScore { get; init; }
public string OriginalScoreHash { get; init; } = "";
public string Signature { get; init; } = "";
public string[] CertificateChain { get; init; } = Array.Empty<string>();
}
public record ReplayResult
{
public bool Success { get; init; }
public double Score { get; init; }
public string ScoreHash { get; init; } = "";
public DateTime ReplayedAt { get; init; }
public AuditEntry[] AuditTrail { get; init; } = Array.Empty<AuditEntry>();
}
public record AuditEntry
{
public string Type { get; init; } = "";
public DateTime Timestamp { get; init; }
}
public record VerificationResult
{
public bool Valid { get; init; }
public DateTime VerifiedAt { get; init; }
public string TrustSource { get; init; } = "";
public string[] CertificateChain { get; init; } = Array.Empty<string>();
public string? FailureReason { get; init; }
public string[] Warnings { get; init; } = Array.Empty<string>();
}
public record OnlineUpdateResult
{
public bool Success { get; init; }
public string? FailureReason { get; init; }
public string? SuggestedAction { get; init; }
}
#endregion

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.10.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Scanner\StellaOps.Scanner.WebService\StellaOps.Scanner.WebService.csproj" />
<ProjectReference Include="..\Attestor\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
<ProjectReference Include="..\Cli\StellaOps.Cli\StellaOps.Cli.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\offline-kit\**\*" LinkBase="offline-kit" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,586 @@
// -----------------------------------------------------------------------------
// AirGapBundleDeterminismTests.cs
// Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate)
// Task: T7 - AirGap Bundle Export Determinism
// Description: Tests to validate AirGap bundle generation determinism
// -----------------------------------------------------------------------------
using System.Text;
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Integration.Determinism;
/// <summary>
/// Determinism validation tests for AirGap bundle generation.
/// Ensures identical inputs produce identical bundles across:
/// - NDJSON bundle file generation
/// - Bundle manifest creation
/// - Entry trace generation
/// - Multiple runs with frozen time
/// - Parallel execution
/// </summary>
public class AirGapBundleDeterminismTests
{
#region NDJSON Bundle Determinism Tests
[Fact]
public void AirGapBundle_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate bundle multiple times
var bundle1 = GenerateNdjsonBundle(input, frozenTime);
var bundle2 = GenerateNdjsonBundle(input, frozenTime);
var bundle3 = GenerateNdjsonBundle(input, frozenTime);
// Assert - All outputs should be identical
bundle1.Should().Be(bundle2);
bundle2.Should().Be(bundle3);
}
[Fact]
public void AirGapBundle_CanonicalHash_IsStable()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate bundle and compute canonical hash twice
var bundle1 = GenerateNdjsonBundle(input, frozenTime);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(bundle1));
var bundle2 = GenerateNdjsonBundle(input, frozenTime);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(bundle2));
// Assert
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void AirGapBundle_DeterminismManifest_CanBeCreated()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var bundle = GenerateNdjsonBundle(input, frozenTime);
var bundleBytes = Encoding.UTF8.GetBytes(bundle);
var artifactInfo = new ArtifactInfo
{
Type = "airgap-bundle",
Name = "concelier-airgap-export",
Version = "1.0.0",
Format = "NDJSON"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Concelier", Version = "1.0.0" }
}
};
// Act - Create determinism manifest
var manifest = DeterminismManifestWriter.CreateManifest(
bundleBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Format.Should().Be("NDJSON");
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public async Task AirGapBundle_ParallelGeneration_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate in parallel 20 times
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => GenerateNdjsonBundle(input, frozenTime)))
.ToArray();
var bundles = await Task.WhenAll(tasks);
// Assert - All outputs should be identical
bundles.Should().AllBe(bundles[0]);
}
[Fact]
public void AirGapBundle_ItemOrdering_IsDeterministic()
{
// Arrange - Items in random order
var input = CreateUnorderedAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate bundle multiple times
var bundle1 = GenerateNdjsonBundle(input, frozenTime);
var bundle2 = GenerateNdjsonBundle(input, frozenTime);
// Assert - Items should be sorted deterministically
bundle1.Should().Be(bundle2);
// Verify items are lexicographically sorted
var lines = bundle1.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var sortedLines = lines.OrderBy(l => l, StringComparer.Ordinal).ToArray();
lines.Should().BeEquivalentTo(sortedLines, options => options.WithStrictOrdering());
}
#endregion
#region Bundle Manifest Determinism Tests
[Fact]
public void BundleManifest_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate manifest multiple times
var manifest1 = GenerateBundleManifest(input, frozenTime);
var manifest2 = GenerateBundleManifest(input, frozenTime);
var manifest3 = GenerateBundleManifest(input, frozenTime);
// Assert - All outputs should be identical
manifest1.Should().Be(manifest2);
manifest2.Should().Be(manifest3);
}
[Fact]
public void BundleManifest_CanonicalHash_IsStable()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var manifest1 = GenerateBundleManifest(input, frozenTime);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(manifest1));
var manifest2 = GenerateBundleManifest(input, frozenTime);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(manifest2));
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void BundleManifest_BundleSha256_MatchesNdjsonHash()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var bundle = GenerateNdjsonBundle(input, frozenTime);
var bundleHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(bundle));
var manifest = GenerateBundleManifest(input, frozenTime);
// Assert - Manifest should contain matching bundle hash
manifest.Should().Contain($"\"bundleSha256\": \"{bundleHash}\"");
}
[Fact]
public void BundleManifest_ItemCount_IsAccurate()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var manifest = GenerateBundleManifest(input, frozenTime);
// Assert
manifest.Should().Contain($"\"count\": {input.Items.Length}");
}
#endregion
#region Entry Trace Determinism Tests
[Fact]
public void EntryTrace_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate entry trace multiple times
var trace1 = GenerateEntryTrace(input, frozenTime);
var trace2 = GenerateEntryTrace(input, frozenTime);
var trace3 = GenerateEntryTrace(input, frozenTime);
// Assert - All outputs should be identical
trace1.Should().Be(trace2);
trace2.Should().Be(trace3);
}
[Fact]
public void EntryTrace_LineNumbers_AreSequential()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var trace = GenerateEntryTrace(input, frozenTime);
// Assert - Line numbers should be sequential starting from 1
for (int i = 1; i <= input.Items.Length; i++)
{
trace.Should().Contain($"\"lineNumber\": {i}");
}
}
[Fact]
public void EntryTrace_ItemHashes_AreCorrect()
{
// Arrange
var input = CreateSampleAirGapInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var trace = GenerateEntryTrace(input, frozenTime);
// Assert - Each item hash should be present
var sortedItems = input.Items.OrderBy(i => i, StringComparer.Ordinal);
foreach (var item in sortedItems)
{
var expectedHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(item));
trace.Should().Contain(expectedHash);
}
}
#endregion
#region Feed Snapshot Determinism Tests
[Fact]
public void FeedSnapshot_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreateFeedSnapshotInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate snapshot multiple times
var snapshot1 = GenerateFeedSnapshot(input, frozenTime);
var snapshot2 = GenerateFeedSnapshot(input, frozenTime);
var snapshot3 = GenerateFeedSnapshot(input, frozenTime);
// Assert - All outputs should be identical
snapshot1.Should().Be(snapshot2);
snapshot2.Should().Be(snapshot3);
}
[Fact]
public void FeedSnapshot_SourceOrdering_IsDeterministic()
{
// Arrange - Sources in random order
var input = CreateFeedSnapshotInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var snapshot = GenerateFeedSnapshot(input, frozenTime);
// Assert - Sources should appear in sorted order
var sourcePositions = input.Sources
.OrderBy(s => s, StringComparer.Ordinal)
.Select(s => snapshot.IndexOf($"\"{s}\""))
.ToArray();
// Positions should be ascending
for (int i = 1; i < sourcePositions.Length; i++)
{
sourcePositions[i].Should().BeGreaterThan(sourcePositions[i - 1]);
}
}
[Fact]
public void FeedSnapshot_Hash_IsStable()
{
// Arrange
var input = CreateFeedSnapshotInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var snapshot1 = GenerateFeedSnapshot(input, frozenTime);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(snapshot1));
var snapshot2 = GenerateFeedSnapshot(input, frozenTime);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(snapshot2));
// Assert
hash1.Should().Be(hash2);
}
#endregion
#region Policy Pack Bundle Determinism Tests
[Fact]
public void PolicyPackBundle_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreatePolicyPackInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var bundle1 = GeneratePolicyPackBundle(input, frozenTime);
var bundle2 = GeneratePolicyPackBundle(input, frozenTime);
// Assert
bundle1.Should().Be(bundle2);
}
[Fact]
public void PolicyPackBundle_RuleOrdering_IsDeterministic()
{
// Arrange
var input = CreatePolicyPackInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var bundle = GeneratePolicyPackBundle(input, frozenTime);
// Assert - Rules should appear in sorted order
var rulePositions = input.Rules
.OrderBy(r => r.Name, StringComparer.Ordinal)
.Select(r => bundle.IndexOf($"\"{r.Name}\""))
.ToArray();
for (int i = 1; i < rulePositions.Length; i++)
{
rulePositions[i].Should().BeGreaterThan(rulePositions[i - 1]);
}
}
#endregion
#region Helper Methods
private static AirGapInput CreateSampleAirGapInput()
{
return new AirGapInput
{
Items = new[]
{
"{\"cveId\":\"CVE-2024-0001\",\"source\":\"nvd\"}",
"{\"cveId\":\"CVE-2024-0002\",\"source\":\"nvd\"}",
"{\"cveId\":\"CVE-2024-0003\",\"source\":\"osv\"}",
"{\"cveId\":\"GHSA-0001\",\"source\":\"ghsa\"}"
}
};
}
private static AirGapInput CreateUnorderedAirGapInput()
{
return new AirGapInput
{
Items = new[]
{
"{\"cveId\":\"CVE-2024-9999\",\"source\":\"nvd\"}",
"{\"cveId\":\"CVE-2024-0001\",\"source\":\"nvd\"}",
"{\"cveId\":\"GHSA-zzzz\",\"source\":\"ghsa\"}",
"{\"cveId\":\"CVE-2024-5555\",\"source\":\"osv\"}",
"{\"cveId\":\"GHSA-aaaa\",\"source\":\"ghsa\"}"
}
};
}
private static FeedSnapshotInput CreateFeedSnapshotInput()
{
return new FeedSnapshotInput
{
Sources = new[] { "nvd", "osv", "ghsa", "kev", "epss" },
SnapshotId = "snapshot-2024-001",
ItemCounts = new Dictionary<string, int>
{
{ "nvd", 25000 },
{ "osv", 15000 },
{ "ghsa", 8000 },
{ "kev", 1200 },
{ "epss", 250000 }
}
};
}
private static PolicyPackInput CreatePolicyPackInput()
{
return new PolicyPackInput
{
PackId = "policy-pack-2024-001",
Version = "1.0.0",
Rules = new[]
{
new PolicyRule { Name = "kev-critical-block", Priority = 1, Action = "block" },
new PolicyRule { Name = "high-cvss-warn", Priority = 2, Action = "warn" },
new PolicyRule { Name = "default-pass", Priority = 100, Action = "allow" }
}
};
}
private static string GenerateNdjsonBundle(AirGapInput input, DateTimeOffset timestamp)
{
var sortedItems = input.Items
.OrderBy(item => item, StringComparer.Ordinal);
return string.Join("\n", sortedItems);
}
private static string GenerateBundleManifest(AirGapInput input, DateTimeOffset timestamp)
{
var sortedItems = input.Items
.OrderBy(item => item, StringComparer.Ordinal)
.ToArray();
var bundle = GenerateNdjsonBundle(input, timestamp);
var bundleHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(bundle));
var entries = sortedItems.Select((item, index) => new
{
lineNumber = index + 1,
sha256 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(item))
});
var entriesJson = string.Join(",\n ", entries.Select(e =>
$"{{\"lineNumber\": {e.lineNumber}, \"sha256\": \"{e.sha256}\"}}"));
var itemsJson = string.Join(",\n ", sortedItems.Select(i => $"\"{EscapeJson(i)}\""));
return $$"""
{
"bundleSha256": "{{bundleHash}}",
"count": {{sortedItems.Length}},
"createdUtc": "{{timestamp:O}}",
"entries": [
{{entriesJson}}
],
"items": [
{{itemsJson}}
]
}
""";
}
private static string GenerateEntryTrace(AirGapInput input, DateTimeOffset timestamp)
{
var sortedItems = input.Items
.OrderBy(item => item, StringComparer.Ordinal)
.ToArray();
var entries = sortedItems.Select((item, index) =>
$$"""
{
"lineNumber": {{index + 1}},
"sha256": "{{CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(item))}}"
}
""");
return $$"""
{
"createdUtc": "{{timestamp:O}}",
"entries": [
{{string.Join(",\n ", entries)}}
]
}
""";
}
private static string GenerateFeedSnapshot(FeedSnapshotInput input, DateTimeOffset timestamp)
{
var sortedSources = input.Sources
.OrderBy(s => s, StringComparer.Ordinal)
.ToArray();
var sourceCounts = sortedSources.Select(s =>
$"\"{s}\": {input.ItemCounts.GetValueOrDefault(s, 0)}");
return $$"""
{
"snapshotId": "{{input.SnapshotId}}",
"createdUtc": "{{timestamp:O}}",
"sources": [{{string.Join(", ", sortedSources.Select(s => $"\"{s}\""))}}],
"itemCounts": {
{{string.Join(",\n ", sourceCounts)}}
}
}
""";
}
private static string GeneratePolicyPackBundle(PolicyPackInput input, DateTimeOffset timestamp)
{
var sortedRules = input.Rules
.OrderBy(r => r.Name, StringComparer.Ordinal)
.ToArray();
var rulesJson = string.Join(",\n ", sortedRules.Select(r =>
$$"""{"name": "{{r.Name}}", "priority": {{r.Priority}}, "action": "{{r.Action}}"}"""));
return $$"""
{
"packId": "{{input.PackId}}",
"version": "{{input.Version}}",
"createdUtc": "{{timestamp:O}}",
"rules": [
{{rulesJson}}
]
}
""";
}
private static string EscapeJson(string value)
{
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\n", "\\n")
.Replace("\r", "\\r")
.Replace("\t", "\\t");
}
#endregion
#region DTOs
private sealed record AirGapInput
{
public required string[] Items { get; init; }
}
private sealed record FeedSnapshotInput
{
public required string[] Sources { get; init; }
public required string SnapshotId { get; init; }
public required Dictionary<string, int> ItemCounts { get; init; }
}
private sealed record PolicyPackInput
{
public required string PackId { get; init; }
public required string Version { get; init; }
public required PolicyRule[] Rules { get; init; }
}
private sealed record PolicyRule
{
public required string Name { get; init; }
public required int Priority { get; init; }
public required string Action { get; init; }
}
#endregion
}

View File

@@ -0,0 +1,408 @@
// -----------------------------------------------------------------------------
// DeterminismValidationTests.cs
// Sprint: SPRINT_3500_0004_0003_integration_tests_corpus
// Task: T5 - Determinism Validation Suite
// Description: Tests to validate scoring determinism across runs, platforms, and time
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace StellaOps.Integration.Determinism;
/// <summary>
/// Determinism validation tests for the scoring engine.
/// Ensures identical inputs produce identical outputs across:
/// - Multiple runs
/// - Different timestamps (with frozen time)
/// - Parallel execution
/// </summary>
public class DeterminismValidationTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
#region T5-AC1: Same input produces identical score hash
[Fact]
public void IdenticalInput_ProducesIdenticalHash_AcrossRuns()
{
// Arrange
var input = new ScoringInput
{
ScanId = "test-scan-001",
SbomHash = "sha256:abc123",
RulesHash = "sha256:def456",
PolicyHash = "sha256:ghi789",
FeedHash = "sha256:jkl012",
Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z")
};
// Act - Compute hash multiple times
var hash1 = ComputeInputHash(input);
var hash2 = ComputeInputHash(input);
var hash3 = ComputeInputHash(input);
// Assert
hash1.Should().Be(hash2);
hash2.Should().Be(hash3);
}
[Fact]
public void DifferentInput_ProducesDifferentHash()
{
// Arrange
var input1 = new ScoringInput
{
ScanId = "scan-001",
SbomHash = "sha256:abc",
RulesHash = "sha256:def",
PolicyHash = "sha256:ghi",
FeedHash = "sha256:jkl",
Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z")
};
var input2 = new ScoringInput
{
ScanId = "scan-001",
SbomHash = "sha256:DIFFERENT", // Changed
RulesHash = "sha256:def",
PolicyHash = "sha256:ghi",
FeedHash = "sha256:jkl",
Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z")
};
// Act
var hash1 = ComputeInputHash(input1);
var hash2 = ComputeInputHash(input2);
// Assert
hash1.Should().NotBe(hash2);
}
#endregion
#region T5-AC2: Cross-platform determinism
[Fact]
public void HashComputation_IsConsistent_WithKnownVector()
{
// Arrange - Known test vector for cross-platform verification
var input = new ScoringInput
{
ScanId = "determinism-test-001",
SbomHash = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
RulesHash = "sha256:0000000000000000000000000000000000000000000000000000000000000000",
PolicyHash = "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
FeedHash = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
Timestamp = DateTimeOffset.Parse("2024-06-15T12:00:00Z")
};
// Act
var hash = ComputeInputHash(input);
// Assert - This hash should be identical on any platform
hash.Should().NotBeNullOrEmpty();
hash.Should().HaveLength(64); // SHA-256 hex = 64 chars
hash.Should().MatchRegex("^[a-f0-9]{64}$");
}
[Fact]
public void CanonicalJson_ProducesStableOutput()
{
// Arrange - Same data, different property order
var obj1 = new Dictionary<string, object>
{
["zebra"] = "last",
["alpha"] = "first",
["middle"] = 123
};
var obj2 = new Dictionary<string, object>
{
["alpha"] = "first",
["middle"] = 123,
["zebra"] = "last"
};
// Act
var json1 = ToCanonicalJson(obj1);
var json2 = ToCanonicalJson(obj2);
// Assert - Canonical JSON should sort keys
json1.Should().Be(json2);
}
#endregion
#region T5-AC3: Timestamp independence (frozen time tests)
[Fact]
public void ScoringWithFrozenTime_IsDeterministic()
{
// Arrange - Freeze timestamp
var frozenTime = DateTimeOffset.Parse("2024-06-15T00:00:00Z");
var input1 = new ScoringInput
{
ScanId = "frozen-time-001",
SbomHash = "sha256:sbom",
RulesHash = "sha256:rules",
PolicyHash = "sha256:policy",
FeedHash = "sha256:feed",
Timestamp = frozenTime
};
var input2 = new ScoringInput
{
ScanId = "frozen-time-001",
SbomHash = "sha256:sbom",
RulesHash = "sha256:rules",
PolicyHash = "sha256:policy",
FeedHash = "sha256:feed",
Timestamp = frozenTime
};
// Act
var hash1 = ComputeInputHash(input1);
var hash2 = ComputeInputHash(input2);
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void DifferentTimestamps_ProduceDifferentHashes()
{
// Arrange
var input1 = new ScoringInput
{
ScanId = "time-test-001",
SbomHash = "sha256:same",
RulesHash = "sha256:same",
PolicyHash = "sha256:same",
FeedHash = "sha256:same",
Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z")
};
var input2 = new ScoringInput
{
ScanId = "time-test-001",
SbomHash = "sha256:same",
RulesHash = "sha256:same",
PolicyHash = "sha256:same",
FeedHash = "sha256:same",
Timestamp = DateTimeOffset.Parse("2024-01-02T00:00:00Z") // Different
};
// Act
var hash1 = ComputeInputHash(input1);
var hash2 = ComputeInputHash(input2);
// Assert
hash1.Should().NotBe(hash2);
}
#endregion
#region T5-AC4: Parallel execution determinism
[Fact]
public async Task ParallelExecution_ProducesIdenticalHashes()
{
// Arrange
var input = new ScoringInput
{
ScanId = "parallel-test-001",
SbomHash = "sha256:parallel",
RulesHash = "sha256:parallel",
PolicyHash = "sha256:parallel",
FeedHash = "sha256:parallel",
Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z")
};
// Act - Compute hash in parallel 100 times
var tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() => ComputeInputHash(input)))
.ToArray();
var hashes = await Task.WhenAll(tasks);
// Assert - All hashes should be identical
hashes.Should().AllBe(hashes[0]);
}
[Fact]
public async Task ConcurrentScoring_MaintainsDeterminism()
{
// Arrange - Multiple different inputs
var inputs = Enumerable.Range(0, 50)
.Select(i => new ScoringInput
{
ScanId = $"concurrent-{i:D3}",
SbomHash = $"sha256:sbom{i:D3}",
RulesHash = "sha256:rules",
PolicyHash = "sha256:policy",
FeedHash = "sha256:feed",
Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z")
})
.ToArray();
// Act - Run twice in parallel
var hashes1 = await Task.WhenAll(inputs.Select(i => Task.Run(() => ComputeInputHash(i))));
var hashes2 = await Task.WhenAll(inputs.Select(i => Task.Run(() => ComputeInputHash(i))));
// Assert - Both runs should produce identical results
hashes1.Should().BeEquivalentTo(hashes2);
}
#endregion
#region T5-AC5: Replay after code changes produces same result
[Fact]
public void GoldenVectorReplay_ProducesExpectedHash()
{
// Arrange - Golden test vector (version-locked)
// This test ensures code changes don't break determinism
var goldenInput = new ScoringInput
{
ScanId = "golden-vector-001",
SbomHash = "sha256:goldensbom0000000000000000000000000000000000000000000000000",
RulesHash = "sha256:goldenrule0000000000000000000000000000000000000000000000000",
PolicyHash = "sha256:goldenpoli0000000000000000000000000000000000000000000000000",
FeedHash = "sha256:goldenfeed0000000000000000000000000000000000000000000000000",
Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z")
};
// Act
var hash = ComputeInputHash(goldenInput);
// Assert - This is the expected hash for the golden vector
// If this test fails after a code change, it indicates a breaking change to determinism
hash.Should().NotBeNullOrEmpty();
// The actual expected hash would be computed once and stored here:
// hash.Should().Be("expected_golden_hash_here");
// For now, verify it's a valid hash format
hash.Should().MatchRegex("^[a-f0-9]{64}$");
}
[Fact]
public void MerkleRoot_IsStable_ForSameNodes()
{
// Arrange
var nodes = new[]
{
"sha256:node1",
"sha256:node2",
"sha256:node3",
"sha256:node4"
};
// Act - Compute merkle root multiple times
var root1 = ComputeMerkleRoot(nodes);
var root2 = ComputeMerkleRoot(nodes);
var root3 = ComputeMerkleRoot(nodes);
// Assert
root1.Should().Be(root2);
root2.Should().Be(root3);
}
[Fact]
public void MerkleRoot_ChangesWhenNodeChanges()
{
// Arrange
var nodes1 = new[] { "sha256:a", "sha256:b", "sha256:c" };
var nodes2 = new[] { "sha256:a", "sha256:DIFFERENT", "sha256:c" };
// Act
var root1 = ComputeMerkleRoot(nodes1);
var root2 = ComputeMerkleRoot(nodes2);
// Assert
root1.Should().NotBe(root2);
}
#endregion
#region Helper Methods
private static string ComputeInputHash(ScoringInput input)
{
var canonical = ToCanonicalJson(input);
return ComputeSha256(canonical);
}
private static string ToCanonicalJson<T>(T obj)
{
// Sort keys for canonical JSON
if (obj is IDictionary<string, object> dict)
{
var sorted = dict.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
return JsonSerializer.Serialize(sorted, JsonOptions);
}
return JsonSerializer.Serialize(obj, JsonOptions);
}
private static string ComputeSha256(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
private static string ComputeMerkleRoot(string[] nodes)
{
if (nodes.Length == 0)
return ComputeSha256("");
if (nodes.Length == 1)
return nodes[0];
var current = nodes.ToList();
while (current.Count > 1)
{
var next = new List<string>();
for (var i = 0; i < current.Count; i += 2)
{
var left = current[i];
var right = i + 1 < current.Count ? current[i + 1] : left;
var combined = left + right;
next.Add("sha256:" + ComputeSha256(combined));
}
current = next;
}
return current[0];
}
#endregion
#region DTOs
private sealed record ScoringInput
{
public required string ScanId { get; init; }
public required string SbomHash { get; init; }
public required string RulesHash { get; init; }
public required string PolicyHash { get; init; }
public required string FeedHash { get; init; }
public required DateTimeOffset Timestamp { get; init; }
}
#endregion
}

View File

@@ -0,0 +1,560 @@
// -----------------------------------------------------------------------------
// EvidenceBundleDeterminismTests.cs
// Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate)
// Task: T6 - Evidence Bundle Determinism (DSSE envelopes, in-toto attestations)
// Description: Tests to validate evidence bundle generation determinism
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Integration.Determinism;
/// <summary>
/// Determinism validation tests for evidence bundle generation.
/// Ensures identical inputs produce identical bundles across:
/// - Evidence bundle creation
/// - DSSE envelope wrapping
/// - in-toto attestation generation
/// - Multiple runs with frozen time
/// - Parallel execution
/// </summary>
public class EvidenceBundleDeterminismTests
{
#region Evidence Bundle Determinism Tests
[Fact]
public void EvidenceBundle_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act - Generate bundle multiple times
var bundle1 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var bundle2 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var bundle3 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
// Assert - All outputs should be identical
bundle1.Should().Be(bundle2);
bundle2.Should().Be(bundle3);
}
[Fact]
public void EvidenceBundle_CanonicalHash_IsStable()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act - Generate bundle and compute canonical hash twice
var bundle1 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(bundle1));
var bundle2 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(bundle2));
// Assert
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void EvidenceBundle_DeterminismManifest_CanBeCreated()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
var bundle = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var bundleBytes = Encoding.UTF8.GetBytes(bundle);
var artifactInfo = new ArtifactInfo
{
Type = "evidence-bundle",
Name = "test-finding-evidence",
Version = "1.0.0",
Format = "EvidenceBundle JSON"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Evidence.Bundle", Version = "1.0.0" }
}
};
// Act - Create determinism manifest
var manifest = DeterminismManifestWriter.CreateManifest(
bundleBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Format.Should().Be("EvidenceBundle JSON");
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public async Task EvidenceBundle_ParallelGeneration_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act - Generate in parallel 20 times
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => CreateEvidenceBundle(input, frozenTime, deterministicBundleId)))
.ToArray();
var bundles = await Task.WhenAll(tasks);
// Assert - All outputs should be identical
bundles.Should().AllBe(bundles[0]);
}
#endregion
#region DSSE Envelope Determinism Tests
[Fact]
public void DsseEnvelope_WithIdenticalPayload_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
var bundle = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
// Act - Wrap in DSSE envelope multiple times
var envelope1 = CreateDsseEnvelope(bundle, frozenTime);
var envelope2 = CreateDsseEnvelope(bundle, frozenTime);
var envelope3 = CreateDsseEnvelope(bundle, frozenTime);
// Assert - Payloads should be identical (signatures depend on key)
var payload1 = ExtractDssePayload(envelope1);
var payload2 = ExtractDssePayload(envelope2);
var payload3 = ExtractDssePayload(envelope3);
payload1.Should().Be(payload2);
payload2.Should().Be(payload3);
}
[Fact]
public void DsseEnvelope_PayloadHash_IsStable()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
var bundle = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
// Act
var envelope1 = CreateDsseEnvelope(bundle, frozenTime);
var payload1 = ExtractDssePayload(envelope1);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(payload1));
var envelope2 = CreateDsseEnvelope(bundle, frozenTime);
var payload2 = ExtractDssePayload(envelope2);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(payload2));
// Assert
hash1.Should().Be(hash2);
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void DsseEnvelope_PayloadType_IsConsistent()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
var bundle = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
// Act
var envelope = CreateDsseEnvelope(bundle, frozenTime);
// Assert
envelope.Should().Contain("\"payloadType\"");
envelope.Should().Contain("application/vnd.stellaops.evidence+json");
}
#endregion
#region in-toto Attestation Determinism Tests
[Fact]
public void InTotoAttestation_WithIdenticalSubject_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act - Generate attestation multiple times
var attestation1 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
var attestation2 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
var attestation3 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
// Assert - All outputs should be identical
attestation1.Should().Be(attestation2);
attestation2.Should().Be(attestation3);
}
[Fact]
public void InTotoAttestation_CanonicalHash_IsStable()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act
var attestation1 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(attestation1));
var attestation2 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(attestation2));
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void InTotoAttestation_SubjectOrdering_IsDeterministic()
{
// Arrange - Multiple subjects
var input = CreateMultiSubjectEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act
var attestation1 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
var attestation2 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
// Assert - Subject order should be deterministic
attestation1.Should().Be(attestation2);
}
[Fact]
public void InTotoAttestation_PredicateType_IsConsistent()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act
var attestation = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
// Assert
attestation.Should().Contain("\"predicateType\"");
attestation.Should().Contain("https://stellaops.io/evidence/v1");
}
[Fact]
public void InTotoAttestation_StatementType_IsConsistent()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act
var attestation = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
// Assert
attestation.Should().Contain("\"_type\"");
attestation.Should().Contain("https://in-toto.io/Statement/v1");
}
#endregion
#region Evidence Hash Determinism Tests
[Fact]
public void EvidenceHashes_WithIdenticalContent_ProduceDeterministicHashes()
{
// Arrange
var content = "test content for hashing";
// Act - Hash the same content multiple times
var hash1 = ComputeEvidenceHash(content);
var hash2 = ComputeEvidenceHash(content);
var hash3 = ComputeEvidenceHash(content);
// Assert
hash1.Should().Be(hash2);
hash2.Should().Be(hash3);
hash1.Should().MatchRegex("^sha256:[0-9a-f]{64}$");
}
[Fact]
public void EvidenceHashSet_Ordering_IsDeterministic()
{
// Arrange - Multiple hashes in random order
var hashes = new[]
{
("artifact", "sha256:abcd1234"),
("sbom", "sha256:efgh5678"),
("vex", "sha256:ijkl9012"),
("policy", "sha256:mnop3456")
};
// Act - Create hash sets multiple times
var hashSet1 = CreateHashSet(hashes);
var hashSet2 = CreateHashSet(hashes);
// Assert - Serialized hash sets should be identical
var json1 = SerializeHashSet(hashSet1);
var json2 = SerializeHashSet(hashSet2);
json1.Should().Be(json2);
}
#endregion
#region Completeness Score Determinism Tests
[Theory]
[InlineData(true, true, true, true, 4)]
[InlineData(true, true, true, false, 3)]
[InlineData(true, true, false, false, 2)]
[InlineData(true, false, false, false, 1)]
[InlineData(false, false, false, false, 0)]
public void CompletenessScore_IsDeterministic(
bool hasReachability,
bool hasCallStack,
bool hasProvenance,
bool hasVexStatus,
int expectedScore)
{
// Arrange
var input = new EvidenceInput
{
AlertId = "ALERT-001",
ArtifactId = "sha256:abc123",
FindingId = "CVE-2024-1234",
HasReachability = hasReachability,
HasCallStack = hasCallStack,
HasProvenance = hasProvenance,
HasVexStatus = hasVexStatus,
Subjects = Array.Empty<string>()
};
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act
var bundle1 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var bundle2 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
// Assert - Both should have same completeness score
bundle1.Should().Contain($"\"completenessScore\": {expectedScore}");
bundle2.Should().Contain($"\"completenessScore\": {expectedScore}");
}
#endregion
#region Helper Methods
private static EvidenceInput CreateSampleEvidenceInput()
{
return new EvidenceInput
{
AlertId = "ALERT-2024-001",
ArtifactId = "sha256:abc123def456",
FindingId = "CVE-2024-1234",
HasReachability = true,
HasCallStack = true,
HasProvenance = true,
HasVexStatus = true,
Subjects = new[] { "pkg:oci/myapp@sha256:abc123" }
};
}
private static EvidenceInput CreateMultiSubjectEvidenceInput()
{
return new EvidenceInput
{
AlertId = "ALERT-2024-002",
ArtifactId = "sha256:multi123",
FindingId = "CVE-2024-5678",
HasReachability = true,
HasCallStack = false,
HasProvenance = true,
HasVexStatus = false,
Subjects = new[]
{
"pkg:oci/app-c@sha256:ccc",
"pkg:oci/app-a@sha256:aaa",
"pkg:oci/app-b@sha256:bbb"
}
};
}
private static string GenerateDeterministicBundleId(EvidenceInput input, DateTimeOffset timestamp)
{
var seed = $"{input.AlertId}:{input.ArtifactId}:{input.FindingId}:{timestamp:O}";
var hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(seed));
return hash[..32]; // Use first 32 chars as bundle ID
}
private static string CreateEvidenceBundle(EvidenceInput input, DateTimeOffset timestamp, string bundleId)
{
var completenessScore = CalculateCompletenessScore(input);
var reachabilityStatus = input.HasReachability ? "available" : "unavailable";
var callStackStatus = input.HasCallStack ? "available" : "unavailable";
var provenanceStatus = input.HasProvenance ? "available" : "unavailable";
var vexStatusValue = input.HasVexStatus ? "available" : "unavailable";
var artifactHash = ComputeEvidenceHash(input.ArtifactId);
return $$"""
{
"bundleId": "{{bundleId}}",
"schemaVersion": "1.0",
"alertId": "{{input.AlertId}}",
"artifactId": "{{input.ArtifactId}}",
"completenessScore": {{completenessScore}},
"createdAt": "{{timestamp:O}}",
"hashes": {
"artifact": "{{artifactHash}}",
"bundle": "sha256:{{bundleId}}"
},
"reachability": {
"status": "{{reachabilityStatus}}"
},
"callStack": {
"status": "{{callStackStatus}}"
},
"provenance": {
"status": "{{provenanceStatus}}"
},
"vexStatus": {
"status": "{{vexStatusValue}}"
}
}
""";
}
private static string CreateDsseEnvelope(string payload, DateTimeOffset timestamp)
{
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
var payloadHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(payload));
// Note: In production, signature would be computed with actual key
// For determinism testing, we use a deterministic placeholder
var deterministicSig = $"sig:{payloadHash[..32]}";
var sigBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(deterministicSig));
return $$"""
{
"payloadType": "application/vnd.stellaops.evidence+json",
"payload": "{{payloadBase64}}",
"signatures": [
{
"keyid": "stellaops-signing-key-v1",
"sig": "{{sigBase64}}"
}
]
}
""";
}
private static string ExtractDssePayload(string envelope)
{
// Extract base64 payload and decode
var payloadStart = envelope.IndexOf("\"payload\": \"") + 12;
var payloadEnd = envelope.IndexOf("\"", payloadStart);
var payloadBase64 = envelope[payloadStart..payloadEnd];
return Encoding.UTF8.GetString(Convert.FromBase64String(payloadBase64));
}
private static string CreateInTotoAttestation(EvidenceInput input, DateTimeOffset timestamp, string bundleId)
{
var subjects = input.Subjects
.OrderBy(s => s, StringComparer.Ordinal)
.Select(s => $$"""
{
"name": "{{s}}",
"digest": {
"sha256": "{{CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(s))}}"
}
}
""");
var bundle = CreateEvidenceBundle(input, timestamp, bundleId);
return $$"""
{
"_type": "https://in-toto.io/Statement/v1",
"predicateType": "https://stellaops.io/evidence/v1",
"subject": [
{{string.Join(",\n ", subjects)}}
],
"predicate": {{bundle}}
}
""";
}
private static int CalculateCompletenessScore(EvidenceInput input)
{
var score = 0;
if (input.HasReachability) score++;
if (input.HasCallStack) score++;
if (input.HasProvenance) score++;
if (input.HasVexStatus) score++;
return score;
}
private static string ComputeEvidenceHash(string content)
{
var hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(content));
return $"sha256:{hash}";
}
private static Dictionary<string, string> CreateHashSet((string name, string hash)[] hashes)
{
return hashes
.OrderBy(h => h.name, StringComparer.Ordinal)
.ToDictionary(h => h.name, h => h.hash);
}
private static string SerializeHashSet(Dictionary<string, string> hashSet)
{
var entries = hashSet
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.Select(kvp => $"\"{kvp.Key}\": \"{kvp.Value}\"");
return $"{{\n {string.Join(",\n ", entries)}\n}}";
}
#endregion
#region DTOs
private sealed record EvidenceInput
{
public required string AlertId { get; init; }
public required string ArtifactId { get; init; }
public required string FindingId { get; init; }
public required bool HasReachability { get; init; }
public required bool HasCallStack { get; init; }
public required bool HasProvenance { get; init; }
public required bool HasVexStatus { get; init; }
public required string[] Subjects { get; init; }
}
#endregion
}

View File

@@ -0,0 +1,658 @@
// -----------------------------------------------------------------------------
// PolicyDeterminismTests.cs
// Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate)
// Task: T5 - Policy Verdict Determinism
// Description: Tests to validate policy verdict generation determinism
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text;
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Integration.Determinism;
/// <summary>
/// Determinism validation tests for policy verdict generation.
/// Ensures identical inputs produce identical verdicts across:
/// - Single verdict generation
/// - Batch verdict generation
/// - Verdict serialization
/// - Multiple runs with frozen time
/// - Parallel execution
/// </summary>
public class PolicyDeterminismTests
{
#region Single Verdict Determinism Tests
[Fact]
public void PolicyVerdict_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSamplePolicyInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate verdict multiple times
var verdict1 = EvaluatePolicy(input, frozenTime);
var verdict2 = EvaluatePolicy(input, frozenTime);
var verdict3 = EvaluatePolicy(input, frozenTime);
// Assert - All outputs should be identical
verdict1.Should().BeEquivalentTo(verdict2);
verdict2.Should().BeEquivalentTo(verdict3);
}
[Fact]
public void PolicyVerdict_CanonicalHash_IsStable()
{
// Arrange
var input = CreateSamplePolicyInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate verdict and compute canonical hash twice
var verdict1 = EvaluatePolicy(input, frozenTime);
var json1 = SerializeVerdict(verdict1);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json1));
var verdict2 = EvaluatePolicy(input, frozenTime);
var json2 = SerializeVerdict(verdict2);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json2));
// Assert
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void PolicyVerdict_DeterminismManifest_CanBeCreated()
{
// Arrange
var input = CreateSamplePolicyInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var verdict = EvaluatePolicy(input, frozenTime);
var json = SerializeVerdict(verdict);
var verdictBytes = Encoding.UTF8.GetBytes(json);
var artifactInfo = new ArtifactInfo
{
Type = "policy-verdict",
Name = "test-finding-verdict",
Version = "1.0.0",
Format = "PolicyVerdict JSON"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Policy.Engine", Version = "1.0.0" }
}
};
// Act - Create determinism manifest
var manifest = DeterminismManifestWriter.CreateManifest(
verdictBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Format.Should().Be("PolicyVerdict JSON");
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public async Task PolicyVerdict_ParallelGeneration_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSamplePolicyInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate in parallel 20 times
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => EvaluatePolicy(input, frozenTime)))
.ToArray();
var verdicts = await Task.WhenAll(tasks);
// Assert - All outputs should be identical
var first = verdicts[0];
verdicts.Should().AllSatisfy(v => v.Should().BeEquivalentTo(first));
}
#endregion
#region Batch Verdict Determinism Tests
[Fact]
public void PolicyVerdictBatch_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var inputs = CreateSampleBatchPolicyInputs();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate batch verdicts multiple times
var batch1 = EvaluatePolicyBatch(inputs, frozenTime);
var batch2 = EvaluatePolicyBatch(inputs, frozenTime);
var batch3 = EvaluatePolicyBatch(inputs, frozenTime);
// Assert - All batches should be identical
batch1.Should().BeEquivalentTo(batch2);
batch2.Should().BeEquivalentTo(batch3);
}
[Fact]
public void PolicyVerdictBatch_Ordering_IsDeterministic()
{
// Arrange - Findings in random order
var inputs = CreateSampleBatchPolicyInputs();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate batch verdicts multiple times
var batch1 = EvaluatePolicyBatch(inputs, frozenTime);
var batch2 = EvaluatePolicyBatch(inputs, frozenTime);
// Assert - Order should be deterministic
var json1 = SerializeBatch(batch1);
var json2 = SerializeBatch(batch2);
json1.Should().Be(json2);
}
[Fact]
public void PolicyVerdictBatch_CanonicalHash_IsStable()
{
// Arrange
var inputs = CreateSampleBatchPolicyInputs();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var batch1 = EvaluatePolicyBatch(inputs, frozenTime);
var json1 = SerializeBatch(batch1);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json1));
var batch2 = EvaluatePolicyBatch(inputs, frozenTime);
var json2 = SerializeBatch(batch2);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json2));
// Assert
hash1.Should().Be(hash2);
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
#endregion
#region Verdict Status Determinism Tests
[Theory]
[InlineData(PolicyVerdictStatus.Pass)]
[InlineData(PolicyVerdictStatus.Blocked)]
[InlineData(PolicyVerdictStatus.Ignored)]
[InlineData(PolicyVerdictStatus.Warned)]
[InlineData(PolicyVerdictStatus.Deferred)]
[InlineData(PolicyVerdictStatus.Escalated)]
[InlineData(PolicyVerdictStatus.RequiresVex)]
public void PolicyVerdict_WithStatus_IsDeterministic(PolicyVerdictStatus status)
{
// Arrange
var input = CreatePolicyInputWithExpectedStatus(status);
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var verdict1 = EvaluatePolicy(input, frozenTime);
var verdict2 = EvaluatePolicy(input, frozenTime);
// Assert
verdict1.Status.Should().Be(status);
verdict2.Status.Should().Be(status);
verdict1.Should().BeEquivalentTo(verdict2);
}
#endregion
#region Score Calculation Determinism Tests
[Fact]
public void PolicyScore_WithSameInputs_ProducesDeterministicScore()
{
// Arrange
var input = CreateSamplePolicyInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var verdict1 = EvaluatePolicy(input, frozenTime);
var verdict2 = EvaluatePolicy(input, frozenTime);
// Assert - Scores should be identical (not floating point approximate)
verdict1.Score.Should().Be(verdict2.Score);
}
[Fact]
public void PolicyScore_InputOrdering_DoesNotAffectScore()
{
// Arrange - Same inputs but in different order
var inputs1 = new Dictionary<string, double>
{
{ "cvss", 7.5 },
{ "epss", 0.001 },
{ "kev", 0.0 },
{ "reachability", 0.8 }
};
var inputs2 = new Dictionary<string, double>
{
{ "reachability", 0.8 },
{ "kev", 0.0 },
{ "epss", 0.001 },
{ "cvss", 7.5 }
};
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var verdict1 = EvaluatePolicyWithInputs("CVE-2024-1234", inputs1, frozenTime);
var verdict2 = EvaluatePolicyWithInputs("CVE-2024-1234", inputs2, frozenTime);
// Assert
verdict1.Score.Should().Be(verdict2.Score);
verdict1.Status.Should().Be(verdict2.Status);
}
[Fact]
public void PolicyScore_FloatingPointPrecision_IsConsistent()
{
// Arrange - Inputs that might cause floating point issues
var inputs = new Dictionary<string, double>
{
{ "cvss", 0.1 + 0.2 }, // Classic floating point precision test
{ "epss", 1.0 / 3.0 },
{ "weight_a", 0.33333333333333333 },
{ "weight_b", 0.66666666666666666 }
};
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var verdict1 = EvaluatePolicyWithInputs("CVE-2024-5678", inputs, frozenTime);
var verdict2 = EvaluatePolicyWithInputs("CVE-2024-5678", inputs, frozenTime);
// Assert - Score should be rounded to consistent precision
verdict1.Score.Should().Be(verdict2.Score);
}
#endregion
#region Rule Matching Determinism Tests
[Fact]
public void PolicyRuleMatching_WithMultipleMatchingRules_SelectsDeterministically()
{
// Arrange - Input that matches multiple rules
var input = CreateInputMatchingMultipleRules();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var verdict1 = EvaluatePolicy(input, frozenTime);
var verdict2 = EvaluatePolicy(input, frozenTime);
// Assert - Same rule should be selected each time
verdict1.RuleName.Should().Be(verdict2.RuleName);
verdict1.RuleAction.Should().Be(verdict2.RuleAction);
}
[Fact]
public void PolicyQuieting_IsDeterministic()
{
// Arrange - Input that triggers quieting
var input = CreateQuietedInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act
var verdict1 = EvaluatePolicy(input, frozenTime);
var verdict2 = EvaluatePolicy(input, frozenTime);
// Assert
verdict1.Quiet.Should().Be(verdict2.Quiet);
verdict1.QuietedBy.Should().Be(verdict2.QuietedBy);
}
#endregion
#region Helper Methods
private static PolicyInput CreateSamplePolicyInput()
{
return new PolicyInput
{
FindingId = "CVE-2024-1234",
CvssScore = 7.5,
EpssScore = 0.001,
IsKev = false,
ReachabilityScore = 0.8,
SourceTrust = "high",
PackageType = "npm",
Severity = "high"
};
}
private static PolicyInput[] CreateSampleBatchPolicyInputs()
{
return new[]
{
new PolicyInput
{
FindingId = "CVE-2024-1111",
CvssScore = 9.8,
EpssScore = 0.5,
IsKev = true,
ReachabilityScore = 1.0,
SourceTrust = "high",
PackageType = "npm",
Severity = "critical"
},
new PolicyInput
{
FindingId = "CVE-2024-2222",
CvssScore = 5.5,
EpssScore = 0.01,
IsKev = false,
ReachabilityScore = 0.3,
SourceTrust = "medium",
PackageType = "pypi",
Severity = "medium"
},
new PolicyInput
{
FindingId = "CVE-2024-3333",
CvssScore = 3.2,
EpssScore = 0.001,
IsKev = false,
ReachabilityScore = 0.1,
SourceTrust = "low",
PackageType = "maven",
Severity = "low"
}
};
}
private static PolicyInput CreatePolicyInputWithExpectedStatus(PolicyVerdictStatus status)
{
return status switch
{
PolicyVerdictStatus.Pass => new PolicyInput
{
FindingId = "CVE-PASS-001",
CvssScore = 2.0,
EpssScore = 0.0001,
IsKev = false,
ReachabilityScore = 0.0,
SourceTrust = "high",
PackageType = "npm",
Severity = "low"
},
PolicyVerdictStatus.Blocked => new PolicyInput
{
FindingId = "CVE-BLOCKED-001",
CvssScore = 9.8,
EpssScore = 0.9,
IsKev = true,
ReachabilityScore = 1.0,
SourceTrust = "high",
PackageType = "npm",
Severity = "critical"
},
PolicyVerdictStatus.Warned => new PolicyInput
{
FindingId = "CVE-WARNED-001",
CvssScore = 7.0,
EpssScore = 0.05,
IsKev = false,
ReachabilityScore = 0.5,
SourceTrust = "medium",
PackageType = "npm",
Severity = "high"
},
PolicyVerdictStatus.RequiresVex => new PolicyInput
{
FindingId = "CVE-VEXREQ-001",
CvssScore = 7.5,
EpssScore = 0.1,
IsKev = false,
ReachabilityScore = null, // Unknown reachability
SourceTrust = "high",
PackageType = "npm",
Severity = "high"
},
_ => new PolicyInput
{
FindingId = $"CVE-{status}-001",
CvssScore = 5.0,
EpssScore = 0.01,
IsKev = false,
ReachabilityScore = 0.5,
SourceTrust = "medium",
PackageType = "npm",
Severity = "medium"
}
};
}
private static PolicyInput CreateInputMatchingMultipleRules()
{
return new PolicyInput
{
FindingId = "CVE-MULTIRULE-001",
CvssScore = 7.0,
EpssScore = 0.1,
IsKev = false,
ReachabilityScore = 0.5,
SourceTrust = "high",
PackageType = "npm",
Severity = "high"
};
}
private static PolicyInput CreateQuietedInput()
{
return new PolicyInput
{
FindingId = "CVE-2024-QUIETED",
CvssScore = 9.0,
EpssScore = 0.5,
IsKev = false,
ReachabilityScore = 1.0,
SourceTrust = "high",
PackageType = "npm",
Severity = "critical",
QuietedBy = "waiver:WAIVER-2024-001"
};
}
private static PolicyVerdictResult EvaluatePolicy(PolicyInput input, DateTimeOffset timestamp)
{
// TODO: Integrate with actual PolicyEngine
// For now, return deterministic stub
var status = DetermineStatus(input);
var score = CalculateScore(input);
var ruleName = DetermineRuleName(input);
return new PolicyVerdictResult
{
FindingId = input.FindingId,
Status = status,
Score = score,
RuleName = ruleName,
RuleAction = status == PolicyVerdictStatus.Pass ? "allow" : "block",
Notes = null,
ConfigVersion = "1.0",
Inputs = new Dictionary<string, double>
{
{ "cvss", input.CvssScore },
{ "epss", input.EpssScore },
{ "kev", input.IsKev ? 1.0 : 0.0 },
{ "reachability", input.ReachabilityScore ?? 0.5 }
}.ToImmutableDictionary(),
Quiet = input.QuietedBy != null,
QuietedBy = input.QuietedBy,
Timestamp = timestamp
};
}
private static PolicyVerdictResult EvaluatePolicyWithInputs(
string findingId,
Dictionary<string, double> inputs,
DateTimeOffset timestamp)
{
// Calculate score from inputs
var cvss = inputs.GetValueOrDefault("cvss", 0);
var epss = inputs.GetValueOrDefault("epss", 0);
var score = Math.Round((cvss * 10 + epss * 100) / 2, 4);
var status = score > 70 ? PolicyVerdictStatus.Blocked :
score > 40 ? PolicyVerdictStatus.Warned :
PolicyVerdictStatus.Pass;
return new PolicyVerdictResult
{
FindingId = findingId,
Status = status,
Score = score,
RuleName = "calculated-score-rule",
RuleAction = status == PolicyVerdictStatus.Pass ? "allow" : "block",
Notes = null,
ConfigVersion = "1.0",
Inputs = inputs.ToImmutableDictionary(),
Quiet = false,
QuietedBy = null,
Timestamp = timestamp
};
}
private static PolicyVerdictResult[] EvaluatePolicyBatch(PolicyInput[] inputs, DateTimeOffset timestamp)
{
return inputs
.Select(input => EvaluatePolicy(input, timestamp))
.OrderBy(v => v.FindingId, StringComparer.Ordinal)
.ToArray();
}
private static PolicyVerdictStatus DetermineStatus(PolicyInput input)
{
if (input.QuietedBy != null)
return PolicyVerdictStatus.Ignored;
if (input.ReachabilityScore == null)
return PolicyVerdictStatus.RequiresVex;
if (input.IsKev || input.CvssScore >= 9.0 || input.EpssScore >= 0.5)
return PolicyVerdictStatus.Blocked;
if (input.CvssScore >= 7.0 || input.EpssScore >= 0.05)
return PolicyVerdictStatus.Warned;
return PolicyVerdictStatus.Pass;
}
private static double CalculateScore(PolicyInput input)
{
var baseScore = input.CvssScore * 10;
var epssMultiplier = 1 + (input.EpssScore * 10);
var kevBonus = input.IsKev ? 20 : 0;
var reachabilityFactor = input.ReachabilityScore ?? 0.5;
var rawScore = (baseScore * epssMultiplier + kevBonus) * reachabilityFactor;
return Math.Round(rawScore, 4);
}
private static string DetermineRuleName(PolicyInput input)
{
if (input.IsKev)
return "kev-critical-block";
if (input.CvssScore >= 9.0)
return "critical-cvss-block";
if (input.EpssScore >= 0.5)
return "high-exploit-likelihood-block";
if (input.CvssScore >= 7.0)
return "high-cvss-warn";
return "default-pass";
}
private static string SerializeVerdict(PolicyVerdictResult verdict)
{
// Canonical JSON serialization
var inputsJson = string.Join(", ", verdict.Inputs
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.Select(kvp => $"\"{kvp.Key}\": {kvp.Value}"));
return $$"""
{
"configVersion": "{{verdict.ConfigVersion}}",
"findingId": "{{verdict.FindingId}}",
"inputs": {{{inputsJson}}},
"notes": {{(verdict.Notes == null ? "null" : $"\"{verdict.Notes}\"")}},
"quiet": {{verdict.Quiet.ToString().ToLowerInvariant()}},
"quietedBy": {{(verdict.QuietedBy == null ? "null" : $"\"{verdict.QuietedBy}\"")}},
"ruleAction": "{{verdict.RuleAction}}",
"ruleName": "{{verdict.RuleName}}",
"score": {{verdict.Score}},
"status": "{{verdict.Status}}",
"timestamp": "{{verdict.Timestamp:O}}"
}
""";
}
private static string SerializeBatch(PolicyVerdictResult[] verdicts)
{
var items = verdicts.Select(SerializeVerdict);
return $"[\n {string.Join(",\n ", items)}\n]";
}
#endregion
#region DTOs
private sealed record PolicyInput
{
public required string FindingId { get; init; }
public required double CvssScore { get; init; }
public required double EpssScore { get; init; }
public required bool IsKev { get; init; }
public double? ReachabilityScore { get; init; }
public required string SourceTrust { get; init; }
public required string PackageType { get; init; }
public required string Severity { get; init; }
public string? QuietedBy { get; init; }
}
private sealed record PolicyVerdictResult
{
public required string FindingId { get; init; }
public required PolicyVerdictStatus Status { get; init; }
public required double Score { get; init; }
public required string RuleName { get; init; }
public required string RuleAction { get; init; }
public string? Notes { get; init; }
public required string ConfigVersion { get; init; }
public required ImmutableDictionary<string, double> Inputs { get; init; }
public required bool Quiet { get; init; }
public string? QuietedBy { get; init; }
public required DateTimeOffset Timestamp { get; init; }
}
public enum PolicyVerdictStatus
{
Pass,
Blocked,
Ignored,
Warned,
Deferred,
Escalated,
RequiresVex
}
#endregion
}

View File

@@ -0,0 +1,496 @@
// -----------------------------------------------------------------------------
// SbomDeterminismTests.cs
// Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate)
// Task: T3 - SBOM Export Determinism (SPDX 3.0.1, CycloneDX 1.6, CycloneDX 1.7)
// Task: SCANNER-5100-007 - Expand determinism tests for Scanner SBOM hash stable
// Description: Tests to validate SBOM generation determinism across formats
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text;
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Composition;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Integration.Determinism;
/// <summary>
/// Determinism validation tests for SBOM generation.
/// Ensures identical inputs produce identical SBOMs across:
/// - SPDX 3.0.1
/// - CycloneDX 1.6
/// - CycloneDX 1.7
/// - Multiple runs with frozen time
/// - Parallel execution
/// </summary>
public class SbomDeterminismTests
{
#region SPDX 3.0.1 Determinism Tests
[Fact]
public void SpdxSbom_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate SBOM multiple times
var sbom1 = GenerateSpdxSbom(input, frozenTime);
var sbom2 = GenerateSpdxSbom(input, frozenTime);
var sbom3 = GenerateSpdxSbom(input, frozenTime);
// Assert - All outputs should be identical
sbom1.Should().Be(sbom2);
sbom2.Should().Be(sbom3);
}
[Fact]
public void SpdxSbom_CanonicalHash_IsStable()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate SBOM and compute canonical hash twice
var sbom1 = GenerateSpdxSbom(input, frozenTime);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom1));
var sbom2 = GenerateSpdxSbom(input, frozenTime);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom2));
// Assert
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void SpdxSbom_DeterminismManifest_CanBeCreated()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var sbom = GenerateSpdxSbom(input, frozenTime);
var sbomBytes = Encoding.UTF8.GetBytes(sbom);
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test-container-sbom",
Version = "1.0.0",
Format = "SPDX 3.0.1"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" }
}
};
// Act - Create determinism manifest
var manifest = DeterminismManifestWriter.CreateManifest(
sbomBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Format.Should().Be("SPDX 3.0.1");
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public async Task SpdxSbom_ParallelGeneration_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate in parallel 20 times
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => GenerateSpdxSbom(input, frozenTime)))
.ToArray();
var sboms = await Task.WhenAll(tasks);
// Assert - All outputs should be identical
sboms.Should().AllBe(sboms[0]);
}
#endregion
#region CycloneDX 1.6 Determinism Tests
[Fact]
public void CycloneDx16Sbom_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate SBOM multiple times
var sbom1 = GenerateCycloneDx16Sbom(input, frozenTime);
var sbom2 = GenerateCycloneDx16Sbom(input, frozenTime);
var sbom3 = GenerateCycloneDx16Sbom(input, frozenTime);
// Assert - All outputs should be identical
sbom1.Should().Be(sbom2);
sbom2.Should().Be(sbom3);
}
[Fact]
public void CycloneDx16Sbom_CanonicalHash_IsStable()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate SBOM and compute canonical hash twice
var sbom1 = GenerateCycloneDx16Sbom(input, frozenTime);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom1));
var sbom2 = GenerateCycloneDx16Sbom(input, frozenTime);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom2));
// Assert
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void CycloneDx16Sbom_DeterminismManifest_CanBeCreated()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var sbom = GenerateCycloneDx16Sbom(input, frozenTime);
var sbomBytes = Encoding.UTF8.GetBytes(sbom);
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test-container-sbom",
Version = "1.0.0",
Format = "CycloneDX 1.6"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" }
}
};
// Act - Create determinism manifest
var manifest = DeterminismManifestWriter.CreateManifest(
sbomBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Format.Should().Be("CycloneDX 1.6");
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public async Task CycloneDx16Sbom_ParallelGeneration_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate in parallel 20 times
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => GenerateCycloneDx16Sbom(input, frozenTime)))
.ToArray();
var sboms = await Task.WhenAll(tasks);
// Assert - All outputs should be identical
sboms.Should().AllBe(sboms[0]);
}
#endregion
#region CycloneDX 1.7 Determinism Tests
[Fact]
public void CycloneDx17Sbom_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate SBOM multiple times
var sbom1 = GenerateCycloneDx17Sbom(input, frozenTime);
var sbom2 = GenerateCycloneDx17Sbom(input, frozenTime);
var sbom3 = GenerateCycloneDx17Sbom(input, frozenTime);
// Assert - All outputs should be identical
sbom1.Should().Be(sbom2);
sbom2.Should().Be(sbom3);
}
[Fact]
public void CycloneDx17Sbom_CanonicalHash_IsStable()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate SBOM and compute canonical hash twice
var sbom1 = GenerateCycloneDx17Sbom(input, frozenTime);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom1));
var sbom2 = GenerateCycloneDx17Sbom(input, frozenTime);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom2));
// Assert
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void CycloneDx17Sbom_DeterminismManifest_CanBeCreated()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var sbom = GenerateCycloneDx17Sbom(input, frozenTime);
var sbomBytes = Encoding.UTF8.GetBytes(sbom);
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test-container-sbom",
Version = "1.0.0",
Format = "CycloneDX 1.7"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" }
}
};
// Act - Create determinism manifest
var manifest = DeterminismManifestWriter.CreateManifest(
sbomBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Format.Should().Be("CycloneDX 1.7");
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public async Task CycloneDx17Sbom_ParallelGeneration_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate in parallel 20 times
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => GenerateCycloneDx17Sbom(input, frozenTime)))
.ToArray();
var sboms = await Task.WhenAll(tasks);
// Assert - All outputs should be identical
sboms.Should().AllBe(sboms[0]);
}
#endregion
#region Cross-Format Consistency Tests
[Fact]
public void AllFormats_WithSameInput_ProduceDifferentButStableHashes()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
// Act - Generate all formats
var spdx = GenerateSpdxSbom(input, frozenTime);
var cdx16 = GenerateCycloneDx16Sbom(input, frozenTime);
var cdx17 = GenerateCycloneDx17Sbom(input, frozenTime);
var spdxHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(spdx));
var cdx16Hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(cdx16));
var cdx17Hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(cdx17));
// Assert - SPDX should differ from CycloneDX
spdxHash.Should().NotBe(cdx16Hash);
spdxHash.Should().NotBe(cdx17Hash);
// Note: CycloneDX 1.6 and 1.7 produce same output because CycloneDxComposer
// only outputs spec version 1.7. This is expected behavior.
cdx16Hash.Should().Be(cdx17Hash, "CycloneDxComposer outputs 1.7 for both");
// All hashes should be valid SHA-256
spdxHash.Should().MatchRegex("^[0-9a-f]{64}$");
cdx16Hash.Should().MatchRegex("^[0-9a-f]{64}$");
cdx17Hash.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void AllFormats_CanProduceDeterminismManifests()
{
// Arrange
var input = CreateSampleSbomInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" }
}
};
// Act - Generate all formats and create manifests
var spdxManifest = DeterminismManifestWriter.CreateManifest(
Encoding.UTF8.GetBytes(GenerateSpdxSbom(input, frozenTime)),
new ArtifactInfo { Type = "sbom", Name = "test-sbom", Version = "1.0.0", Format = "SPDX 3.0.1" },
toolchain);
var cdx16Manifest = DeterminismManifestWriter.CreateManifest(
Encoding.UTF8.GetBytes(GenerateCycloneDx16Sbom(input, frozenTime)),
new ArtifactInfo { Type = "sbom", Name = "test-sbom", Version = "1.0.0", Format = "CycloneDX 1.6" },
toolchain);
var cdx17Manifest = DeterminismManifestWriter.CreateManifest(
Encoding.UTF8.GetBytes(GenerateCycloneDx17Sbom(input, frozenTime)),
new ArtifactInfo { Type = "sbom", Name = "test-sbom", Version = "1.0.0", Format = "CycloneDX 1.7" },
toolchain);
// Assert - All manifests should be valid
spdxManifest.SchemaVersion.Should().Be("1.0");
cdx16Manifest.SchemaVersion.Should().Be("1.0");
cdx17Manifest.SchemaVersion.Should().Be("1.0");
spdxManifest.Artifact.Format.Should().Be("SPDX 3.0.1");
cdx16Manifest.Artifact.Format.Should().Be("CycloneDX 1.6");
cdx17Manifest.Artifact.Format.Should().Be("CycloneDX 1.7");
}
#endregion
#region Helper Methods
private static SbomInput CreateSampleSbomInput()
{
return new SbomInput
{
ContainerImage = "alpine:3.18",
PackageUrls = new[]
{
"pkg:apk/alpine/musl@1.2.4-r2?arch=x86_64",
"pkg:apk/alpine/busybox@1.36.1-r2?arch=x86_64",
"pkg:apk/alpine/alpine-baselayout@3.4.3-r1?arch=x86_64"
},
Timestamp = DateTimeOffset.Parse("2025-12-23T18:00:00Z")
};
}
private static SbomCompositionRequest CreateCompositionRequest(SbomInput input, DateTimeOffset timestamp)
{
var fragments = new[]
{
LayerComponentFragment.Create("sha256:layer1", input.PackageUrls.Select((purl, i) =>
new ComponentRecord
{
Identity = ComponentIdentity.Create(
purl.Split('@')[0],
purl.Split('/').Last().Split('@')[0],
purl.Split('@').Last().Split('?')[0],
purl,
"library"),
LayerDigest = "sha256:layer1",
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath($"/lib/{purl.Split('/').Last().Split('@')[0]}")),
Dependencies = ImmutableArray<string>.Empty,
Usage = ComponentUsage.Create(false),
Metadata = new ComponentMetadata { Scope = "runtime" }
}).ToArray())
};
var image = new ImageArtifactDescriptor
{
ImageDigest = "sha256:determinism1234567890determinism1234567890determinism1234567890",
ImageReference = $"docker.io/library/{input.ContainerImage}",
Repository = "docker.io/library/alpine",
Tag = input.ContainerImage.Split(':').Last(),
Architecture = "amd64"
};
return SbomCompositionRequest.Create(
image,
fragments,
timestamp,
generatorName: "StellaOps.Scanner",
generatorVersion: "1.0.0",
properties: new Dictionary<string, string>
{
["stellaops:scanId"] = "determinism-test-001",
["stellaops:tenantId"] = "test-tenant"
});
}
private static string GenerateSpdxSbom(SbomInput input, DateTimeOffset timestamp)
{
var request = CreateCompositionRequest(input, timestamp);
var composer = new SpdxComposer();
var result = composer.Compose(request, new SpdxCompositionOptions());
return Encoding.UTF8.GetString(result.JsonBytes);
}
private static string GenerateCycloneDx16Sbom(SbomInput input, DateTimeOffset timestamp)
{
// CycloneDxComposer produces 1.7 format; for 1.6 testing we use the same composer
// as the actual production code would. The API doesn't support version selection.
var request = CreateCompositionRequest(input, timestamp);
var composer = new CycloneDxComposer();
var result = composer.Compose(request);
return Encoding.UTF8.GetString(result.Inventory.JsonBytes);
}
private static string GenerateCycloneDx17Sbom(SbomInput input, DateTimeOffset timestamp)
{
var request = CreateCompositionRequest(input, timestamp);
var composer = new CycloneDxComposer();
var result = composer.Compose(request);
return Encoding.UTF8.GetString(result.Inventory.JsonBytes);
}
#endregion
#region DTOs
private sealed record SbomInput
{
public required string ContainerImage { get; init; }
public required string[] PackageUrls { get; init; }
public required DateTimeOffset Timestamp { get; init; }
}
#endregion
}

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
StellaOps.Integration.Determinism.csproj
Sprint: SPRINT_3500_0004_0003_integration_tests_corpus
Task: T5 - Determinism Validation Suite
Description: Tests to validate scoring determinism across runs, platforms, and time
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<!-- Policy scoring for determinism tests -->
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
<!-- Policy for VerdictId content-addressing tests (SPRINT_8200_0001_0001) -->
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<!-- Proof chain for hash verification -->
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.ProofChain/StellaOps.Attestor.ProofChain.csproj" />
<!-- Cryptography for hashing -->
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<!-- Canonical JSON -->
<ProjectReference Include="../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
<!-- Determinism manifest writer/reader (NEW for SPRINT_5100_0007_0003) -->
<ProjectReference Include="../__Libraries/StellaOps.Testing.Determinism/StellaOps.Testing.Determinism.csproj" />
<!-- Scanner Emit for SBOM generation (SPRINT_5100_0009_0001 Task 7) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
<!-- Scanner Core contracts for composition requests -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
<!-- Scanner Reachability for reachability evidence determinism (SPRINT_5100_0009_0001 Task 8) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj" />
<!-- Scanner Evidence for reachability evidence models (SPRINT_5100_0009_0001 Task 8) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Evidence/StellaOps.Scanner.Evidence.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Determinism corpus -->
<Content Include="../__Benchmarks/determinism/**/*">
<Link>determinism/%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,462 @@
// -----------------------------------------------------------------------------
// TriageOutputDeterminismTests.cs
// Sprint: SPRINT_5100_0009_0001 - Scanner Module Test Implementation
// Task: SCANNER-5100-009 - Expand determinism tests: triage output hash stable
// Description: Tests to validate triage output generation determinism
// -----------------------------------------------------------------------------
using System.Text;
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Integration.Determinism;
/// <summary>
/// Determinism validation tests for triage output generation.
/// Ensures identical inputs produce identical triage outputs across:
/// - Multiple runs with frozen time
/// - Parallel execution
/// - Finding ordering
/// - Status transitions
/// </summary>
public class TriageOutputDeterminismTests
{
#region Basic Determinism Tests
[Fact]
public void TriageOutput_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
var input = CreateSampleTriageInput();
// Act - Generate triage output multiple times
var output1 = GenerateTriageOutput(input, frozenTime);
var output2 = GenerateTriageOutput(input, frozenTime);
var output3 = GenerateTriageOutput(input, frozenTime);
// Serialize to canonical JSON
var json1 = CanonJson.Serialize(output1);
var json2 = CanonJson.Serialize(output2);
var json3 = CanonJson.Serialize(output3);
// Assert - All outputs should be identical
json1.Should().Be(json2);
json2.Should().Be(json3);
}
[Fact]
public void TriageOutput_CanonicalHash_IsStable()
{
// Arrange
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
var input = CreateSampleTriageInput();
// Act - Generate output and compute canonical hash twice
var output1 = GenerateTriageOutput(input, frozenTime);
var hash1 = ComputeCanonicalHash(output1);
var output2 = GenerateTriageOutput(input, frozenTime);
var hash2 = ComputeCanonicalHash(output2);
// Assert
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void TriageOutput_DeterminismManifest_CanBeCreated()
{
// Arrange
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
var input = CreateSampleTriageInput();
var output = GenerateTriageOutput(input, frozenTime);
var outputBytes = Encoding.UTF8.GetBytes(CanonJson.Serialize(output));
var artifactInfo = new ArtifactInfo
{
Type = "triage-output",
Name = "test-scan-triage",
Version = "1.0.0",
Format = "triage-output@1.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Scanner.Triage", Version = "1.0.0" }
}
};
// Act - Create determinism manifest
var manifest = DeterminismManifestWriter.CreateManifest(
outputBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Format.Should().Be("triage-output@1.0");
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public async Task TriageOutput_ParallelGeneration_ProducesDeterministicOutput()
{
// Arrange
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
var input = CreateSampleTriageInput();
// Act - Generate in parallel 20 times
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => CanonJson.Serialize(GenerateTriageOutput(input, frozenTime))))
.ToArray();
var outputs = await Task.WhenAll(tasks);
// Assert - All outputs should be identical
outputs.Should().AllBe(outputs[0]);
}
#endregion
#region Finding Ordering Tests
[Fact]
public void TriageOutput_FindingsAreDeterministicallyOrdered()
{
// Arrange - Create input with findings in random order
var findings = new[]
{
CreateFinding("CVE-2024-0003", "critical"),
CreateFinding("CVE-2024-0001", "high"),
CreateFinding("CVE-2024-0002", "medium")
};
var input = new TriageInput
{
ScanId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
Findings = findings
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var output1 = GenerateTriageOutput(input, frozenTime);
var output2 = GenerateTriageOutput(input, frozenTime);
// Assert - Outputs should be identical
var json1 = CanonJson.Serialize(output1);
var json2 = CanonJson.Serialize(output2);
json1.Should().Be(json2);
// Verify findings are sorted by CVE ID
for (int i = 1; i < output1.Findings.Count; i++)
{
string.CompareOrdinal(output1.Findings[i - 1].CveId, output1.Findings[i].CveId)
.Should().BeLessOrEqualTo(0, "Findings should be sorted by CVE ID");
}
}
[Fact]
public void TriageOutput_FindingsWithSameCve_SortedByPackage()
{
// Arrange - Multiple findings for same CVE
var findings = new[]
{
CreateFinding("CVE-2024-0001", "high", "pkg:npm/package-z@1.0.0"),
CreateFinding("CVE-2024-0001", "high", "pkg:npm/package-a@1.0.0"),
CreateFinding("CVE-2024-0001", "high", "pkg:npm/package-m@1.0.0")
};
var input = new TriageInput
{
ScanId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
Findings = findings
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var output1 = GenerateTriageOutput(input, frozenTime);
var output2 = GenerateTriageOutput(input, frozenTime);
// Assert
var json1 = CanonJson.Serialize(output1);
var json2 = CanonJson.Serialize(output2);
json1.Should().Be(json2);
}
#endregion
#region Status Transition Tests
[Theory]
[InlineData("open")]
[InlineData("acknowledged")]
[InlineData("mitigated")]
[InlineData("resolved")]
[InlineData("false_positive")]
public void TriageOutput_StatusIsPreserved(string status)
{
// Arrange
var finding = CreateFinding("CVE-2024-0001", "high") with { Status = status };
var input = new TriageInput
{
ScanId = Guid.Parse("33333333-3333-3333-3333-333333333333"),
Findings = new[] { finding }
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var output = GenerateTriageOutput(input, frozenTime);
// Assert
output.Findings[0].Status.Should().Be(status);
}
[Fact]
public void TriageOutput_StatusTransitionHistoryIsOrdered()
{
// Arrange
var finding = CreateFinding("CVE-2024-0001", "high") with
{
StatusHistory = new[]
{
new StatusTransition { Status = "mitigated", Timestamp = DateTimeOffset.Parse("2025-12-24T10:00:00Z") },
new StatusTransition { Status = "open", Timestamp = DateTimeOffset.Parse("2025-12-24T08:00:00Z") },
new StatusTransition { Status = "acknowledged", Timestamp = DateTimeOffset.Parse("2025-12-24T09:00:00Z") }
}
};
var input = new TriageInput
{
ScanId = Guid.Parse("44444444-4444-4444-4444-444444444444"),
Findings = new[] { finding }
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var output1 = GenerateTriageOutput(input, frozenTime);
var output2 = GenerateTriageOutput(input, frozenTime);
// Assert
var json1 = CanonJson.Serialize(output1);
var json2 = CanonJson.Serialize(output2);
json1.Should().Be(json2);
// Verify history is sorted by timestamp
var history = output1.Findings[0].StatusHistory;
for (int i = 1; i < history.Count; i++)
{
history[i - 1].Timestamp.Should().BeBefore(history[i].Timestamp,
"Status history should be sorted by timestamp");
}
}
#endregion
#region Inputs Hash Tests
[Fact]
public void TriageOutput_InputsHashIsStable()
{
// Arrange
var input = CreateSampleTriageInput();
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var output1 = GenerateTriageOutput(input, frozenTime);
var output2 = GenerateTriageOutput(input, frozenTime);
// Assert
output1.InputsHash.Should().Be(output2.InputsHash);
output1.InputsHash.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void TriageOutput_DifferentInputs_ProduceDifferentHashes()
{
// Arrange
var input1 = CreateSampleTriageInput();
var input2 = CreateSampleTriageInput() with
{
ScanId = Guid.Parse("55555555-5555-5555-5555-555555555555")
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var output1 = GenerateTriageOutput(input1, frozenTime);
var output2 = GenerateTriageOutput(input2, frozenTime);
// Assert
output1.InputsHash.Should().NotBe(output2.InputsHash);
}
#endregion
#region Empty/Edge Case Tests
[Fact]
public void TriageOutput_EmptyFindings_ProducesDeterministicOutput()
{
// Arrange
var input = new TriageInput
{
ScanId = Guid.Parse("66666666-6666-6666-6666-666666666666"),
Findings = Array.Empty<FindingInput>()
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var hash1 = ComputeCanonicalHash(GenerateTriageOutput(input, frozenTime));
var hash2 = ComputeCanonicalHash(GenerateTriageOutput(input, frozenTime));
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void TriageOutput_ManyFindings_ProducesDeterministicOutput()
{
// Arrange - Create 500 findings
var findings = Enumerable.Range(0, 500)
.Select(i => CreateFinding($"CVE-2024-{i:D4}", i % 4 == 0 ? "critical" : i % 3 == 0 ? "high" : "medium"))
.ToArray();
var input = new TriageInput
{
ScanId = Guid.Parse("77777777-7777-7777-7777-777777777777"),
Findings = findings
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var hash1 = ComputeCanonicalHash(GenerateTriageOutput(input, frozenTime));
var hash2 = ComputeCanonicalHash(GenerateTriageOutput(input, frozenTime));
// Assert
hash1.Should().Be(hash2);
}
#endregion
#region Helper Methods
private static TriageInput CreateSampleTriageInput()
{
return new TriageInput
{
ScanId = Guid.Parse("88888888-8888-8888-8888-888888888888"),
Findings = new[]
{
CreateFinding("CVE-2024-1234", "critical"),
CreateFinding("CVE-2024-5678", "high"),
CreateFinding("CVE-2024-9012", "medium")
}
};
}
private static FindingInput CreateFinding(string cveId, string severity, string? packageUrl = null)
{
return new FindingInput
{
CveId = cveId,
Severity = severity,
PackageUrl = packageUrl ?? $"pkg:npm/test-package@1.0.0",
Status = "open",
StatusHistory = Array.Empty<StatusTransition>()
};
}
private static TriageOutput GenerateTriageOutput(TriageInput input, DateTimeOffset timestamp)
{
// Sort findings deterministically by CVE ID, then by package URL
var sortedFindings = input.Findings
.OrderBy(f => f.CveId, StringComparer.Ordinal)
.ThenBy(f => f.PackageUrl, StringComparer.Ordinal)
.Select(f => new TriageFindingOutput
{
CveId = f.CveId,
Severity = f.Severity,
PackageUrl = f.PackageUrl,
Status = f.Status,
StatusHistory = f.StatusHistory
.OrderBy(s => s.Timestamp)
.ToList()
})
.ToList();
// Compute inputs hash
var inputsJson = CanonJson.Serialize(input);
var inputsHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(inputsJson));
return new TriageOutput
{
ScanId = input.ScanId,
Timestamp = timestamp,
Findings = sortedFindings,
InputsHash = inputsHash
};
}
private static string ComputeCanonicalHash(TriageOutput output)
{
var json = CanonJson.Serialize(output);
return CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json));
}
#endregion
#region DTOs
private sealed record TriageInput
{
public required Guid ScanId { get; init; }
public required FindingInput[] Findings { get; init; }
}
private sealed record FindingInput
{
public required string CveId { get; init; }
public required string Severity { get; init; }
public required string PackageUrl { get; init; }
public required string Status { get; init; }
public required StatusTransition[] StatusHistory { get; init; }
}
private sealed record StatusTransition
{
public required string Status { get; init; }
public required DateTimeOffset Timestamp { get; init; }
}
private sealed record TriageOutput
{
public required Guid ScanId { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public required IReadOnlyList<TriageFindingOutput> Findings { get; init; }
public required string InputsHash { get; init; }
}
private sealed record TriageFindingOutput
{
public required string CveId { get; init; }
public required string Severity { get; init; }
public required string PackageUrl { get; init; }
public required string Status { get; init; }
public required IReadOnlyList<StatusTransition> StatusHistory { get; init; }
}
#endregion
}

View File

@@ -0,0 +1,585 @@
// -----------------------------------------------------------------------------
// VerdictArtifactDeterminismTests.cs
// Sprint: SPRINT_5100_0009_0001 - Scanner Module Test Implementation
// Task: SCANNER-5100-010 - Expand determinism tests: verdict artifact payload hash stable
// Description: Tests to validate verdict artifact generation determinism
// -----------------------------------------------------------------------------
using System.Text;
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Integration.Determinism;
/// <summary>
/// Determinism validation tests for verdict artifact generation.
/// Ensures identical inputs produce identical verdict artifacts across:
/// - Multiple runs with frozen time
/// - Parallel execution
/// - Change ordering
/// - Proof spine integration
/// </summary>
public class VerdictArtifactDeterminismTests
{
#region Basic Determinism Tests
[Fact]
public void VerdictArtifact_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
var input = CreateSampleVerdictInput();
// Act - Generate verdict artifact multiple times
var verdict1 = GenerateVerdictArtifact(input, frozenTime);
var verdict2 = GenerateVerdictArtifact(input, frozenTime);
var verdict3 = GenerateVerdictArtifact(input, frozenTime);
// Serialize to canonical JSON
var json1 = CanonJson.Serialize(verdict1);
var json2 = CanonJson.Serialize(verdict2);
var json3 = CanonJson.Serialize(verdict3);
// Assert - All outputs should be identical
json1.Should().Be(json2);
json2.Should().Be(json3);
}
[Fact]
public void VerdictArtifact_CanonicalHash_IsStable()
{
// Arrange
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
var input = CreateSampleVerdictInput();
// Act - Generate verdict and compute canonical hash twice
var verdict1 = GenerateVerdictArtifact(input, frozenTime);
var hash1 = ComputeCanonicalHash(verdict1);
var verdict2 = GenerateVerdictArtifact(input, frozenTime);
var hash2 = ComputeCanonicalHash(verdict2);
// Assert
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void VerdictArtifact_DeterminismManifest_CanBeCreated()
{
// Arrange
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
var input = CreateSampleVerdictInput();
var verdict = GenerateVerdictArtifact(input, frozenTime);
var verdictBytes = Encoding.UTF8.GetBytes(CanonJson.Serialize(verdict));
var artifactInfo = new ArtifactInfo
{
Type = "verdict-artifact",
Name = "test-delta-verdict",
Version = "1.0.0",
Format = "delta-verdict@1.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Scanner.SmartDiff", Version = "1.0.0" }
}
};
// Act - Create determinism manifest
var manifest = DeterminismManifestWriter.CreateManifest(
verdictBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Format.Should().Be("delta-verdict@1.0");
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public async Task VerdictArtifact_ParallelGeneration_ProducesDeterministicOutput()
{
// Arrange
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
var input = CreateSampleVerdictInput();
// Act - Generate in parallel 20 times
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => CanonJson.Serialize(GenerateVerdictArtifact(input, frozenTime))))
.ToArray();
var verdicts = await Task.WhenAll(tasks);
// Assert - All outputs should be identical
verdicts.Should().AllBe(verdicts[0]);
}
#endregion
#region Change Ordering Tests
[Fact]
public void VerdictArtifact_ChangesAreDeterministicallyOrdered()
{
// Arrange - Create input with changes in random order
var changes = new[]
{
CreateChange("CVE-2024-0003", "pkg:npm/c@1.0.0", "new"),
CreateChange("CVE-2024-0001", "pkg:npm/a@1.0.0", "resolved"),
CreateChange("CVE-2024-0002", "pkg:npm/b@1.0.0", "severity_changed")
};
var input = new VerdictInput
{
VerdictId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
BaselineScanId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
CurrentScanId = Guid.Parse("00000000-0000-0000-0000-000000000002"),
Changes = changes
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var verdict1 = GenerateVerdictArtifact(input, frozenTime);
var verdict2 = GenerateVerdictArtifact(input, frozenTime);
// Assert - Outputs should be identical
var json1 = CanonJson.Serialize(verdict1);
var json2 = CanonJson.Serialize(verdict2);
json1.Should().Be(json2);
// Verify changes are sorted by CVE ID, then by package URL
for (int i = 1; i < verdict1.Changes.Count; i++)
{
var cmp = string.CompareOrdinal(verdict1.Changes[i - 1].CveId, verdict1.Changes[i].CveId);
if (cmp == 0)
{
cmp = string.CompareOrdinal(verdict1.Changes[i - 1].PackageUrl, verdict1.Changes[i].PackageUrl);
}
cmp.Should().BeLessOrEqualTo(0, "Changes should be sorted by CVE ID, then package URL");
}
}
[Fact]
public void VerdictArtifact_ChangesWithSameCveAndPackage_SortedByChangeType()
{
// Arrange - Multiple changes for same CVE/package
var changes = new[]
{
CreateChange("CVE-2024-0001", "pkg:npm/test@1.0.0", "severity_changed"),
CreateChange("CVE-2024-0001", "pkg:npm/test@1.0.0", "status_changed"),
CreateChange("CVE-2024-0001", "pkg:npm/test@1.0.0", "epss_changed")
};
var input = new VerdictInput
{
VerdictId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
BaselineScanId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
CurrentScanId = Guid.Parse("00000000-0000-0000-0000-000000000002"),
Changes = changes
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var verdict1 = GenerateVerdictArtifact(input, frozenTime);
var verdict2 = GenerateVerdictArtifact(input, frozenTime);
// Assert
var json1 = CanonJson.Serialize(verdict1);
var json2 = CanonJson.Serialize(verdict2);
json1.Should().Be(json2);
}
#endregion
#region Change Type Tests
[Theory]
[InlineData("new")]
[InlineData("resolved")]
[InlineData("severity_changed")]
[InlineData("status_changed")]
[InlineData("epss_changed")]
[InlineData("reachability_changed")]
[InlineData("vex_status_changed")]
public void VerdictArtifact_ChangeTypeIsPreserved(string changeType)
{
// Arrange
var change = CreateChange("CVE-2024-0001", "pkg:npm/test@1.0.0", changeType);
var input = new VerdictInput
{
VerdictId = Guid.Parse("33333333-3333-3333-3333-333333333333"),
BaselineScanId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
CurrentScanId = Guid.Parse("00000000-0000-0000-0000-000000000002"),
Changes = new[] { change }
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var verdict = GenerateVerdictArtifact(input, frozenTime);
// Assert
verdict.Changes[0].ChangeType.Should().Be(changeType);
}
#endregion
#region Proof Spine Tests
[Fact]
public void VerdictArtifact_ProofSpinesAreDeterministicallyOrdered()
{
// Arrange
var changes = new[]
{
CreateChange("CVE-2024-0001", "pkg:npm/a@1.0.0", "new") with
{
ProofSpine = new ProofSpine
{
SpineId = "spine-a",
Evidences = new[]
{
CreateProofEvidence("epss", 0.8),
CreateProofEvidence("reachability", 0.9),
CreateProofEvidence("vex", 1.0)
}
}
}
};
var input = new VerdictInput
{
VerdictId = Guid.Parse("44444444-4444-4444-4444-444444444444"),
BaselineScanId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
CurrentScanId = Guid.Parse("00000000-0000-0000-0000-000000000002"),
Changes = changes
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var verdict1 = GenerateVerdictArtifact(input, frozenTime);
var verdict2 = GenerateVerdictArtifact(input, frozenTime);
// Assert
var json1 = CanonJson.Serialize(verdict1);
var json2 = CanonJson.Serialize(verdict2);
json1.Should().Be(json2);
// Verify evidences in proof spine are sorted
var evidences = verdict1.Changes[0].ProofSpine!.Evidences;
for (int i = 1; i < evidences.Count; i++)
{
string.CompareOrdinal(evidences[i - 1].EvidenceType, evidences[i].EvidenceType)
.Should().BeLessOrEqualTo(0, "Proof spine evidences should be sorted by type");
}
}
[Fact]
public void VerdictArtifact_ProofSpineHashIsStable()
{
// Arrange
var change = CreateChange("CVE-2024-0001", "pkg:npm/test@1.0.0", "new") with
{
ProofSpine = new ProofSpine
{
SpineId = "spine-test",
Evidences = new[]
{
CreateProofEvidence("epss", 0.5),
CreateProofEvidence("reachability", 0.75)
}
}
};
var input = new VerdictInput
{
VerdictId = Guid.Parse("55555555-5555-5555-5555-555555555555"),
BaselineScanId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
CurrentScanId = Guid.Parse("00000000-0000-0000-0000-000000000002"),
Changes = new[] { change }
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var verdict1 = GenerateVerdictArtifact(input, frozenTime);
var verdict2 = GenerateVerdictArtifact(input, frozenTime);
// Assert
verdict1.Changes[0].ProofSpine!.SpineHash.Should().Be(verdict2.Changes[0].ProofSpine!.SpineHash);
verdict1.Changes[0].ProofSpine!.SpineHash.Should().MatchRegex("^[0-9a-f]{64}$");
}
#endregion
#region Summary Statistics Tests
[Fact]
public void VerdictArtifact_SummaryStatisticsAreDeterministic()
{
// Arrange
var changes = new[]
{
CreateChange("CVE-2024-0001", "pkg:npm/a@1.0.0", "new"),
CreateChange("CVE-2024-0002", "pkg:npm/b@1.0.0", "new"),
CreateChange("CVE-2024-0003", "pkg:npm/c@1.0.0", "resolved"),
CreateChange("CVE-2024-0004", "pkg:npm/d@1.0.0", "severity_changed")
};
var input = new VerdictInput
{
VerdictId = Guid.Parse("66666666-6666-6666-6666-666666666666"),
BaselineScanId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
CurrentScanId = Guid.Parse("00000000-0000-0000-0000-000000000002"),
Changes = changes
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var verdict1 = GenerateVerdictArtifact(input, frozenTime);
var verdict2 = GenerateVerdictArtifact(input, frozenTime);
// Assert
verdict1.Summary.Should().NotBeNull();
verdict1.Summary.TotalChanges.Should().Be(verdict2.Summary.TotalChanges);
verdict1.Summary.NewFindings.Should().Be(verdict2.Summary.NewFindings);
verdict1.Summary.ResolvedFindings.Should().Be(verdict2.Summary.ResolvedFindings);
}
#endregion
#region Empty/Edge Case Tests
[Fact]
public void VerdictArtifact_NoChanges_ProducesDeterministicOutput()
{
// Arrange
var input = new VerdictInput
{
VerdictId = Guid.Parse("77777777-7777-7777-7777-777777777777"),
BaselineScanId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
CurrentScanId = Guid.Parse("00000000-0000-0000-0000-000000000002"),
Changes = Array.Empty<VerdictChange>()
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var hash1 = ComputeCanonicalHash(GenerateVerdictArtifact(input, frozenTime));
var hash2 = ComputeCanonicalHash(GenerateVerdictArtifact(input, frozenTime));
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void VerdictArtifact_ManyChanges_ProducesDeterministicOutput()
{
// Arrange - Create 500 changes
var changes = Enumerable.Range(0, 500)
.Select(i => CreateChange(
$"CVE-2024-{i:D4}",
$"pkg:npm/package-{i}@1.0.0",
i % 3 == 0 ? "new" : i % 2 == 0 ? "resolved" : "severity_changed"))
.ToArray();
var input = new VerdictInput
{
VerdictId = Guid.Parse("88888888-8888-8888-8888-888888888888"),
BaselineScanId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
CurrentScanId = Guid.Parse("00000000-0000-0000-0000-000000000002"),
Changes = changes
};
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
// Act
var hash1 = ComputeCanonicalHash(GenerateVerdictArtifact(input, frozenTime));
var hash2 = ComputeCanonicalHash(GenerateVerdictArtifact(input, frozenTime));
// Assert
hash1.Should().Be(hash2);
}
#endregion
#region Helper Methods
private static VerdictInput CreateSampleVerdictInput()
{
return new VerdictInput
{
VerdictId = Guid.Parse("99999999-9999-9999-9999-999999999999"),
BaselineScanId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
CurrentScanId = Guid.Parse("00000000-0000-0000-0000-000000000002"),
Changes = new[]
{
CreateChange("CVE-2024-1234", "pkg:npm/lodash@4.17.20", "new"),
CreateChange("CVE-2024-5678", "pkg:npm/axios@0.21.0", "resolved"),
CreateChange("CVE-2024-9012", "pkg:npm/express@4.17.1", "severity_changed")
}
};
}
private static VerdictChange CreateChange(string cveId, string packageUrl, string changeType)
{
return new VerdictChange
{
CveId = cveId,
PackageUrl = packageUrl,
ChangeType = changeType,
ProofSpine = null
};
}
private static ProofEvidence CreateProofEvidence(string evidenceType, double confidence)
{
return new ProofEvidence
{
EvidenceType = evidenceType,
Confidence = confidence,
Summary = $"{evidenceType} evidence"
};
}
private static VerdictArtifact GenerateVerdictArtifact(VerdictInput input, DateTimeOffset timestamp)
{
// Sort changes deterministically
var sortedChanges = input.Changes
.OrderBy(c => c.CveId, StringComparer.Ordinal)
.ThenBy(c => c.PackageUrl, StringComparer.Ordinal)
.ThenBy(c => c.ChangeType, StringComparer.Ordinal)
.Select(c => new VerdictChangeOutput
{
CveId = c.CveId,
PackageUrl = c.PackageUrl,
ChangeType = c.ChangeType,
ProofSpine = c.ProofSpine != null ? ProcessProofSpine(c.ProofSpine) : null
})
.ToList();
// Compute summary statistics
var summary = new VerdictSummary
{
TotalChanges = sortedChanges.Count,
NewFindings = sortedChanges.Count(c => c.ChangeType == "new"),
ResolvedFindings = sortedChanges.Count(c => c.ChangeType == "resolved"),
OtherChanges = sortedChanges.Count(c => c.ChangeType != "new" && c.ChangeType != "resolved")
};
return new VerdictArtifact
{
VerdictId = input.VerdictId,
BaselineScanId = input.BaselineScanId,
CurrentScanId = input.CurrentScanId,
Timestamp = timestamp,
Changes = sortedChanges,
Summary = summary
};
}
private static ProofSpineOutput ProcessProofSpine(ProofSpine spine)
{
var sortedEvidences = spine.Evidences
.OrderBy(e => e.EvidenceType, StringComparer.Ordinal)
.ToList();
// Compute spine hash from sorted evidences
var evidenceJson = CanonJson.Serialize(sortedEvidences);
var spineHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(evidenceJson));
return new ProofSpineOutput
{
SpineId = spine.SpineId,
Evidences = sortedEvidences,
SpineHash = spineHash
};
}
private static string ComputeCanonicalHash(VerdictArtifact artifact)
{
var json = CanonJson.Serialize(artifact);
return CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json));
}
#endregion
#region DTOs
private sealed record VerdictInput
{
public required Guid VerdictId { get; init; }
public required Guid BaselineScanId { get; init; }
public required Guid CurrentScanId { get; init; }
public required VerdictChange[] Changes { get; init; }
}
private sealed record VerdictChange
{
public required string CveId { get; init; }
public required string PackageUrl { get; init; }
public required string ChangeType { get; init; }
public ProofSpine? ProofSpine { get; init; }
}
private sealed record ProofSpine
{
public required string SpineId { get; init; }
public required ProofEvidence[] Evidences { get; init; }
}
private sealed record ProofEvidence
{
public required string EvidenceType { get; init; }
public required double Confidence { get; init; }
public required string Summary { get; init; }
}
private sealed record VerdictArtifact
{
public required Guid VerdictId { get; init; }
public required Guid BaselineScanId { get; init; }
public required Guid CurrentScanId { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public required IReadOnlyList<VerdictChangeOutput> Changes { get; init; }
public required VerdictSummary Summary { get; init; }
}
private sealed record VerdictChangeOutput
{
public required string CveId { get; init; }
public required string PackageUrl { get; init; }
public required string ChangeType { get; init; }
public ProofSpineOutput? ProofSpine { get; init; }
}
private sealed record ProofSpineOutput
{
public required string SpineId { get; init; }
public required IReadOnlyList<ProofEvidence> Evidences { get; init; }
public required string SpineHash { get; init; }
}
private sealed record VerdictSummary
{
public required int TotalChanges { get; init; }
public required int NewFindings { get; init; }
public required int ResolvedFindings { get; init; }
public required int OtherChanges { get; init; }
}
#endregion
}

Some files were not shown because too many files have changed in this diff Show More