|
|
|
|
@@ -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 ?? ""
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|