wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -24,7 +24,7 @@ public sealed class FindingsEvidenceControllerTests
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
var mockTriageService = new Mock<ITriageQueryService>();
|
||||
mockTriageService.Setup(s => s.GetFindingAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
mockTriageService.Setup(s => s.GetFindingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TriageFinding?)null);
|
||||
|
||||
await using var factory = new ScannerApplicationFactory().WithOverrides(
|
||||
@@ -71,6 +71,7 @@ public sealed class FindingsEvidenceControllerTests
|
||||
var finding = new TriageFinding
|
||||
{
|
||||
Id = findingId,
|
||||
TenantId = "default",
|
||||
AssetId = Guid.NewGuid(),
|
||||
AssetLabel = "prod/api-gateway:1.2.3",
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
@@ -79,9 +80,9 @@ public sealed class FindingsEvidenceControllerTests
|
||||
LastSeenAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
|
||||
var mockTriageService = new Mock<ITriageQueryService>();
|
||||
mockTriageService.Setup(s => s.GetFindingAsync(findingId.ToString(), It.IsAny<CancellationToken>()))
|
||||
mockTriageService.Setup(s => s.GetFindingAsync(It.IsAny<string>(), findingId.ToString(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(finding);
|
||||
|
||||
var mockEvidenceService = new Mock<IEvidenceCompositionService>();
|
||||
@@ -147,6 +148,7 @@ public sealed class FindingsEvidenceControllerTests
|
||||
var finding = new TriageFinding
|
||||
{
|
||||
Id = findingId,
|
||||
TenantId = "default",
|
||||
AssetId = Guid.NewGuid(),
|
||||
AssetLabel = "prod/api-gateway:1.2.3",
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
@@ -155,11 +157,11 @@ public sealed class FindingsEvidenceControllerTests
|
||||
LastSeenAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
|
||||
var mockTriageService = new Mock<ITriageQueryService>();
|
||||
mockTriageService.Setup(s => s.GetFindingAsync(findingId.ToString(), It.IsAny<CancellationToken>()))
|
||||
mockTriageService.Setup(s => s.GetFindingAsync(It.IsAny<string>(), findingId.ToString(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(finding);
|
||||
mockTriageService.Setup(s => s.GetFindingAsync(It.Is<string>(id => id != findingId.ToString()), It.IsAny<CancellationToken>()))
|
||||
mockTriageService.Setup(s => s.GetFindingAsync(It.IsAny<string>(), It.Is<string>(id => id != findingId.ToString()), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TriageFinding?)null);
|
||||
|
||||
var mockEvidenceService = new Mock<IEvidenceCompositionService>();
|
||||
|
||||
@@ -503,6 +503,7 @@ public sealed class GatingReasonServiceTests
|
||||
var finding = new TriageFinding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "default",
|
||||
AssetLabel = "test-asset",
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
CveId = "CVE-2024-1234",
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
@@ -87,7 +88,48 @@ public sealed class ReachabilityDriftEndpointsTests
|
||||
Assert.Single(sinksPayload.Sinks);
|
||||
}
|
||||
|
||||
private static async Task SeedCallGraphSnapshotsAsync(IServiceProvider services, string baseScanId, string headScanId)
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DriftSinksEndpoint_IsTenantIsolated()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var baseScanId = await CreateScanAsync(client, "base-tenant-a", "tenant-a");
|
||||
var headScanId = await CreateScanAsync(client, "head-tenant-a", "tenant-a");
|
||||
|
||||
await SeedCallGraphSnapshotsAsync(factory.Services, baseScanId, headScanId, "tenant-a");
|
||||
|
||||
using var ownerDriftRequest = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"/api/v1/scans/{headScanId}/drift?baseScanId={baseScanId}&language=dotnet&includeFullPath=false");
|
||||
ownerDriftRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
|
||||
|
||||
var driftResponse = await client.SendAsync(ownerDriftRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, driftResponse.StatusCode);
|
||||
|
||||
var drift = await driftResponse.Content.ReadFromJsonAsync<ReachabilityDriftResult>();
|
||||
Assert.NotNull(drift);
|
||||
|
||||
using var crossTenantSinksRequest = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"/api/v1/drift/{drift!.Id}/sinks?direction=became_reachable&offset=0&limit=10");
|
||||
crossTenantSinksRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
|
||||
|
||||
var crossTenantSinksResponse = await client.SendAsync(crossTenantSinksRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, crossTenantSinksResponse.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task SeedCallGraphSnapshotsAsync(
|
||||
IServiceProvider services,
|
||||
string baseScanId,
|
||||
string headScanId,
|
||||
string? tenantId = null)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICallGraphSnapshotRepository>();
|
||||
@@ -99,8 +141,8 @@ public sealed class ReachabilityDriftEndpointsTests
|
||||
scanId: headScanId,
|
||||
edges: ImmutableArray.Create(new CallGraphEdge("entry", "sink", CallKind.Direct, "Demo.cs:1")));
|
||||
|
||||
await repo.StoreAsync(baseSnapshot);
|
||||
await repo.StoreAsync(headSnapshot);
|
||||
await repo.StoreAsync(baseSnapshot, tenantId: tenantId);
|
||||
await repo.StoreAsync(headSnapshot, tenantId: tenantId);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateSnapshot(string scanId, ImmutableArray<CallGraphEdge> edges)
|
||||
@@ -142,21 +184,31 @@ public sealed class ReachabilityDriftEndpointsTests
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
private static async Task<string> CreateScanAsync(HttpClient client, string? clientRequestId = null)
|
||||
private static async Task<string> CreateScanAsync(HttpClient client, string? clientRequestId = null, string? tenantId = null)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/scans")
|
||||
{
|
||||
Image = new ScanImageDescriptor
|
||||
Content = JsonContent.Create(new ScanSubmitRequest
|
||||
{
|
||||
Reference = "example.com/demo:1.0",
|
||||
Digest = "sha256:0123456789abcdef"
|
||||
},
|
||||
ClientRequestId = clientRequestId,
|
||||
Metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["test.request"] = clientRequestId ?? string.Empty
|
||||
}
|
||||
});
|
||||
Image = new ScanImageDescriptor
|
||||
{
|
||||
Reference = "example.com/demo:1.0",
|
||||
Digest = "sha256:0123456789abcdef"
|
||||
},
|
||||
ClientRequestId = clientRequestId,
|
||||
Metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["test.request"] = clientRequestId ?? string.Empty
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, tenantId);
|
||||
}
|
||||
|
||||
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scanner.WebService.Tenancy;
|
||||
using StellaOps.TestKit;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class ScannerRequestContextResolverTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryResolveTenantReturnsMissingWhenNoClaimOrHeader()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
|
||||
var resolved = ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var error,
|
||||
allowDefaultTenant: false);
|
||||
|
||||
Assert.False(resolved);
|
||||
Assert.Equal(string.Empty, tenantId);
|
||||
Assert.Equal("tenant_missing", error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryResolveTenantReturnsConflictWhenClaimAndHeaderDiffer()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "tenant-a")
|
||||
}));
|
||||
context.Request.Headers["X-Stella-Tenant"] = "tenant-b";
|
||||
|
||||
var resolved = ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out _,
|
||||
out var error,
|
||||
allowDefaultTenant: false);
|
||||
|
||||
Assert.False(resolved);
|
||||
Assert.Equal("tenant_conflict", error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveTenantPartitionKeyFallsBackToIpWhenTenantMissing()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Connection.RemoteIpAddress = IPAddress.Parse("10.11.12.13");
|
||||
|
||||
var partitionKey = ScannerRequestContextResolver.ResolveTenantPartitionKey(context);
|
||||
|
||||
Assert.Equal("ip:10.11.12.13", partitionKey);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryResolveTenantSupportsLegacyHeaderAlias()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers["X-Tenant-Id"] = "Tenant-Alpha";
|
||||
|
||||
var resolved = ScannerRequestContextResolver.TryResolveTenant(
|
||||
context,
|
||||
out var tenantId,
|
||||
out var error,
|
||||
allowDefaultTenant: false);
|
||||
|
||||
Assert.True(resolved);
|
||||
Assert.Equal("tenant-alpha", tenantId);
|
||||
Assert.Null(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class ScannerTenantIsolationAndEndpointRegistrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SourcesAndWebhookEndpoints_HaveExplicitAuthPosture()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
|
||||
var endpoints = factory.Services
|
||||
.GetServices<EndpointDataSource>()
|
||||
.SelectMany(source => source.Endpoints)
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToList();
|
||||
|
||||
var sourcesList = FindNamedEndpoint(endpoints, "scanner.sources.list");
|
||||
AssertPolicy(sourcesList, ScannerPolicies.SourcesRead);
|
||||
AssertAnonymous(sourcesList, expected: false);
|
||||
|
||||
var sourcesCreate = FindNamedEndpoint(endpoints, "scanner.sources.create");
|
||||
AssertPolicy(sourcesCreate, ScannerPolicies.SourcesWrite);
|
||||
AssertAnonymous(sourcesCreate, expected: false);
|
||||
|
||||
var sourcesDelete = FindNamedEndpoint(endpoints, "scanner.sources.delete");
|
||||
AssertPolicy(sourcesDelete, ScannerPolicies.SourcesAdmin);
|
||||
AssertAnonymous(sourcesDelete, expected: false);
|
||||
|
||||
var webhookGeneric = FindNamedEndpoint(endpoints, "scanner.webhooks.receive");
|
||||
AssertAnonymous(webhookGeneric, expected: true);
|
||||
|
||||
var webhookGitHub = FindNamedEndpoint(endpoints, "scanner.webhooks.github");
|
||||
AssertAnonymous(webhookGitHub, expected: true);
|
||||
|
||||
var unknownsList = FindNamedEndpoint(endpoints, "scanner.unknowns.list");
|
||||
AssertPolicy(unknownsList, ScannerPolicies.ScansRead);
|
||||
AssertAnonymous(unknownsList, expected: false);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ScanStatus_RejectsCrossTenantAccess()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(
|
||||
configureConfiguration: configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ISurfacePointerService>();
|
||||
services.AddSingleton<ISurfacePointerService, NoopSurfacePointerService>();
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var scanId = await SubmitScanAsync(client, tenantId: "tenant-a", TestContext.Current.CancellationToken);
|
||||
|
||||
using var ownerRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/scans/{scanId}");
|
||||
ownerRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
|
||||
var ownerResponse = await client.SendAsync(ownerRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode);
|
||||
|
||||
using var crossTenantRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/scans/{scanId}");
|
||||
crossTenantRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
|
||||
var crossTenantResponse = await client.SendAsync(crossTenantRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, crossTenantResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CallGraphSubmission_RejectsCrossTenantScanOwnership()
|
||||
{
|
||||
var ingestion = new RecordingCallGraphIngestionService();
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(
|
||||
configureConfiguration: configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ICallGraphIngestionService>();
|
||||
services.AddSingleton<ICallGraphIngestionService>(ingestion);
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var scanId = await SubmitScanAsync(client, tenantId: "tenant-a", TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/callgraphs")
|
||||
{
|
||||
Content = JsonContent.Create(CreateMinimalCallGraph(scanId))
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
|
||||
request.Headers.TryAddWithoutValidation("Content-Digest", "sha256:tenant-bound-callgraph");
|
||||
|
||||
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal(0, ingestion.FindByDigestCalls);
|
||||
Assert.Equal(0, ingestion.IngestCalls);
|
||||
}
|
||||
|
||||
private static async Task<string> SubmitScanAsync(HttpClient client, string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/scans")
|
||||
{
|
||||
Content = JsonContent.Create(new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor
|
||||
{
|
||||
Reference = "example.com/scanner/tenant-test:1.0",
|
||||
Digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd"
|
||||
}
|
||||
})
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, tenantId);
|
||||
|
||||
var response = await client.SendAsync(request, cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>(cancellationToken: cancellationToken);
|
||||
Assert.NotNull(payload);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload!.ScanId));
|
||||
return payload.ScanId;
|
||||
}
|
||||
|
||||
private static CallGraphV1Dto CreateMinimalCallGraph(string scanId)
|
||||
{
|
||||
return new CallGraphV1Dto(
|
||||
Schema: "stella.callgraph.v1",
|
||||
ScanKey: scanId,
|
||||
Language: "dotnet",
|
||||
Nodes: new[]
|
||||
{
|
||||
new CallGraphNodeDto("n1", "Demo.Entry", null, "public", true),
|
||||
new CallGraphNodeDto("n2", "Demo.Vuln", null, "public", false)
|
||||
},
|
||||
Edges: new[]
|
||||
{
|
||||
new CallGraphEdgeDto("n1", "n2", "static", "direct", 1.0)
|
||||
});
|
||||
}
|
||||
|
||||
private static RouteEndpoint FindNamedEndpoint(IEnumerable<RouteEndpoint> endpoints, string endpointName)
|
||||
{
|
||||
return endpoints.Single(endpoint =>
|
||||
{
|
||||
var nameMetadata = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>();
|
||||
return string.Equals(nameMetadata?.EndpointName, endpointName, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertPolicy(RouteEndpoint endpoint, string policyName)
|
||||
{
|
||||
var authorizeData = endpoint.Metadata.GetOrderedMetadata<IAuthorizeData>();
|
||||
Assert.Contains(authorizeData, data => string.Equals(data.Policy, policyName, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static void AssertAnonymous(RouteEndpoint endpoint, bool expected)
|
||||
{
|
||||
var hasAnonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;
|
||||
Assert.Equal(expected, hasAnonymous);
|
||||
}
|
||||
|
||||
private sealed class RecordingCallGraphIngestionService : ICallGraphIngestionService
|
||||
{
|
||||
public int FindByDigestCalls { get; private set; }
|
||||
public int IngestCalls { get; private set; }
|
||||
|
||||
public Task<ExistingCallGraphDto?> FindByDigestAsync(
|
||||
ScanId scanId,
|
||||
string tenantId,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
FindByDigestCalls++;
|
||||
return Task.FromResult<ExistingCallGraphDto?>(null);
|
||||
}
|
||||
|
||||
public Task<CallGraphIngestionResult> IngestAsync(
|
||||
ScanId scanId,
|
||||
string tenantId,
|
||||
CallGraphV1Dto callGraph,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IngestCalls++;
|
||||
return Task.FromResult(new CallGraphIngestionResult(
|
||||
CallgraphId: "cg-test",
|
||||
NodeCount: callGraph.Nodes.Count,
|
||||
EdgeCount: callGraph.Edges.Count,
|
||||
Digest: contentDigest));
|
||||
}
|
||||
|
||||
public CallGraphValidationResult Validate(CallGraphV1Dto callGraph)
|
||||
=> CallGraphValidationResult.Success();
|
||||
}
|
||||
|
||||
private sealed class NoopSurfacePointerService : ISurfacePointerService
|
||||
{
|
||||
public Task<SurfacePointersDto?> TryBuildAsync(string imageDigest, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<SurfacePointersDto?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class SecretExceptionPatternServiceTenantIsolationTests
|
||||
{
|
||||
private static readonly FakeTimeProvider TimeProvider = new(
|
||||
new DateTimeOffset(2026, 1, 2, 1, 0, 0, TimeSpan.Zero));
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public async Task GetPatternAsync_UsesTenantScopedRepositoryLookup()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var patternId = Guid.NewGuid();
|
||||
var repository = new Mock<ISecretExceptionPatternRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(r => r.GetByIdAsync(tenantId, patternId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreatePatternRow(tenantId, patternId));
|
||||
|
||||
var service = new SecretExceptionPatternService(repository.Object, TimeProvider);
|
||||
|
||||
var result = await service.GetPatternAsync(tenantId, patternId);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.TenantId.Should().Be(tenantId);
|
||||
repository.Verify(
|
||||
r => r.GetByIdAsync(tenantId, patternId, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public async Task UpdatePatternAsync_PassesTenantToReadAndWriteCalls()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var patternId = Guid.NewGuid();
|
||||
var existing = CreatePatternRow(tenantId, patternId);
|
||||
var repository = new Mock<ISecretExceptionPatternRepository>(MockBehavior.Strict);
|
||||
|
||||
repository
|
||||
.SetupSequence(r => r.GetByIdAsync(tenantId, patternId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existing)
|
||||
.ReturnsAsync(CreatePatternRow(tenantId, patternId, name: "updated-name"));
|
||||
repository
|
||||
.Setup(r => r.UpdateAsync(tenantId, It.IsAny<SecretExceptionPatternRow>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = new SecretExceptionPatternService(repository.Object, TimeProvider);
|
||||
var request = new SecretExceptionPatternDto
|
||||
{
|
||||
Name = "updated-name",
|
||||
Description = "updated description",
|
||||
ValuePattern = "AKIA[0-9A-Z]{16}",
|
||||
ApplicableRuleIds = ["aws-access-key"],
|
||||
Justification = "Updated pattern to match current key format.",
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var (success, pattern, errors) = await service.UpdatePatternAsync(
|
||||
tenantId,
|
||||
patternId,
|
||||
request,
|
||||
"updater");
|
||||
|
||||
success.Should().BeTrue();
|
||||
errors.Should().BeEmpty();
|
||||
pattern.Should().NotBeNull();
|
||||
pattern!.Pattern.Name.Should().Be("updated-name");
|
||||
repository.Verify(
|
||||
r => r.UpdateAsync(tenantId, It.IsAny<SecretExceptionPatternRow>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public async Task DeletePatternAsync_UsesTenantScopedRepositoryDelete()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var patternId = Guid.NewGuid();
|
||||
var repository = new Mock<ISecretExceptionPatternRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
.Setup(r => r.DeleteAsync(tenantId, patternId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = new SecretExceptionPatternService(repository.Object, TimeProvider);
|
||||
|
||||
var deleted = await service.DeletePatternAsync(tenantId, patternId);
|
||||
|
||||
deleted.Should().BeTrue();
|
||||
repository.Verify(
|
||||
r => r.DeleteAsync(tenantId, patternId, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
private static SecretExceptionPatternRow CreatePatternRow(
|
||||
Guid tenantId,
|
||||
Guid patternId,
|
||||
string name = "aws-key-exception")
|
||||
{
|
||||
return new SecretExceptionPatternRow
|
||||
{
|
||||
ExceptionId = patternId,
|
||||
TenantId = tenantId,
|
||||
Name = name,
|
||||
Description = "Known test fixture key pattern.",
|
||||
ValuePattern = "AKIA[0-9A-Z]{16}",
|
||||
ApplicableRuleIds = ["aws-access-key"],
|
||||
Justification = "This key format is used in non-production fixtures only.",
|
||||
IsActive = true,
|
||||
CreatedAt = new DateTimeOffset(2026, 1, 2, 1, 0, 0, TimeSpan.Zero),
|
||||
CreatedBy = "tester"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
@@ -103,6 +104,31 @@ public sealed class SmartDiffEndpointsTests
|
||||
Assert.Contains("pkg:npm/test-component@1.0.0", body, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CandidateLookup_UsesResolvedTenantContext()
|
||||
{
|
||||
var imageDigest = "sha256:tenant-bound";
|
||||
var candidateStore = new InMemoryVexCandidateStore();
|
||||
await candidateStore.StoreCandidatesAsync(
|
||||
[CreateCandidate("candidate-tenant", imageDigest, requiresReview: true)],
|
||||
TestContext.Current.CancellationToken,
|
||||
tenantId: "tenant-a");
|
||||
|
||||
await using var factory = CreateFactory(candidateStore, new StubScanMetadataRepository());
|
||||
await factory.InitializeAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var ownerRequest = new HttpRequestMessage(HttpMethod.Get, $"{BasePath}/candidates/candidate-tenant");
|
||||
ownerRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
|
||||
var ownerResponse = await client.SendAsync(ownerRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode);
|
||||
|
||||
using var crossTenantRequest = new HttpRequestMessage(HttpMethod.Get, $"{BasePath}/candidates/candidate-tenant");
|
||||
crossTenantRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
|
||||
var crossTenantResponse = await client.SendAsync(crossTenantRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, crossTenantResponse.StatusCode);
|
||||
}
|
||||
|
||||
private static ScannerApplicationFactory CreateFactory(
|
||||
IVexCandidateStore candidateStore,
|
||||
IScanMetadataRepository metadataRepository)
|
||||
@@ -156,17 +182,17 @@ public sealed class SmartDiffEndpointsTests
|
||||
|
||||
private sealed class StubMaterialRiskChangeRepository : IMaterialRiskChangeRepository
|
||||
{
|
||||
public Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default, string? tenantId = null) => Task.CompletedTask;
|
||||
|
||||
public Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default, string? tenantId = null) => Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default) =>
|
||||
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default, string? tenantId = null) =>
|
||||
Task.FromResult<IReadOnlyList<MaterialRiskChangeResult>>(Array.Empty<MaterialRiskChangeResult>());
|
||||
|
||||
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(FindingKey findingKey, int limit = 10, CancellationToken ct = default) =>
|
||||
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(FindingKey findingKey, int limit = 10, CancellationToken ct = default, string? tenantId = null) =>
|
||||
Task.FromResult<IReadOnlyList<MaterialRiskChangeResult>>(Array.Empty<MaterialRiskChangeResult>());
|
||||
|
||||
public Task<MaterialRiskChangeQueryResult> QueryChangesAsync(MaterialRiskChangeQuery query, CancellationToken ct = default) =>
|
||||
public Task<MaterialRiskChangeQueryResult> QueryChangesAsync(MaterialRiskChangeQuery query, CancellationToken ct = default, string? tenantId = null) =>
|
||||
Task.FromResult(new MaterialRiskChangeQueryResult(ImmutableArray<MaterialRiskChangeResult>.Empty, 0, query.Offset, query.Limit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,3 +11,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| SPRINT-20260208-063-TRIAGE-001 | DONE | Add endpoint tests for triage cluster inbox stats and batch triage actions (2026-02-08). |
|
||||
| HOT-004 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: added endpoint tests for payload/component/pending triage hot-lookup APIs. |
|
||||
| HOT-006 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: deterministic query ordering/latency coverage added; local execution is Docker-gated in this environment. |
|
||||
| SPRINT-20260222-057-SCAN-TEN-09 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: added focused cross-tenant triage/evidence endpoint isolation tests (`TriageTenantIsolationEndpointsTests`) and reran targeted tenant suites (2026-02-22). |
|
||||
| SPRINT-20260222-057-SCAN-TEN-10 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: added focused Unknowns endpoint tenant-isolation coverage (`UnknownsTenantIsolationEndpointsTests`) for cross-tenant not-found and tenant-conflict rejection (2026-02-22). |
|
||||
| SPRINT-20260222-057-SCAN-TEN-11 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: added SmartDiff and Reachability tenant-propagation regression checks (`SmartDiffEndpointsTests`, `ReachabilityDriftEndpointsTests`) and validated focused suites (2026-02-23). |
|
||||
| SPRINT-20260222-057-SCAN-TEN-13 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: added `SecretExceptionPatternServiceTenantIsolationTests` validating tenant-scoped repository lookups for exception get/update/delete (`3` tests, 2026-02-23). |
|
||||
|
||||
@@ -145,7 +145,7 @@ public sealed class TriageClusterEndpointsTests
|
||||
_findings = findings;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct)
|
||||
public Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string tenantId, string artifactDigest, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<Finding>>(
|
||||
_findings.Where(f => string.Equals(f.ArtifactDigest, artifactDigest, StringComparison.Ordinal)).ToArray());
|
||||
}
|
||||
@@ -154,10 +154,11 @@ public sealed class TriageClusterEndpointsTests
|
||||
{
|
||||
public List<string> UpdatedFindingIds { get; } = [];
|
||||
|
||||
public Task<FindingTriageStatusDto?> GetFindingStatusAsync(string findingId, CancellationToken ct = default)
|
||||
public Task<FindingTriageStatusDto?> GetFindingStatusAsync(string tenantId, string findingId, CancellationToken ct = default)
|
||||
=> Task.FromResult<FindingTriageStatusDto?>(null);
|
||||
|
||||
public Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
UpdateTriageStatusRequestDto request,
|
||||
string actor,
|
||||
@@ -177,6 +178,7 @@ public sealed class TriageClusterEndpointsTests
|
||||
}
|
||||
|
||||
public Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
SubmitVexStatementRequestDto request,
|
||||
string actor,
|
||||
@@ -184,6 +186,7 @@ public sealed class TriageClusterEndpointsTests
|
||||
=> Task.FromResult<SubmitVexStatementResponseDto?>(null);
|
||||
|
||||
public Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
|
||||
string tenantId,
|
||||
BulkTriageQueryRequestDto request,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
@@ -201,7 +204,7 @@ public sealed class TriageClusterEndpointsTests
|
||||
}
|
||||
});
|
||||
|
||||
public Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default)
|
||||
public Task<TriageSummaryDto> GetSummaryAsync(string tenantId, string artifactDigest, CancellationToken ct = default)
|
||||
=> Task.FromResult(new TriageSummaryDto
|
||||
{
|
||||
ByLane = new Dictionary<string, int>(),
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Endpoints.Triage;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class TriageTenantIsolationEndpointsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TriageFindingStatus_RejectsCrossTenantAccess()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(
|
||||
configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ITriageStatusService>();
|
||||
services.AddSingleton<ITriageStatusService>(
|
||||
new TenantAwareStatusService("finding-tenant-a"));
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
var findingId = "finding-tenant-a";
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var ownerRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/triage/findings/{findingId}");
|
||||
ownerRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
|
||||
using var ownerResponse = await client.SendAsync(ownerRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode);
|
||||
|
||||
using var crossTenantRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/triage/findings/{findingId}");
|
||||
crossTenantRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
|
||||
using var crossTenantResponse = await client.SendAsync(crossTenantRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, crossTenantResponse.StatusCode);
|
||||
|
||||
var statusService = factory.Services.GetRequiredService<ITriageStatusService>() as TenantAwareStatusService;
|
||||
Assert.NotNull(statusService);
|
||||
Assert.Equal(new[] { "tenant-a", "tenant-b" }, statusService!.RequestedTenants);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FindingsEvidence_RejectsCrossTenantAccess()
|
||||
{
|
||||
var triageQueryService = new TenantAwareTriageQueryService("finding-tenant-a");
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(
|
||||
configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ITriageQueryService>();
|
||||
services.AddSingleton<ITriageQueryService>(triageQueryService);
|
||||
services.RemoveAll<IEvidenceCompositionService>();
|
||||
services.AddSingleton<IEvidenceCompositionService>(new StaticEvidenceCompositionService());
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var tenantARequest = new HttpRequestMessage(HttpMethod.Get, "/api/v1/findings/finding-tenant-a/evidence");
|
||||
tenantARequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
|
||||
using var tenantAResponse = await client.SendAsync(tenantARequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, tenantAResponse.StatusCode);
|
||||
|
||||
using var tenantBRequest = new HttpRequestMessage(HttpMethod.Get, "/api/v1/findings/finding-tenant-a/evidence");
|
||||
tenantBRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
|
||||
using var tenantBResponse = await client.SendAsync(tenantBRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, tenantBResponse.StatusCode);
|
||||
|
||||
Assert.Equal(new[] { "tenant-a", "tenant-b" }, triageQueryService.RequestedTenants);
|
||||
}
|
||||
|
||||
private sealed class TenantAwareStatusService : ITriageStatusService
|
||||
{
|
||||
private readonly string _ownedFindingId;
|
||||
|
||||
public TenantAwareStatusService(string ownedFindingId)
|
||||
{
|
||||
_ownedFindingId = ownedFindingId;
|
||||
}
|
||||
|
||||
public List<string> RequestedTenants { get; } = [];
|
||||
|
||||
public Task<FindingTriageStatusDto?> GetFindingStatusAsync(string tenantId, string findingId, CancellationToken ct = default)
|
||||
{
|
||||
RequestedTenants.Add(tenantId);
|
||||
if (!string.Equals(tenantId, "tenant-a", StringComparison.Ordinal) ||
|
||||
!string.Equals(findingId, _ownedFindingId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<FindingTriageStatusDto?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<FindingTriageStatusDto?>(new FindingTriageStatusDto
|
||||
{
|
||||
FindingId = findingId,
|
||||
Lane = "Active",
|
||||
Verdict = "Block",
|
||||
ComputedAt = new DateTimeOffset(2026, 2, 22, 0, 0, 0, TimeSpan.Zero)
|
||||
});
|
||||
}
|
||||
|
||||
public Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
UpdateTriageStatusRequestDto request,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<UpdateTriageStatusResponseDto?>(null);
|
||||
|
||||
public Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
SubmitVexStatementRequestDto request,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<SubmitVexStatementResponseDto?>(null);
|
||||
|
||||
public Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
|
||||
string tenantId,
|
||||
BulkTriageQueryRequestDto request,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult(new BulkTriageQueryResponseDto
|
||||
{
|
||||
Findings = [],
|
||||
TotalCount = 0,
|
||||
Summary = new TriageSummaryDto
|
||||
{
|
||||
ByLane = new Dictionary<string, int>(),
|
||||
ByVerdict = new Dictionary<string, int>(),
|
||||
CanShipCount = 0,
|
||||
BlockingCount = 0
|
||||
}
|
||||
});
|
||||
|
||||
public Task<TriageSummaryDto> GetSummaryAsync(string tenantId, string artifactDigest, CancellationToken ct = default)
|
||||
=> Task.FromResult(new TriageSummaryDto
|
||||
{
|
||||
ByLane = new Dictionary<string, int>(),
|
||||
ByVerdict = new Dictionary<string, int>(),
|
||||
CanShipCount = 0,
|
||||
BlockingCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class TenantAwareTriageQueryService : ITriageQueryService
|
||||
{
|
||||
private readonly string _ownedFindingId;
|
||||
|
||||
public TenantAwareTriageQueryService(string ownedFindingId)
|
||||
{
|
||||
_ownedFindingId = ownedFindingId;
|
||||
}
|
||||
|
||||
public List<string> RequestedTenants { get; } = [];
|
||||
|
||||
public Task<TriageFinding?> GetFindingAsync(string tenantId, string findingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestedTenants.Add(tenantId);
|
||||
if (string.Equals(tenantId, "tenant-a", StringComparison.Ordinal) &&
|
||||
string.Equals(findingId, _ownedFindingId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<TriageFinding?>(new TriageFinding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-a",
|
||||
AssetId = Guid.NewGuid(),
|
||||
AssetLabel = "prod/tenant-a/api:1.0.0",
|
||||
Purl = "pkg:npm/acme/demo@1.0.0",
|
||||
CveId = "CVE-2026-9999",
|
||||
ArtifactDigest = "sha256:tenant-a-artifact",
|
||||
FirstSeenAt = new DateTimeOffset(2026, 2, 22, 0, 0, 0, TimeSpan.Zero),
|
||||
LastSeenAt = new DateTimeOffset(2026, 2, 22, 0, 0, 0, TimeSpan.Zero),
|
||||
UpdatedAt = new DateTimeOffset(2026, 2, 22, 0, 0, 0, TimeSpan.Zero)
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<TriageFinding?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticEvidenceCompositionService : IEvidenceCompositionService
|
||||
{
|
||||
public Task<FindingEvidenceResponse?> GetEvidenceAsync(
|
||||
ScanId scanId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<FindingEvidenceResponse?>(null);
|
||||
|
||||
public Task<FindingEvidenceResponse> ComposeAsync(
|
||||
TriageFinding finding,
|
||||
bool includeRaw,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new FindingEvidenceResponse
|
||||
{
|
||||
FindingId = finding.Id.ToString(),
|
||||
Cve = finding.CveId ?? "CVE-unknown",
|
||||
Component = new ComponentInfo
|
||||
{
|
||||
Name = "demo",
|
||||
Version = "1.0.0",
|
||||
Purl = finding.Purl
|
||||
},
|
||||
LastSeen = finding.LastSeenAt
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class UnknownsTenantIsolationEndpointsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UnknownById_RejectsCrossTenantAccess()
|
||||
{
|
||||
var queryService = new TenantAwareUnknownsQueryService();
|
||||
var unknownId = queryService.OwnedUnknownId;
|
||||
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(
|
||||
configureConfiguration: configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IUnknownsQueryService>();
|
||||
services.AddSingleton<IUnknownsQueryService>(queryService);
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var ownerRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/unknowns/unk-{unknownId:N}");
|
||||
ownerRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
|
||||
using var ownerResponse = await client.SendAsync(ownerRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode);
|
||||
|
||||
using var otherTenantRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/unknowns/unk-{unknownId:N}");
|
||||
otherTenantRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
|
||||
using var otherTenantResponse = await client.SendAsync(otherTenantRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, otherTenantResponse.StatusCode);
|
||||
|
||||
Assert.Equal(new[] { "tenant-a", "tenant-b" }, queryService.RequestedTenants);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UnknownsList_RejectsConflictingTenantHeaders()
|
||||
{
|
||||
await using var factory = ScannerApplicationFactory.CreateLightweight()
|
||||
.WithOverrides(
|
||||
configureConfiguration: configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
await factory.InitializeAsync();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/unknowns?limit=10");
|
||||
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-Id", "tenant-b");
|
||||
|
||||
using var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
private sealed class TenantAwareUnknownsQueryService : IUnknownsQueryService
|
||||
{
|
||||
public Guid OwnedUnknownId { get; } = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
public List<string> RequestedTenants { get; } = [];
|
||||
|
||||
public Task<UnknownsListResult> ListAsync(
|
||||
string tenantId,
|
||||
UnknownsListQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestedTenants.Add(tenantId);
|
||||
return Task.FromResult(new UnknownsListResult
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
public Task<UnknownsDetail?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid unknownId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestedTenants.Add(tenantId);
|
||||
if (!string.Equals(tenantId, "tenant-a", StringComparison.Ordinal) || unknownId != OwnedUnknownId)
|
||||
{
|
||||
return Task.FromResult<UnknownsDetail?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<UnknownsDetail?>(new UnknownsDetail
|
||||
{
|
||||
UnknownId = unknownId,
|
||||
ArtifactDigest = "sha256:tenant-a-artifact",
|
||||
VulnerabilityId = "CVE-2026-1111",
|
||||
PackagePurl = "pkg:npm/acme/example@1.0.0",
|
||||
Score = 0.92,
|
||||
ProofRef = "proof://tenant-a/unknown",
|
||||
CreatedAtUtc = new DateTimeOffset(2026, 2, 22, 0, 0, 0, TimeSpan.Zero),
|
||||
UpdatedAtUtc = new DateTimeOffset(2026, 2, 22, 0, 5, 0, TimeSpan.Zero)
|
||||
});
|
||||
}
|
||||
|
||||
public Task<UnknownsStats> GetStatsAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestedTenants.Add(tenantId);
|
||||
return Task.FromResult(new UnknownsStats
|
||||
{
|
||||
Total = 0,
|
||||
Hot = 0,
|
||||
Warm = 0,
|
||||
Cold = 0
|
||||
});
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, long>> GetBandDistributionAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestedTenants.Add(tenantId);
|
||||
return Task.FromResult<IReadOnlyDictionary<string, long>>(new Dictionary<string, long>(StringComparer.Ordinal)
|
||||
{
|
||||
["HOT"] = 0,
|
||||
["WARM"] = 0,
|
||||
["COLD"] = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Persistence;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using StellaOps.TestKit;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class WebhookEndpointsTenantLookupTests
|
||||
{
|
||||
private static readonly JsonDocument MinimalGitConfig = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"provider": "GitHub",
|
||||
"repositoryUrl": "https://github.com/stella-ops/shared.git",
|
||||
"branches": { "include": ["main"] },
|
||||
"triggers": { "onPush": true, "onPullRequest": false, "onTag": false },
|
||||
"scanOptions": { "analyzers": ["syft"] }
|
||||
}
|
||||
""");
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FindSourceByNameIsTenantScoped()
|
||||
{
|
||||
var repository = new InMemorySourceRepository();
|
||||
repository.Add(CreateSource("tenant-a", "shared", SbomSourceType.Git));
|
||||
|
||||
var resolvedForOwner = await WebhookEndpoints.FindSourceByNameAsync(
|
||||
repository,
|
||||
"tenant-a",
|
||||
"shared",
|
||||
SbomSourceType.Git,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var resolvedForOtherTenant = await WebhookEndpoints.FindSourceByNameAsync(
|
||||
repository,
|
||||
"tenant-b",
|
||||
"shared",
|
||||
SbomSourceType.Git,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(resolvedForOwner);
|
||||
Assert.Equal("tenant-a", resolvedForOwner!.TenantId);
|
||||
Assert.Null(resolvedForOtherTenant);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FindSourceByNameRequiresExpectedSourceType()
|
||||
{
|
||||
var repository = new InMemorySourceRepository();
|
||||
repository.Add(CreateSource("tenant-a", "shared", SbomSourceType.Zastava));
|
||||
|
||||
var resolved = await WebhookEndpoints.FindSourceByNameAsync(
|
||||
repository,
|
||||
"tenant-a",
|
||||
"shared",
|
||||
SbomSourceType.Git,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Null(resolved);
|
||||
}
|
||||
|
||||
private static SbomSource CreateSource(string tenantId, string name, SbomSourceType sourceType)
|
||||
{
|
||||
return SbomSource.Create(
|
||||
tenantId: tenantId,
|
||||
name: name,
|
||||
sourceType: sourceType,
|
||||
configuration: MinimalGitConfig,
|
||||
createdBy: "test",
|
||||
timeProvider: TimeProvider.System,
|
||||
guidProvider: new DeterministicGuidProvider());
|
||||
}
|
||||
|
||||
private sealed class InMemorySourceRepository : ISbomSourceRepository
|
||||
{
|
||||
private readonly Dictionary<(string TenantId, string Name), SbomSource> _byTenantAndName =
|
||||
new();
|
||||
|
||||
public void Add(SbomSource source)
|
||||
{
|
||||
_byTenantAndName[(source.TenantId, source.Name)] = source;
|
||||
}
|
||||
|
||||
public Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||
=> Task.FromResult<SbomSource?>(null);
|
||||
|
||||
public Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default)
|
||||
=> Task.FromResult<SbomSource?>(null);
|
||||
|
||||
public Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
|
||||
{
|
||||
_byTenantAndName.TryGetValue((tenantId, name), out var source);
|
||||
return Task.FromResult(source);
|
||||
}
|
||||
|
||||
public Task<PagedResponse<SbomSource>> ListAsync(string tenantId, ListSourcesRequest request, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> GetDueScheduledSourcesAsync(DateTimeOffset asOf, int limit = 100, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task CreateAsync(SbomSource source, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task UpdateAsync(SbomSource source, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<bool> NameExistsAsync(string tenantId, string name, Guid? excludeSourceId = null, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> SearchByNameAsync(string name, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> GetDueForScheduledRunAsync(CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class DeterministicGuidProvider : IGuidProvider
|
||||
{
|
||||
private int _counter;
|
||||
|
||||
public Guid NewGuid()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
BitConverter.TryWriteBytes(bytes, ++_counter);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user