Some checks failed
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
358 lines
14 KiB
C#
358 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using StellaOps.Policy;
|
|
using StellaOps.Scanner.Storage.Catalog;
|
|
using StellaOps.Scanner.Storage.Repositories;
|
|
using StellaOps.Scanner.WebService.Contracts;
|
|
using StellaOps.Zastava.Core.Contracts;
|
|
|
|
namespace StellaOps.Scanner.WebService.Tests;
|
|
|
|
public sealed class RuntimeEndpointsTests
|
|
{
|
|
[Fact]
|
|
public async Task RuntimeEventsEndpointPersistsEvents()
|
|
{
|
|
using var factory = new ScannerApplicationFactory();
|
|
using var client = factory.CreateClient();
|
|
|
|
var request = new RuntimeEventsIngestRequestDto
|
|
{
|
|
BatchId = "batch-1",
|
|
Events = new[]
|
|
{
|
|
CreateEnvelope("evt-001", buildId: "ABCDEF1234567890ABCDEF1234567890ABCDEF12"),
|
|
CreateEnvelope("evt-002", buildId: "abcdef1234567890abcdef1234567890abcdef12")
|
|
}
|
|
};
|
|
|
|
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
|
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
|
|
|
var payload = await response.Content.ReadFromJsonAsync<RuntimeEventsIngestResponseDto>();
|
|
Assert.NotNull(payload);
|
|
Assert.Equal(2, payload!.Accepted);
|
|
Assert.Equal(0, payload.Duplicates);
|
|
|
|
using var scope = factory.Services.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<RuntimeEventRepository>();
|
|
var stored = await repository.ListAsync(CancellationToken.None);
|
|
Assert.Equal(2, stored.Count);
|
|
Assert.Contains(stored, doc => doc.EventId == "evt-001");
|
|
Assert.All(stored, doc =>
|
|
{
|
|
Assert.Equal("tenant-alpha", doc.Tenant);
|
|
Assert.True(doc.ExpiresAt > doc.ReceivedAt);
|
|
Assert.Equal("sha256:deadbeef", doc.ImageDigest);
|
|
Assert.Equal("abcdef1234567890abcdef1234567890abcdef12", doc.BuildId);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RuntimeEventsEndpointRejectsUnsupportedSchema()
|
|
{
|
|
using var factory = new ScannerApplicationFactory();
|
|
using var client = factory.CreateClient();
|
|
|
|
var envelope = CreateEnvelope("evt-100", schemaVersion: "zastava.runtime.event@v2.0");
|
|
|
|
var request = new RuntimeEventsIngestRequestDto
|
|
{
|
|
Events = new[] { envelope }
|
|
};
|
|
|
|
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RuntimeEventsEndpointEnforcesRateLimit()
|
|
{
|
|
using var factory = new ScannerApplicationFactory(configuration =>
|
|
{
|
|
configuration["scanner:runtime:perNodeBurst"] = "1";
|
|
configuration["scanner:runtime:perNodeEventsPerSecond"] = "1";
|
|
configuration["scanner:runtime:perTenantBurst"] = "1";
|
|
configuration["scanner:runtime:perTenantEventsPerSecond"] = "1";
|
|
});
|
|
using var client = factory.CreateClient();
|
|
|
|
var request = new RuntimeEventsIngestRequestDto
|
|
{
|
|
Events = new[]
|
|
{
|
|
CreateEnvelope("evt-500"),
|
|
CreateEnvelope("evt-501")
|
|
}
|
|
};
|
|
|
|
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
|
|
Assert.Equal((HttpStatusCode)StatusCodes.Status429TooManyRequests, response.StatusCode);
|
|
Assert.NotNull(response.Headers.RetryAfter);
|
|
|
|
using var scope = factory.Services.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<RuntimeEventRepository>();
|
|
var count = await repository.CountAsync(CancellationToken.None);
|
|
Assert.Equal(0, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RuntimePolicyEndpointReturnsDecisions()
|
|
{
|
|
using var factory = new ScannerApplicationFactory(configuration =>
|
|
{
|
|
configuration["scanner:runtime:policyCacheTtlSeconds"] = "600";
|
|
});
|
|
|
|
const string imageDigest = "sha256:deadbeef";
|
|
|
|
using var client = factory.CreateClient();
|
|
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var artifacts = scope.ServiceProvider.GetRequiredService<ArtifactRepository>();
|
|
var links = scope.ServiceProvider.GetRequiredService<LinkRepository>();
|
|
var policyStore = scope.ServiceProvider.GetRequiredService<PolicySnapshotStore>();
|
|
var runtimeRepository = scope.ServiceProvider.GetRequiredService<RuntimeEventRepository>();
|
|
await runtimeRepository.TruncateAsync(CancellationToken.None);
|
|
|
|
const string policyYaml = """
|
|
version: "1.0"
|
|
rules:
|
|
- name: Block Critical
|
|
severity: [Critical]
|
|
action: block
|
|
""";
|
|
var saveResult = await policyStore.SaveAsync(
|
|
new PolicySnapshotContent(policyYaml, PolicyDocumentFormat.Yaml, "tester", "tests", "seed"),
|
|
CancellationToken.None);
|
|
Assert.True(saveResult.Success);
|
|
|
|
var snapshot = await policyStore.GetLatestAsync(CancellationToken.None);
|
|
Assert.NotNull(snapshot);
|
|
|
|
var sbomArtifactId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.ImageBom, "sha256:sbomdigest");
|
|
var attestationArtifactId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.Attestation, "sha256:attdigest");
|
|
|
|
await artifacts.UpsertAsync(new ArtifactDocument
|
|
{
|
|
Id = sbomArtifactId,
|
|
Type = ArtifactDocumentType.ImageBom,
|
|
Format = ArtifactDocumentFormat.CycloneDxJson,
|
|
MediaType = "application/json",
|
|
BytesSha256 = "sha256:sbomdigest",
|
|
RefCount = 1
|
|
}, CancellationToken.None);
|
|
|
|
await artifacts.UpsertAsync(new ArtifactDocument
|
|
{
|
|
Id = attestationArtifactId,
|
|
Type = ArtifactDocumentType.Attestation,
|
|
Format = ArtifactDocumentFormat.DsseJson,
|
|
MediaType = "application/vnd.dsse.envelope+json",
|
|
BytesSha256 = "sha256:attdigest",
|
|
RefCount = 1,
|
|
Rekor = new RekorReference { Uuid = "rekor-uuid", Url = "https://rekor.example/uuid/rekor-uuid", Index = 7 }
|
|
}, CancellationToken.None);
|
|
|
|
await links.UpsertAsync(new LinkDocument
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
FromType = LinkSourceType.Image,
|
|
FromDigest = imageDigest,
|
|
ArtifactId = sbomArtifactId,
|
|
CreatedAtUtc = DateTime.UtcNow
|
|
}, CancellationToken.None);
|
|
|
|
await links.UpsertAsync(new LinkDocument
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
FromType = LinkSourceType.Image,
|
|
FromDigest = imageDigest,
|
|
ArtifactId = attestationArtifactId,
|
|
CreatedAtUtc = DateTime.UtcNow
|
|
}, CancellationToken.None);
|
|
}
|
|
|
|
var ingestRequest = new RuntimeEventsIngestRequestDto
|
|
{
|
|
Events = new[]
|
|
{
|
|
CreateEnvelope("evt-210", imageDigest: imageDigest, buildId: "1122aabbccddeeff00112233445566778899aabb"),
|
|
CreateEnvelope("evt-211", imageDigest: imageDigest, buildId: "1122AABBCCDDEEFF00112233445566778899AABB")
|
|
}
|
|
};
|
|
var ingestResponse = await client.PostAsJsonAsync("/api/v1/runtime/events", ingestRequest);
|
|
Assert.Equal(HttpStatusCode.Accepted, ingestResponse.StatusCode);
|
|
|
|
var request = new RuntimePolicyRequestDto
|
|
{
|
|
Namespace = "payments",
|
|
Images = new[] { imageDigest, imageDigest },
|
|
Labels = new Dictionary<string, string> { ["app"] = "api" }
|
|
};
|
|
|
|
var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", request);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
var raw = await response.Content.ReadAsStringAsync();
|
|
Assert.False(string.IsNullOrWhiteSpace(raw), "Runtime policy response body was empty.");
|
|
var payload = JsonSerializer.Deserialize<RuntimePolicyResponseDto>(raw);
|
|
Assert.True(payload is not null, $"Runtime policy response: {raw}");
|
|
Assert.Equal(600, payload!.TtlSeconds);
|
|
Assert.NotNull(payload.PolicyRevision);
|
|
Assert.True(payload.ExpiresAtUtc > DateTimeOffset.UtcNow);
|
|
|
|
var decision = payload.Results[imageDigest];
|
|
Assert.Equal("pass", decision.PolicyVerdict);
|
|
Assert.True(decision.Signed);
|
|
Assert.True(decision.HasSbomReferrers);
|
|
Assert.True(decision.HasSbomLegacy);
|
|
Assert.Empty(decision.Reasons);
|
|
Assert.NotNull(decision.Rekor);
|
|
Assert.Equal("rekor-uuid", decision.Rekor!.Uuid);
|
|
Assert.True(decision.Rekor.Verified);
|
|
Assert.NotNull(decision.Confidence);
|
|
Assert.InRange(decision.Confidence!.Value, 0.0, 1.0);
|
|
Assert.False(decision.Quieted.GetValueOrDefault());
|
|
Assert.Null(decision.QuietedBy);
|
|
Assert.NotNull(decision.BuildIds);
|
|
Assert.Contains("1122aabbccddeeff00112233445566778899aabb", decision.BuildIds!);
|
|
var metadataString = decision.Metadata;
|
|
Console.WriteLine($"Runtime policy metadata: {metadataString ?? "<null>"}");
|
|
Assert.False(string.IsNullOrWhiteSpace(metadataString));
|
|
using var metadataDocument = JsonDocument.Parse(decision.Metadata!);
|
|
Assert.True(metadataDocument.RootElement.TryGetProperty("heuristics", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RuntimePolicyEndpointFlagsUnsignedAndMissingSbom()
|
|
{
|
|
using var factory = new ScannerApplicationFactory();
|
|
using var client = factory.CreateClient();
|
|
|
|
const string imageDigest = "sha256:feedface";
|
|
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var runtimeRepository = scope.ServiceProvider.GetRequiredService<RuntimeEventRepository>();
|
|
var policyStore = scope.ServiceProvider.GetRequiredService<PolicySnapshotStore>();
|
|
|
|
const string policyYaml = """
|
|
version: "1.0"
|
|
rules: []
|
|
""";
|
|
await policyStore.SaveAsync(
|
|
new PolicySnapshotContent(policyYaml, PolicyDocumentFormat.Yaml, "tester", "tests", "baseline"),
|
|
CancellationToken.None);
|
|
|
|
// Intentionally skip artifacts/links to simulate missing metadata.
|
|
await runtimeRepository.TruncateAsync(CancellationToken.None);
|
|
}
|
|
|
|
var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", new RuntimePolicyRequestDto
|
|
{
|
|
Namespace = "payments",
|
|
Images = new[] { imageDigest }
|
|
});
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var payload = await response.Content.ReadFromJsonAsync<RuntimePolicyResponseDto>();
|
|
Assert.NotNull(payload);
|
|
var decision = payload!.Results[imageDigest];
|
|
|
|
Assert.Equal("fail", decision.PolicyVerdict);
|
|
Assert.False(decision.Signed);
|
|
Assert.False(decision.HasSbomReferrers);
|
|
Assert.Contains("image.metadata.missing", decision.Reasons);
|
|
Assert.Contains("unsigned", decision.Reasons);
|
|
Assert.Contains("missing SBOM", decision.Reasons);
|
|
Assert.NotNull(decision.Confidence);
|
|
Assert.InRange(decision.Confidence!.Value, 0.0, 1.0);
|
|
if (!string.IsNullOrWhiteSpace(decision.Metadata))
|
|
{
|
|
using var failureMetadata = JsonDocument.Parse(decision.Metadata!);
|
|
if (failureMetadata.RootElement.TryGetProperty("heuristics", out var heuristicsElement))
|
|
{
|
|
var heuristics = heuristicsElement.EnumerateArray().Select(item => item.GetString()).ToArray();
|
|
Assert.Contains("image.metadata.missing", heuristics);
|
|
Assert.Contains("unsigned", heuristics);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RuntimePolicyEndpointValidatesRequest()
|
|
{
|
|
using var factory = new ScannerApplicationFactory();
|
|
using var client = factory.CreateClient();
|
|
|
|
var request = new RuntimePolicyRequestDto
|
|
{
|
|
Images = Array.Empty<string>()
|
|
};
|
|
|
|
var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", request);
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
private static RuntimeEventEnvelope CreateEnvelope(
|
|
string eventId,
|
|
string? schemaVersion = null,
|
|
string? imageDigest = null,
|
|
string? buildId = null)
|
|
{
|
|
var digest = string.IsNullOrWhiteSpace(imageDigest) ? "sha256:deadbeef" : imageDigest;
|
|
var runtimeEvent = new RuntimeEvent
|
|
{
|
|
EventId = eventId,
|
|
When = DateTimeOffset.UtcNow,
|
|
Kind = RuntimeEventKind.ContainerStart,
|
|
Tenant = "tenant-alpha",
|
|
Node = "node-a",
|
|
Runtime = new RuntimeEngine
|
|
{
|
|
Engine = "containerd",
|
|
Version = "1.7.0"
|
|
},
|
|
Workload = new RuntimeWorkload
|
|
{
|
|
Platform = "kubernetes",
|
|
Namespace = "default",
|
|
Pod = "api-123",
|
|
Container = "api",
|
|
ContainerId = "containerd://abc",
|
|
ImageRef = $"ghcr.io/example/api@{digest}"
|
|
},
|
|
Delta = new RuntimeDelta
|
|
{
|
|
BaselineImageDigest = digest
|
|
},
|
|
Process = new RuntimeProcess
|
|
{
|
|
Pid = 123,
|
|
Entrypoint = new[] { "/bin/start" },
|
|
EntryTrace = Array.Empty<RuntimeEntryTrace>(),
|
|
BuildId = buildId
|
|
}
|
|
};
|
|
|
|
if (schemaVersion is null)
|
|
{
|
|
return RuntimeEventEnvelope.Create(runtimeEvent, ZastavaContractVersions.RuntimeEvent);
|
|
}
|
|
|
|
return new RuntimeEventEnvelope
|
|
{
|
|
SchemaVersion = schemaVersion,
|
|
Event = runtimeEvent
|
|
};
|
|
}
|
|
}
|