256 lines
11 KiB
C#
256 lines
11 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using StellaOps.Policy;
|
|
using StellaOps.Scanner.WebService.Contracts;
|
|
using StellaOps.Scanner.WebService.Services;
|
|
using System.Linq;
|
|
|
|
namespace StellaOps.Scanner.WebService.Tests;
|
|
|
|
public sealed class ReportsEndpointsTests
|
|
{
|
|
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
[Fact]
|
|
public async Task ReportsEndpointReturnsSignedEnvelope()
|
|
{
|
|
const string policyYaml = """
|
|
version: "1.0"
|
|
rules:
|
|
- name: Block Critical
|
|
severity: [Critical]
|
|
action: block
|
|
""";
|
|
|
|
var hmacKey = Convert.ToBase64String(Encoding.UTF8.GetBytes("scanner-report-hmac-key-2025!"));
|
|
|
|
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
|
{
|
|
configuration["scanner:signing:enabled"] = "true";
|
|
configuration["scanner:signing:keyId"] = "scanner-report-signing";
|
|
configuration["scanner:signing:algorithm"] = "hs256";
|
|
configuration["scanner:signing:keyPem"] = hmacKey;
|
|
configuration["scanner:features:enableSignedReports"] = "true";
|
|
});
|
|
|
|
var store = factory.Services.GetRequiredService<PolicySnapshotStore>();
|
|
await store.SaveAsync(
|
|
new PolicySnapshotContent(policyYaml, PolicyDocumentFormat.Yaml, "tester", "seed", "initial"),
|
|
CancellationToken.None);
|
|
|
|
using var client = factory.CreateClient();
|
|
|
|
var request = new ReportRequestDto
|
|
{
|
|
ImageDigest = "sha256:deadbeef",
|
|
Findings = new[]
|
|
{
|
|
new PolicyPreviewFindingDto
|
|
{
|
|
Id = "finding-1",
|
|
Severity = "Critical",
|
|
Source = "NVD",
|
|
Tags = new[] { "reachability:runtime" }
|
|
}
|
|
}
|
|
};
|
|
|
|
var response = await client.PostAsJsonAsync("/api/v1/reports", request);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
var raw = await response.Content.ReadAsStringAsync();
|
|
Assert.False(string.IsNullOrWhiteSpace(raw), raw);
|
|
var payload = JsonSerializer.Deserialize<ReportResponseDto>(raw, SerializerOptions);
|
|
Assert.NotNull(payload);
|
|
Assert.NotNull(payload!.Report);
|
|
Assert.NotNull(payload.Dsse);
|
|
Assert.StartsWith("report-", payload.Report.ReportId, StringComparison.Ordinal);
|
|
Assert.Equal("blocked", payload.Report.Verdict);
|
|
|
|
var dsse = payload.Dsse!;
|
|
Assert.Equal("application/vnd.stellaops.report+json", dsse.PayloadType);
|
|
var decodedPayload = Convert.FromBase64String(dsse.Payload);
|
|
var canonicalPayload = JsonSerializer.SerializeToUtf8Bytes(payload.Report, SerializerOptions);
|
|
var expectedBase64 = Convert.ToBase64String(canonicalPayload);
|
|
Assert.Equal(expectedBase64, dsse.Payload);
|
|
|
|
var reportVerdict = Assert.Single(payload.Report.Verdicts);
|
|
Assert.Equal("NVD", reportVerdict.SourceTrust);
|
|
Assert.Equal("runtime", reportVerdict.Reachability);
|
|
Assert.NotNull(reportVerdict.Inputs);
|
|
Assert.True(reportVerdict.Inputs!.ContainsKey("severityWeight"));
|
|
Assert.Equal(PolicyScoringConfig.Default.Version, reportVerdict.ConfigVersion);
|
|
|
|
var signature = Assert.Single(dsse.Signatures);
|
|
Assert.Equal("scanner-report-signing", signature.KeyId);
|
|
Assert.Equal("hs256", signature.Algorithm, ignoreCase: true);
|
|
|
|
using var hmac = new System.Security.Cryptography.HMACSHA256(Convert.FromBase64String(hmacKey));
|
|
var expectedSig = Convert.ToBase64String(hmac.ComputeHash(decodedPayload));
|
|
var actualSig = signature.Signature;
|
|
Assert.True(expectedSig == actualSig, $"expected:{expectedSig}, actual:{actualSig}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReportsEndpointValidatesDigest()
|
|
{
|
|
using var factory = new ScannerApplicationFactory();
|
|
using var client = factory.CreateClient();
|
|
|
|
var request = new ReportRequestDto
|
|
{
|
|
ImageDigest = "",
|
|
Findings = Array.Empty<PolicyPreviewFindingDto>()
|
|
};
|
|
|
|
var response = await client.PostAsJsonAsync("/api/v1/reports", request);
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReportsEndpointReturnsServiceUnavailableWhenPolicyMissing()
|
|
{
|
|
using var factory = new ScannerApplicationFactory();
|
|
using var client = factory.CreateClient();
|
|
|
|
var request = new ReportRequestDto
|
|
{
|
|
ImageDigest = "sha256:feedface",
|
|
Findings = new[]
|
|
{
|
|
new PolicyPreviewFindingDto { Id = "finding-1", Severity = "High" }
|
|
}
|
|
};
|
|
|
|
var response = await client.PostAsJsonAsync("/api/v1/reports", request);
|
|
Assert.Equal((HttpStatusCode)StatusCodes.Status503ServiceUnavailable, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReportsEndpointPublishesPlatformEvents()
|
|
{
|
|
const string policyYaml = """
|
|
version: "1.0"
|
|
rules:
|
|
- name: Block Critical
|
|
severity: [Critical]
|
|
action: block
|
|
""";
|
|
|
|
using var factory = new ScannerApplicationFactory().WithOverrides(
|
|
configuration =>
|
|
{
|
|
configuration["scanner:signing:enabled"] = "true";
|
|
configuration["scanner:signing:keyId"] = "scanner-report-signing";
|
|
configuration["scanner:signing:algorithm"] = "hs256";
|
|
configuration["scanner:signing:keyPem"] = Convert.ToBase64String(Encoding.UTF8.GetBytes("scanner-report-hmac-key-events!"));
|
|
configuration["scanner:features:enableSignedReports"] = "true";
|
|
configuration["scanner:events:enabled"] = "true";
|
|
configuration["scanner:events:driver"] = "redis";
|
|
configuration["scanner:events:dsn"] = "redis://tests";
|
|
configuration["scanner:events:stream"] = "stella.events.tests";
|
|
configuration["scanner:events:publishTimeoutSeconds"] = "5";
|
|
configuration["scanner:events:maxStreamLength"] = "100";
|
|
},
|
|
services =>
|
|
{
|
|
services.RemoveAll<IPlatformEventPublisher>();
|
|
services.AddSingleton<RecordingPlatformEventPublisher>();
|
|
services.AddSingleton<IPlatformEventPublisher>(sp => sp.GetRequiredService<RecordingPlatformEventPublisher>());
|
|
});
|
|
|
|
var store = factory.Services.GetRequiredService<PolicySnapshotStore>();
|
|
var saveResult = await store.SaveAsync(
|
|
new PolicySnapshotContent(policyYaml, PolicyDocumentFormat.Yaml, "tester", "seed", "initial"),
|
|
CancellationToken.None);
|
|
var revisionId = saveResult.Snapshot?.RevisionId ?? string.Empty;
|
|
|
|
var recorder = factory.Services.GetRequiredService<RecordingPlatformEventPublisher>();
|
|
|
|
using var client = factory.CreateClient();
|
|
|
|
var request = new ReportRequestDto
|
|
{
|
|
ImageDigest = "sha256:cafebabe",
|
|
Findings = new[]
|
|
{
|
|
new PolicyPreviewFindingDto
|
|
{
|
|
Id = "finding-42",
|
|
Severity = "Critical",
|
|
Repository = "acme/edge/api",
|
|
Tags = new[] { "reachability:runtime", "kev:CVE-2024-1234" }
|
|
}
|
|
}
|
|
};
|
|
|
|
var response = await client.PostAsJsonAsync("/api/v1/reports", request);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
Assert.Equal(2, recorder.Events.Count);
|
|
var ready = recorder.Events.Single(evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady);
|
|
var completed = recorder.Events.Single(evt => evt.Kind == OrchestratorEventKinds.ScannerScanCompleted);
|
|
|
|
Assert.Equal("default", ready.Tenant);
|
|
Assert.Equal("default", completed.Tenant);
|
|
Assert.NotEqual(Guid.Empty, ready.EventId);
|
|
Assert.NotEqual(Guid.Empty, completed.EventId);
|
|
|
|
var readyPayload = Assert.IsType<ReportReadyEventPayload>(ready.Payload);
|
|
var completedPayload = Assert.IsType<ScanCompletedEventPayload>(completed.Payload);
|
|
Assert.Equal(readyPayload.ReportId, completedPayload.ReportId);
|
|
Assert.Equal("sha256:cafebabe", completedPayload.ImageDigest);
|
|
|
|
Assert.Equal("fail", readyPayload.Verdict);
|
|
Assert.Equal("acme/edge", ready.Scope?.Namespace);
|
|
Assert.Equal("api", ready.Scope?.Repo);
|
|
Assert.Equal("sha256:cafebabe", ready.Scope?.Digest);
|
|
Assert.NotNull(readyPayload.Dsse);
|
|
Assert.Equal(readyPayload.ReportId, readyPayload.Report.ReportId);
|
|
Assert.Equal("http://localhost/ui/reports/" + readyPayload.ReportId, readyPayload.Links.Report?.Ui);
|
|
Assert.Equal("http://localhost/api/v1/reports/" + readyPayload.ReportId, readyPayload.Links.Report?.Api);
|
|
if (!string.IsNullOrWhiteSpace(revisionId))
|
|
{
|
|
Assert.Equal("http://localhost/ui/policy/revisions/" + revisionId, readyPayload.Links.Policy?.Ui);
|
|
Assert.Equal("http://localhost/api/v1/policy/revisions/" + revisionId, readyPayload.Links.Policy?.Api);
|
|
}
|
|
Assert.Equal("http://localhost/ui/attestations/" + readyPayload.ReportId, readyPayload.Links.Attestation?.Ui);
|
|
Assert.Equal("http://localhost/api/v1/reports/" + readyPayload.ReportId + "/attestation", readyPayload.Links.Attestation?.Api);
|
|
|
|
Assert.Equal("fail", completedPayload.Verdict);
|
|
Assert.NotEmpty(completedPayload.Findings);
|
|
Assert.Equal("finding-42", completedPayload.Findings[0].Id);
|
|
Assert.Equal("http://localhost/api/v1/reports/" + completedPayload.ReportId, completedPayload.Links.Report?.Api);
|
|
Assert.Equal("http://localhost/ui/reports/" + completedPayload.ReportId, completedPayload.Links.Report?.Ui);
|
|
if (!string.IsNullOrWhiteSpace(revisionId))
|
|
{
|
|
Assert.Equal("http://localhost/ui/policy/revisions/" + revisionId, completedPayload.Links.Policy?.Ui);
|
|
Assert.Equal("http://localhost/api/v1/policy/revisions/" + revisionId, completedPayload.Links.Policy?.Api);
|
|
}
|
|
Assert.Equal("http://localhost/ui/attestations/" + completedPayload.ReportId, completedPayload.Links.Attestation?.Ui);
|
|
Assert.Equal("http://localhost/api/v1/reports/" + completedPayload.ReportId + "/attestation", completedPayload.Links.Attestation?.Api);
|
|
}
|
|
|
|
private sealed class RecordingPlatformEventPublisher : IPlatformEventPublisher
|
|
{
|
|
public List<OrchestratorEvent> Events { get; } = new();
|
|
|
|
public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
|
|
{
|
|
Events.Add(@event);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|