chore(sprints): archive 20260226 advisories and expand deterministic tests
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Symbols.Bundle;
|
||||
using StellaOps.Symbols.Bundle.Abstractions;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Xunit;
|
||||
using BundleManifest = StellaOps.Symbols.Bundle.Models.BundleManifest;
|
||||
using CoreSymbolEntry = StellaOps.Symbols.Core.Models.SymbolEntry;
|
||||
|
||||
namespace StellaOps.Symbols.Tests.Bundle;
|
||||
|
||||
public sealed class BundleBuilderVerificationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_SignedAndRekorBundle_PassesWithRequiredGates()
|
||||
{
|
||||
var fixture = await CreateFixtureAsync(sign: true, submitRekor: true);
|
||||
try
|
||||
{
|
||||
var verify = await fixture.Builder.VerifyAsync(
|
||||
fixture.BundlePath,
|
||||
new BundleVerifyOptions
|
||||
{
|
||||
RequireSignature = true,
|
||||
RequireRekorProof = true,
|
||||
VerifyRekorOffline = true
|
||||
});
|
||||
|
||||
Assert.True(verify.Valid, string.Join("; ", verify.Errors));
|
||||
Assert.Equal(SignatureStatus.Valid, verify.SignatureStatus);
|
||||
Assert.Equal(RekorVerifyStatus.VerifiedOffline, verify.RekorStatus);
|
||||
Assert.NotNull(verify.Manifest);
|
||||
Assert.StartsWith("blake3:", verify.Manifest!.BundleId, StringComparison.Ordinal);
|
||||
Assert.All(verify.Manifest.Entries, entry =>
|
||||
{
|
||||
Assert.StartsWith("blake3:", entry.ManifestHash, StringComparison.Ordinal);
|
||||
Assert.StartsWith("blake3:", entry.BlobHash, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TamperedSignature_FailsDeterministically()
|
||||
{
|
||||
var fixture = await CreateFixtureAsync(sign: true, submitRekor: false);
|
||||
try
|
||||
{
|
||||
await RewriteManifestAsync(
|
||||
fixture.BundlePath,
|
||||
manifest =>
|
||||
{
|
||||
var signature = manifest.Signature!;
|
||||
return manifest with
|
||||
{
|
||||
Signature = signature with
|
||||
{
|
||||
Signature = TamperBase64(signature.Signature!)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var verify = await fixture.Builder.VerifyAsync(
|
||||
fixture.BundlePath,
|
||||
new BundleVerifyOptions { RequireSignature = true });
|
||||
|
||||
Assert.False(verify.Valid);
|
||||
Assert.Equal(SignatureStatus.Invalid, verify.SignatureStatus);
|
||||
Assert.Contains(
|
||||
verify.Errors,
|
||||
error => error.StartsWith("signature_verification_failed:signature_mismatch", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_UnsignedBundle_WhenSignatureRequired_Fails()
|
||||
{
|
||||
var fixture = await CreateFixtureAsync(sign: false, submitRekor: false);
|
||||
try
|
||||
{
|
||||
var verify = await fixture.Builder.VerifyAsync(
|
||||
fixture.BundlePath,
|
||||
new BundleVerifyOptions { RequireSignature = true });
|
||||
|
||||
Assert.False(verify.Valid);
|
||||
Assert.Equal(SignatureStatus.Unsigned, verify.SignatureStatus);
|
||||
Assert.Contains(
|
||||
verify.Errors,
|
||||
error => error.StartsWith("signature_verification_failed:signature_not_present", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WhenRekorProofRequiredButCheckpointMissing_FailsDeterministically()
|
||||
{
|
||||
var fixture = await CreateFixtureAsync(sign: true, submitRekor: false);
|
||||
try
|
||||
{
|
||||
var verify = await fixture.Builder.VerifyAsync(
|
||||
fixture.BundlePath,
|
||||
new BundleVerifyOptions
|
||||
{
|
||||
RequireSignature = true,
|
||||
RequireRekorProof = true,
|
||||
VerifyRekorOffline = true
|
||||
});
|
||||
|
||||
Assert.False(verify.Valid);
|
||||
Assert.Null(verify.RekorStatus);
|
||||
Assert.Contains("rekor_proof_required:missing_checkpoint", verify.Errors);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TruncatedInclusionProof_FailsDeterministically()
|
||||
{
|
||||
var fixture = await CreateFixtureAsync(sign: true, submitRekor: true);
|
||||
try
|
||||
{
|
||||
await RewriteManifestAsync(
|
||||
fixture.BundlePath,
|
||||
manifest =>
|
||||
{
|
||||
var checkpoint = manifest.RekorCheckpoint!;
|
||||
var proof = checkpoint.InclusionProof!;
|
||||
return manifest with
|
||||
{
|
||||
RekorCheckpoint = checkpoint with
|
||||
{
|
||||
InclusionProof = proof with
|
||||
{
|
||||
Hashes = proof.Hashes.Take(1).ToArray()
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var verify = await fixture.Builder.VerifyAsync(
|
||||
fixture.BundlePath,
|
||||
new BundleVerifyOptions
|
||||
{
|
||||
RequireSignature = true,
|
||||
RequireRekorProof = true,
|
||||
VerifyRekorOffline = true
|
||||
});
|
||||
|
||||
Assert.False(verify.Valid);
|
||||
Assert.Equal(RekorVerifyStatus.Invalid, verify.RekorStatus);
|
||||
Assert.Contains(
|
||||
verify.Errors,
|
||||
error => error.StartsWith("rekor_inclusion_proof_failed:proof_nodes_truncated", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_CorruptedInclusionProofRoot_FailsDeterministically()
|
||||
{
|
||||
var fixture = await CreateFixtureAsync(sign: true, submitRekor: true);
|
||||
try
|
||||
{
|
||||
await RewriteManifestAsync(
|
||||
fixture.BundlePath,
|
||||
manifest =>
|
||||
{
|
||||
var checkpoint = manifest.RekorCheckpoint!;
|
||||
var proof = checkpoint.InclusionProof!;
|
||||
return manifest with
|
||||
{
|
||||
RekorCheckpoint = checkpoint with
|
||||
{
|
||||
InclusionProof = proof with
|
||||
{
|
||||
RootHash = "blake3:" + new string('0', 64)
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var verify = await fixture.Builder.VerifyAsync(
|
||||
fixture.BundlePath,
|
||||
new BundleVerifyOptions
|
||||
{
|
||||
RequireSignature = true,
|
||||
RequireRekorProof = true,
|
||||
VerifyRekorOffline = true
|
||||
});
|
||||
|
||||
Assert.False(verify.Valid);
|
||||
Assert.Equal(RekorVerifyStatus.Invalid, verify.RekorStatus);
|
||||
Assert.Contains(
|
||||
verify.Errors,
|
||||
error => error.StartsWith("rekor_inclusion_proof_failed:proof_root_mismatch", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<TestFixture> CreateFixtureAsync(bool sign, bool submitRekor)
|
||||
{
|
||||
var rootDir = Path.Combine(Path.GetTempPath(), "stella-symbols-tests", Guid.NewGuid().ToString("N"));
|
||||
var sourceDir = Path.Combine(rootDir, "source");
|
||||
var outputDir = Path.Combine(rootDir, "out");
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
const string debugId = "DBG001";
|
||||
var manifest = new SymbolManifest
|
||||
{
|
||||
ManifestId = "blake3:manifest-dbg001",
|
||||
DebugId = debugId,
|
||||
CodeId = "code-001",
|
||||
BinaryName = "libsample.so",
|
||||
Platform = "linux-x64",
|
||||
Format = BinaryFormat.Elf,
|
||||
Symbols =
|
||||
[
|
||||
new CoreSymbolEntry
|
||||
{
|
||||
Address = 0x1000,
|
||||
Size = 16,
|
||||
MangledName = "_ZL6samplev",
|
||||
DemangledName = "sample()"
|
||||
}
|
||||
],
|
||||
TenantId = "tenant-default",
|
||||
BlobUri = "cas://symbols/tenant-default/dbg001/blob",
|
||||
CreatedAt = new DateTimeOffset(2026, 2, 26, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(sourceDir, $"{debugId}.symbols.json"),
|
||||
JsonSerializer.Serialize(manifest, JsonOptions));
|
||||
await File.WriteAllBytesAsync(
|
||||
Path.Combine(sourceDir, $"{debugId}.sym"),
|
||||
Encoding.UTF8.GetBytes("deterministic-symbol-blob-content"));
|
||||
|
||||
string? signingKeyPath = null;
|
||||
if (sign)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
signingKeyPath = Path.Combine(rootDir, "signing-key.pem");
|
||||
await File.WriteAllTextAsync(signingKeyPath, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
var builder = new BundleBuilder(NullLogger<BundleBuilder>.Instance);
|
||||
var build = await builder.BuildAsync(new BundleBuildOptions
|
||||
{
|
||||
Name = "symbols-fixture",
|
||||
Version = "1.0.0",
|
||||
SourceDir = sourceDir,
|
||||
OutputDir = outputDir,
|
||||
Sign = sign,
|
||||
SigningKeyPath = signingKeyPath,
|
||||
SubmitRekor = submitRekor,
|
||||
RekorUrl = "https://rekor.example.test"
|
||||
});
|
||||
|
||||
Assert.True(build.Success, build.Error);
|
||||
Assert.NotNull(build.BundlePath);
|
||||
|
||||
return new TestFixture(builder, build.BundlePath!, rootDir);
|
||||
}
|
||||
|
||||
private static async Task RewriteManifestAsync(
|
||||
string bundlePath,
|
||||
Func<BundleManifest, BundleManifest> rewrite)
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "stella-symbols-mutate", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
ZipFile.ExtractToDirectory(bundlePath, tempDir);
|
||||
var manifestPath = Path.Combine(tempDir, "manifest.json");
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(await File.ReadAllTextAsync(manifestPath))
|
||||
?? throw new InvalidOperationException("Bundle manifest is missing or invalid.");
|
||||
|
||||
var mutated = rewrite(manifest);
|
||||
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(mutated, JsonOptions));
|
||||
|
||||
File.Delete(bundlePath);
|
||||
ZipFile.CreateFromDirectory(tempDir, bundlePath, CompressionLevel.NoCompression, includeBaseDirectory: false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string TamperBase64(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
var replacement = input[0] == 'A' ? 'B' : 'A';
|
||||
return replacement + input[1..];
|
||||
}
|
||||
|
||||
private sealed record TestFixture(BundleBuilder Builder, string BundlePath, string RootDir) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(RootDir))
|
||||
{
|
||||
Directory.Delete(RootDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user