save progress

This commit is contained in:
StellaOps Bot
2026-01-03 11:02:24 +02:00
parent ca578801fd
commit 83c37243e0
446 changed files with 22798 additions and 4031 deletions

View File

@@ -0,0 +1,84 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Builders;
using Xunit;
namespace StellaOps.BinaryIndex.Builders.Tests;
public sealed class PatchDiffEngineTests
{
[Fact]
public void ComputeDiff_UsesWeightsForSimilarity()
{
var engine = new PatchDiffEngine(NullLogger<PatchDiffEngine>.Instance);
var vulnerable = new[]
{
CreateFingerprint("func", basicBlock: new byte[] { 0x01 }, cfg: new byte[] { 0x02 }, stringRefs: new byte[] { 0xAA })
};
var patched = new[]
{
CreateFingerprint("func", basicBlock: new byte[] { 0x02 }, cfg: new byte[] { 0x03 }, stringRefs: new byte[] { 0xAA })
};
var options = new DiffOptions
{
SimilarityThreshold = 0.9m,
Weights = new HashWeights
{
BasicBlockWeight = 0m,
CfgWeight = 0m,
StringRefsWeight = 1m
}
};
var diff = engine.ComputeDiff(vulnerable, patched, options);
Assert.Single(diff.Changes);
Assert.Equal(ChangeType.Modified, diff.Changes[0].Type);
}
[Fact]
public void ComputeDiff_FuzzyNameMatchingLinksFunctions()
{
var engine = new PatchDiffEngine(NullLogger<PatchDiffEngine>.Instance);
var vulnerable = new[]
{
CreateFingerprint("Foo::Bar", basicBlock: new byte[] { 0x01 }, cfg: new byte[] { 0x02 }, stringRefs: new byte[] { 0x03 })
};
var patched = new[]
{
CreateFingerprint("foo_bar", basicBlock: new byte[] { 0x01 }, cfg: new byte[] { 0x02 }, stringRefs: new byte[] { 0x03 })
};
var options = new DiffOptions
{
SimilarityThreshold = 0.9m,
FuzzyNameMatching = true,
DetectRenames = false
};
var diff = engine.ComputeDiff(vulnerable, patched, options);
Assert.Single(diff.Changes);
Assert.Equal(ChangeType.Modified, diff.Changes[0].Type);
Assert.Equal("Foo::Bar", diff.Changes[0].FunctionName);
}
private static FunctionFingerprint CreateFingerprint(string name, byte[] basicBlock, byte[] cfg, byte[] stringRefs)
{
return new FunctionFingerprint
{
Name = name,
Offset = 0,
Size = 32,
BasicBlockHash = basicBlock,
CfgHash = cfg,
StringRefsHash = stringRefs,
IsExported = true,
HasDebugInfo = false
};
}
}

View File

@@ -0,0 +1,114 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.BinaryIndex.Builders;
using Xunit;
namespace StellaOps.BinaryIndex.Builders.Tests;
public sealed class ReproducibleBuildJobTests
{
[Fact]
public async Task ProcessCveAsync_InvalidBuildId_SkipsClaims()
{
var builder = new Mock<IReproducibleBuilder>();
builder.SetupGet(x => x.Distro).Returns("debian");
builder.SetupGet(x => x.SupportedReleases).Returns(new[] { "bookworm" });
var buildResult = new BuildResult
{
Success = true,
Duration = TimeSpan.FromSeconds(1),
Binaries = new[]
{
new BuiltBinary
{
Path = "bin/app",
BuildId = "not-a-guid",
TextSha256 = new byte[] { 0x01 },
Fingerprint = new byte[] { 0x02 },
Functions = new[]
{
new FunctionFingerprint
{
Name = "func",
Offset = 0,
Size = 16,
BasicBlockHash = new byte[] { 0x01 },
CfgHash = new byte[] { 0x02 },
StringRefsHash = new byte[] { 0x03 },
IsExported = true,
HasDebugInfo = false
}
}
}
}
};
builder.SetupSequence(x => x.BuildAsync(It.IsAny<BuildRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(buildResult)
.ReturnsAsync(buildResult);
var diffEngine = new Mock<IPatchDiffEngine>();
diffEngine.Setup(x => x.ComputeDiff(It.IsAny<IReadOnlyList<FunctionFingerprint>>(), It.IsAny<IReadOnlyList<FunctionFingerprint>>(), It.IsAny<DiffOptions>()))
.Returns(new FunctionDiffResult
{
Changes = new[]
{
new FunctionChange
{
FunctionName = "func",
Type = ChangeType.Modified,
VulnerableFingerprint = null,
PatchedFingerprint = null,
SimilarityScore = 1m
}
},
TotalFunctionsVulnerable = 1,
TotalFunctionsPatched = 1
});
var claimRepository = new Mock<IFingerprintClaimRepository>();
var advisoryMonitor = new Mock<IAdvisoryFeedMonitor>();
var extractor = new Mock<IFunctionFingerprintExtractor>();
var job = new ReproducibleBuildJob(
NullLogger<ReproducibleBuildJob>.Instance,
Options.Create(new ReproducibleBuildOptions()),
new[] { builder.Object },
extractor.Object,
diffEngine.Object,
claimRepository.Object,
advisoryMonitor.Object,
TimeProvider.System,
new TestGuidProvider());
var cve = new CveAttribution
{
CveId = "CVE-2025-0001",
SourcePackage = "openssl",
Distro = "debian",
Release = "bookworm",
VulnerableVersion = "1.0",
FixedVersion = "1.1"
};
await job.ProcessCveAsync(cve, CancellationToken.None);
claimRepository.Verify(
x => x.CreateClaimsBatchAsync(It.IsAny<IEnumerable<FingerprintClaim>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
private sealed class TestGuidProvider : IGuidProvider
{
private Guid _current = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
public Guid NewGuid()
{
var value = _current;
_current = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
return value;
}
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Builders;
using Xunit;
namespace StellaOps.BinaryIndex.Builders.Tests;
public sealed class ServiceCollectionExtensionsTests
{
[Fact]
public void AddBinaryIndexBuilders_BindsOptionsFromConfiguration()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["BinaryIndex:Builders:MaxConcurrentBuilds"] = "7",
["BinaryIndex:FunctionExtraction:MinFunctionSize"] = "32"
})
.Build();
var services = new ServiceCollection();
services.AddBinaryIndexBuilders(config);
using var provider = services.BuildServiceProvider();
var builderOptions = provider.GetRequiredService<IOptions<BuilderServiceOptions>>().Value;
var extractionOptions = provider.GetRequiredService<IOptions<FunctionExtractionOptions>>().Value;
Assert.Equal(7, builderOptions.MaxConcurrentBuilds);
Assert.Equal(32, extractionOptions.MinFunctionSize);
}
}

View File

@@ -0,0 +1,61 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
namespace StellaOps.BinaryIndex.Cache.Tests;
public sealed class CacheOptionsValidationTests
{
[Fact]
public void BinaryCacheOptionsValidator_AcceptsValidOptions()
{
var options = new BinaryCacheOptions
{
KeyPrefix = "stellaops:binary:",
IdentityTtl = TimeSpan.FromMinutes(10),
FixStatusTtl = TimeSpan.FromMinutes(10),
FingerprintTtl = TimeSpan.FromMinutes(10),
MaxTtl = TimeSpan.FromHours(1),
TargetHitRate = 0.5
};
var result = new BinaryCacheOptionsValidator().Validate(null, options);
result.Succeeded.Should().BeTrue();
}
[Fact]
public void BinaryCacheOptionsValidator_RejectsInvalidOptions()
{
var options = new BinaryCacheOptions
{
KeyPrefix = " ",
IdentityTtl = TimeSpan.Zero,
FixStatusTtl = TimeSpan.FromMinutes(1),
FingerprintTtl = TimeSpan.FromMinutes(1),
MaxTtl = TimeSpan.FromMinutes(0),
TargetHitRate = 1.5,
FingerprintHashLength = -1
};
var result = new BinaryCacheOptionsValidator().Validate(null, options);
result.Succeeded.Should().BeFalse();
}
[Fact]
public void ResolutionCacheOptionsValidator_RejectsInvalidOptions()
{
var options = new ResolutionCacheOptions
{
KeyPrefix = "",
FixedTtl = TimeSpan.Zero,
VulnerableTtl = TimeSpan.Zero,
UnknownTtl = TimeSpan.Zero,
EarlyExpiryFactor = 2.0
};
var result = new ResolutionCacheOptionsValidator().Validate(null, options);
result.Succeeded.Should().BeFalse();
}
}

View File

@@ -0,0 +1,174 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StackExchange.Redis;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
namespace StellaOps.BinaryIndex.Cache.Tests;
public sealed class CachedBinaryVulnerabilityServiceTests
{
[Fact]
public async Task LookupByFingerprintAsync_UsesFullHashByDefault()
{
var fingerprint = Enumerable.Range(1, 16).Select(i => (byte)i).ToArray();
var options = new BinaryCacheOptions { FingerprintHashLength = 0 };
var expectedHash = Convert.ToHexString(fingerprint).ToLowerInvariant();
var expectedKey = $"{options.KeyPrefix}fp:tlsh:{expectedHash}";
var inner = new Mock<IBinaryVulnerabilityService>();
inner.Setup(i => i.LookupByFingerprintAsync(
fingerprint,
It.IsAny<FingerprintLookupOptions?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray<BinaryVulnMatch>.Empty);
var db = new Mock<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(RedisValue.Null);
db.Setup(d => d.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<When>(),
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
.Returns(db.Object);
var service = new CachedBinaryVulnerabilityService(
inner.Object,
mux.Object,
Options.Create(options),
NullLogger<CachedBinaryVulnerabilityService>.Instance);
await service.LookupByFingerprintAsync(
fingerprint,
new FingerprintLookupOptions { Algorithm = "tlsh" },
CancellationToken.None);
db.Verify(d => d.StringGetAsync(expectedKey, It.IsAny<CommandFlags>()), Times.Once);
}
[Fact]
public async Task LookupByFingerprintAsync_RespectsConfiguredHashLength()
{
var fingerprint = Enumerable.Range(1, 16).Select(i => (byte)i).ToArray();
var options = new BinaryCacheOptions { FingerprintHashLength = 8 };
var expectedHash = Convert.ToHexString(fingerprint).ToLowerInvariant()[..8];
var expectedKey = $"{options.KeyPrefix}fp:combined:{expectedHash}";
var inner = new Mock<IBinaryVulnerabilityService>();
inner.Setup(i => i.LookupByFingerprintAsync(
fingerprint,
It.IsAny<FingerprintLookupOptions?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray<BinaryVulnMatch>.Empty);
var db = new Mock<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(RedisValue.Null);
db.Setup(d => d.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<When>(),
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
.Returns(db.Object);
var service = new CachedBinaryVulnerabilityService(
inner.Object,
mux.Object,
Options.Create(options),
NullLogger<CachedBinaryVulnerabilityService>.Instance);
await service.LookupByFingerprintAsync(
fingerprint,
null,
CancellationToken.None);
db.Verify(d => d.StringGetAsync(expectedKey, It.IsAny<CommandFlags>()), Times.Once);
}
[Fact]
public async Task LookupBatchAsync_DoesNotThrowOnUnexpectedKeys()
{
var identityA = CreateIdentity("bin-a");
var identityB = CreateIdentity("bin-b");
var options = new BinaryCacheOptions();
var lookupOptions = new LookupOptions { TenantId = "tenant" };
var fetchedResults = ImmutableDictionary<string, ImmutableArray<BinaryVulnMatch>>.Empty
.Add(identityA.BinaryKey, ImmutableArray<BinaryVulnMatch>.Empty)
.Add(identityB.BinaryKey, ImmutableArray<BinaryVulnMatch>.Empty)
.Add("unexpected", ImmutableArray<BinaryVulnMatch>.Empty);
var inner = new Mock<IBinaryVulnerabilityService>();
inner.Setup(i => i.LookupBatchAsync(
It.IsAny<IEnumerable<BinaryIdentity>>(),
It.IsAny<LookupOptions?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(fetchedResults);
var db = new Mock<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey[]>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(new RedisValue[] { RedisValue.Null, RedisValue.Null });
var batch = new Mock<IBatch>();
var batchAsync = batch.As<IDatabaseAsync>();
batchAsync.Setup(b => b.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<When>(),
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
batch.Setup(b => b.Execute());
db.Setup(d => d.CreateBatch(It.IsAny<object>()))
.Returns(batch.Object);
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
.Returns(db.Object);
var service = new CachedBinaryVulnerabilityService(
inner.Object,
mux.Object,
Options.Create(options),
NullLogger<CachedBinaryVulnerabilityService>.Instance);
var results = await service.LookupBatchAsync(
new[] { identityA, identityB },
lookupOptions,
CancellationToken.None);
results.Should().ContainKey(identityA.BinaryKey);
results.Should().ContainKey(identityB.BinaryKey);
results.Should().ContainKey("unexpected");
batch.Invocations.Count(invocation => invocation.Method.Name == "StringSetAsync")
.Should().Be(2);
}
private static BinaryIdentity CreateIdentity(string binaryKey)
{
return new BinaryIdentity
{
BinaryKey = binaryKey,
FileSha256 = "sha256:deadbeef",
Format = BinaryFormat.Elf,
Architecture = "x86_64",
CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
UpdatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
};
}
}

View File

@@ -0,0 +1,103 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StackExchange.Redis;
using StellaOps.BinaryIndex.Contracts.Resolution;
namespace StellaOps.BinaryIndex.Cache.Tests;
public sealed class ResolutionCacheServiceTests
{
[Fact]
public async Task GetAsync_ExpiresEarly_WhenRandomBelowThreshold()
{
var options = new ResolutionCacheOptions
{
EnableEarlyExpiry = true,
EarlyExpiryFactor = 1.0
};
var cached = new CachedResolution
{
Status = ResolutionStatus.Fixed,
CachedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Confidence = 1.0m
};
var payload = JsonSerializer.Serialize(
cached,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var db = new Mock<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(payload);
db.Setup(d => d.KeyTimeToLiveAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(TimeSpan.FromHours(1));
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
.Returns(db.Object);
var service = new ResolutionCacheService(
mux.Object,
Options.Create(options),
NullLogger<ResolutionCacheService>.Instance,
new FixedRandomSource(0.1));
var result = await service.GetAsync("resolution:key", CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task GetAsync_ReturnsCachedEntry_WhenRandomAboveThreshold()
{
var options = new ResolutionCacheOptions
{
EnableEarlyExpiry = true,
EarlyExpiryFactor = 1.0
};
var cached = new CachedResolution
{
Status = ResolutionStatus.Vulnerable,
CachedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Confidence = 0.5m
};
var payload = JsonSerializer.Serialize(
cached,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var db = new Mock<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(payload);
db.Setup(d => d.KeyTimeToLiveAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(TimeSpan.FromHours(1));
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
.Returns(db.Object);
var service = new ResolutionCacheService(
mux.Object,
Options.Create(options),
NullLogger<ResolutionCacheService>.Instance,
new FixedRandomSource(0.9));
var result = await service.GetAsync("resolution:key", CancellationToken.None);
result.Should().NotBeNull();
result!.Status.Should().Be(ResolutionStatus.Vulnerable);
}
private sealed class FixedRandomSource : IRandomSource
{
private readonly double _value;
public FixedRandomSource(double value)
{
_value = value;
}
public double NextDouble() => _value;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Cache\StellaOps.BinaryIndex.Cache.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Core\StellaOps.BinaryIndex.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Contracts\StellaOps.BinaryIndex.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,100 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using FluentAssertions;
using StellaOps.BinaryIndex.Contracts.Resolution;
namespace StellaOps.BinaryIndex.Contracts.Tests;
public sealed class VulnResolutionContractsTests
{
[Fact]
public void VulnResolutionRequest_RequiresAtLeastOneIdentifier()
{
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@1.2.3"
};
var results = Validate(request);
results.Should().Contain(r => r.ErrorMessage!.Contains("At least one identifier"));
}
[Fact]
public void VulnResolutionRequest_AllowsBuildId()
{
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@1.2.3",
BuildId = "build-id"
};
var results = Validate(request);
results.Should().BeEmpty();
}
[Fact]
public void BatchVulnResolutionRequest_RequiresItems()
{
var request = new BatchVulnResolutionRequest
{
Items = Array.Empty<VulnResolutionRequest>()
};
var results = Validate(request);
results.Should().Contain(r =>
r.ErrorMessage!.Contains("minimum length")
|| r.ErrorMessage!.Contains("at least one request"));
}
[Fact]
public void VulnResolutionResponse_RequiresResolvedAt()
{
var response = new VulnResolutionResponse
{
Package = "pkg:deb/debian/openssl@1.2.3",
Status = ResolutionStatus.Fixed
};
var results = Validate(response);
results.Should().Contain(r => r.ErrorMessage!.Contains("ResolvedAt"));
}
[Fact]
public void VulnResolutionResponse_RoundTripsWithJson()
{
var response = new VulnResolutionResponse
{
Package = "pkg:deb/debian/openssl@1.2.3",
Status = ResolutionStatus.Vulnerable,
ResolvedAt = new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero),
Evidence = new ResolutionEvidence
{
MatchType = ResolutionMatchTypes.HashExact,
Confidence = 0.9m,
FixMethod = ResolutionFixMethods.SecurityFeed
}
};
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(response, options);
var roundTrip = JsonSerializer.Deserialize<VulnResolutionResponse>(json, options);
roundTrip.Should().NotBeNull();
roundTrip!.Package.Should().Be(response.Package);
roundTrip.Status.Should().Be(response.Status);
roundTrip.ResolvedAt.Should().Be(response.ResolvedAt);
roundTrip.Evidence!.MatchType.Should().Be(response.Evidence!.MatchType);
roundTrip.Evidence!.FixMethod.Should().Be(response.Evidence!.FixMethod);
}
private static List<ValidationResult> Validate(object instance)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(instance, new ValidationContext(instance), results, true);
return results;
}
}

View File

@@ -0,0 +1,85 @@
using FluentAssertions;
using StellaOps.BinaryIndex.Core.Services;
namespace StellaOps.BinaryIndex.Core.Tests;
public sealed class NonSeekableStreamTests
{
[Fact]
public void CanExtract_ReturnsFalse_ForNonSeekableStream()
{
var extractor = new ElfFeatureExtractor();
using var stream = new NonSeekableReadStream(new byte[] { 0x7F, 0x45, 0x4C, 0x46 });
extractor.CanExtract(stream).Should().BeFalse();
}
[Fact]
public async Task ExtractMetadataAsync_Throws_ForNonSeekableStream()
{
var extractor = new PeFeatureExtractor();
using var stream = new NonSeekableReadStream(new byte[128]);
var act = async () => await extractor.ExtractMetadataAsync(
stream,
TestContext.Current.CancellationToken);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task ExtractIdentityAsync_Throws_ForNonSeekableStream()
{
var extractor = new MachoFeatureExtractor();
using var stream = new NonSeekableReadStream(new byte[128]);
var act = async () => await extractor.ExtractIdentityAsync(
stream,
TestContext.Current.CancellationToken);
await act.Should().ThrowAsync<InvalidOperationException>();
}
private sealed class NonSeekableReadStream : Stream
{
private readonly MemoryStream _inner;
public NonSeekableReadStream(byte[] data)
{
_inner = new MemoryStream(data);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count)
{
return _inner.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}
}

View File

@@ -0,0 +1,212 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Contracts.Resolution;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Resolution;
using StellaOps.BinaryIndex.Core.Services;
namespace StellaOps.BinaryIndex.Core.Tests;
public sealed class ResolutionServiceTests
{
[Fact]
public async Task ResolveAsync_ThrowsWhenIdentifiersMissing()
{
var service = CreateService(new StubBinaryVulnerabilityService());
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@1.2.3"
};
var act = async () => await service.ResolveAsync(request, ct: TestContext.Current.CancellationToken);
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task ResolveAsync_UsesTimeProviderForResolvedAt()
{
var now = new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero);
var service = CreateService(
new StubBinaryVulnerabilityService(),
new ResolutionServiceOptions(),
new FixedTimeProvider(now));
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@1.2.3",
BuildId = "build-id"
};
var result = await service.ResolveAsync(request, ct: TestContext.Current.CancellationToken);
result.ResolvedAt.Should().Be(now);
}
[Fact]
public async Task ResolveAsync_FingerprintConfidenceBelowThreshold_ReturnsUnknown()
{
var fingerprintBytes = new byte[] { 0x01, 0x02, 0x03 };
var stub = new StubBinaryVulnerabilityService
{
OnFingerprint = _ => ImmutableArray.Create(new BinaryVulnMatch
{
CveId = "CVE-2024-0001",
VulnerablePurl = "pkg:deb/debian/openssl@1.2.3",
Method = MatchMethod.FingerprintMatch,
Confidence = 0.4m
})
};
var service = CreateService(
stub,
new ResolutionServiceOptions { MinConfidenceThreshold = 0.9m });
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@1.2.3",
Fingerprint = Convert.ToBase64String(fingerprintBytes),
FingerprintAlgorithm = "combined"
};
var result = await service.ResolveAsync(request, ct: TestContext.Current.CancellationToken);
result.Status.Should().Be(ResolutionStatus.Unknown);
result.Evidence!.MatchType.Should().Be(ResolutionMatchTypes.Fingerprint);
}
[Fact]
public async Task ResolveBatchAsync_TruncatesToMaxBatchSize()
{
var options = new ResolutionServiceOptions { MaxBatchSize = 1 };
var service = CreateService(new StubBinaryVulnerabilityService(), options);
var request = new BatchVulnResolutionRequest
{
Items = new[]
{
new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@1.2.3",
BuildId = "build-id"
},
new VulnResolutionRequest
{
Package = "pkg:deb/debian/libssl@1.2.3",
BuildId = "build-id-2"
}
}
};
var response = await service.ResolveBatchAsync(request, ct: TestContext.Current.CancellationToken);
response.Results.Should().HaveCount(1);
}
private static ResolutionService CreateService(
StubBinaryVulnerabilityService stub,
ResolutionServiceOptions? options = null,
TimeProvider? timeProvider = null)
{
return new ResolutionService(
stub,
Options.Create(options ?? new ResolutionServiceOptions()),
NullLogger<ResolutionService>.Instance,
timeProvider ?? TimeProvider.System);
}
private sealed class StubBinaryVulnerabilityService : IBinaryVulnerabilityService
{
public Func<BinaryIdentity, ImmutableArray<BinaryVulnMatch>>? OnIdentity { get; init; }
public Func<byte[], ImmutableArray<BinaryVulnMatch>>? OnFingerprint { get; init; }
public Task<ImmutableArray<BinaryVulnMatch>> LookupByIdentityAsync(
BinaryIdentity identity,
LookupOptions? options = null,
CancellationToken ct = default)
{
var matches = OnIdentity?.Invoke(identity) ?? ImmutableArray<BinaryVulnMatch>.Empty;
return Task.FromResult(matches);
}
public Task<ImmutableDictionary<string, ImmutableArray<BinaryVulnMatch>>> LookupBatchAsync(
IEnumerable<BinaryIdentity> identities,
LookupOptions? options = null,
CancellationToken ct = default)
{
return Task.FromResult(
ImmutableDictionary<string, ImmutableArray<BinaryVulnMatch>>.Empty);
}
public Task<FixStatusResult?> GetFixStatusAsync(
string distro,
string release,
string sourcePkg,
string cveId,
CancellationToken ct = default)
{
return Task.FromResult<FixStatusResult?>(null);
}
public Task<ImmutableDictionary<string, FixStatusResult>> GetFixStatusBatchAsync(
string distro,
string release,
string sourcePkg,
IEnumerable<string> cveIds,
CancellationToken ct = default)
{
return Task.FromResult(
ImmutableDictionary<string, FixStatusResult>.Empty);
}
public Task<ImmutableArray<BinaryVulnMatch>> LookupByFingerprintAsync(
byte[] fingerprint,
FingerprintLookupOptions? options = null,
CancellationToken ct = default)
{
var matches = OnFingerprint?.Invoke(fingerprint) ?? ImmutableArray<BinaryVulnMatch>.Empty;
return Task.FromResult(matches);
}
public Task<ImmutableDictionary<string, ImmutableArray<BinaryVulnMatch>>> LookupByFingerprintBatchAsync(
IEnumerable<(string Key, byte[] Fingerprint)> fingerprints,
FingerprintLookupOptions? options = null,
CancellationToken ct = default)
{
return Task.FromResult(
ImmutableDictionary<string, ImmutableArray<BinaryVulnMatch>>.Empty);
}
public Task<ImmutableArray<BinaryVulnMatch>> LookupByDeltaSignatureAsync(
Stream binaryStream,
DeltaSigLookupOptions? options = null,
CancellationToken ct = default)
{
return Task.FromResult(ImmutableArray<BinaryVulnMatch>.Empty);
}
public Task<ImmutableArray<BinaryVulnMatch>> LookupBySymbolHashAsync(
string symbolHash,
string symbolName,
DeltaSigLookupOptions? options = null,
CancellationToken ct = default)
{
return Task.FromResult(ImmutableArray<BinaryVulnMatch>.Empty);
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixed;
public FixedTimeProvider(DateTimeOffset fixedTime)
{
_fixed = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixed;
}
}

View File

@@ -0,0 +1,152 @@
using System.IO.Compression;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using StellaOps.BinaryIndex.Corpus;
using StellaOps.BinaryIndex.Corpus.Alpine;
namespace StellaOps.BinaryIndex.Corpus.Alpine.Tests;
public sealed class AlpinePackageExtractorTests
{
[Fact]
public async Task ExtractBinariesAsync_ReturnsElfEntries()
{
var elfBytes = new byte[] { 0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01, 0x01, 0x00 };
using var apkStream = CreateApkStream("bin/test", elfBytes);
var extractor = new AlpinePackageExtractor(
new FakeFeatureExtractor(),
NullLogger<AlpinePackageExtractor>.Instance);
var pkg = CreatePackageInfo();
var results = await extractor.ExtractBinariesAsync(
apkStream,
pkg,
TestContext.Current.CancellationToken);
results.Should().HaveCount(1);
results[0].FilePath.Should().Be("bin/test");
}
[Fact]
public async Task ExtractBinariesAsync_SkipsNonElfEntries()
{
var content = new byte[] { 0x01, 0x02, 0x03 };
using var apkStream = CreateApkStream("bin/readme", content);
var extractor = new AlpinePackageExtractor(
new FakeFeatureExtractor(),
NullLogger<AlpinePackageExtractor>.Instance);
var pkg = CreatePackageInfo();
var results = await extractor.ExtractBinariesAsync(
apkStream,
pkg,
TestContext.Current.CancellationToken);
results.Should().BeEmpty();
}
private static PackageInfo CreatePackageInfo()
{
return new PackageInfo
{
Name = "openssl",
Version = "1.2.3",
SourcePackage = "openssl",
Architecture = "x86_64",
Filename = "openssl.apk",
Size = 123,
Sha256 = new string('a', 64)
};
}
private static MemoryStream CreateApkStream(string entryName, byte[] content)
{
var tarBytes = CreateTarArchive(entryName, content);
var ms = new MemoryStream();
using (var gzip = new GZipStream(ms, CompressionLevel.SmallestSize, leaveOpen: true))
{
gzip.Write(tarBytes, 0, tarBytes.Length);
}
ms.Position = 0;
return ms;
}
private static byte[] CreateTarArchive(string entryName, byte[] content)
{
var header = new byte[512];
WriteString(header, 0, 100, entryName);
WriteOctal(header, 100, 8, 0x1FF);
WriteOctal(header, 108, 8, 0);
WriteOctal(header, 116, 8, 0);
WriteOctal(header, 124, 12, content.Length);
WriteOctal(header, 136, 12, 0);
header[156] = (byte)'0';
for (var i = 148; i < 156; i++)
{
header[i] = 0x20;
}
var checksum = header.Sum(b => (int)b);
WriteString(header, 148, 6, Convert.ToString(checksum, 8).PadLeft(6, '0'));
header[154] = 0;
header[155] = (byte)' ';
var padding = (512 - (content.Length % 512)) % 512;
var total = new byte[512 + content.Length + padding + 1024];
Buffer.BlockCopy(header, 0, total, 0, 512);
Buffer.BlockCopy(content, 0, total, 512, content.Length);
return total;
}
private static void WriteString(byte[] buffer, int offset, int length, string value)
{
var bytes = System.Text.Encoding.ASCII.GetBytes(value);
var count = Math.Min(bytes.Length, length);
Buffer.BlockCopy(bytes, 0, buffer, offset, count);
}
private static void WriteOctal(byte[] buffer, int offset, int length, int value)
{
var octal = Convert.ToString(value, 8).PadLeft(length - 1, '0');
WriteString(buffer, offset, length - 1, octal);
buffer[offset + length - 1] = 0;
}
private sealed class FakeFeatureExtractor : IBinaryFeatureExtractor
{
public bool CanExtract(Stream stream) => true;
public Task<BinaryIdentity> ExtractIdentityAsync(Stream stream, CancellationToken ct = default)
{
var identity = new BinaryIdentity
{
BinaryKey = "bin:test",
FileSha256 = new string('a', 64),
Format = BinaryFormat.Elf,
Architecture = "x86_64",
CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
UpdatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
};
return Task.FromResult(identity);
}
public Task<BinaryMetadata> ExtractMetadataAsync(Stream stream, CancellationToken ct = default)
{
return Task.FromResult(new BinaryMetadata
{
Format = BinaryFormat.Elf,
Architecture = "x86_64",
IsStripped = false
});
}
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Corpus.Alpine\StellaOps.BinaryIndex.Corpus.Alpine.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Core\StellaOps.BinaryIndex.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,105 @@
using System.IO.Compression;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace StellaOps.BinaryIndex.Corpus.Debian.Tests;
public sealed class DebianMirrorPackageSourceTests
{
[Fact]
public async Task FetchPackageIndexAsync_ParsesContinuationLinesAndSize()
{
var packages = string.Join(
"\n",
"Package: zlib1g",
"Version: 1.2.13",
"Architecture: amd64",
"Filename: pool/main/z/zlib/zlib1g_1.2.13_amd64.deb",
"SHA256: 1111111111111111111111111111111111111111111111111111111111111111",
"Size: 2048",
"Description: zlib library",
" more details",
"",
"Package: alpha",
"Version: 0.1",
"Architecture: amd64",
"Filename: pool/main/a/alpha/alpha_0.1_amd64.deb",
"SHA256: 2222222222222222222222222222222222222222222222222222222222222222",
"Size: 1024",
"");
var client = new HttpClient(new FixedResponseHandler(CreateGzip(packages)));
var source = new DebianMirrorPackageSource(
client,
NullLogger<DebianMirrorPackageSource>.Instance,
"https://example.invalid/debian");
var result = await source.FetchPackageIndexAsync(
"debian",
"stable",
"amd64",
TestContext.Current.CancellationToken);
result.Should().HaveCount(2);
result[0].Package.Should().Be("alpha");
result[0].Size.Should().Be(1024);
result[1].Package.Should().Be("zlib1g");
result[1].Size.Should().Be(2048);
}
[Fact]
public async Task FetchPackageIndexAsync_RejectsUnknownDistro()
{
var client = new HttpClient(new FixedResponseHandler(CreateGzip("")));
var source = new DebianMirrorPackageSource(
client,
NullLogger<DebianMirrorPackageSource>.Instance,
"https://example.invalid/debian");
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => source.FetchPackageIndexAsync(
"unsupported",
"stable",
"amd64",
TestContext.Current.CancellationToken));
}
private static byte[] CreateGzip(string content)
{
var inputBytes = Encoding.UTF8.GetBytes(content);
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
{
gzip.Write(inputBytes, 0, inputBytes.Length);
}
return output.ToArray();
}
private sealed class FixedResponseHandler : HttpMessageHandler
{
private readonly byte[] _payload;
public FixedResponseHandler(byte[] payload)
{
_payload = payload;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(_payload)
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/gzip");
return Task.FromResult(response);
}
}
}

View File

@@ -0,0 +1,108 @@
using System.Formats.Tar;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using Xunit;
namespace StellaOps.BinaryIndex.Corpus.Debian.Tests;
public sealed class DebianPackageExtractorTests
{
[Fact]
public async Task ExtractFromDataTarAsync_ReturnsElfEntries()
{
await using var tarStream = new MemoryStream();
using (var writer = new TarWriter(tarStream, leaveOpen: true))
{
var elfBytes = new byte[] { 0x7F, (byte)'E', (byte)'L', (byte)'F', 1, 2, 3, 4 };
var elfEntry = new PaxTarEntry(TarEntryType.RegularFile, "./usr/bin/demo")
{
DataStream = new MemoryStream(elfBytes)
};
writer.WriteEntry(elfEntry);
var textBytes = Encoding.ASCII.GetBytes("docs");
var textEntry = new PaxTarEntry(TarEntryType.RegularFile, "./usr/share/doc/readme")
{
DataStream = new MemoryStream(textBytes)
};
writer.WriteEntry(textEntry);
}
tarStream.Position = 0;
var extractor = new DebianPackageExtractor(
new FakeBinaryFeatureExtractor(),
NullLogger<DebianPackageExtractor>.Instance);
var metadata = new DebianPackageMetadata
{
Package = "demo",
Version = "1.0",
Architecture = "amd64",
Filename = "pool/main/d/demo/demo_1.0_amd64.deb",
SHA256 = new string('a', 64)
};
var extracted = await extractor.ExtractFromDataTarAsync(
tarStream,
metadata,
TestContext.Current.CancellationToken);
extracted.Should().ContainSingle();
extracted[0].FilePath.Should().Be("./usr/bin/demo");
extracted[0].PackageName.Should().Be("demo");
}
private sealed class FakeBinaryFeatureExtractor : IBinaryFeatureExtractor
{
private static readonly BinaryIdentity Identity = new()
{
Id = Guid.Parse("9d5b77ad-0d0c-4c37-8f6a-6f2fd1f4d4c0"),
BinaryKey = "demo",
FileSha256 = new string('b', 64),
Format = BinaryFormat.Elf,
Architecture = "x86_64",
CreatedAt = DateTimeOffset.UnixEpoch,
UpdatedAt = DateTimeOffset.UnixEpoch
};
public bool CanExtract(Stream stream)
{
if (!stream.CanSeek || !stream.CanRead)
{
return false;
}
var buffer = new byte[4];
var read = stream.Read(buffer, 0, buffer.Length);
stream.Position = 0;
return read == 4 &&
buffer[0] == 0x7F &&
buffer[1] == (byte)'E' &&
buffer[2] == (byte)'L' &&
buffer[3] == (byte)'F';
}
public Task<BinaryIdentity> ExtractIdentityAsync(Stream stream, CancellationToken ct = default)
{
return Task.FromResult(Identity);
}
public Task<BinaryMetadata> ExtractMetadataAsync(Stream stream, CancellationToken ct = default)
{
var metadata = new BinaryMetadata
{
Format = BinaryFormat.Elf,
Architecture = "x86_64",
IsStripped = false
};
return Task.FromResult(metadata);
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Core\StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Corpus.Debian\StellaOps.BinaryIndex.Corpus.Debian.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,46 @@
using System.IO.Compression;
using System.Text;
using FluentAssertions;
using Xunit;
namespace StellaOps.BinaryIndex.Corpus.Rpm.Tests;
public sealed class RpmPackageExtractorTests
{
[Fact]
public void DetectCompression_RecognizesKnownMagic()
{
var xz = new MemoryStream(new byte[] { 0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, 0x00 });
var gzip = new MemoryStream(new byte[] { 0x1F, 0x8B, 0x08, 0x00 });
var zstd = new MemoryStream(new byte[] { 0x28, 0xB5, 0x2F, 0xFD, 0x00 });
var none = new MemoryStream(new byte[] { 0x00, 0x01, 0x02 });
RpmPackageExtractor.DetectCompression(xz).Should().Be(RpmPackageExtractor.PayloadCompression.Xz);
RpmPackageExtractor.DetectCompression(gzip).Should().Be(RpmPackageExtractor.PayloadCompression.Gzip);
RpmPackageExtractor.DetectCompression(zstd).Should().Be(RpmPackageExtractor.PayloadCompression.Zstd);
RpmPackageExtractor.DetectCompression(none).Should().Be(RpmPackageExtractor.PayloadCompression.None);
}
[Fact]
public async Task DecompressPayloadAsync_HandlesGzip()
{
var payloadBytes = Encoding.ASCII.GetBytes("payload");
await using var compressed = new MemoryStream();
await using (var gzip = new GZipStream(compressed, CompressionLevel.Optimal, leaveOpen: true))
{
await gzip.WriteAsync(payloadBytes, TestContext.Current.CancellationToken);
}
compressed.Position = 0;
await using var decompressed = await RpmPackageExtractor.DecompressPayloadAsync(
compressed,
RpmPackageExtractor.PayloadCompression.Gzip,
TestContext.Current.CancellationToken);
using var reader = new StreamReader(decompressed, Encoding.ASCII, leaveOpen: true);
var result = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
result.Should().Be("payload");
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Core\StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Corpus.Rpm\StellaOps.BinaryIndex.Corpus.Rpm.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,83 @@
using System.ComponentModel.DataAnnotations;
using FluentAssertions;
using StellaOps.BinaryIndex.Corpus;
namespace StellaOps.BinaryIndex.Corpus.Tests;
public sealed class CorpusContractsTests
{
[Fact]
public void CorpusQuery_NormalizesComponentFilter()
{
var query = new CorpusQuery(
distro: "debian",
release: "bookworm",
architecture: "amd64",
componentFilter: new[] { " main", "contrib", "main", "non-free" });
query.ComponentFilter.Should().Equal("contrib", "main", "non-free");
}
[Fact]
public void CorpusSnapshot_RequiresUtcCapturedAt()
{
var snapshot = new CorpusSnapshot
{
Id = Guid.NewGuid(),
Distro = "debian",
Release = "bookworm",
Architecture = "amd64",
MetadataDigest = "digest",
CapturedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.FromHours(2))
};
var results = Validate(snapshot);
results.Should().Contain(r => r.ErrorMessage!.Contains("UTC"));
}
[Fact]
public void PackageInfo_RejectsInvalidSha256()
{
var package = new PackageInfo
{
Name = "openssl",
Version = "1.2.3",
SourcePackage = "openssl",
Architecture = "amd64",
Filename = "pool/o/openssl.deb",
Size = 123,
Sha256 = "not-a-digest"
};
var results = Validate(package);
results.Should().Contain(r => r.ErrorMessage!.Contains("Sha256"));
}
[Fact]
public void PackageInfo_AcceptsSha256WithPrefix()
{
var package = new PackageInfo
{
Name = "openssl",
Version = "1.2.3",
SourcePackage = "openssl",
Architecture = "amd64",
Filename = "pool/o/openssl.deb",
Size = 123,
Sha256 = "sha256:" + new string('a', 64)
};
var results = Validate(package);
results.Should().BeEmpty();
}
private static List<ValidationResult> Validate(object instance)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(instance, new ValidationContext(instance), results, true);
return results;
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Corpus\StellaOps.BinaryIndex.Corpus.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,7 +1,7 @@
// -----------------------------------------------------------------------------
// BasicBlockFingerprintGeneratorTests.cs
// Sprint: SPRINT_20251226_013_BINIDX_fingerprint_factory
// Task: FPRINT-21 Add unit tests for fingerprint generation
// Task: FPRINT-21 - Add unit tests for fingerprint generation
// -----------------------------------------------------------------------------
using FluentAssertions;
@@ -59,7 +59,7 @@ public class BasicBlockFingerprintGeneratorTests
var input = CreateInput(binaryData);
var result = await _generator.GenerateAsync(input);
var result = await _generator.GenerateAsync(input, TestContext.Current.CancellationToken);
result.Should().NotBeNull();
result.Hash.Should().HaveCount(16); // 16-byte fingerprint
@@ -81,8 +81,8 @@ public class BasicBlockFingerprintGeneratorTests
var input = CreateInput(binaryData);
var result1 = await _generator.GenerateAsync(input);
var result2 = await _generator.GenerateAsync(input);
var result1 = await _generator.GenerateAsync(input, TestContext.Current.CancellationToken);
var result2 = await _generator.GenerateAsync(input, TestContext.Current.CancellationToken);
result1.Hash.Should().BeEquivalentTo(result2.Hash);
result1.FingerprintId.Should().Be(result2.FingerprintId);
@@ -103,8 +103,8 @@ public class BasicBlockFingerprintGeneratorTests
0x89, 0x7d, 0xec, 0x8b, 0x45, 0xec, 0xc3
};
var result1 = await _generator.GenerateAsync(CreateInput(binaryData1));
var result2 = await _generator.GenerateAsync(CreateInput(binaryData2));
var result1 = await _generator.GenerateAsync(CreateInput(binaryData1), TestContext.Current.CancellationToken);
var result2 = await _generator.GenerateAsync(CreateInput(binaryData2), TestContext.Current.CancellationToken);
result1.FingerprintId.Should().NotBe(result2.FingerprintId);
}
@@ -124,7 +124,7 @@ public class BasicBlockFingerprintGeneratorTests
0xc3 // ret
};
var result = await _generator.GenerateAsync(CreateInput(binaryData));
var result = await _generator.GenerateAsync(CreateInput(binaryData), TestContext.Current.CancellationToken);
result.Metadata.Should().NotBeNull();
result.Metadata!.BasicBlockCount.Should().BeGreaterThan(0);
@@ -139,7 +139,7 @@ public class BasicBlockFingerprintGeneratorTests
{
var input = CreateInput(new byte[32], architecture: arch);
var result = await _generator.GenerateAsync(input);
var result = await _generator.GenerateAsync(input, TestContext.Current.CancellationToken);
result.Should().NotBeNull();
result.Hash.Should().NotBeEmpty();
@@ -158,3 +158,4 @@ public class BasicBlockFingerprintGeneratorTests
};
}
}

View File

@@ -0,0 +1,52 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Fingerprints.Generators;
using StellaOps.BinaryIndex.Fingerprints.Models;
using Xunit;
namespace StellaOps.BinaryIndex.Fingerprints.Tests.Generators;
public sealed class CombinedFingerprintGeneratorTests
{
private readonly CombinedFingerprintGenerator _generator;
public CombinedFingerprintGeneratorTests()
{
_generator = new CombinedFingerprintGenerator(
NullLogger<CombinedFingerprintGenerator>.Instance,
new BasicBlockFingerprintGenerator(NullLogger<BasicBlockFingerprintGenerator>.Instance),
new ControlFlowGraphFingerprintGenerator(NullLogger<ControlFlowGraphFingerprintGenerator>.Instance),
new StringRefsFingerprintGenerator(NullLogger<StringRefsFingerprintGenerator>.Instance));
}
[Fact]
public async Task GenerateAsync_ReturnsDeterministicFingerprint()
{
var input = CreateInput(new byte[]
{
0x55, 0x48, 0x89, 0xE5, 0x48, 0x83, 0xEC, 0x10,
0x48, 0x8D, 0x3D, 0x00, 0x00, 0x00, 0x00, 0xC3,
(byte)'e', (byte)'r', (byte)'r', (byte)'o', (byte)'r', 0x00
});
var result1 = await _generator.GenerateAsync(input, TestContext.Current.CancellationToken);
var result2 = await _generator.GenerateAsync(input, TestContext.Current.CancellationToken);
result1.Algorithm.Should().Be(FingerprintAlgorithm.Combined);
result1.Hash.Should().HaveCount(48);
result1.FingerprintId.Should().Be(result2.FingerprintId);
result1.Hash.Should().BeEquivalentTo(result2.Hash);
}
private static FingerprintInput CreateInput(byte[] binaryData)
{
return new FingerprintInput
{
BinaryData = binaryData,
Architecture = "x86_64",
CveId = "CVE-2024-TEST",
Component = "test-component"
};
}
}

View File

@@ -0,0 +1,34 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Fingerprints.Generators;
using StellaOps.BinaryIndex.Fingerprints.Models;
using Xunit;
namespace StellaOps.BinaryIndex.Fingerprints.Tests.Generators;
public sealed class ControlFlowGraphFingerprintGeneratorTests
{
private readonly ControlFlowGraphFingerprintGenerator _generator =
new(NullLogger<ControlFlowGraphFingerprintGenerator>.Instance);
[Fact]
public async Task GenerateAsync_ReturnsDeterministicFingerprint()
{
var input = new FingerprintInput
{
BinaryData = new byte[64],
Architecture = "x86_64",
CveId = "CVE-2024-TEST",
Component = "test-component"
};
var result1 = await _generator.GenerateAsync(input, TestContext.Current.CancellationToken);
var result2 = await _generator.GenerateAsync(input, TestContext.Current.CancellationToken);
result1.Hash.Should().HaveCount(32);
result1.FingerprintId.Should().Be(result2.FingerprintId);
result1.Hash.Should().BeEquivalentTo(result2.Hash);
result1.Algorithm.Should().Be(FingerprintAlgorithm.ControlFlowGraph);
}
}

View File

@@ -0,0 +1,40 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Fingerprints.Generators;
using StellaOps.BinaryIndex.Fingerprints.Models;
using Xunit;
namespace StellaOps.BinaryIndex.Fingerprints.Tests.Generators;
public sealed class StringRefsFingerprintGeneratorTests
{
private readonly StringRefsFingerprintGenerator _generator =
new(NullLogger<StringRefsFingerprintGenerator>.Instance);
[Fact]
public async Task GenerateAsync_ReturnsDeterministicFingerprint()
{
var input = new FingerprintInput
{
BinaryData = new byte[]
{
(byte)'e', (byte)'r', (byte)'r', (byte)'o', (byte)'r', 0x00,
(byte)'f', (byte)'a', (byte)'i', (byte)'l', 0x00
},
Architecture = "x86_64",
CveId = "CVE-2024-TEST",
Component = "test-component"
};
_generator.CanProcess(input).Should().BeTrue();
var result1 = await _generator.GenerateAsync(input, TestContext.Current.CancellationToken);
var result2 = await _generator.GenerateAsync(input, TestContext.Current.CancellationToken);
result1.Hash.Should().HaveCount(16);
result1.FingerprintId.Should().Be(result2.FingerprintId);
result1.Hash.Should().BeEquivalentTo(result2.Hash);
result1.Algorithm.Should().Be(FingerprintAlgorithm.StringRefs);
}
}

View File

@@ -1,7 +1,7 @@
// -----------------------------------------------------------------------------
// FingerprintMatcherTests.cs
// Sprint: SPRINT_20251226_013_BINIDX_fingerprint_factory
// Task: FPRINT-22 Add integration tests for matching pipeline
// Task: FPRINT-22 - Add integration tests for matching pipeline
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
@@ -34,12 +34,15 @@ public class FingerprintMatcherTests
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray<VulnFingerprint>.Empty);
var fingerprint = new byte[16];
var result = await _matcher.MatchAsync(fingerprint);
var result = await _matcher.MatchAsync(
fingerprint,
null,
TestContext.Current.CancellationToken);
result.IsMatch.Should().BeFalse();
result.Similarity.Should().Be(0);
@@ -56,11 +59,14 @@ public class FingerprintMatcherTests
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
var result = await _matcher.MatchAsync(testFingerprint);
var result = await _matcher.MatchAsync(
testFingerprint,
null,
TestContext.Current.CancellationToken);
result.IsMatch.Should().BeTrue();
result.Similarity.Should().Be(1.0m);
@@ -80,11 +86,14 @@ public class FingerprintMatcherTests
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
var result = await _matcher.MatchAsync(testFingerprint, new MatchOptions { MinSimilarity = 0.9m });
var result = await _matcher.MatchAsync(
testFingerprint,
new MatchOptions { MinSimilarity = 0.9m },
TestContext.Current.CancellationToken);
result.IsMatch.Should().BeTrue();
result.Similarity.Should().BeGreaterThan(0.9m);
@@ -101,7 +110,7 @@ public class FingerprintMatcherTests
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
@@ -122,7 +131,7 @@ public class FingerprintMatcherTests
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
@@ -145,7 +154,7 @@ public class FingerprintMatcherTests
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
@@ -154,6 +163,52 @@ public class FingerprintMatcherTests
result.IsMatch.Should().BeFalse(); // Filtered out because not validated
}
[Fact]
public async Task MatchAsync_RespectsAlgorithmFilter()
{
var testFingerprint = new byte[16];
var options = new MatchOptions
{
Algorithms = ImmutableArray.Create(FingerprintAlgorithm.ControlFlowGraph)
};
var result = await _matcher.MatchAsync(testFingerprint, options);
result.IsMatch.Should().BeFalse();
_repositoryMock.Verify(
r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task MatchAsync_UsesArchitectureFilterWhenPresent()
{
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray<VulnFingerprint>.Empty);
var options = new MatchOptions { Architecture = "x86_64" };
await _matcher.MatchAsync(new byte[16], options);
_repositoryMock.Verify(
r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
"x86_64",
It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}
[Fact]
public async Task MatchBatchAsync_ProcessesAllFingerprints()
{
@@ -161,7 +216,7 @@ public class FingerprintMatcherTests
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray<VulnFingerprint>.Empty);
@@ -197,12 +252,23 @@ public class FingerprintMatcherTests
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
FingerprintAlgorithm.BasicBlock,
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
var result = await _matcher.MatchAsync(testFingerprint);
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
FingerprintAlgorithm.StringRefs,
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray<VulnFingerprint>.Empty);
var result = await _matcher.MatchAsync(
testFingerprint,
null,
TestContext.Current.CancellationToken);
result.Details.Should().NotBeNull();
result.Details!.MatchingAlgorithm.Should().Be(FingerprintAlgorithm.BasicBlock);
@@ -214,7 +280,7 @@ public class FingerprintMatcherTests
{
return new VulnFingerprint
{
Id = Guid.NewGuid(),
Id = Guid.Parse("0a8b3db1-2f3d-4b92-9b6c-69ed0f9cc0f1"),
CveId = "CVE-2024-TEST",
Component = "test-component",
Algorithm = FingerprintAlgorithm.BasicBlock,
@@ -222,7 +288,8 @@ public class FingerprintMatcherTests
FingerprintHash = hash,
Architecture = "x86_64",
Validated = validated,
IndexedAt = DateTimeOffset.UtcNow
IndexedAt = DateTimeOffset.UnixEpoch
};
}
}

View File

@@ -0,0 +1,194 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.BinaryIndex.Fingerprints.Generators;
using StellaOps.BinaryIndex.Fingerprints.Models;
using StellaOps.BinaryIndex.Fingerprints.Pipeline;
using StellaOps.BinaryIndex.Fingerprints.Storage;
using Xunit;
namespace StellaOps.BinaryIndex.Fingerprints.Tests.Pipeline;
public sealed class ReferenceBuildPipelineTests
{
[Fact]
public async Task ExecuteAsync_ReturnsFailureWhenBuildArtifactsMissing()
{
var storage = new Mock<IFingerprintBlobStorage>();
var repository = new Mock<IFingerprintRepository>();
var executor = new FakeReferenceBuildExecutor
{
VulnArtifacts = []
};
var pipeline = CreatePipeline(storage.Object, repository.Object, executor);
var result = await pipeline.ExecuteAsync(CreateRequest(), TestContext.Current.CancellationToken);
result.Success.Should().BeFalse();
repository.Verify(r => r.CreateAsync(It.IsAny<VulnFingerprint>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ExecuteAsync_ReturnsFailureWhenNoFunctionsExtracted()
{
var storage = new Mock<IFingerprintBlobStorage>();
var repository = new Mock<IFingerprintRepository>();
var executor = new FakeReferenceBuildExecutor
{
VulnArtifacts = [CreateArtifact(isVulnerable: true)],
FixedArtifacts = [CreateArtifact(isVulnerable: false)],
VulnFunctions = [],
FixedFunctions = []
};
var pipeline = CreatePipeline(storage.Object, repository.Object, executor);
var result = await pipeline.ExecuteAsync(CreateRequest(), TestContext.Current.CancellationToken);
result.Success.Should().BeFalse();
result.Error.Should().Be("No functions extracted from reference builds");
}
[Fact]
public async Task ExecuteAsync_PersistsFingerprintsWhenSuccessful()
{
var storage = new Mock<IFingerprintBlobStorage>();
storage.Setup(s => s.StoreReferenceBuildAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync("refbuild/path");
var repository = new Mock<IFingerprintRepository>();
repository.Setup(r => r.CreateAsync(It.IsAny<Models.VulnFingerprint>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((VulnFingerprint fp, CancellationToken _) => fp);
var executor = new FakeReferenceBuildExecutor
{
VulnArtifacts = [CreateArtifact(isVulnerable: true)],
FixedArtifacts = [CreateArtifact(isVulnerable: false)],
VulnFunctions = [CreateFunction("vuln-func")],
FixedFunctions = [CreateFunction("vuln-func", different: true)]
};
var pipeline = CreatePipeline(storage.Object, repository.Object, executor);
var result = await pipeline.ExecuteAsync(CreateRequest(), TestContext.Current.CancellationToken);
result.Success.Should().BeTrue();
result.Fingerprints.Should().NotBeEmpty();
repository.Verify(r => r.CreateAsync(It.IsAny<VulnFingerprint>(), It.IsAny<CancellationToken>()), Times.AtLeastOnce);
}
private static ReferenceBuildPipeline CreatePipeline(
IFingerprintBlobStorage storage,
IFingerprintRepository repository,
IReferenceBuildExecutor executor)
{
var combinedGenerator = new CombinedFingerprintGenerator(
NullLogger<CombinedFingerprintGenerator>.Instance,
new BasicBlockFingerprintGenerator(NullLogger<BasicBlockFingerprintGenerator>.Instance),
new ControlFlowGraphFingerprintGenerator(NullLogger<ControlFlowGraphFingerprintGenerator>.Instance),
new StringRefsFingerprintGenerator(NullLogger<StringRefsFingerprintGenerator>.Instance));
return new ReferenceBuildPipeline(
NullLogger<ReferenceBuildPipeline>.Instance,
storage,
repository,
combinedGenerator,
executor,
new FakeTimeProvider(DateTimeOffset.UnixEpoch),
new FixedGuidProvider(Guid.Parse("2f7c0f98-9a27-4f5b-8f5c-22a5e1f0f3c9")));
}
private static ReferenceBuildRequest CreateRequest()
{
return new ReferenceBuildRequest
{
CveId = "CVE-2024-TEST",
Component = "test-component",
RepoUrl = "https://example.invalid/repo.git",
VulnerableRef = "v1",
FixedRef = "v2"
};
}
private static BuildArtifact CreateArtifact(bool isVulnerable)
{
return new BuildArtifact
{
Path = "bin/demo",
Content = [0x55, 0x48, 0x89, 0xE5, 0xC3],
Architecture = "x86_64",
IsVulnerable = isVulnerable
};
}
private static ExtractedFunction CreateFunction(string name, bool different = false)
{
var data = new byte[64];
data[0] = 0x55;
data[1] = 0x48;
data[2] = 0x89;
data[3] = 0xE5;
if (different)
{
data[^1] = 0x90;
}
return new ExtractedFunction
{
Name = name,
Data = data,
Offset = 0,
Size = data.Length
};
}
private sealed class FakeReferenceBuildExecutor : IReferenceBuildExecutor
{
public IReadOnlyList<BuildArtifact> VulnArtifacts { get; init; } = [];
public IReadOnlyList<BuildArtifact> FixedArtifacts { get; init; } = [];
public IReadOnlyList<ExtractedFunction> VulnFunctions { get; init; } = [];
public IReadOnlyList<ExtractedFunction> FixedFunctions { get; init; } = [];
public Task<IReadOnlyList<BuildArtifact>> BuildVersionAsync(
ReferenceBuildRequest request,
bool isVulnerable,
CancellationToken ct = default)
{
return Task.FromResult(isVulnerable ? VulnArtifacts : FixedArtifacts);
}
public Task<IReadOnlyList<ExtractedFunction>> ExtractFunctionsAsync(
IReadOnlyList<BuildArtifact> artifacts,
string[]? targetFunctions,
CancellationToken ct = default)
{
return Task.FromResult(artifacts.Any(a => a.IsVulnerable) ? VulnFunctions : FixedFunctions);
}
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
}
private sealed class FixedGuidProvider : IGuidProvider
{
private readonly Guid _guid;
public FixedGuidProvider(Guid guid)
{
_guid = guid;
}
public Guid NewGuid() => _guid;
}
}

View File

@@ -0,0 +1,29 @@
# BinaryIndex FixIndex Tests Charter
## Mission
Validate FixIndex parsers with deterministic, offline-friendly tests.
## Responsibilities
- Maintain `StellaOps.BinaryIndex.FixIndex.Tests`.
- Keep tests deterministic and offline-friendly.
- Surface open work on `TASKS.md`; update statuses (TODO/DOING/DONE/BLOCKED/REVIEW).
## Key Paths
- `Parsers/DebianChangelogParserTests.cs`
- `Parsers/RpmChangelogParserTests.cs`
- `Parsers/AlpineSecfixesParserTests.cs`
- `Parsers/PatchHeaderParserTests.cs`
## Coordination
- FixIndex library maintainers for parser behavior changes.
## Required Reading
- `docs/modules/binaryindex/architecture.md`
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both corresponding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -0,0 +1,37 @@
using FluentAssertions;
using StellaOps.BinaryIndex.FixIndex.Models;
using StellaOps.BinaryIndex.FixIndex.Parsers;
namespace StellaOps.BinaryIndex.FixIndex.Tests.Parsers;
public sealed class AlpineSecfixesParserTests
{
[Fact]
public void Parse_AcceptsFlexibleVersionAndCveFormatting()
{
var apkbuild = string.Join('\n',
"# Maintainer: Example <a@b>",
"# secfixes:",
"# v1.2.3-r0:",
"# - CVE-2024-1111",
"# 2.0.0-r1:",
"# - CVE-2024-2222 extra");
var now = new DateTimeOffset(2025, 12, 30, 14, 0, 0, TimeSpan.Zero);
var parser = new AlpineSecfixesParser(
new FixIndexParserOptions { SecurityFeedConfidence = 0.91m },
new FixedTimeProvider(now));
var results = parser.Parse(apkbuild, "Alpine", "V3.19", "busybox").ToList();
results.Should().HaveCount(2);
results[0].Distro.Should().Be("alpine");
results[0].Release.Should().Be("v3.19");
results[0].Confidence.Should().Be(0.91m);
results[0].CreatedAt.Should().Be(now);
var evidence = (SecurityFeedEvidence)results[0].Evidence;
evidence.PublishedAt.Should().Be(now);
results.Select(r => r.FixedVersion).Should().Contain(new[] { "v1.2.3-r0", "2.0.0-r1" });
}
}

View File

@@ -0,0 +1,39 @@
using FluentAssertions;
using StellaOps.BinaryIndex.FixIndex.Models;
using StellaOps.BinaryIndex.FixIndex.Parsers;
namespace StellaOps.BinaryIndex.FixIndex.Tests.Parsers;
public sealed class DebianChangelogParserTests
{
[Fact]
public void ParseTopEntry_TruncatesExcerptToWholeLinesAndUsesFixedTime()
{
var line1 = "pkg (1.2.3-1) unstable; urgency=medium";
var line2 = " * Fix CVE-2024-0001";
var line3 = " * Fix CVE-2024-0002";
var trailer = " -- Maintainer <a@b> Mon, 01 Jan 2024 00:00:00 +0000";
var changelog = string.Join('\n', line1, line2, line3, trailer);
var options = new FixIndexParserOptions
{
ChangelogExcerptMaxLength = line1.Length + 1 + line2.Length,
DebianChangelogConfidence = 0.42m
};
var now = new DateTimeOffset(2025, 12, 30, 12, 0, 0, TimeSpan.Zero);
var parser = new DebianChangelogParser(options, new FixedTimeProvider(now));
var results = parser.ParseTopEntry(changelog, "Debian", "Bookworm", "pkg").ToList();
results.Should().HaveCount(2);
results[0].Distro.Should().Be("debian");
results[0].Release.Should().Be("bookworm");
results[0].Confidence.Should().Be(0.42m);
results[0].CreatedAt.Should().Be(now);
results[0].Evidence.Should().BeOfType<ChangelogEvidence>();
var excerpt = ((ChangelogEvidence)results[0].Evidence).Excerpt;
excerpt.Should().Be(string.Join('\n', line1, line2));
excerpt.Should().NotContain(line3);
}
}

View File

@@ -0,0 +1,64 @@
using FluentAssertions;
using StellaOps.BinaryIndex.FixIndex.Models;
using StellaOps.BinaryIndex.FixIndex.Parsers;
namespace StellaOps.BinaryIndex.FixIndex.Tests.Parsers;
public sealed class PatchHeaderParserTests
{
[Fact]
public void ParsePatches_SkipsHeadersWithInvalidEncoding()
{
var options = new FixIndexParserOptions
{
PatchHeaderMaxLines = 3,
PatchHeaderMaxChars = 200,
PatchHeaderExcerptMaxLength = 120
};
var parser = new PatchHeaderParser(options, new FixedTimeProvider(DateTimeOffset.UnixEpoch));
var patches = new[]
{
("CVE-2024-9999.patch", "Binary\0Content", "sha256")
};
var results = parser.ParsePatches(patches, "debian", "bookworm", "pkg", "1.0.0").ToList();
results.Should().BeEmpty();
}
[Fact]
public void ParsePatches_RespectsLimitsAndUsesFixedTime()
{
var line1 = "Description: Fix CVE-2024-3333";
var line2 = "Origin: upstream";
var line3 = "Bug: https://example.test";
var content = string.Join('\n', line1, line2, line3);
var options = new FixIndexParserOptions
{
PatchHeaderMaxLines = 2,
PatchHeaderMaxChars = 200,
PatchHeaderExcerptMaxLength = line1.Length,
PatchHeaderConfidence = 0.88m
};
var now = new DateTimeOffset(2025, 12, 30, 15, 0, 0, TimeSpan.Zero);
var parser = new PatchHeaderParser(options, new FixedTimeProvider(now));
var patches = new[]
{
("patches/CVE-2024-3333.patch", content, "sha256")
};
var results = parser.ParsePatches(patches, "Ubuntu", "Jammy", "pkg", "1.0.0").ToList();
results.Should().HaveCount(1);
results[0].Distro.Should().Be("ubuntu");
results[0].Release.Should().Be("jammy");
results[0].Confidence.Should().Be(0.88m);
results[0].CreatedAt.Should().Be(now);
var evidence = (PatchHeaderEvidence)results[0].Evidence;
evidence.HeaderExcerpt.Should().Be(line1);
}
}

View File

@@ -0,0 +1,42 @@
using FluentAssertions;
using StellaOps.BinaryIndex.FixIndex.Models;
using StellaOps.BinaryIndex.FixIndex.Parsers;
namespace StellaOps.BinaryIndex.FixIndex.Tests.Parsers;
public sealed class RpmChangelogParserTests
{
[Fact]
public void ParseTopEntry_TruncatesExcerptToWholeLinesAndNormalizesKeys()
{
var header = "* Mon Jan 01 2024 Packager <email> - 1.2.3-4";
var line1 = "- Fix CVE-2024-1111";
var line2 = "- Fix CVE-2024-2222";
var spec = string.Join('\n',
"%changelog",
header,
line1,
line2,
"%files");
var options = new FixIndexParserOptions
{
ChangelogExcerptMaxLength = header.Length + 1 + line1.Length,
RpmChangelogConfidence = 0.33m
};
var now = new DateTimeOffset(2025, 12, 30, 13, 0, 0, TimeSpan.Zero);
var parser = new RpmChangelogParser(options, new FixedTimeProvider(now));
var results = parser.ParseTopEntry(spec, "RHEL", "9", "openssl").ToList();
results.Should().HaveCount(2);
results[0].Distro.Should().Be("rhel");
results[0].Release.Should().Be("9");
results[0].Confidence.Should().Be(0.33m);
results[0].CreatedAt.Should().Be(now);
var excerpt = ((ChangelogEvidence)results[0].Evidence).Excerpt;
excerpt.Should().Be(string.Join('\n', header, line1));
excerpt.Should().NotContain(line2);
}
}

View File

@@ -0,0 +1,18 @@
<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="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.FixIndex\StellaOps.BinaryIndex.FixIndex.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
# BinaryIndex FixIndex Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0124-M | DONE | Maintainability audit for StellaOps.BinaryIndex.FixIndex. |
| AUDIT-0124-T | DONE | Test coverage audit for StellaOps.BinaryIndex.FixIndex. |
| AUDIT-0124-A | DONE | Pending approval for changes. |

View File

@@ -0,0 +1,13 @@
namespace StellaOps.BinaryIndex.FixIndex.Tests;
internal sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime)
{
_fixedTime = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixedTime;
}

View File

@@ -0,0 +1,34 @@
// -----------------------------------------------------------------------------
// BinaryIndexDbContextTests.cs
// Sprint: SPRINT_20251229_049_BE_csproj_audit_maint_tests
// Task: AUDIT-0125-A - Persistence fixes
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.BinaryIndex.Persistence.Tests;
[Collection(nameof(BinaryIndexDatabaseCollection))]
public sealed class BinaryIndexDbContextTests
{
private readonly BinaryIndexIntegrationFixture _fixture;
public BinaryIndexDbContextTests(BinaryIndexIntegrationFixture fixture)
{
_fixture = fixture;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task OpenConnectionAsync_WithInvalidTenantId_Throws()
{
var dbContext = _fixture.CreateDbContext("not-a-guid");
var action = async () => await dbContext.OpenConnectionAsync(CancellationToken.None);
await action.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Invalid tenant ID format*");
}
}

View File

@@ -34,13 +34,15 @@ public sealed class CorpusSnapshotRepositoryTests
var dbContext = _fixture.CreateDbContext();
var repository = new CorpusSnapshotRepository(dbContext, NullLogger<CorpusSnapshotRepository>.Instance);
var snapshot = new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: "debian",
Release: "bookworm",
Architecture: "amd64",
MetadataDigest: $"sha256:{Guid.NewGuid():N}",
CapturedAt: DateTimeOffset.UtcNow);
var snapshot = new CorpusSnapshot
{
Id = Guid.NewGuid(),
Distro = "debian",
Release = "bookworm",
Architecture = "amd64",
MetadataDigest = $"sha256:{Guid.NewGuid():N}",
CapturedAt = DateTimeOffset.UtcNow
};
// Act
var result = await repository.CreateAsync(snapshot, CancellationToken.None);
@@ -62,13 +64,15 @@ public sealed class CorpusSnapshotRepositoryTests
var dbContext = _fixture.CreateDbContext();
var repository = new CorpusSnapshotRepository(dbContext, NullLogger<CorpusSnapshotRepository>.Instance);
var snapshot = new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: "ubuntu",
Release: "noble",
Architecture: "arm64",
MetadataDigest: $"sha256:{Guid.NewGuid():N}",
CapturedAt: DateTimeOffset.UtcNow);
var snapshot = new CorpusSnapshot
{
Id = Guid.NewGuid(),
Distro = "ubuntu",
Release = "noble",
Architecture = "arm64",
MetadataDigest = $"sha256:{Guid.NewGuid():N}",
CapturedAt = DateTimeOffset.UtcNow
};
await repository.CreateAsync(snapshot, CancellationToken.None);
@@ -110,24 +114,28 @@ public sealed class CorpusSnapshotRepositoryTests
var architecture = "x86_64";
// Create older snapshot
var older = new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: distro,
Release: release,
Architecture: architecture,
MetadataDigest: "sha256:older",
CapturedAt: DateTimeOffset.UtcNow.AddDays(-1));
var older = new CorpusSnapshot
{
Id = Guid.NewGuid(),
Distro = distro,
Release = release,
Architecture = architecture,
MetadataDigest = "sha256:older",
CapturedAt = DateTimeOffset.UtcNow.AddDays(-1)
};
await repository.CreateAsync(older, CancellationToken.None);
// Create newer snapshot
var newer = new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: distro,
Release: release,
Architecture: architecture,
MetadataDigest: "sha256:newer",
CapturedAt: DateTimeOffset.UtcNow);
var newer = new CorpusSnapshot
{
Id = Guid.NewGuid(),
Distro = distro,
Release = release,
Architecture = architecture,
MetadataDigest = "sha256:newer",
CapturedAt = DateTimeOffset.UtcNow
};
await repository.CreateAsync(newer, CancellationToken.None);
@@ -164,13 +172,15 @@ public sealed class CorpusSnapshotRepositoryTests
var repository = new CorpusSnapshotRepository(dbContext, NullLogger<CorpusSnapshotRepository>.Instance);
var distros = new[] { "debian", "ubuntu", "alpine" };
var snapshots = distros.Select(d => new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: $"{d}-{Guid.NewGuid():N}",
Release: "latest",
Architecture: "amd64",
MetadataDigest: $"sha256:{d}",
CapturedAt: DateTimeOffset.UtcNow)).ToList();
var snapshots = distros.Select(d => new CorpusSnapshot
{
Id = Guid.NewGuid(),
Distro = $"{d}-{Guid.NewGuid():N}",
Release = "latest",
Architecture = "amd64",
MetadataDigest = $"sha256:{d}",
CapturedAt = DateTimeOffset.UtcNow
}).ToList();
// Act
foreach (var snapshot in snapshots)

View File

@@ -0,0 +1,112 @@
// -----------------------------------------------------------------------------
// FingerprintRepositoryTests.cs
// Sprint: SPRINT_20251229_049_BE_csproj_audit_maint_tests
// Task: AUDIT-0125-A - Persistence fixes
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.BinaryIndex.Fingerprints.Models;
using StellaOps.BinaryIndex.Persistence.Repositories;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.BinaryIndex.Persistence.Tests;
[Collection(nameof(BinaryIndexDatabaseCollection))]
public sealed class FingerprintRepositoryTests
{
private readonly BinaryIndexIntegrationFixture _fixture;
public FingerprintRepositoryTests(BinaryIndexIntegrationFixture fixture)
{
_fixture = fixture;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateAsync_ThenGetByIdAsync_RoundTripsFields()
{
var dbContext = _fixture.CreateDbContext();
var repository = new FingerprintRepository(dbContext);
var fingerprint = new VulnFingerprint
{
CveId = "CVE-2025-0002",
Component = "openssl",
Purl = "pkg:deb/debian/openssl@1.2.3",
Algorithm = FingerprintAlgorithm.ControlFlowGraph,
FingerprintId = "cfg-123",
FingerprintHash = [0x01, 0x02, 0x03],
Architecture = "x86_64",
FunctionName = "ssl_read",
SourceFile = "ssl.c",
SourceLine = 123,
SimilarityThreshold = 0.92m,
Confidence = 0.85m,
Validated = true,
ValidationStats = new FingerprintValidationStats
{
TruePositives = 10,
FalsePositives = 1,
TrueNegatives = 20,
FalseNegatives = 2
},
VulnBuildRef = "vuln-build",
FixedBuildRef = "fixed-build",
IndexedAt = DateTimeOffset.Parse("2025-12-30T12:00:00Z")
};
var created = await repository.CreateAsync(fingerprint, CancellationToken.None);
var fetched = await repository.GetByIdAsync(created.Id, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Algorithm.Should().Be(FingerprintAlgorithm.ControlFlowGraph);
fetched.ValidationStats.Should().NotBeNull();
fetched.ValidationStats!.TruePositives.Should().Be(10);
fetched.ValidationStats.FalseNegatives.Should().Be(2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SearchByHashAsync_WithNullArchitecture_ReturnsAllMatches()
{
var dbContext = _fixture.CreateDbContext();
var repository = new FingerprintRepository(dbContext);
var hash = new byte[] { 0x0A, 0x0B, 0x0C };
var first = new VulnFingerprint
{
CveId = "CVE-2025-0003",
Component = "libssl",
Algorithm = FingerprintAlgorithm.BasicBlock,
FingerprintId = "bb-1",
FingerprintHash = hash,
Architecture = "x86_64",
SimilarityThreshold = 0.9m,
IndexedAt = DateTimeOffset.Parse("2025-12-30T12:10:00Z")
};
var second = new VulnFingerprint
{
CveId = "CVE-2025-0004",
Component = "libssl",
Algorithm = FingerprintAlgorithm.BasicBlock,
FingerprintId = "bb-2",
FingerprintHash = hash,
Architecture = "arm64",
SimilarityThreshold = 0.9m,
IndexedAt = DateTimeOffset.Parse("2025-12-30T12:11:00Z")
};
await repository.CreateAsync(first, CancellationToken.None);
await repository.CreateAsync(second, CancellationToken.None);
var results = await repository.SearchByHashAsync(
hash,
FingerprintAlgorithm.BasicBlock,
architecture: null,
ct: CancellationToken.None);
results.Should().HaveCount(2);
}
}

View File

@@ -0,0 +1,66 @@
// -----------------------------------------------------------------------------
// FixIndexRepositoryTests.cs
// Sprint: SPRINT_20251229_049_BE_csproj_audit_maint_tests
// Task: AUDIT-0125-A - Persistence fixes
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.FixIndex.Models;
using StellaOps.BinaryIndex.Persistence.Repositories;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.BinaryIndex.Persistence.Tests;
[Collection(nameof(BinaryIndexDatabaseCollection))]
public sealed class FixIndexRepositoryTests
{
private readonly BinaryIndexIntegrationFixture _fixture;
public FixIndexRepositoryTests(BinaryIndexIntegrationFixture fixture)
{
_fixture = fixture;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertAsync_WithUpstreamMatch_PersistsMethod()
{
var dbContext = _fixture.CreateDbContext();
var repository = new FixIndexRepository(dbContext);
var evidence = new FixEvidence
{
Distro = "debian",
Release = "bookworm",
SourcePkg = "openssl",
CveId = "CVE-2025-0001",
State = FixState.Fixed,
FixedVersion = "1.2.3-4",
Method = FixMethod.UpstreamPatchMatch,
Confidence = 0.91m,
Evidence = new PatchHeaderEvidence
{
PatchPath = "debian/patches/CVE-2025-0001.patch",
PatchSha256 = "sha256-test",
HeaderExcerpt = "CVE-2025-0001"
},
CreatedAt = DateTimeOffset.Parse("2025-12-30T00:00:00Z")
};
var entry = await repository.UpsertAsync(evidence, CancellationToken.None);
entry.Method.Should().Be(FixMethod.UpstreamPatchMatch);
var status = await repository.GetFixStatusAsync(
evidence.Distro,
evidence.Release,
evidence.SourcePkg,
evidence.CveId,
CancellationToken.None);
status.Should().NotBeNull();
status!.Method.Should().Be(FixMethod.UpstreamPatchMatch);
}
}

View File

@@ -81,7 +81,7 @@ public class VexBridgeIntegrationTests
FixedVersion = "3.0.7-1+deb12u1",
Method = FixMethod.SecurityFeed,
Confidence = 0.95m,
EvidenceId = Guid.NewGuid()
EvidenceId = new Guid("22222222-2222-2222-2222-222222222222")
};
var context = new VexGenerationContext
@@ -95,7 +95,7 @@ public class VexBridgeIntegrationTests
// Act - Generate VEX observation
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
match, identity, fixStatus, context, TestContext.Current.CancellationToken);
// Assert - Verify complete observation structure
observation.Should().NotBeNull();
@@ -165,7 +165,7 @@ public class VexBridgeIntegrationTests
}).ToList();
// Act
var observations = await _generator.GenerateBatchAsync(matchesWithContext);
var observations = await _generator.GenerateBatchAsync(matchesWithContext, TestContext.Current.CancellationToken);
// Assert
observations.Should().HaveCount(3);
@@ -190,10 +190,10 @@ public class VexBridgeIntegrationTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
match, identity, null, context, TestContext.Current.CancellationToken);
// Simulate persistence
var persistedId = await _mockObservationStore.Object.AppendAsync(observation);
var persistedId = await _mockObservationStore.Object.AppendAsync(observation, TestContext.Current.CancellationToken);
// Assert
persistedId.Should().Be(observation.ObservationId);

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -12,6 +13,7 @@ public class VexEvidenceGeneratorTests
{
private readonly VexEvidenceGenerator _generator;
private readonly VexBridgeOptions _options;
private readonly DateTimeOffset _fixedTime = new(2026, 1, 3, 12, 0, 0, TimeSpan.Zero);
public VexEvidenceGeneratorTests()
{
@@ -24,7 +26,8 @@ public class VexEvidenceGeneratorTests
_generator = new VexEvidenceGenerator(
NullLogger<VexEvidenceGenerator>.Instance,
Options.Create(_options));
Options.Create(_options),
timeProvider: new FixedTimeProvider(_fixedTime));
}
#region GenerateFromBinaryMatchAsync Tests
@@ -40,7 +43,7 @@ public class VexEvidenceGeneratorTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
match, identity, fixStatus, context, TestContext.Current.CancellationToken);
// Assert
observation.Should().NotBeNull();
@@ -61,7 +64,7 @@ public class VexEvidenceGeneratorTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
match, identity, fixStatus, context, TestContext.Current.CancellationToken);
// Assert
observation.Statements[0].Status.Should().Be(VexClaimStatus.Affected);
@@ -79,7 +82,7 @@ public class VexEvidenceGeneratorTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
match, identity, fixStatus, context, TestContext.Current.CancellationToken);
// Assert
observation.Statements[0].Status.Should().Be(VexClaimStatus.UnderInvestigation);
@@ -95,7 +98,7 @@ public class VexEvidenceGeneratorTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
match, identity, null, context, TestContext.Current.CancellationToken);
// Assert
observation.Statements[0].Status.Should().Be(VexClaimStatus.UnderInvestigation);
@@ -111,7 +114,7 @@ public class VexEvidenceGeneratorTests
// Act
var act = () => _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
match, identity, null, context, TestContext.Current.CancellationToken);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
@@ -129,7 +132,7 @@ public class VexEvidenceGeneratorTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
match, identity, fixStatus, context, TestContext.Current.CancellationToken);
// Assert
observation.Statements[0].Status.Should().Be(VexClaimStatus.NotAffected);
@@ -146,7 +149,7 @@ public class VexEvidenceGeneratorTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
match, identity, null, context, TestContext.Current.CancellationToken);
// Assert
observation.ProviderId.Should().Be("test.provider");
@@ -216,7 +219,7 @@ public class VexEvidenceGeneratorTests
};
// Act
var observations = await _generator.GenerateBatchAsync(matches);
var observations = await _generator.GenerateBatchAsync(matches, TestContext.Current.CancellationToken);
// Assert
observations.Should().HaveCount(3);
@@ -237,7 +240,7 @@ public class VexEvidenceGeneratorTests
};
// Act
var observations = await _generator.GenerateBatchAsync(matches);
var observations = await _generator.GenerateBatchAsync(matches, TestContext.Current.CancellationToken);
// Assert
observations.Should().HaveCount(2);
@@ -258,7 +261,7 @@ public class VexEvidenceGeneratorTests
.ToList();
// Act
var observations = await generator.GenerateBatchAsync(matches);
var observations = await generator.GenerateBatchAsync(matches, TestContext.Current.CancellationToken);
// Assert
observations.Should().HaveCount(5);
@@ -268,7 +271,7 @@ public class VexEvidenceGeneratorTests
public async Task GenerateBatchAsync_EmptyInput_ReturnsEmptyList()
{
// Act
var observations = await _generator.GenerateBatchAsync(Array.Empty<BinaryMatchWithContext>());
var observations = await _generator.GenerateBatchAsync(Array.Empty<BinaryMatchWithContext>(), TestContext.Current.CancellationToken);
// Assert
observations.Should().BeEmpty();
@@ -294,7 +297,7 @@ public class VexEvidenceGeneratorTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
match, identity, fixStatus, context, TestContext.Current.CancellationToken);
// Assert
var content = observation.Content.Raw;
@@ -309,6 +312,43 @@ public class VexEvidenceGeneratorTests
json["fixed_version"]?.GetValue<string>().Should().Be("1.0.0-fix1");
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_EvidencePassesSchemaValidation()
{
var match = CreateBinaryVulnMatch("CVE-2024-SCHEMA",
confidence: 0.95m,
method: MatchMethod.FingerprintMatch);
var identity = CreateBinaryIdentity(buildId: "build123");
var context = CreateContext(distroRelease: "debian:bookworm");
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context, TestContext.Current.CancellationToken);
var evidence = observation.Content.Raw.AsObject();
BinaryMatchEvidenceSchema.ValidateEvidence(evidence, out var error).Should().BeTrue(error);
evidence["fingerprint_algorithm"]?.GetValue<string>().Should().Be("basic_block");
evidence["resolved_at"]?.GetValue<string>().Should().Be(_fixedTime.ToString("O"));
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_ParsesSourcePackageFromPurl()
{
var match = CreateBinaryVulnMatch("CVE-2024-PURL",
confidence: 0.95m,
method: MatchMethod.FingerprintMatch) with
{
VulnerablePurl = "pkg:maven/org.apache.commons/commons-lang3@3.12.0?type=jar"
};
var identity = CreateBinaryIdentity();
var context = CreateContext();
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context, TestContext.Current.CancellationToken);
var evidence = observation.Content.Raw.AsObject();
evidence["source_package"]?.GetValue<string>().Should().Be("commons-lang3");
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_WithBuildIdMatch_SetsCorrectMatchType()
{
@@ -321,7 +361,7 @@ public class VexEvidenceGeneratorTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
match, identity, null, context, TestContext.Current.CancellationToken);
// Assert
var json = observation.Content.Raw.AsObject();
@@ -342,7 +382,7 @@ public class VexEvidenceGeneratorTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
match, identity, null, context, TestContext.Current.CancellationToken);
// Assert
// Note: VexObservationLinkset normalizes aliases to lowercase for case-insensitive comparison
@@ -360,13 +400,108 @@ public class VexEvidenceGeneratorTests
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
match, identity, null, context, TestContext.Current.CancellationToken);
// Assert
observation.Linkset.References
.Should().Contain(r => r.Type == "build_id" && r.Url.Contains("test-build-id-12345"));
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_SuppressesExternalLinks_WhenConfigured()
{
var options = new VexBridgeOptions
{
MinConfidenceThreshold = 0.70m,
SignWithDsse = false,
IncludeExternalLinks = false
};
var generator = new VexEvidenceGenerator(
NullLogger<VexEvidenceGenerator>.Instance,
Options.Create(options),
timeProvider: new FixedTimeProvider(_fixedTime));
var match = CreateBinaryVulnMatch("CVE-2024-NVD", confidence: 0.95m);
var identity = CreateBinaryIdentity();
var context = CreateContext();
var observation = await generator.GenerateFromBinaryMatchAsync(
match, identity, null, context, TestContext.Current.CancellationToken);
observation.Linkset.References.Should().NotContain(r => r.Type == "vulnerability");
observation.Linkset.References.Should().Contain(r => r.Type == "package");
}
#endregion
#region DSSE Tests
[Fact]
public async Task GenerateFromBinaryMatchAsync_WithDsseSigning_AddsSignatureMetadata()
{
var signer = new FakeDsseSigningAdapter(shouldThrow: false);
var generator = new VexEvidenceGenerator(
NullLogger<VexEvidenceGenerator>.Instance,
Options.Create(_options),
signer,
new FixedTimeProvider(_fixedTime));
var match = CreateBinaryVulnMatch("CVE-2024-DSSE", confidence: 0.95m);
var identity = CreateBinaryIdentity();
var context = CreateContext();
context = context with { SignWithDsse = true };
var observation = await generator.GenerateFromBinaryMatchAsync(
match, identity, null, context, TestContext.Current.CancellationToken);
observation.Attributes.Should().ContainKey("dsse_signed");
observation.Upstream.Metadata["dsse_status"].Should().Be("signed");
observation.Upstream.Metadata.Should().ContainKey("dsse_envelope_hash");
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_WithDsseFailure_RecordsMetadata()
{
var signer = new FakeDsseSigningAdapter(shouldThrow: true);
var generator = new VexEvidenceGenerator(
NullLogger<VexEvidenceGenerator>.Instance,
Options.Create(_options),
signer,
new FixedTimeProvider(_fixedTime));
var match = CreateBinaryVulnMatch("CVE-2024-DSSEFAIL", confidence: 0.95m);
var identity = CreateBinaryIdentity();
var context = CreateContext() with { SignWithDsse = true };
var observation = await generator.GenerateFromBinaryMatchAsync(
match, identity, null, context, TestContext.Current.CancellationToken);
observation.Upstream.Metadata["dsse_status"].Should().Be("failed");
observation.Upstream.Metadata["dsse_error"].Should().Be(nameof(InvalidOperationException));
}
#endregion
#region Timestamp Tests
[Fact]
public async Task GenerateFromBinaryMatchAsync_UsesSingleTimestamp()
{
var match = CreateBinaryVulnMatch("CVE-2024-TIME", confidence: 0.95m);
var identity = CreateBinaryIdentity();
var context = CreateContext();
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context, TestContext.Current.CancellationToken);
observation.CreatedAt.Should().Be(_fixedTime);
observation.Upstream.FetchedAt.Should().Be(_fixedTime);
observation.Upstream.ReceivedAt.Should().Be(_fixedTime);
observation.Statements[0].LastObserved.Should().Be(_fixedTime);
observation.Content.Raw.AsObject()["resolved_at"]?.GetValue<string>()
.Should().Be(_fixedTime.ToString("O"));
}
#endregion
#region Helper Methods
@@ -386,7 +521,8 @@ public class VexEvidenceGeneratorTests
{
BuildId = null,
Similarity = confidence,
MatchedFunction = null
MatchedFunction = null,
FingerprintAlgorithm = method == MatchMethod.FingerprintMatch ? "basic_block" : null
}
};
}
@@ -417,7 +553,7 @@ public class VexEvidenceGeneratorTests
FixedVersion = fixedVersion,
Method = FixMethod.SecurityFeed,
Confidence = 0.95m,
EvidenceId = Guid.NewGuid()
EvidenceId = new Guid("11111111-1111-1111-1111-111111111111")
};
}
@@ -441,6 +577,45 @@ public class VexEvidenceGeneratorTests
};
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixed;
public FixedTimeProvider(DateTimeOffset fixedTime)
{
_fixed = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixed;
}
private sealed class FakeDsseSigningAdapter : IDsseSigningAdapter
{
private readonly bool _shouldThrow;
public FakeDsseSigningAdapter(bool shouldThrow)
{
_shouldThrow = shouldThrow;
}
public Task<byte[]> SignAsync(byte[] payload, string payloadType, CancellationToken ct = default)
{
if (_shouldThrow)
{
throw new InvalidOperationException("DSSE signing failed");
}
return Task.FromResult(Encoding.UTF8.GetBytes("{\"dsse\":\"ok\"}"));
}
public Task<bool> VerifyAsync(byte[] envelope, CancellationToken ct = default)
=> Task.FromResult(true);
public string SigningKeyId => "test-key";
public bool IsAvailable => true;
}
private static BinaryMatchWithContext CreateBinaryMatchWithContext(
string cveId,
string scanId,

View File

@@ -0,0 +1,18 @@
# BinaryIndex WebService Tests Charter
## Mission
Validate BinaryIndex WebService controller, cache wiring, and middleware behavior with deterministic tests.
## Responsibilities
- Keep tests deterministic (fixed time/IDs; use TimeProvider).
- Avoid network access; use fakes for Redis and service dependencies.
- Track task status in `TASKS.md`.
## Required Reading
- `docs/modules/binaryindex/architecture.md`
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status in the sprint file and local `TASKS.md`.
- 2. Keep tests deterministic and offline-friendly.
- 3. Add coverage for controller error mapping, cache usage, and rate limiting.

View File

@@ -1,407 +1,492 @@
// -----------------------------------------------------------------------------
// ResolutionControllerIntegrationTests.cs
// Sprint: SPRINT_1227_0001_0002_BE_resolution_api
// Task: T9 — Integration tests for resolution API
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Cache;
using StellaOps.BinaryIndex.Contracts.Resolution;
using Xunit;
using StellaOps.BinaryIndex.Core.Resolution;
using StellaOps.BinaryIndex.WebService.Controllers;
using StellaOps.BinaryIndex.WebService.Middleware;
using StellaOps.BinaryIndex.WebService.Services;
namespace StellaOps.BinaryIndex.WebService.Tests;
/// <summary>
/// Integration tests for the Resolution API endpoints.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "BinaryIndex")]
public class ResolutionControllerIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
public sealed class ResolutionControllerTests
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public ResolutionControllerIntegrationTests(WebApplicationFactory<Program> factory)
[Fact]
public async Task ResolveVulnerabilityAsync_UsesDefaultDsseSetting()
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Add test-specific services if needed
});
});
_client = _factory.CreateClient();
}
var fakeService = new CapturingResolutionService();
var options = Options.Create(new ResolutionServiceOptions { EnableDsseByDefault = false });
var controller = new ResolutionController(fakeService, options, NullLogger<ResolutionController>.Instance);
#region Single Resolution Tests
[Fact(DisplayName = "POST /api/v1/resolve/vuln returns 200 for valid request")]
public async Task ResolveVuln_ValidRequest_Returns200()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "abc123def456789",
DistroRelease = "debian:bookworm"
BuildId = "build-1"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await controller.ResolveVulnerabilityAsync(request, bypassCache: false, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
result.Should().NotBeNull();
result!.Package.Should().Be("pkg:deb/debian/openssl@3.0.7");
result.Status.Should().BeOneOf(ResolutionStatus.Fixed, ResolutionStatus.Vulnerable,
ResolutionStatus.NotAffected, ResolutionStatus.Unknown);
result.ResolvedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
result.Result.Should().BeOfType<OkObjectResult>();
fakeService.LastOptions.Should().NotBeNull();
fakeService.LastOptions!.IncludeDsseAttestation.Should().BeFalse();
}
[Fact(DisplayName = "POST /api/v1/resolve/vuln returns 400 for missing package")]
public async Task ResolveVuln_MissingPackage_Returns400()
[Fact]
public async Task ResolveVulnerabilityAsync_BadRequest_SetsProblemStatus()
{
// Arrange
var request = new { BuildId = "abc123" }; // Missing required Package field
var fakeService = new CapturingResolutionService();
var options = Options.Create(new ResolutionServiceOptions());
var controller = new ResolutionController(fakeService, options, NullLogger<ResolutionController>.Instance);
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var request = new VulnResolutionRequest
{
Package = "",
BuildId = "build-1"
};
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var result = await controller.ResolveVulnerabilityAsync(request, bypassCache: false, TestContext.Current.CancellationToken);
var badRequest = result.Result.Should().BeOfType<BadRequestObjectResult>().Subject;
var problem = badRequest.Value.Should().BeOfType<ProblemDetails>().Subject;
problem.Status.Should().Be(StatusCodes.Status400BadRequest);
}
[Fact(DisplayName = "POST /api/v1/resolve/vuln with CVE returns targeted resolution")]
public async Task ResolveVuln_WithCveId_ReturnsTargetedResolution()
[Fact]
public async Task ResolveVulnerabilityAsync_Error_SetsProblemStatus()
{
// Arrange
var fakeService = new ThrowingResolutionService();
var options = Options.Create(new ResolutionServiceOptions());
var controller = new ResolutionController(fakeService, options, NullLogger<ResolutionController>.Instance);
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
CveId = "CVE-2024-0001",
BuildId = "abc123def456789"
BuildId = "build-1"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await controller.ResolveVulnerabilityAsync(request, bypassCache: false, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var error = result.Result.Should().BeOfType<ObjectResult>().Subject;
error.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
var problem = error.Value.Should().BeOfType<ProblemDetails>().Subject;
problem.Status.Should().Be(StatusCodes.Status500InternalServerError);
}
[Fact(DisplayName = "Resolution includes cache headers")]
public async Task ResolveVuln_IncludesCacheHeaders()
[Fact]
public async Task ResolveBatchAsync_UsesDefaultDsseSetting()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7"
};
var fakeService = new CapturingResolutionService();
var options = Options.Create(new ResolutionServiceOptions { EnableDsseByDefault = true });
var controller = new ResolutionController(fakeService, options, NullLogger<ResolutionController>.Instance);
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
// Assert
response.Headers.Should().ContainKey("X-RateLimit-Limit");
response.Headers.Should().ContainKey("X-RateLimit-Remaining");
}
#endregion
#region Batch Resolution Tests
[Fact(DisplayName = "POST /api/v1/resolve/vuln/batch handles multiple items")]
public async Task ResolveBatch_MultipleItems_ReturnsAllResults()
{
// Arrange
var request = new BatchVulnResolutionRequest
{
Items = new[]
{
new VulnResolutionRequest { Package = "pkg:deb/debian/openssl@3.0.7" },
new VulnResolutionRequest { Package = "pkg:deb/debian/libcurl@7.88.1" },
new VulnResolutionRequest { Package = "pkg:deb/debian/zlib@1.2.13" }
new VulnResolutionRequest { Package = "pkg:deb/debian/openssl@3.0.7", BuildId = "build-1" }
}
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
var result = await controller.ResolveBatchAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
result.Result.Should().BeOfType<OkObjectResult>();
fakeService.LastOptions.Should().NotBeNull();
fakeService.LastOptions!.IncludeDsseAttestation.Should().BeTrue();
}
}
var result = await response.Content.ReadFromJsonAsync<BatchVulnResolutionResponse>();
result.Should().NotBeNull();
result!.Results.Should().HaveCount(3);
public sealed class CachedResolutionServiceTests
{
private readonly FixedTimeProvider _timeProvider = new(new DateTimeOffset(2026, 1, 3, 12, 0, 0, TimeSpan.Zero));
[Fact]
public async Task ResolveAsync_CachesResult_AndServesFromCache()
{
var fakeInner = new FakeResolutionService(_timeProvider);
var cache = new FakeResolutionCacheService();
var cacheOptions = Options.Create(new ResolutionCacheOptions());
var serviceOptions = Options.Create(new ResolutionServiceOptions());
var service = new CachedResolutionService(
fakeInner,
cache,
cacheOptions,
serviceOptions,
_timeProvider,
NullLogger<CachedResolutionService>.Instance);
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "build-1"
};
var first = await service.ResolveAsync(request, null, TestContext.Current.CancellationToken);
var second = await service.ResolveAsync(request, null, TestContext.Current.CancellationToken);
first.FromCache.Should().BeFalse();
second.FromCache.Should().BeTrue();
fakeInner.ResolveCalls.Should().Be(1);
cache.SetCalls.Should().Be(1);
cache.GetCalls.Should().Be(2);
}
[Fact(DisplayName = "Batch resolution respects size limit")]
public async Task ResolveBatch_ExceedsSizeLimit_Returns400()
[Fact]
public async Task ResolveAsync_BypassCache_SkipsCache()
{
// Arrange - Create 501 items (assuming 500 is the limit)
var items = Enumerable.Range(0, 501)
.Select(i => new VulnResolutionRequest { Package = $"pkg:npm/package{i}@1.0.0" })
.ToArray();
var fakeInner = new FakeResolutionService(_timeProvider);
var cache = new FakeResolutionCacheService();
var cacheOptions = Options.Create(new ResolutionCacheOptions());
var serviceOptions = Options.Create(new ResolutionServiceOptions());
var request = new BatchVulnResolutionRequest { Items = items };
var service = new CachedResolutionService(
fakeInner,
cache,
cacheOptions,
serviceOptions,
_timeProvider,
NullLogger<CachedResolutionService>.Instance);
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "build-2"
};
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var options = new ResolutionOptions { BypassCache = true };
var result = await service.ResolveAsync(request, options, TestContext.Current.CancellationToken);
result.FromCache.Should().BeFalse();
fakeInner.ResolveCalls.Should().Be(1);
cache.SetCalls.Should().Be(0);
cache.GetCalls.Should().Be(0);
}
[Fact(DisplayName = "Batch resolution performance under 500ms for 100 cached items")]
public async Task ResolveBatch_CachedItems_PerformanceAcceptable()
[Fact]
public async Task ResolveBatchAsync_UsesCacheForHits()
{
// Arrange
var items = Enumerable.Range(0, 100)
.Select(i => new VulnResolutionRequest
var fakeInner = new FakeResolutionService(_timeProvider);
var cache = new FakeResolutionCacheService();
var cacheOptions = Options.Create(new ResolutionCacheOptions());
var serviceOptions = Options.Create(new ResolutionServiceOptions { MaxBatchSize = 10 });
var service = new CachedResolutionService(
fakeInner,
cache,
cacheOptions,
serviceOptions,
_timeProvider,
NullLogger<CachedResolutionService>.Instance);
var cachedRequest = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "build-3"
};
var cachedKey = cache.GenerateCacheKey(cachedRequest);
cache.Entries[cachedKey] = new CachedResolution
{
Status = ResolutionStatus.Fixed,
FixedVersion = "1.0.1",
CachedAt = _timeProvider.GetUtcNow(),
Confidence = 0.95m,
MatchType = ResolutionMatchTypes.BuildId
};
var batch = new BatchVulnResolutionRequest
{
Items = new[]
{
Package = $"pkg:deb/debian/test-package{i}@1.0.0",
BuildId = $"build-{i}"
})
.ToArray();
var request = new BatchVulnResolutionRequest { Items = items };
// Warm up cache with first request
await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
// Act
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
stopwatch.Stop();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
stopwatch.ElapsedMilliseconds.Should().BeLessThan(500,
"Cached batch resolution should complete in under 500ms");
}
#endregion
#region Cache Tests
[Fact(DisplayName = "Second request returns cached result")]
public async Task ResolveVuln_SecondRequest_ReturnsCachedResult()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "cache-test-build-id"
cachedRequest,
new VulnResolutionRequest { Package = "pkg:deb/debian/curl@8.0.0", BuildId = "build-4" }
}
};
// Act
var response1 = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result1 = await response1.Content.ReadFromJsonAsync<VulnResolutionResponse>();
var response = await service.ResolveBatchAsync(batch, null, TestContext.Current.CancellationToken);
var response2 = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result2 = await response2.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
result1.Should().NotBeNull();
result2.Should().NotBeNull();
result2!.FromCache.Should().BeTrue();
result1!.Status.Should().Be(result2.Status);
response.CacheHits.Should().Be(1);
response.Results.Should().HaveCount(2);
response.Results[0].FromCache.Should().BeTrue();
response.Results[1].FromCache.Should().BeFalse();
}
[Fact(DisplayName = "Bypass cache option works")]
public async Task ResolveVuln_BypassCache_FreshResult()
[Fact]
public async Task ResolveBatchAsync_RespectsMaxBatchSize()
{
// Arrange
var request = new VulnResolutionRequest
var fakeInner = new FakeResolutionService(_timeProvider);
var cache = new FakeResolutionCacheService();
var cacheOptions = Options.Create(new ResolutionCacheOptions());
var serviceOptions = Options.Create(new ResolutionServiceOptions { MaxBatchSize = 1 });
var service = new CachedResolutionService(
fakeInner,
cache,
cacheOptions,
serviceOptions,
_timeProvider,
NullLogger<CachedResolutionService>.Instance);
var batch = new BatchVulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "bypass-cache-test"
Items = new[]
{
new VulnResolutionRequest { Package = "pkg:deb/debian/openssl@3.0.7", BuildId = "build-5" },
new VulnResolutionRequest { Package = "pkg:deb/debian/curl@8.0.0", BuildId = "build-6" }
}
};
// First request to populate cache
await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var response = await service.ResolveBatchAsync(batch, null, TestContext.Current.CancellationToken);
// Second request with bypass
_client.DefaultRequestHeaders.Add("X-Bypass-Cache", "true");
response.Results.Should().HaveCount(1);
}
}
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
public sealed class RateLimitingMiddlewareTests
{
[Fact]
public async Task InvokeAsync_Disabled_SkipsRateLimiting()
{
var options = Options.Create(new RateLimitingOptions
{
Enabled = false,
MaxRequests = 1
});
// Assert
result.Should().NotBeNull();
result!.FromCache.Should().BeFalse();
var called = false;
RequestDelegate next = _ =>
{
called = true;
return Task.CompletedTask;
};
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Bypass-Cache");
var middleware = new RateLimitingMiddleware(next, NullLogger<RateLimitingMiddleware>.Instance, options, null, new FixedTimeProvider());
var context = CreateContext();
await middleware.InvokeAsync(context);
called.Should().BeTrue();
context.Response.Headers.ContainsKey("X-RateLimit-Limit").Should().BeFalse();
}
#endregion
#region DSSE Attestation Tests
[Fact(DisplayName = "Response includes DSSE attestation when requested")]
public async Task ResolveVuln_WithDsseRequest_IncludesAttestation()
[Fact]
public async Task InvokeAsync_EnforcesLimit()
{
// Arrange
var request = new VulnResolutionRequest
var options = Options.Create(new RateLimitingOptions
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "dsse-test-build"
};
Enabled = true,
MaxRequests = 1,
WindowSize = TimeSpan.FromMinutes(1),
RetryAfterSeconds = 60,
CleanupEveryNRequests = 1,
EvictionAfter = TimeSpan.FromMinutes(5)
});
_client.DefaultRequestHeaders.Add("X-Include-Attestation", "true");
RequestDelegate next = _ => Task.CompletedTask;
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 1, 3, 12, 0, 0, TimeSpan.Zero));
var middleware = new RateLimitingMiddleware(next, NullLogger<RateLimitingMiddleware>.Instance, options, null, timeProvider);
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
var firstContext = CreateContext();
await middleware.InvokeAsync(firstContext);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Note: Attestation may be null if signing is not configured
var secondContext = CreateContext();
await middleware.InvokeAsync(secondContext);
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Include-Attestation");
secondContext.Response.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests);
secondContext.Response.Headers.ContainsKey("X-RateLimit-Remaining").Should().BeTrue();
secondContext.Response.Headers.ContainsKey("Retry-After").Should().BeTrue();
}
[Fact(DisplayName = "DSSE attestation is valid base64")]
public async Task ResolveVuln_DsseAttestation_IsValidBase64()
private static DefaultHttpContext CreateContext()
{
// Arrange
var request = new VulnResolutionRequest
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/resolve/vuln";
context.Connection.RemoteIpAddress = IPAddress.Parse("127.0.0.1");
context.Response.Body = new MemoryStream();
return context;
}
}
internal sealed class FixedTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FixedTimeProvider() : this(new DateTimeOffset(2026, 1, 3, 12, 0, 0, TimeSpan.Zero))
{
}
public FixedTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
internal sealed class CapturingResolutionService : IResolutionService
{
public ResolutionOptions? LastOptions { get; private set; }
public Task<VulnResolutionResponse> ResolveAsync(
VulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
LastOptions = options;
var response = new VulnResolutionResponse
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "dsse-validation-test"
Package = request.Package,
Status = ResolutionStatus.Unknown,
ResolvedAt = new DateTimeOffset(2026, 1, 3, 12, 0, 0, TimeSpan.Zero),
FromCache = false,
CveId = request.CveId
};
_client.DefaultRequestHeaders.Add("X-Include-Attestation", "true");
return Task.FromResult(response);
}
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
if (!string.IsNullOrEmpty(result?.AttestationDsse))
public Task<BatchVulnResolutionResponse> ResolveBatchAsync(
BatchVulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
LastOptions = options;
var results = request.Items.Select(item => new VulnResolutionResponse
{
// Should not throw
var bytes = Convert.FromBase64String(result.AttestationDsse);
bytes.Should().NotBeEmpty();
Package = item.Package,
Status = ResolutionStatus.Unknown,
ResolvedAt = new DateTimeOffset(2026, 1, 3, 12, 0, 0, TimeSpan.Zero),
FromCache = false,
CveId = item.CveId
}).ToList();
// Should be valid JSON
var json = System.Text.Encoding.UTF8.GetString(bytes);
var doc = JsonDocument.Parse(json);
doc.RootElement.TryGetProperty("payload", out _).Should().BeTrue();
doc.RootElement.TryGetProperty("payloadType", out _).Should().BeTrue();
return Task.FromResult(new BatchVulnResolutionResponse
{
Results = results,
TotalCount = results.Count,
CacheHits = 0,
ProcessingTimeMs = 0
});
}
}
internal sealed class ThrowingResolutionService : IResolutionService
{
public Task<VulnResolutionResponse> ResolveAsync(
VulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
throw new InvalidOperationException("boom");
}
public Task<BatchVulnResolutionResponse> ResolveBatchAsync(
BatchVulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
throw new InvalidOperationException("boom");
}
}
internal sealed class FakeResolutionService : IResolutionService
{
private readonly FixedTimeProvider _timeProvider;
public FakeResolutionService(FixedTimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public int ResolveCalls { get; private set; }
public Task<VulnResolutionResponse> ResolveAsync(
VulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
ResolveCalls++;
var response = new VulnResolutionResponse
{
Package = request.Package,
Status = ResolutionStatus.Fixed,
FixedVersion = "1.0.1",
Evidence = new ResolutionEvidence
{
MatchType = ResolutionMatchTypes.BuildId,
Confidence = 0.95m
},
ResolvedAt = _timeProvider.GetUtcNow(),
FromCache = false,
CveId = request.CveId
};
return Task.FromResult(response);
}
public async Task<BatchVulnResolutionResponse> ResolveBatchAsync(
BatchVulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
var results = new List<VulnResolutionResponse>();
foreach (var item in request.Items)
{
results.Add(await ResolveAsync(item, options, ct));
}
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Include-Attestation");
}
#endregion
#region Rate Limiting Tests
[Fact(DisplayName = "Rate limiting returns 429 when exceeded")]
public async Task ResolveVuln_RateLimitExceeded_Returns429()
{
// Arrange - This test depends on rate limit configuration
// Create a client with test tenant that has low rate limit
var request = new VulnResolutionRequest
return new BatchVulnResolutionResponse
{
Package = "pkg:npm/rate-limit-test@1.0.0"
Results = results,
TotalCount = results.Count,
CacheHits = 0,
ProcessingTimeMs = 0
};
_client.DefaultRequestHeaders.Add("X-Tenant-Id", "rate-limit-test-tenant");
// Act - Send many requests quickly
var tasks = Enumerable.Range(0, 150)
.Select(_ => _client.PostAsJsonAsync("/api/v1/resolve/vuln", request));
var responses = await Task.WhenAll(tasks);
// Assert - At least some should be rate limited
var rateLimited = responses.Where(r => r.StatusCode == HttpStatusCode.TooManyRequests);
// Note: This may pass or fail depending on actual rate limit config
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Tenant-Id");
}
}
[Fact(DisplayName = "Rate limit headers are present")]
public async Task ResolveVuln_RateLimitHeaders_Present()
internal sealed class FakeResolutionCacheService : IResolutionCacheService
{
public Dictionary<string, CachedResolution> Entries { get; } = new();
public int GetCalls { get; private set; }
public int SetCalls { get; private set; }
public Task<CachedResolution?> GetAsync(string cacheKey, CancellationToken ct = default)
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:npm/headers-test@1.0.0"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
// Assert
response.Headers.Contains("X-RateLimit-Limit").Should().BeTrue();
response.Headers.Contains("X-RateLimit-Remaining").Should().BeTrue();
response.Headers.Contains("X-RateLimit-Reset").Should().BeTrue();
GetCalls++;
return Task.FromResult(Entries.TryGetValue(cacheKey, out var cached) ? cached : null);
}
#endregion
#region Evidence Tests
[Fact(DisplayName = "Fixed resolution includes evidence")]
public async Task ResolveVuln_FixedStatus_IncludesEvidence()
public Task SetAsync(string cacheKey, CachedResolution result, TimeSpan ttl, CancellationToken ct = default)
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7-1+deb12u1",
BuildId = "fixed-binary-build-id",
DistroRelease = "debian:bookworm"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
if (result?.Status == ResolutionStatus.Fixed)
{
result.Evidence.Should().NotBeNull();
result.Evidence!.MatchType.Should().NotBeNullOrEmpty();
result.Evidence.Confidence.Should().BeGreaterThan(0);
}
SetCalls++;
Entries[cacheKey] = result;
return Task.CompletedTask;
}
#endregion
}
public Task InvalidateByPatternAsync(string pattern, CancellationToken ct = default)
{
return Task.CompletedTask;
}
/// <summary>
/// Placeholder for batch request if not in Contracts.
/// </summary>
public record BatchVulnResolutionRequest
{
public VulnResolutionRequest[] Items { get; init; } = Array.Empty<VulnResolutionRequest>();
public ResolutionOptions? Options { get; init; }
}
public record BatchVulnResolutionResponse
{
public VulnResolutionResponse[] Results { get; init; } = Array.Empty<VulnResolutionResponse>();
public int TotalCount { get; init; }
public int SuccessCount { get; init; }
public int ErrorCount { get; init; }
}
public record ResolutionOptions
{
public bool BypassCache { get; init; }
public bool IncludeDsseAttestation { get; init; }
public string GenerateCacheKey(VulnResolutionRequest request)
{
return string.Join(":", new[]
{
"resolution",
request.Package,
request.CveId ?? "all",
request.BuildId ?? "",
request.Hashes?.FileSha256 ?? "",
request.Hashes?.TextSha256 ?? "",
request.Fingerprint ?? ""
});
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.BinaryIndex.WebService/StellaOps.BinaryIndex.WebService.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.BinaryIndex.Cache/StellaOps.BinaryIndex.Cache.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.BinaryIndex.Contracts/StellaOps.BinaryIndex.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# BinaryIndex WebService Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0129-A | DONE | Added deterministic controller/cache/middleware tests. |