Add unit tests for AST parsing and security sink detection
- Created `StellaOps.AuditPack.Tests.csproj` for unit testing the AuditPack library. - Implemented comprehensive unit tests in `index.test.js` for AST parsing, covering various JavaScript and TypeScript constructs including functions, classes, decorators, and JSX. - Added `sink-detect.test.js` to test security sink detection patterns, validating command injection, SQL injection, file write, deserialization, SSRF, NoSQL injection, and more. - Included tests for taint source detection in various contexts such as Express, Koa, and AWS Lambda.
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AirGapTrustStoreIntegrationTests.cs
|
||||
// Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI)
|
||||
// Description: Unit tests for AirGapTrustStoreIntegration.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public class AirGapTrustStoreIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public AirGapTrustStoreIntegrationTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"trust-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_LoadsPemFiles()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "test-key.pem"), keyPem);
|
||||
|
||||
// Act
|
||||
var result = await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.LoadedCount);
|
||||
Assert.Contains("test-key", result.KeyIds!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_FailsWithNonExistentDirectory()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
|
||||
// Act
|
||||
var result = await integration.LoadFromDirectoryAsync("/nonexistent/path");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_FailsWithEmptyPath()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
|
||||
// Act
|
||||
var result = await integration.LoadFromDirectoryAsync("");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("required", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromDirectoryAsync_LoadsFromManifest()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "signing-key.pem"), keyPem);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "stella-signing-key-001",
|
||||
relativePath = "signing-key.pem",
|
||||
algorithm = "ES256",
|
||||
purpose = "signing"
|
||||
}
|
||||
}
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "trust-manifest.json"),
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
|
||||
// Act
|
||||
var result = await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.LoadedCount);
|
||||
Assert.Contains("stella-signing-key-001", result.KeyIds!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromBundle_ParsesJsonBundle()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
var bundle = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "bundle-key-001",
|
||||
publicKeyPem = keyPem,
|
||||
algorithm = "ES256",
|
||||
purpose = "signing"
|
||||
}
|
||||
}
|
||||
};
|
||||
var bundleBytes = Encoding.UTF8.GetBytes(
|
||||
JsonSerializer.Serialize(bundle, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
|
||||
// Act
|
||||
var result = integration.LoadFromBundle(bundleBytes);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.LoadedCount);
|
||||
Assert.Contains("bundle-key-001", result.KeyIds!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromBundle_FailsWithEmptyContent()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
|
||||
// Act
|
||||
var result = integration.LoadFromBundle([]);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("empty", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromBundle_FailsWithInvalidJson()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var invalidJson = Encoding.UTF8.GetBytes("not valid json");
|
||||
|
||||
// Act
|
||||
var result = integration.LoadFromBundle(invalidJson);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPublicKey_ReturnsKey()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "test-key.pem"), keyPem);
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = integration.GetPublicKey("test-key");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal("test-key", result.KeyId);
|
||||
Assert.NotNull(result.KeyBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPublicKey_ReturnsNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = integration.GetPublicKey("nonexistent-key");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Found);
|
||||
Assert.Equal("nonexistent-key", result.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPublicKey_DetectsExpiredKey()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "expired-key.pem"), keyPem);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "expired-key",
|
||||
relativePath = "expired-key.pem",
|
||||
algorithm = "ES256",
|
||||
expiresAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
}
|
||||
}
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "trust-manifest.json"),
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = integration.GetPublicKey("expired-key");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.Expired);
|
||||
Assert.Contains("expired", result.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVerificationKey_ReturnsEcdsaKey()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
var keyPem = GenerateEcdsaPublicKeyPem();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "ecdsa-key.pem"), keyPem);
|
||||
|
||||
// Use manifest to explicitly set algorithm (SPKI format doesn't include algorithm in PEM header)
|
||||
var manifest = new
|
||||
{
|
||||
roots = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyId = "ecdsa-key",
|
||||
relativePath = "ecdsa-key.pem",
|
||||
algorithm = "ES256",
|
||||
purpose = "signing"
|
||||
}
|
||||
}
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_tempDir, "trust-manifest.json"),
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var key = integration.CreateVerificationKey("ecdsa-key");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(key);
|
||||
Assert.IsAssignableFrom<ECDsa>(key);
|
||||
key.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVerificationKey_ReturnsNullForMissingKey()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var key = integration.CreateVerificationKey("nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAvailableKeyIds_ReturnsAllKeys()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key1.pem"), GenerateEcdsaPublicKeyPem());
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key2.pem"), GenerateEcdsaPublicKeyPem());
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act
|
||||
var keyIds = integration.GetAvailableKeyIds();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, keyIds.Count);
|
||||
Assert.Contains("key1", keyIds);
|
||||
Assert.Contains("key2", keyIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Count_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var integration = new AirGapTrustStoreIntegration();
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key1.pem"), GenerateEcdsaPublicKeyPem());
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "key2.pem"), GenerateEcdsaPublicKeyPem());
|
||||
await integration.LoadFromDirectoryAsync(_tempDir);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(2, integration.Count);
|
||||
}
|
||||
|
||||
private static string GenerateEcdsaPublicKeyPem()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
return ecdsa.ExportSubjectPublicKeyInfoPem();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuditBundleWriterTests.cs
|
||||
// Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI)
|
||||
// Description: Unit tests for AuditBundleWriter.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
public class AuditBundleWriterTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public AuditBundleWriterTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"audit-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_CreatesValidBundle()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "test-bundle.tar.gz");
|
||||
|
||||
var request = CreateValidRequest(outputPath);
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.True(File.Exists(outputPath));
|
||||
Assert.NotNull(result.BundleId);
|
||||
Assert.NotNull(result.MerkleRoot);
|
||||
Assert.NotNull(result.BundleDigest);
|
||||
Assert.True(result.TotalSizeBytes > 0);
|
||||
Assert.True(result.FileCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_ComputesMerkleRoot()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "merkle-test.tar.gz");
|
||||
|
||||
var request = CreateValidRequest(outputPath);
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.MerkleRoot);
|
||||
Assert.StartsWith("sha256:", result.MerkleRoot);
|
||||
Assert.Equal(71, result.MerkleRoot.Length); // sha256: + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_SignsManifest_WhenSignIsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "signed-test.tar.gz");
|
||||
|
||||
var request = CreateValidRequest(outputPath) with { Sign = true };
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.Signed);
|
||||
Assert.NotNull(result.SigningKeyId);
|
||||
Assert.NotNull(result.SigningAlgorithm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_DoesNotSign_WhenSignIsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "unsigned-test.tar.gz");
|
||||
|
||||
var request = CreateValidRequest(outputPath) with { Sign = false };
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.Signed);
|
||||
Assert.Null(result.SigningKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_FailsWithoutSbom()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "no-sbom.tar.gz");
|
||||
|
||||
var request = new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
ScanId = "scan-001",
|
||||
ImageRef = "test:latest",
|
||||
ImageDigest = "sha256:abc123",
|
||||
Decision = "pass",
|
||||
Sbom = null!,
|
||||
FeedsSnapshot = CreateFeedsSnapshot(),
|
||||
PolicyBundle = CreatePolicyBundle(),
|
||||
Verdict = CreateVerdict()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("SBOM", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_IncludesOptionalVex()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "with-vex.tar.gz");
|
||||
|
||||
var vexContent = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "https://openvex.dev/ns/v0.2.0",
|
||||
statements = new[]
|
||||
{
|
||||
new { vulnerability = "CVE-2024-1234", status = "not_affected" }
|
||||
}
|
||||
}));
|
||||
|
||||
var request = CreateValidRequest(outputPath) with
|
||||
{
|
||||
VexStatements = vexContent
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.FileCount >= 5); // sbom, feeds, policy, verdict, vex
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_AddsTimeAnchor()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
var outputPath = Path.Combine(_tempDir, "with-anchor.tar.gz");
|
||||
|
||||
var request = CreateValidRequest(outputPath) with
|
||||
{
|
||||
TimeAnchor = new TimeAnchorInput
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Source = "local"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await writer.WriteAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_DeterministicMerkleRoot()
|
||||
{
|
||||
// Arrange
|
||||
var writer = new AuditBundleWriter();
|
||||
|
||||
var sbom = CreateSbom();
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict();
|
||||
|
||||
var request1 = new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = Path.Combine(_tempDir, "det-1.tar.gz"),
|
||||
ScanId = "scan-001",
|
||||
ImageRef = "test:latest",
|
||||
ImageDigest = "sha256:abc123",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
};
|
||||
|
||||
var request2 = request1 with
|
||||
{
|
||||
OutputPath = Path.Combine(_tempDir, "det-2.tar.gz")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await writer.WriteAsync(request1);
|
||||
var result2 = await writer.WriteAsync(request2);
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.MerkleRoot, result2.MerkleRoot);
|
||||
}
|
||||
|
||||
private AuditBundleWriteRequest CreateValidRequest(string outputPath)
|
||||
{
|
||||
return new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
ScanId = "scan-001",
|
||||
ImageRef = "test:latest",
|
||||
ImageDigest = "sha256:abc123def456",
|
||||
Decision = "pass",
|
||||
Sbom = CreateSbom(),
|
||||
FeedsSnapshot = CreateFeedsSnapshot(),
|
||||
PolicyBundle = CreatePolicyBundle(),
|
||||
Verdict = CreateVerdict(),
|
||||
Sign = true
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateSbom()
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.6",
|
||||
version = 1,
|
||||
components = Array.Empty<object>()
|
||||
}));
|
||||
}
|
||||
|
||||
private static byte[] CreateFeedsSnapshot()
|
||||
{
|
||||
return Encoding.UTF8.GetBytes("{\"type\":\"feed-snapshot\"}\n");
|
||||
}
|
||||
|
||||
private static byte[] CreatePolicyBundle()
|
||||
{
|
||||
// Minimal gzip content
|
||||
return new byte[] { 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
|
||||
}
|
||||
|
||||
private static byte[] CreateVerdict()
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
|
||||
{
|
||||
decision = "pass",
|
||||
evaluatedAt = DateTimeOffset.UtcNow
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuditReplayE2ETests.cs
|
||||
// Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI)
|
||||
// Task: REPLAY-028 - E2E test: export -> transfer -> replay offline
|
||||
// Description: End-to-end integration tests for audit bundle export and replay.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.AuditPack.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end integration tests that verify the complete audit bundle workflow:
|
||||
/// export -> transfer -> replay offline.
|
||||
/// </summary>
|
||||
public class AuditReplayE2ETests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string _exportDir;
|
||||
private readonly string _importDir;
|
||||
|
||||
public AuditReplayE2ETests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"e2e-test-{Guid.NewGuid():N}");
|
||||
_exportDir = Path.Combine(_tempDir, "export");
|
||||
_importDir = Path.Combine(_tempDir, "import");
|
||||
|
||||
Directory.CreateDirectory(_exportDir);
|
||||
Directory.CreateDirectory(_importDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task E2E_ExportTransferReplayOffline_MatchingVerdict()
|
||||
{
|
||||
// ===== PHASE 1: EXPORT =====
|
||||
// Create scan data
|
||||
var scanId = $"scan-{Guid.NewGuid():N}";
|
||||
var imageRef = "registry.example.com/app:v1.2.3";
|
||||
var imageDigest = "sha256:abc123def456789";
|
||||
var decision = "pass";
|
||||
|
||||
var sbom = CreateCycloneDxSbom(imageRef);
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict(decision, scanId);
|
||||
var vex = CreateVexStatements();
|
||||
|
||||
// Create audit bundle (unsigned for E2E test simplicity)
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "audit-bundle.tar.gz");
|
||||
|
||||
var writeRequest = new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = scanId,
|
||||
ImageRef = imageRef,
|
||||
ImageDigest = imageDigest,
|
||||
Decision = decision,
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
VexStatements = vex,
|
||||
Sign = false, // Skip signing for unit test
|
||||
TimeAnchor = new TimeAnchorInput
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Source = "local-test"
|
||||
}
|
||||
};
|
||||
|
||||
var writeResult = await writer.WriteAsync(writeRequest);
|
||||
|
||||
// Assert export succeeded
|
||||
Assert.True(writeResult.Success, $"Export failed: {writeResult.Error}");
|
||||
Assert.True(File.Exists(bundlePath), "Bundle file not created");
|
||||
Assert.NotNull(writeResult.MerkleRoot);
|
||||
Assert.NotNull(writeResult.BundleDigest);
|
||||
|
||||
// ===== PHASE 2: TRANSFER (simulate by copying) =====
|
||||
var transferredBundlePath = Path.Combine(_importDir, "transferred-bundle.tar.gz");
|
||||
File.Copy(bundlePath, transferredBundlePath);
|
||||
|
||||
// Verify transfer integrity
|
||||
var originalHash = await ComputeFileHashAsync(bundlePath);
|
||||
var transferredHash = await ComputeFileHashAsync(transferredBundlePath);
|
||||
Assert.Equal(originalHash, transferredHash);
|
||||
|
||||
// ===== PHASE 3: REPLAY OFFLINE =====
|
||||
// Read the bundle
|
||||
var reader = new AuditBundleReader();
|
||||
var readRequest = new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = transferredBundlePath,
|
||||
VerifySignature = false, // No signature in this test
|
||||
VerifyMerkleRoot = true,
|
||||
VerifyInputDigests = true,
|
||||
LoadReplayInputs = true
|
||||
};
|
||||
|
||||
var readResult = await reader.ReadAsync(readRequest);
|
||||
|
||||
// Assert read succeeded
|
||||
Assert.True(readResult.Success, $"Read failed: {readResult.Error}");
|
||||
Assert.True(readResult.MerkleRootVerified ?? false, "Merkle root validation failed");
|
||||
Assert.True(readResult.InputDigestsVerified ?? false, "Input digests validation failed");
|
||||
|
||||
// Create isolated replay context
|
||||
using var replayContext = new IsolatedReplayContext(new IsolatedReplayContextOptions
|
||||
{
|
||||
CleanupOnDispose = true,
|
||||
EnforceOffline = true
|
||||
});
|
||||
|
||||
var initResult = await replayContext.InitializeAsync(readResult);
|
||||
Assert.True(initResult.Success, $"Replay context init failed: {initResult.Error}");
|
||||
|
||||
// Execute replay
|
||||
var executor = new ReplayExecutor();
|
||||
var replayResult = await executor.ExecuteAsync(
|
||||
replayContext,
|
||||
readResult.Manifest!,
|
||||
new ReplayExecutionOptions
|
||||
{
|
||||
FailOnInputDrift = false,
|
||||
DetailedDriftDetection = true
|
||||
});
|
||||
|
||||
// Assert replay succeeded with matching verdict
|
||||
Assert.True(replayResult.Success, $"Replay failed: {replayResult.Error}");
|
||||
Assert.Equal(ReplayStatus.Match, replayResult.Status);
|
||||
Assert.True(replayResult.InputsVerified, "Inputs should be verified");
|
||||
Assert.True(replayResult.DecisionMatches, "Decision should match");
|
||||
Assert.Equal(decision, replayResult.OriginalDecision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task E2E_ReplayDetectsTamperedSbom()
|
||||
{
|
||||
// Setup
|
||||
var scanId = $"scan-{Guid.NewGuid():N}";
|
||||
var sbom = CreateCycloneDxSbom("app:v1");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict("pass", scanId);
|
||||
|
||||
// Export original bundle
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "original.tar.gz");
|
||||
|
||||
var writeResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = scanId,
|
||||
ImageRef = "app:v1",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
Assert.True(writeResult.Success);
|
||||
|
||||
// Export tampered bundle with modified SBOM
|
||||
var tamperedSbom = CreateCycloneDxSbom("app:v1", addMaliciousComponent: true);
|
||||
var tamperedBundlePath = Path.Combine(_importDir, "tampered.tar.gz");
|
||||
|
||||
var tamperedResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = tamperedBundlePath,
|
||||
ScanId = scanId,
|
||||
ImageRef = "app:v1",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = "pass",
|
||||
Sbom = tamperedSbom, // Different SBOM
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
Assert.True(tamperedResult.Success);
|
||||
|
||||
// Read both bundles
|
||||
var reader = new AuditBundleReader();
|
||||
|
||||
var originalRead = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
VerifySignature = false,
|
||||
LoadReplayInputs = true
|
||||
});
|
||||
|
||||
var tamperedRead = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = tamperedBundlePath,
|
||||
VerifySignature = false,
|
||||
LoadReplayInputs = true
|
||||
});
|
||||
|
||||
// The merkle roots should differ
|
||||
Assert.NotEqual(originalRead.Manifest?.MerkleRoot, tamperedRead.Manifest?.MerkleRoot);
|
||||
|
||||
// Input digests should differ
|
||||
Assert.NotEqual(
|
||||
originalRead.Manifest?.Inputs.SbomDigest,
|
||||
tamperedRead.Manifest?.Inputs.SbomDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task E2E_DeterministicMerkleRoot_SameInputs()
|
||||
{
|
||||
// Create identical inputs
|
||||
var sbom = CreateCycloneDxSbom("app:deterministic");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict("pass", "scan-deterministic");
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
|
||||
// Write bundle 1
|
||||
var bundle1Path = Path.Combine(_exportDir, "deterministic-1.tar.gz");
|
||||
var result1 = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundle1Path,
|
||||
ScanId = "scan-deterministic",
|
||||
ImageRef = "app:deterministic",
|
||||
ImageDigest = "sha256:deterministic123",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
// Write bundle 2 with same inputs
|
||||
var bundle2Path = Path.Combine(_exportDir, "deterministic-2.tar.gz");
|
||||
var result2 = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundle2Path,
|
||||
ScanId = "scan-deterministic",
|
||||
ImageRef = "app:deterministic",
|
||||
ImageDigest = "sha256:deterministic123",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
// Merkle roots must be identical
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.MerkleRoot, result2.MerkleRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task E2E_BundleContainsAllRequiredFiles()
|
||||
{
|
||||
// Setup
|
||||
var sbom = CreateCycloneDxSbom("app:v1");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict("pass", "scan-files-test");
|
||||
var vex = CreateVexStatements();
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "files-test.tar.gz");
|
||||
|
||||
var writeResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = "scan-files-test",
|
||||
ImageRef = "app:v1",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
VexStatements = vex,
|
||||
Sign = false
|
||||
});
|
||||
|
||||
Assert.True(writeResult.Success);
|
||||
Assert.True(writeResult.FileCount >= 5, $"Expected at least 5 files, got {writeResult.FileCount}");
|
||||
|
||||
// Read and verify manifest contains all files
|
||||
var reader = new AuditBundleReader();
|
||||
var readResult = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
VerifySignature = false
|
||||
});
|
||||
|
||||
Assert.True(readResult.Success);
|
||||
Assert.NotNull(readResult.Manifest);
|
||||
Assert.NotEmpty(readResult.Manifest.Files);
|
||||
|
||||
// Verify essential files are present
|
||||
var filePaths = readResult.Manifest.Files.Select(f => f.RelativePath).ToList();
|
||||
Assert.Contains(filePaths, p => p.Contains("sbom"));
|
||||
Assert.Contains(filePaths, p => p.Contains("feeds"));
|
||||
Assert.Contains(filePaths, p => p.Contains("policy"));
|
||||
Assert.Contains(filePaths, p => p.Contains("verdict"));
|
||||
Assert.Contains(filePaths, p => p.Contains("vex"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task E2E_FullCycleWithTimeAnchor()
|
||||
{
|
||||
// Setup with explicit time anchor
|
||||
var timestamp = new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var sbom = CreateCycloneDxSbom("app:time-test");
|
||||
var feeds = CreateFeedsSnapshot();
|
||||
var policy = CreatePolicyBundle();
|
||||
var verdict = CreateVerdict("pass", "scan-time-test");
|
||||
|
||||
var writer = new AuditBundleWriter();
|
||||
var bundlePath = Path.Combine(_exportDir, "time-anchor-test.tar.gz");
|
||||
|
||||
var writeResult = await writer.WriteAsync(new AuditBundleWriteRequest
|
||||
{
|
||||
OutputPath = bundlePath,
|
||||
ScanId = "scan-time-test",
|
||||
ImageRef = "app:time-test",
|
||||
ImageDigest = "sha256:abc",
|
||||
Decision = "pass",
|
||||
Sbom = sbom,
|
||||
FeedsSnapshot = feeds,
|
||||
PolicyBundle = policy,
|
||||
Verdict = verdict,
|
||||
Sign = false,
|
||||
TimeAnchor = new TimeAnchorInput
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
Source = "test-time-server"
|
||||
}
|
||||
});
|
||||
|
||||
Assert.True(writeResult.Success);
|
||||
|
||||
// Read and verify time anchor
|
||||
var reader = new AuditBundleReader();
|
||||
var readResult = await reader.ReadAsync(new AuditBundleReadRequest
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
VerifySignature = false,
|
||||
LoadReplayInputs = true
|
||||
});
|
||||
|
||||
Assert.True(readResult.Success);
|
||||
Assert.NotNull(readResult.Manifest?.TimeAnchor);
|
||||
Assert.Equal(timestamp, readResult.Manifest.TimeAnchor.Timestamp);
|
||||
Assert.Equal("test-time-server", readResult.Manifest.TimeAnchor.Source);
|
||||
|
||||
// Replay with time anchor context
|
||||
using var context = new IsolatedReplayContext(new IsolatedReplayContextOptions
|
||||
{
|
||||
EvaluationTime = timestamp,
|
||||
CleanupOnDispose = true
|
||||
});
|
||||
|
||||
var initResult = await context.InitializeAsync(readResult);
|
||||
Assert.True(initResult.Success);
|
||||
Assert.Equal(timestamp, context.EvaluationTime);
|
||||
}
|
||||
|
||||
#region Test Data Factories
|
||||
|
||||
private static byte[] CreateCycloneDxSbom(string imageRef, bool addMaliciousComponent = false)
|
||||
{
|
||||
var components = new List<object>
|
||||
{
|
||||
new { type = "library", name = "lodash", version = "4.17.21", purl = "pkg:npm/lodash@4.17.21" },
|
||||
new { type = "library", name = "express", version = "4.18.2", purl = "pkg:npm/express@4.18.2" }
|
||||
};
|
||||
|
||||
if (addMaliciousComponent)
|
||||
{
|
||||
components.Add(new { type = "library", name = "evil-package", version = "1.0.0", purl = "pkg:npm/evil-package@1.0.0" });
|
||||
}
|
||||
|
||||
var sbom = new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.6",
|
||||
version = 1,
|
||||
serialNumber = $"urn:uuid:{Guid.NewGuid()}",
|
||||
metadata = new
|
||||
{
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("o"),
|
||||
component = new { type = "container", name = imageRef }
|
||||
},
|
||||
components = components.ToArray()
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(sbom, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
|
||||
private static byte[] CreateFeedsSnapshot()
|
||||
{
|
||||
var snapshot = new
|
||||
{
|
||||
type = "feed-snapshot",
|
||||
version = "1.0",
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("o"),
|
||||
sources = new[]
|
||||
{
|
||||
new { name = "nvd", lastSync = DateTimeOffset.UtcNow.AddHours(-1).ToString("o") },
|
||||
new { name = "ghsa", lastSync = DateTimeOffset.UtcNow.AddHours(-2).ToString("o") }
|
||||
},
|
||||
advisoryCount = 150000
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(snapshot) + "\n");
|
||||
}
|
||||
|
||||
private static byte[] CreatePolicyBundle()
|
||||
{
|
||||
// Minimal valid gzip content (empty archive)
|
||||
return new byte[]
|
||||
{
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateVerdict(string decision, string scanId)
|
||||
{
|
||||
var verdict = new
|
||||
{
|
||||
version = "1.0",
|
||||
scanId = scanId,
|
||||
decision = decision,
|
||||
evaluatedAt = DateTimeOffset.UtcNow.ToString("o"),
|
||||
policyVersion = "2024.1",
|
||||
findings = new
|
||||
{
|
||||
critical = 0,
|
||||
high = 2,
|
||||
medium = 5,
|
||||
low = 10,
|
||||
unknown = 0
|
||||
},
|
||||
attestation = new
|
||||
{
|
||||
type = "https://stellaops.io/verdict/v1",
|
||||
predicateType = "https://stellaops.io/attestation/verdict/v1"
|
||||
}
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(verdict, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
|
||||
private static byte[] CreateVexStatements()
|
||||
{
|
||||
var vex = new
|
||||
{
|
||||
type = "https://openvex.dev/ns/v0.2.0",
|
||||
id = $"https://stellaops.io/vex/{Guid.NewGuid()}",
|
||||
author = "security-team@example.com",
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("o"),
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { id = "CVE-2024-1234" },
|
||||
status = "not_affected",
|
||||
justification = "vulnerable_code_not_present"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(vex, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string filePath)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.AuditPack/StellaOps.AuditPack.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user