save progress
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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. |
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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*");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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 ?? ""
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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. |
|
||||
Reference in New Issue
Block a user