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