300 lines
11 KiB
C#
300 lines
11 KiB
C#
using System.Collections.Generic;
|
|
using System.Formats.Cbor;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using StellaOps.Scanner.ProofSpine;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Scanner.WebService.Tests;
|
|
|
|
public sealed class ProofSpineEndpointsTests
|
|
{
|
|
private const string CborContentType = "application/cbor";
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task GetSpine_ReturnsSpine_WithVerification()
|
|
{
|
|
await using var factory = new ScannerApplicationFactory();
|
|
using var scope = factory.Services.CreateScope();
|
|
|
|
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
|
|
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
|
|
|
|
var spine = await builder
|
|
.ForArtifact("sha256:feedface")
|
|
.ForVulnerability("CVE-2025-0001")
|
|
.WithPolicyProfile("default")
|
|
.WithScanRun("scan-001")
|
|
.AddSbomSlice("sha256:sbom", new[] { "pkg:a", "pkg:b" }, toolId: "sbom", toolVersion: "1.0.0")
|
|
.AddPolicyEval(
|
|
policyDigest: "sha256:policy",
|
|
factors: new Dictionary<string, string> { ["policy"] = "default" },
|
|
verdict: "not_affected",
|
|
verdictReason: "component_not_present",
|
|
toolId: "policy",
|
|
toolVersion: "1.0.0")
|
|
.BuildAsync();
|
|
|
|
await repository.SaveAsync(spine);
|
|
|
|
var client = factory.CreateClient();
|
|
var response = await client.GetAsync($"/api/v1/spines/{spine.SpineId}");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
|
Assert.Equal(spine.SpineId, body.GetProperty("spineId").GetString());
|
|
|
|
var segments = body.GetProperty("segments");
|
|
Assert.True(segments.GetArrayLength() > 0);
|
|
Assert.True(body.TryGetProperty("verification", out _));
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task GetSpine_ReturnsCbor_WhenAcceptHeaderRequestsCbor()
|
|
{
|
|
await using var factory = new ScannerApplicationFactory();
|
|
using var scope = factory.Services.CreateScope();
|
|
|
|
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
|
|
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
|
|
|
|
var spine = await builder
|
|
.ForArtifact("sha256:feedface")
|
|
.ForVulnerability("CVE-2025-1001")
|
|
.WithPolicyProfile("default")
|
|
.WithScanRun("scan-cbor-001")
|
|
.AddSbomSlice("sha256:sbom", new[] { "pkg:a", "pkg:b" }, toolId: "sbom", toolVersion: "1.0.0")
|
|
.BuildAsync();
|
|
|
|
await repository.SaveAsync(spine);
|
|
|
|
var client = factory.CreateClient();
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/spines/{spine.SpineId}");
|
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(CborContentType));
|
|
|
|
using var response = await client.SendAsync(request);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal(CborContentType, response.Content.Headers.ContentType?.MediaType);
|
|
|
|
var cbor = await response.Content.ReadAsByteArrayAsync();
|
|
var decoded = ReadRootMap(cbor);
|
|
|
|
Assert.Equal(spine.SpineId, decoded["spineId"]);
|
|
Assert.True(decoded.ContainsKey("verification"));
|
|
Assert.True(((List<object?>)decoded["segments"]!).Count > 0);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ListSpinesByScan_ReturnsSummaries_WithSegmentCount()
|
|
{
|
|
await using var factory = new ScannerApplicationFactory();
|
|
using var scope = factory.Services.CreateScope();
|
|
|
|
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
|
|
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
|
|
|
|
var spine = await builder
|
|
.ForArtifact("sha256:feedface")
|
|
.ForVulnerability("CVE-2025-0002")
|
|
.WithPolicyProfile("default")
|
|
.WithScanRun("scan-002")
|
|
.AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0")
|
|
.AddPolicyEval(
|
|
policyDigest: "sha256:policy",
|
|
factors: new Dictionary<string, string> { ["policy"] = "default" },
|
|
verdict: "affected",
|
|
verdictReason: "reachable",
|
|
toolId: "policy",
|
|
toolVersion: "1.0.0")
|
|
.BuildAsync();
|
|
|
|
await repository.SaveAsync(spine);
|
|
|
|
var client = factory.CreateClient();
|
|
var response = await client.GetAsync("/api/v1/scans/scan-002/spines");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
|
var items = body.GetProperty("items");
|
|
Assert.Equal(1, items.GetArrayLength());
|
|
Assert.Equal(spine.SpineId, items[0].GetProperty("spineId").GetString());
|
|
Assert.True(items[0].GetProperty("segmentCount").GetInt32() > 0);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ListSpinesByScan_ReturnsCbor_WhenAcceptHeaderRequestsCbor()
|
|
{
|
|
await using var factory = new ScannerApplicationFactory();
|
|
using var scope = factory.Services.CreateScope();
|
|
|
|
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
|
|
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
|
|
|
|
var spine = await builder
|
|
.ForArtifact("sha256:feedface")
|
|
.ForVulnerability("CVE-2025-1002")
|
|
.WithPolicyProfile("default")
|
|
.WithScanRun("scan-cbor-002")
|
|
.AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0")
|
|
.BuildAsync();
|
|
|
|
await repository.SaveAsync(spine);
|
|
|
|
var client = factory.CreateClient();
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/scans/scan-cbor-002/spines");
|
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(CborContentType));
|
|
|
|
using var response = await client.SendAsync(request);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal(CborContentType, response.Content.Headers.ContentType?.MediaType);
|
|
|
|
var cbor = await response.Content.ReadAsByteArrayAsync();
|
|
var decoded = ReadRootMap(cbor);
|
|
var items = (List<object?>)decoded["items"]!;
|
|
|
|
Assert.Single(items);
|
|
var first = (Dictionary<string, object?>)items[0]!;
|
|
Assert.Equal(spine.SpineId, first["spineId"]);
|
|
Assert.True((int)first["segmentCount"]! > 0);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task GetSpine_ReturnsInvalidStatus_WhenSegmentTampered()
|
|
{
|
|
await using var factory = new ScannerApplicationFactory();
|
|
using var scope = factory.Services.CreateScope();
|
|
|
|
using StellaOps.TestKit;
|
|
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
|
|
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
|
|
|
|
var spine = await builder
|
|
.ForArtifact("sha256:feedface")
|
|
.ForVulnerability("CVE-2025-0003")
|
|
.WithPolicyProfile("default")
|
|
.WithScanRun("scan-003")
|
|
.AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0")
|
|
.AddPolicyEval(
|
|
policyDigest: "sha256:policy",
|
|
factors: new Dictionary<string, string> { ["policy"] = "default" },
|
|
verdict: "affected",
|
|
verdictReason: "reachable",
|
|
toolId: "policy",
|
|
toolVersion: "1.0.0")
|
|
.BuildAsync();
|
|
|
|
var tamperedSegment = spine.Segments[0] with { ResultHash = spine.Segments[0].ResultHash + "00" };
|
|
var tampered = spine with { Segments = new[] { tamperedSegment, spine.Segments[1] } };
|
|
|
|
await repository.SaveAsync(tampered);
|
|
|
|
var client = factory.CreateClient();
|
|
var response = await client.GetAsync($"/api/v1/spines/{spine.SpineId}");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
|
var segments = body.GetProperty("segments");
|
|
Assert.Equal("invalid", segments[0].GetProperty("status").GetString());
|
|
}
|
|
|
|
private static Dictionary<string, object?> ReadRootMap(byte[] cbor)
|
|
{
|
|
var reader = new CborReader(cbor, CborConformanceMode.Canonical);
|
|
return ReadMap(reader);
|
|
}
|
|
|
|
private static Dictionary<string, object?> ReadMap(CborReader reader)
|
|
{
|
|
var length = reader.ReadStartMap();
|
|
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
|
|
if (length is not null)
|
|
{
|
|
for (var i = 0; i < length.Value; i++)
|
|
{
|
|
var key = reader.ReadTextString();
|
|
result[key] = ReadValue(reader);
|
|
}
|
|
reader.ReadEndMap();
|
|
return result;
|
|
}
|
|
|
|
while (reader.PeekState() != CborReaderState.EndMap)
|
|
{
|
|
var key = reader.ReadTextString();
|
|
result[key] = ReadValue(reader);
|
|
}
|
|
|
|
reader.ReadEndMap();
|
|
return result;
|
|
}
|
|
|
|
private static List<object?> ReadArray(CborReader reader)
|
|
{
|
|
var length = reader.ReadStartArray();
|
|
var result = new List<object?>();
|
|
|
|
if (length is not null)
|
|
{
|
|
for (var i = 0; i < length.Value; i++)
|
|
{
|
|
result.Add(ReadValue(reader));
|
|
}
|
|
reader.ReadEndArray();
|
|
return result;
|
|
}
|
|
|
|
while (reader.PeekState() != CborReaderState.EndArray)
|
|
{
|
|
result.Add(ReadValue(reader));
|
|
}
|
|
|
|
reader.ReadEndArray();
|
|
return result;
|
|
}
|
|
|
|
private static object? ReadValue(CborReader reader)
|
|
{
|
|
switch (reader.PeekState())
|
|
{
|
|
case CborReaderState.StartMap:
|
|
return ReadMap(reader);
|
|
case CborReaderState.StartArray:
|
|
return ReadArray(reader);
|
|
case CborReaderState.TextString:
|
|
return reader.ReadTextString();
|
|
case CborReaderState.UnsignedInteger:
|
|
return (int)reader.ReadUInt64();
|
|
case CborReaderState.NegativeInteger:
|
|
return (int)reader.ReadInt64();
|
|
case CborReaderState.DoublePrecisionFloat:
|
|
return reader.ReadDouble();
|
|
case CborReaderState.SinglePrecisionFloat:
|
|
return reader.ReadSingle();
|
|
case CborReaderState.HalfPrecisionFloat:
|
|
return reader.ReadHalf();
|
|
case CborReaderState.Boolean:
|
|
return reader.ReadBoolean();
|
|
case CborReaderState.Null:
|
|
reader.ReadNull();
|
|
return null;
|
|
default:
|
|
throw new InvalidOperationException($"Unexpected CBOR state {reader.PeekState()}");
|
|
}
|
|
}
|
|
}
|