save progress

This commit is contained in:
StellaOps Bot
2025-12-18 09:10:36 +02:00
parent b4235c134c
commit 28823a8960
169 changed files with 11995 additions and 449 deletions

View File

@@ -0,0 +1,104 @@
using System.Net;
using System.Net.Http.Json;
using StellaOps.Scanner.WebService.Contracts;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class CallGraphEndpointsTests
{
[Fact]
public async Task SubmitCallGraphRequiresContentDigestHeader()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
using var client = factory.CreateClient();
var scanId = await CreateScanAsync(client);
var request = CreateMinimalCallGraph(scanId);
var response = await client.PostAsJsonAsync($"/api/v1/scans/{scanId}/callgraphs", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task SubmitCallGraphReturnsAcceptedAndDetectsDuplicates()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
using var client = factory.CreateClient();
var scanId = await CreateScanAsync(client);
var request = CreateMinimalCallGraph(scanId);
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/callgraphs")
{
Content = JsonContent.Create(request)
};
httpRequest.Headers.TryAddWithoutValidation("Content-Digest", "sha256:deadbeef");
var first = await client.SendAsync(httpRequest);
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);
var payload = await first.Content.ReadFromJsonAsync<CallGraphAcceptedResponseDto>();
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload!.CallgraphId));
Assert.Equal("sha256:deadbeef", payload.Digest);
Assert.Equal(2, payload.NodeCount);
Assert.Equal(1, payload.EdgeCount);
using var secondRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/callgraphs")
{
Content = JsonContent.Create(request)
};
secondRequest.Headers.TryAddWithoutValidation("Content-Digest", "sha256:deadbeef");
var second = await client.SendAsync(secondRequest);
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
private static async Task<string> CreateScanAsync(HttpClient client)
{
var response = await client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest
{
Image = new ScanImageDescriptor
{
Reference = "example.com/demo:1.0",
Digest = "sha256:0123456789abcdef"
}
});
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>();
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(NodeId: "n1", SymbolKey: "Demo.Entry", ArtifactKey: null, Visibility: "public", IsEntrypointCandidate: true),
new CallGraphNodeDto(NodeId: "n2", SymbolKey: "Demo.Vuln", ArtifactKey: null, Visibility: "public", IsEntrypointCandidate: false),
},
Edges: new[]
{
new CallGraphEdgeDto(From: "n1", To: "n2", Kind: "static", Reason: "direct", Weight: 1.0)
});
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Scanner.Surface.Env;
@@ -99,10 +100,17 @@ public sealed class LinksetResolverTests
private sealed class FakeSurfaceEnvironment : ISurfaceEnvironment
{
public SurfaceEnvironmentSettings Settings { get; } = new()
{
Tenant = "tenant-a"
};
public SurfaceEnvironmentSettings Settings { get; } = new SurfaceEnvironmentSettings(
SurfaceFsEndpoint: new Uri("https://surface.local"),
SurfaceFsBucket: "surface-bucket",
SurfaceFsRegion: null,
CacheRoot: new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"stellaops-tests-{Guid.NewGuid():N}")),
CacheQuotaMegabytes: 16,
PrefetchEnabled: false,
FeatureFlags: Array.Empty<string>(),
Secrets: new SurfaceSecretsConfiguration("file", "tenant-a", "/etc/secrets", null, null, false),
Tenant: "tenant-a",
Tls: new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()));
public IReadOnlyDictionary<string, string> RawVariables { get; } = new Dictionary<string, string>(StringComparer.Ordinal)
{

View File

@@ -0,0 +1,250 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class OfflineKitEndpointsTests
{
[Fact]
public async Task OfflineKitImport_ThenStatusAndMetrics_Succeeds()
{
using var contentRoot = new TempDirectory();
using var trustRoots = new TempDirectory();
var bundleBytes = Encoding.UTF8.GetBytes("deterministic-offline-kit-bundle");
var bundleSha = ComputeSha256Hex(bundleBytes);
var (keyId, keyPem, dsseJson) = CreateSignedDsse(bundleBytes);
File.WriteAllText(Path.Combine(trustRoots.Path, $"{keyId}.pem"), keyPem, Encoding.UTF8);
using var factory = new ScannerApplicationFactory(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "true";
config["Scanner:OfflineKit:RekorOfflineMode"] = "false";
config["Scanner:OfflineKit:TrustRootDirectory"] = trustRoots.Path;
config["Scanner:OfflineKit:TrustAnchors:0:AnchorId"] = "test";
config["Scanner:OfflineKit:TrustAnchors:0:PurlPattern"] = "*";
config["Scanner:OfflineKit:TrustAnchors:0:AllowedKeyIds:0"] = keyId;
});
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
var metadataJson = JsonSerializer.Serialize(new
{
bundleId = "test-bundle",
bundleSha256 = $"sha256:{bundleSha}",
bundleSize = bundleBytes.Length,
channel = "stable",
kind = "offline-kit",
isDelta = false
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var content = new MultipartFormDataContent();
content.Add(new StringContent(metadataJson, Encoding.UTF8, "application/json"), "metadata");
var bundleContent = new ByteArrayContent(bundleBytes);
bundleContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
content.Add(bundleContent, "bundle", "bundle.tgz");
content.Add(new StringContent(dsseJson, Encoding.UTF8, "application/json"), "bundleSignature", "statement.dsse.json");
using var response = await client.PostAsync("/api/offline-kit/import", content).ConfigureAwait(false);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
using var statusResponse = await client.GetAsync("/api/offline-kit/status").ConfigureAwait(false);
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
var statusJson = await statusResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
using var statusDoc = JsonDocument.Parse(statusJson);
var current = statusDoc.RootElement.GetProperty("current");
Assert.Equal("test-bundle", current.GetProperty("bundleId").GetString());
var metrics = await client.GetStringAsync("/metrics").ConfigureAwait(false);
Assert.Contains("offlinekit_import_total", metrics, StringComparison.Ordinal);
}
[Fact]
public async Task OfflineKitImport_WhenDsseInvalid_ReturnsProblemDetails()
{
using var contentRoot = new TempDirectory();
using var trustRoots = new TempDirectory();
var bundleBytes = Encoding.UTF8.GetBytes("deterministic-offline-kit-bundle");
var bundleSha = ComputeSha256Hex(bundleBytes);
var (keyId, keyPem, _) = CreateSignedDsse(bundleBytes);
File.WriteAllText(Path.Combine(trustRoots.Path, $"{keyId}.pem"), keyPem, Encoding.UTF8);
var invalidDsseJson = JsonSerializer.Serialize(new
{
payloadType = "application/vnd.in-toto+json",
payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
signatures = new[] { new { keyid = keyId, sig = Convert.ToBase64String(new byte[] { 1, 2, 3 }) } }
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var factory = new ScannerApplicationFactory(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "true";
config["Scanner:OfflineKit:RekorOfflineMode"] = "false";
config["Scanner:OfflineKit:TrustRootDirectory"] = trustRoots.Path;
config["Scanner:OfflineKit:TrustAnchors:0:AnchorId"] = "test";
config["Scanner:OfflineKit:TrustAnchors:0:PurlPattern"] = "*";
config["Scanner:OfflineKit:TrustAnchors:0:AllowedKeyIds:0"] = keyId;
});
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
var metadataJson = JsonSerializer.Serialize(new
{
bundleId = "test-bundle",
bundleSha256 = $"sha256:{bundleSha}",
bundleSize = bundleBytes.Length
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var content = new MultipartFormDataContent();
content.Add(new StringContent(metadataJson, Encoding.UTF8, "application/json"), "metadata");
var bundleContent = new ByteArrayContent(bundleBytes);
bundleContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
content.Add(bundleContent, "bundle", "bundle.tgz");
content.Add(new StringContent(invalidDsseJson, Encoding.UTF8, "application/json"), "bundleSignature", "statement.dsse.json");
using var response = await client.PostAsync("/api/offline-kit/import", content).ConfigureAwait(false);
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var problemJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
using var problem = JsonDocument.Parse(problemJson);
Assert.Equal("DSSE_VERIFY_FAIL", problem.RootElement.GetProperty("extensions").GetProperty("reason_code").GetString());
}
[Fact]
public async Task OfflineKitImport_WhenRequireDsseFalse_AllowsSoftFail()
{
using var contentRoot = new TempDirectory();
var bundleBytes = Encoding.UTF8.GetBytes("deterministic-offline-kit-bundle");
var bundleSha = ComputeSha256Hex(bundleBytes);
var invalidDsseJson = JsonSerializer.Serialize(new
{
payloadType = "application/vnd.in-toto+json",
payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
signatures = new[] { new { keyid = "unknown", sig = Convert.ToBase64String(new byte[] { 1, 2, 3 }) } }
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var factory = new ScannerApplicationFactory(config =>
{
config["Scanner:OfflineKit:Enabled"] = "true";
config["Scanner:OfflineKit:RequireDsse"] = "false";
config["Scanner:OfflineKit:RekorOfflineMode"] = "false";
});
using var configured = factory.WithWebHostBuilder(builder => builder.UseContentRoot(contentRoot.Path));
using var client = configured.CreateClient();
var metadataJson = JsonSerializer.Serialize(new
{
bundleId = "test-bundle",
bundleSha256 = $"sha256:{bundleSha}",
bundleSize = bundleBytes.Length
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var content = new MultipartFormDataContent();
content.Add(new StringContent(metadataJson, Encoding.UTF8, "application/json"), "metadata");
var bundleContent = new ByteArrayContent(bundleBytes);
bundleContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
content.Add(bundleContent, "bundle", "bundle.tgz");
content.Add(new StringContent(invalidDsseJson, Encoding.UTF8, "application/json"), "bundleSignature", "statement.dsse.json");
using var response = await client.PostAsync("/api/offline-kit/import", content).ConfigureAwait(false);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}
private static string ComputeSha256Hex(byte[] bytes)
=> Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
private static (string KeyId, string PublicKeyPem, string DsseJson) CreateSignedDsse(byte[] bundleBytes)
{
using var rsa = RSA.Create(2048);
var publicKeyDer = rsa.ExportSubjectPublicKeyInfo();
var fingerprint = ComputeSha256Hex(publicKeyDer);
var pem = new StringBuilder();
pem.AppendLine("-----BEGIN PUBLIC KEY-----");
pem.AppendLine(Convert.ToBase64String(publicKeyDer));
pem.AppendLine("-----END PUBLIC KEY-----");
var bundleSha = ComputeSha256Hex(bundleBytes);
var payloadText = $"{{\"subject\":[{{\"digest\":{{\"sha256\":\"{bundleSha}\"}}}}]}}";
var payloadBytes = Encoding.UTF8.GetBytes(payloadText);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var payloadType = "application/vnd.in-toto+json";
var pae = BuildPae(payloadType, payloadBase64);
var signature = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
var signatureBase64 = Convert.ToBase64String(signature);
var dsseJson = JsonSerializer.Serialize(new
{
payloadType,
payload = payloadBase64,
signatures = new[] { new { keyid = fingerprint, sig = signatureBase64 } }
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
return (fingerprint, pem.ToString(), dsseJson);
}
private static byte[] BuildPae(string payloadType, string payloadBase64)
{
var payloadText = Encoding.UTF8.GetString(Convert.FromBase64String(payloadBase64));
var parts = new[] { "DSSEv1", payloadType, payloadText };
var builder = new StringBuilder();
builder.Append("PAE:");
builder.Append(parts.Length);
foreach (var part in parts)
{
builder.Append(' ');
builder.Append(part.Length);
builder.Append(' ');
builder.Append(part);
}
return Encoding.UTF8.GetBytes(builder.ToString());
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch
{
// ignore best-effort cleanup
}
}
}
}

View File

@@ -0,0 +1,164 @@
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.ReachabilityDrift;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Contracts;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class ReachabilityDriftEndpointsTests
{
[Fact]
public async Task GetDriftReturnsNotFoundWhenNoResultAndNoBaseScanProvided()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
using var client = factory.CreateClient();
var scanId = await CreateScanAsync(client);
var response = await client.GetAsync($"/api/v1/scans/{scanId}/drift?language=dotnet");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetDriftComputesResultAndListsDriftedSinks()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
using var client = factory.CreateClient();
var baseScanId = await CreateScanAsync(client);
var headScanId = await CreateScanAsync(client);
await SeedCallGraphSnapshotsAsync(factory.Services, baseScanId, headScanId);
var response = await client.GetAsync(
$"/api/v1/scans/{headScanId}/drift?baseScanId={baseScanId}&language=dotnet&includeFullPath=false");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var drift = await response.Content.ReadFromJsonAsync<ReachabilityDriftResult>();
Assert.NotNull(drift);
Assert.Equal(baseScanId, drift!.BaseScanId);
Assert.Equal(headScanId, drift.HeadScanId);
Assert.Equal("dotnet", drift.Language);
Assert.Single(drift.NewlyReachable);
Assert.Empty(drift.NewlyUnreachable);
var sink = drift.NewlyReachable[0];
Assert.Equal(DriftDirection.BecameReachable, sink.Direction);
Assert.Equal("sink", sink.SinkNodeId);
Assert.Equal(DriftCauseKind.GuardRemoved, sink.Cause.Kind);
var sinksResponse = await client.GetAsync($"/api/v1/drift/{drift.Id}/sinks?direction=became_reachable&offset=0&limit=10");
Assert.Equal(HttpStatusCode.OK, sinksResponse.StatusCode);
var sinksPayload = await sinksResponse.Content.ReadFromJsonAsync<DriftedSinksResponse>();
Assert.NotNull(sinksPayload);
Assert.Equal(drift.Id, sinksPayload!.DriftId);
Assert.Equal(DriftDirection.BecameReachable, sinksPayload.Direction);
Assert.Equal(0, sinksPayload.Offset);
Assert.Equal(10, sinksPayload.Limit);
Assert.Equal(1, sinksPayload.Count);
Assert.Single(sinksPayload.Sinks);
}
private static async Task SeedCallGraphSnapshotsAsync(IServiceProvider services, string baseScanId, string headScanId)
{
using var scope = services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<ICallGraphSnapshotRepository>();
var baseSnapshot = CreateSnapshot(
scanId: baseScanId,
edges: ImmutableArray<CallGraphEdge>.Empty);
var headSnapshot = CreateSnapshot(
scanId: headScanId,
edges: ImmutableArray.Create(new CallGraphEdge("entry", "sink", CallKind.Direct, "Demo.cs:1")));
await repo.StoreAsync(baseSnapshot);
await repo.StoreAsync(headSnapshot);
}
private static CallGraphSnapshot CreateSnapshot(string scanId, ImmutableArray<CallGraphEdge> edges)
{
var nodes = ImmutableArray.Create(
new CallGraphNode(
NodeId: "entry",
Symbol: "Demo.Entry",
File: "Demo.cs",
Line: 1,
Package: "pkg:generic/demo@1.0.0",
Visibility: Visibility.Public,
IsEntrypoint: true,
EntrypointType: EntrypointType.HttpHandler,
IsSink: false,
SinkCategory: null),
new CallGraphNode(
NodeId: "sink",
Symbol: "Demo.Sink",
File: "Demo.cs",
Line: 2,
Package: "pkg:generic/demo@1.0.0",
Visibility: Visibility.Public,
IsEntrypoint: false,
EntrypointType: null,
IsSink: true,
SinkCategory: SinkCategory.CmdExec));
var provisional = new CallGraphSnapshot(
ScanId: scanId,
GraphDigest: string.Empty,
Language: "dotnet",
ExtractedAt: DateTimeOffset.UnixEpoch,
Nodes: nodes,
Edges: edges,
EntrypointIds: ImmutableArray.Create("entry"),
SinkIds: ImmutableArray.Create("sink"));
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
}
private static async Task<string> CreateScanAsync(HttpClient client)
{
var response = await client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest
{
Image = new ScanImageDescriptor
{
Reference = "example.com/demo:1.0",
Digest = "sha256:0123456789abcdef"
}
});
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>();
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload!.ScanId));
return payload.ScanId;
}
private sealed record DriftedSinksResponse(
Guid DriftId,
DriftDirection Direction,
int Offset,
int Limit,
int Count,
DriftedSink[] Sinks);
}

View File

@@ -142,7 +142,7 @@ public sealed class RuntimeReconciliationTests
("comp-2", "libcrypto", "3.0.0", "pkg:deb/debian/libcrypto@3.0.0", new[] { "lib2hash" }, new[] { "/lib/libcrypto.so.3" })
});
var sbomJson = await Serializer.SerializeAsync(sbom);
var sbomJson = await SerializeSbomAsync(sbom);
var sbomBytes = Encoding.UTF8.GetBytes(sbomJson);
mockObjectStore.Store($"scanner-artifacts/imagebom/cyclonedx-json/{sbomHash}", sbomBytes);
@@ -231,7 +231,7 @@ public sealed class RuntimeReconciliationTests
("comp-1", "zlib", "1.2.11", "pkg:deb/debian/zlib@1.2.11", Array.Empty<string>(), new[] { "/usr/lib/libz.so.1" })
});
var sbomJson = await Serializer.SerializeAsync(sbom);
var sbomJson = await SerializeSbomAsync(sbom);
var sbomBytes = Encoding.UTF8.GetBytes(sbomJson);
mockObjectStore.Store($"scanner-artifacts/imagebom/cyclonedx-json/{sbomHash}", sbomBytes);
@@ -315,7 +315,7 @@ public sealed class RuntimeReconciliationTests
("comp-1", "test-lib", "1.0.0", "pkg:test/lib@1.0.0", new[] { "specifichash" }, Array.Empty<string>())
});
var sbomJson = await Serializer.SerializeAsync(sbom);
var sbomJson = await SerializeSbomAsync(sbom);
var sbomBytes = Encoding.UTF8.GetBytes(sbomJson);
mockObjectStore.Store($"scanner-artifacts/imagebom/cyclonedx-json/{sbomHash}", sbomBytes);
@@ -442,7 +442,7 @@ public sealed class RuntimeReconciliationTests
("comp-known-2", "another-lib", "2.0.0", "pkg:test/another@2.0.0", new[] { "knownhash2" }, Array.Empty<string>())
});
var sbomJson = await Serializer.SerializeAsync(sbom);
var sbomJson = await SerializeSbomAsync(sbom);
var sbomBytes = Encoding.UTF8.GetBytes(sbomJson);
mockObjectStore.Store($"scanner-artifacts/imagebom/cyclonedx-json/{sbomHash}", sbomBytes);
@@ -568,6 +568,13 @@ public sealed class RuntimeReconciliationTests
return bom;
}
private static async Task<string> SerializeSbomAsync(Bom sbom)
{
await using var buffer = new MemoryStream();
await Serializer.SerializeAsync(sbom, buffer);
return Encoding.UTF8.GetString(buffer.ToArray());
}
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
{
private readonly Dictionary<string, byte[]> _store = new(StringComparer.OrdinalIgnoreCase);

View File

@@ -0,0 +1,112 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Storage.ObjectStore;
using StellaOps.Scanner.WebService.Contracts;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class SbomEndpointsTests
{
[Fact]
public async Task SubmitSbomAcceptsCycloneDxJson()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
}, configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(new InMemoryArtifactObjectStore());
});
using var client = factory.CreateClient();
var scanId = await CreateScanAsync(client);
var sbomJson = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"components": []
}
""";
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom")
{
Content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json")
};
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<SbomAcceptedResponseDto>();
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload!.SbomId));
Assert.Equal("cyclonedx", payload.Format);
Assert.Equal(0, payload.ComponentCount);
Assert.StartsWith("sha256:", payload.Digest, StringComparison.Ordinal);
}
private static async Task<string> CreateScanAsync(HttpClient client)
{
var response = await client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest
{
Image = new ScanImageDescriptor
{
Reference = "example.com/demo:1.0",
Digest = "sha256:0123456789abcdef"
}
});
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>();
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload!.ScanId));
return payload.ScanId;
}
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
{
private readonly ConcurrentDictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(content);
using var buffer = new MemoryStream();
await content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
var key = $"{descriptor.Bucket}:{descriptor.Key}";
_objects[key] = buffer.ToArray();
}
public Task<Stream?> GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(descriptor);
var key = $"{descriptor.Bucket}:{descriptor.Key}";
if (!_objects.TryGetValue(key, out var bytes))
{
return Task.FromResult<Stream?>(null);
}
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
}
public Task DeleteAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(descriptor);
var key = $"{descriptor.Bucket}:{descriptor.Key}";
_objects.TryRemove(key, out _);
return Task.CompletedTask;
}
}
}

View File

@@ -13,7 +13,7 @@ using StellaOps.Scanner.WebService.Diagnostics;
namespace StellaOps.Scanner.WebService.Tests;
internal sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceStatus>
public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceStatus>
{
private readonly ScannerWebServicePostgresFixture postgresFixture;
private readonly Dictionary<string, string?> configuration = new()
@@ -72,6 +72,9 @@ internal sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceS
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.local");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", configuration["scanner:artifactStore:bucket"]);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_PREFETCH_ENABLED", "false");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_PROVIDER", "file");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_ROOT", Path.GetTempPath());
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_TENANT", "tenant-a");
if (configuration.TryGetValue("scanner:events:enabled", out var eventsEnabled))
{
Environment.SetEnvironmentVariable("SCANNER__EVENTS__ENABLED", eventsEnabled);
@@ -126,7 +129,7 @@ internal sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceS
if (disposing)
{
postgresFixture.DisposeAsync().AsTask().GetAwaiter().GetResult();
postgresFixture.DisposeAsync().GetAwaiter().GetResult();
}
}

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Replay.Core;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Storage.ObjectStore;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Replay;
using StellaOps.Scanner.WebService.Services;
using Xunit;

View File

@@ -11,6 +11,10 @@
<ProjectReference Include="../../StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres.Testing\\StellaOps.Infrastructure.Postgres.Testing.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\docs\events\samples\scanner.event.report.ready@1.sample.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>

View File

@@ -29,7 +29,7 @@ public sealed class SurfaceManifestStoreOptionsConfiguratorTests
new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()));
var environment = new StubSurfaceEnvironment(settings);
var cacheOptions = Options.Create(new SurfaceCacheOptions { RootDirectory = cacheRoot.FullName });
var cacheOptions = Microsoft.Extensions.Options.Options.Create(new SurfaceCacheOptions { RootDirectory = cacheRoot.FullName });
var configurator = new SurfaceManifestStoreOptionsConfigurator(environment, cacheOptions);
var options = new SurfaceManifestStoreOptions();