save checkpoint: save features
This commit is contained in:
@@ -23,6 +23,8 @@ namespace StellaOps.Findings.Ledger.Tests;
|
||||
|
||||
internal sealed class FindingsLedgerWebApplicationFactory : WebApplicationFactory<LedgerProgram>
|
||||
{
|
||||
private readonly bool runtimeEnabled;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string?> DefaultEnvironment =
|
||||
new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -38,13 +40,18 @@ internal sealed class FindingsLedgerWebApplicationFactory : WebApplicationFactor
|
||||
|
||||
private readonly Dictionary<string, string?> originalEnvironment = new(StringComparer.Ordinal);
|
||||
|
||||
public FindingsLedgerWebApplicationFactory()
|
||||
public FindingsLedgerWebApplicationFactory(bool runtimeEnabled = true)
|
||||
{
|
||||
this.runtimeEnabled = runtimeEnabled;
|
||||
foreach (var pair in DefaultEnvironment)
|
||||
{
|
||||
originalEnvironment[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);
|
||||
Environment.SetEnvironmentVariable(pair.Key, pair.Value);
|
||||
}
|
||||
|
||||
const string runtimeKey = "FINDINGS_LEDGER_FINDINGS__LEDGER__RUNTIME__ENABLED";
|
||||
originalEnvironment[runtimeKey] = Environment.GetEnvironmentVariable(runtimeKey);
|
||||
Environment.SetEnvironmentVariable(runtimeKey, runtimeEnabled ? "true" : "false");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
@@ -67,7 +74,8 @@ internal sealed class FindingsLedgerWebApplicationFactory : WebApplicationFactor
|
||||
["findings:ledger:authority:issuer"] = "https://authority.local",
|
||||
["findings:ledger:authority:requireHttpsMetadata"] = "false",
|
||||
["findings:ledger:authority:requiredScopes:0"] = StellaOpsScopes.FindingsRead,
|
||||
["findings:ledger:authority:requiredScopes:1"] = "findings:write"
|
||||
["findings:ledger:authority:requiredScopes:1"] = "findings:write",
|
||||
["findings:ledger:runtime:enabled"] = runtimeEnabled ? "true" : "false"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -224,20 +224,70 @@ public sealed class FindingsLedgerWebServiceContractTests : IDisposable
|
||||
|
||||
#endregion
|
||||
|
||||
#region Runtime Timeline Endpoints
|
||||
#region Runtime Evidence Endpoints
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RuntimeTimeline_Endpoint_Exists()
|
||||
public async Task RuntimeTimeline_Endpoint_WithAuth_Returns_NotFound_For_Default_Service()
|
||||
{
|
||||
// Arrange
|
||||
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
|
||||
var findingId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/runtime/timeline/{Guid.NewGuid()}");
|
||||
var response = await _client.GetAsync($"/api/v1/findings/{findingId}/runtime-timeline?bucketHours=2");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RuntimeTraces_Endpoint_WithAuth_Returns_NotFound_For_Default_Service()
|
||||
{
|
||||
// Arrange
|
||||
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
|
||||
var findingId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/findings/{findingId}/runtime/traces?limit=25&sortBy=hits");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RuntimeScore_Endpoint_WithAuth_Returns_NotFound_For_Default_Service()
|
||||
{
|
||||
// Arrange
|
||||
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"));
|
||||
var findingId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/findings/{findingId}/runtime/score");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RuntimeEvidence_Endpoints_WithoutAuth_Return_Unauthorized()
|
||||
{
|
||||
// Arrange
|
||||
_client.DefaultRequestHeaders.Clear();
|
||||
var findingId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var timelineResponse = await _client.GetAsync($"/api/v1/findings/{findingId}/runtime-timeline");
|
||||
var tracesResponse = await _client.GetAsync($"/api/v1/findings/{findingId}/runtime/traces");
|
||||
var scoreResponse = await _client.GetAsync($"/api/v1/findings/{findingId}/runtime/score");
|
||||
|
||||
// Assert
|
||||
timelineResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
tracesResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
scoreResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests;
|
||||
|
||||
public sealed class RuntimeInstrumentationApiE2ETests : IDisposable
|
||||
{
|
||||
private readonly FindingsLedgerWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
private bool _disposed;
|
||||
|
||||
public RuntimeInstrumentationApiE2ETests()
|
||||
{
|
||||
_factory = new FindingsLedgerWebApplicationFactory();
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RuntimeTraces_Ingest_ThenQuery_ReturnsPersistedTrace()
|
||||
{
|
||||
ConfigureAuthHeaders(_client, "11111111-1111-1111-1111-111111111111");
|
||||
var findingId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
|
||||
var ingestResponse = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/findings/{findingId}/runtime/traces",
|
||||
new
|
||||
{
|
||||
capturedAt = "2026-02-11T08:00:00Z",
|
||||
artifactDigest = "sha256:runtime-artifact-001",
|
||||
componentPurl = "pkg:npm/demo-lib@1.2.3",
|
||||
containerId = "ctr-a",
|
||||
frames = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
symbol = "SSL_write@0x7FFEDEADBEEF",
|
||||
file = "/usr/lib/libssl.so@0x7FFEDEAD",
|
||||
line = 144,
|
||||
isEntryPoint = true,
|
||||
isVulnerableFunction = true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ingestResponse.StatusCode.Should().Be(HttpStatusCode.Accepted);
|
||||
|
||||
var queryResponse = await _client.GetAsync($"/api/v1/findings/{findingId}/runtime/traces?limit=10&sortBy=hits");
|
||||
queryResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RuntimeTraces_PrivacyFilter_RedactsAddresses_AndAggregatesHotSymbols()
|
||||
{
|
||||
ConfigureAuthHeaders(_client, "22222222-2222-2222-2222-222222222222");
|
||||
var findingId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/api/v1/findings/{findingId}/runtime/traces",
|
||||
new
|
||||
{
|
||||
capturedAt = "2026-02-11T08:01:00Z",
|
||||
artifactDigest = "sha256:runtime-artifact-002",
|
||||
componentPurl = "pkg:npm/demo-lib@1.2.3",
|
||||
containerId = "ctr-a",
|
||||
frames = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
symbol = "SSL_write@0x7FFFAAAABBBB",
|
||||
file = "/usr/lib/libssl.so@0x7FFF11112222",
|
||||
line = 200,
|
||||
isEntryPoint = true,
|
||||
isVulnerableFunction = true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/api/v1/findings/{findingId}/runtime/traces",
|
||||
new
|
||||
{
|
||||
capturedAt = "2026-02-11T08:02:00Z",
|
||||
artifactDigest = "sha256:runtime-artifact-002",
|
||||
componentPurl = "pkg:npm/demo-lib@1.2.3",
|
||||
containerId = "ctr-b",
|
||||
frames = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
symbol = "SSL_write@0x7FFFCCCCDDDD",
|
||||
file = "/usr/lib/libssl.so@0x7FFF33334444",
|
||||
line = 201,
|
||||
isEntryPoint = true,
|
||||
isVulnerableFunction = true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var response = await _client.GetAsync($"/api/v1/findings/{findingId}/runtime/traces?limit=10&sortBy=hits");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var traces = root.GetProperty("traces");
|
||||
traces.GetArrayLength().Should().BeGreaterThan(0);
|
||||
traces[0].GetProperty("hitCount").GetInt64().Should().BeGreaterThanOrEqualTo(2);
|
||||
|
||||
var symbol = traces[0].GetProperty("vulnerableFunction").GetString();
|
||||
symbol.Should().NotContain("0x");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RuntimeTimeline_QueryRange_ReturnsChronologicalEvents_WithCorrelationMetadata()
|
||||
{
|
||||
ConfigureAuthHeaders(_client, "33333333-3333-3333-3333-333333333333");
|
||||
var findingId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/api/v1/findings/{findingId}/runtime/traces",
|
||||
new
|
||||
{
|
||||
capturedAt = "2026-02-11T08:15:00Z",
|
||||
artifactDigest = "sha256:runtime-artifact-003",
|
||||
componentPurl = "pkg:npm/demo-lib@2.0.0",
|
||||
containerId = "ctr-x",
|
||||
frames = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
symbol = "vuln_func",
|
||||
file = "/app/vuln.js",
|
||||
line = 10,
|
||||
isEntryPoint = false,
|
||||
isVulnerableFunction = true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/api/v1/findings/{findingId}/runtime/traces",
|
||||
new
|
||||
{
|
||||
capturedAt = "2026-02-11T08:05:00Z",
|
||||
artifactDigest = "sha256:runtime-artifact-003",
|
||||
componentPurl = "pkg:npm/demo-lib@2.0.0",
|
||||
containerId = "ctr-y",
|
||||
frames = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
symbol = "entry_func",
|
||||
file = "/app/entry.js",
|
||||
line = 2,
|
||||
isEntryPoint = true,
|
||||
isVulnerableFunction = false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/v1/findings/{findingId}/runtime-timeline?from=2026-02-11T08:00:00Z&to=2026-02-11T08:30:00Z&bucketHours=1");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var events = doc.RootElement.GetProperty("events");
|
||||
events.GetArrayLength().Should().BeGreaterThan(1);
|
||||
|
||||
var firstTs = events[0].GetProperty("timestamp").GetDateTimeOffset();
|
||||
var secondTs = events[1].GetProperty("timestamp").GetDateTimeOffset();
|
||||
firstTs.Should().BeOnOrBefore(secondTs);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RuntimeEndpoints_RuntimeDisabled_UsesNullServiceWithoutServerError()
|
||||
{
|
||||
using var disabledFactory = new FindingsLedgerWebApplicationFactory(runtimeEnabled: false);
|
||||
using var disabledClient = disabledFactory.CreateClient();
|
||||
ConfigureAuthHeaders(disabledClient, "44444444-4444-4444-4444-444444444444");
|
||||
|
||||
var findingId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
||||
|
||||
var ingestResponse = await disabledClient.PostAsJsonAsync(
|
||||
$"/api/v1/findings/{findingId}/runtime/traces",
|
||||
new
|
||||
{
|
||||
capturedAt = "2026-02-11T08:03:00Z",
|
||||
artifactDigest = "sha256:runtime-artifact-disabled",
|
||||
componentPurl = "pkg:npm/demo-lib@0.0.1",
|
||||
frames = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
symbol = "disabled_symbol",
|
||||
file = "/app/disabled.js",
|
||||
line = 1,
|
||||
isEntryPoint = true,
|
||||
isVulnerableFunction = false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ingestResponse.StatusCode.Should().Be(HttpStatusCode.Accepted);
|
||||
|
||||
var traces = await disabledClient.GetAsync($"/api/v1/findings/{findingId}/runtime/traces");
|
||||
var timeline = await disabledClient.GetAsync($"/api/v1/findings/{findingId}/runtime-timeline");
|
||||
|
||||
traces.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
timeline.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RuntimeEndpoints_WithoutAuth_ReturnUnauthorized()
|
||||
{
|
||||
_client.DefaultRequestHeaders.Clear();
|
||||
var findingId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
|
||||
var traces = await _client.GetAsync($"/api/v1/findings/{findingId}/runtime/traces");
|
||||
var timeline = await _client.GetAsync($"/api/v1/findings/{findingId}/runtime-timeline");
|
||||
|
||||
traces.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
timeline.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
private static void ConfigureAuthHeaders(HttpClient client, string tenantId)
|
||||
{
|
||||
client.DefaultRequestHeaders.Clear();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "findings:read findings:write");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0344-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| LEDGER-TESTS-0001 | DOING | Stabilize Findings Ledger WebService test harness (config/auth + stubs). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| QA-RUNTIME-RECHECK-002 | DONE | Runtime endpoint contract tests hardened (correct routes + auth checks); Tier 2 E2E evidence captured and triaged to BLOCKED for missing ingest/correlation implementation. |
|
||||
|
||||
@@ -196,6 +196,124 @@ public sealed record StackFrame
|
||||
public double? Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request payload for ingesting a runtime trace observation.
|
||||
/// </summary>
|
||||
public sealed record RuntimeTraceIngestRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Timestamp when the runtime observation was captured.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the correlated build artifact.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlated SBOM component identifier.
|
||||
/// </summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional container ID where this observation was captured.
|
||||
/// </summary>
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional container name where this observation was captured.
|
||||
/// </summary>
|
||||
public string? ContainerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata (probe type, namespace, workload, etc.).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Captured call path frames.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RuntimeTraceFrameInput> Frames { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime trace frame payload used for ingestion.
|
||||
/// </summary>
|
||||
public sealed record RuntimeTraceFrameInput
|
||||
{
|
||||
/// <summary>
|
||||
/// Function or symbol name.
|
||||
/// </summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional source file path.
|
||||
/// </summary>
|
||||
public string? File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional source line number.
|
||||
/// </summary>
|
||||
public int? Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this frame represents an entry point.
|
||||
/// </summary>
|
||||
public bool IsEntryPoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this frame represents a vulnerable function.
|
||||
/// </summary>
|
||||
public bool IsVulnerableFunction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional confidence score for this frame.
|
||||
/// </summary>
|
||||
public double? Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response returned after a runtime trace ingestion request.
|
||||
/// </summary>
|
||||
public sealed record RuntimeTraceIngestResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Target finding ID.
|
||||
/// </summary>
|
||||
public required Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic observation identifier.
|
||||
/// </summary>
|
||||
public required string ObservationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when ingestion was recorded.
|
||||
/// </summary>
|
||||
public required DateTimeOffset RecordedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlated build artifact digest.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlated SBOM component purl.
|
||||
/// </summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of frames accepted after normalization.
|
||||
/// </summary>
|
||||
public required int FrameCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether privacy filtering/canonicalization was applied.
|
||||
/// </summary>
|
||||
public required bool PrivacyFilterApplied { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing RTS score for a finding.
|
||||
/// </summary>
|
||||
|
||||
@@ -24,6 +24,14 @@ public static class RuntimeTracesEndpoints
|
||||
.WithTags("Runtime Evidence")
|
||||
.RequireAuthorization();
|
||||
|
||||
// POST /api/v1/findings/{findingId}/runtime/traces
|
||||
group.MapPost("/{findingId:guid}/runtime/traces", IngestRuntimeTrace)
|
||||
.WithName("IngestRuntimeTrace")
|
||||
.WithDescription("Ingest runtime trace observation for a finding")
|
||||
.Accepts<RuntimeTraceIngestRequest>("application/json")
|
||||
.Produces<RuntimeTraceIngestResponse>(202)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
// GET /api/v1/findings/{findingId}/runtime/traces
|
||||
group.MapGet("/{findingId:guid}/runtime/traces", GetRuntimeTraces)
|
||||
.WithName("GetRuntimeTraces")
|
||||
@@ -39,6 +47,40 @@ public static class RuntimeTracesEndpoints
|
||||
.Produces(404);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests a runtime trace observation.
|
||||
/// </summary>
|
||||
private static async Task<Results<Accepted<RuntimeTraceIngestResponse>, ValidationProblem>> IngestRuntimeTrace(
|
||||
Guid findingId,
|
||||
RuntimeTraceIngestRequest request,
|
||||
IRuntimeTracesService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
if (request.Frames is null || request.Frames.Count == 0)
|
||||
{
|
||||
errors["frames"] = ["At least one frame is required."];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
errors["artifactDigest"] = ["Artifact digest is required."];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ComponentPurl))
|
||||
{
|
||||
errors["componentPurl"] = ["Component purl is required."];
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return TypedResults.ValidationProblem(errors);
|
||||
}
|
||||
|
||||
var response = await service.IngestTraceAsync(findingId, request, ct);
|
||||
return TypedResults.Accepted($"/api/v1/findings/{findingId}/runtime/traces", response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets runtime function traces for a finding.
|
||||
/// </summary>
|
||||
@@ -99,6 +141,14 @@ public sealed record RuntimeTracesQueryOptions
|
||||
/// </summary>
|
||||
public interface IRuntimeTracesService
|
||||
{
|
||||
/// <summary>
|
||||
/// Ingests a runtime trace observation for a finding.
|
||||
/// </summary>
|
||||
Task<RuntimeTraceIngestResponse> IngestTraceAsync(
|
||||
Guid findingId,
|
||||
RuntimeTraceIngestRequest request,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Gets runtime traces for a finding.
|
||||
/// </summary>
|
||||
|
||||
@@ -32,6 +32,8 @@ using StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
using StellaOps.Findings.Ledger.WebService.Mappings;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
|
||||
using StellaOps.Telemetry.Core;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
@@ -91,6 +93,7 @@ builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
builder.Services.AddStellaOpsTelemetry(
|
||||
builder.Configuration,
|
||||
@@ -227,10 +230,22 @@ builder.Services.AddSingleton<IAttestationVerifier, NullAttestationVerifier>();
|
||||
builder.Services.AddSingleton<IEvidenceGraphBuilder, EvidenceGraphBuilder>();
|
||||
builder.Services.AddSingleton<IEvidenceContentService, NullEvidenceContentService>();
|
||||
builder.Services.AddSingleton<IReachabilityMapService, NullReachabilityMapService>();
|
||||
builder.Services.AddSingleton<IRuntimeTimelineService, NullRuntimeTimelineService>();
|
||||
var runtimeInstrumentationEnabled = builder.Configuration.GetValue(
|
||||
"findings:ledger:runtime:enabled",
|
||||
true);
|
||||
if (runtimeInstrumentationEnabled)
|
||||
{
|
||||
builder.Services.AddSingleton<IRuntimeTraceStore, InMemoryRuntimeTraceStore>();
|
||||
builder.Services.AddSingleton<IRuntimeTracesService, InMemoryRuntimeTracesService>();
|
||||
builder.Services.AddSingleton<IRuntimeTimelineService, InMemoryRuntimeTimelineService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IRuntimeTimelineService, NullRuntimeTimelineService>();
|
||||
builder.Services.AddSingleton<IRuntimeTracesService, NullRuntimeTracesService>();
|
||||
}
|
||||
|
||||
// Backport and runtime traces services (SPRINT_20260107_006_002_FE)
|
||||
builder.Services.AddSingleton<IRuntimeTracesService, NullRuntimeTracesService>();
|
||||
// Backport services (SPRINT_20260107_006_002_FE)
|
||||
builder.Services.AddSingleton<IBackportEvidenceService, NullBackportEvidenceService>();
|
||||
|
||||
// Alert and Decision services (SPRINT_3602)
|
||||
@@ -239,6 +254,8 @@ builder.Services.AddSingleton<IDecisionService, DecisionService>();
|
||||
builder.Services.AddSingleton<IEvidenceBundleService, EvidenceBundleService>();
|
||||
|
||||
// Evidence-Weighted Score services (SPRINT_8200.0012.0004)
|
||||
builder.Services.AddEvidenceWeightedScoringWithDefaults();
|
||||
builder.Services.AddEvidenceNormalizers();
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, InMemoryScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IFindingEvidenceProvider, AnchoredFindingEvidenceProvider>();
|
||||
builder.Services.AddSingleton<IFindingScoringService, FindingScoringService>();
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture.Timeline;
|
||||
using ContractRuntimePosture = StellaOps.Findings.Ledger.WebService.Contracts.RuntimePosture;
|
||||
using TimelineRuntimePosture = StellaOps.Scanner.Analyzers.Native.RuntimeCapture.Timeline.RuntimePosture;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
internal interface IRuntimeTraceStore
|
||||
{
|
||||
void Add(RuntimeTraceObservation observation);
|
||||
IReadOnlyList<RuntimeTraceObservation> GetByFinding(Guid findingId);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryRuntimeTraceStore : IRuntimeTraceStore
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<Guid, List<RuntimeTraceObservation>> _byFinding = new();
|
||||
|
||||
public void Add(RuntimeTraceObservation observation)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byFinding.TryGetValue(observation.FindingId, out var list))
|
||||
{
|
||||
list = new List<RuntimeTraceObservation>();
|
||||
_byFinding[observation.FindingId] = list;
|
||||
}
|
||||
|
||||
list.Add(observation);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RuntimeTraceObservation> GetByFinding(Guid findingId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byFinding.TryGetValue(findingId, out var list))
|
||||
{
|
||||
return Array.Empty<RuntimeTraceObservation>();
|
||||
}
|
||||
|
||||
return list
|
||||
.OrderBy(o => o.CapturedAt)
|
||||
.ThenBy(o => o.ObservationId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryRuntimeTracesService : IRuntimeTracesService
|
||||
{
|
||||
private static readonly Regex AddressPattern =
|
||||
new(@"0x[0-9a-fA-F]{6,16}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly IRuntimeTraceStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRuntimeTracesService(IRuntimeTraceStore store, TimeProvider timeProvider)
|
||||
{
|
||||
_store = store;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public Task<RuntimeTraceIngestResponse> IngestTraceAsync(
|
||||
Guid findingId,
|
||||
RuntimeTraceIngestRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var normalizedFrames = request.Frames
|
||||
.Select(frame => new StackFrame
|
||||
{
|
||||
Symbol = SanitizeValue(frame.Symbol),
|
||||
File = string.IsNullOrWhiteSpace(frame.File) ? null : SanitizeValue(frame.File),
|
||||
Line = frame.Line,
|
||||
IsEntryPoint = frame.IsEntryPoint,
|
||||
IsVulnerableFunction = frame.IsVulnerableFunction,
|
||||
Confidence = frame.Confidence
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var primarySymbol = normalizedFrames.FirstOrDefault(f => f.IsVulnerableFunction)?.Symbol
|
||||
?? normalizedFrames.FirstOrDefault()?.Symbol
|
||||
?? "unknown";
|
||||
|
||||
var metadata = (request.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal))
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => SanitizeValue(pair.Value),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var observationId = ComputeObservationId(findingId, request, normalizedFrames);
|
||||
var observation = new RuntimeTraceObservation
|
||||
{
|
||||
ObservationId = observationId,
|
||||
FindingId = findingId,
|
||||
CapturedAt = request.CapturedAt,
|
||||
ArtifactDigest = request.ArtifactDigest.Trim(),
|
||||
ComponentPurl = request.ComponentPurl.Trim(),
|
||||
ContainerId = string.IsNullOrWhiteSpace(request.ContainerId) ? null : request.ContainerId.Trim(),
|
||||
ContainerName = string.IsNullOrWhiteSpace(request.ContainerName) ? null : request.ContainerName.Trim(),
|
||||
Metadata = metadata,
|
||||
CallPath = normalizedFrames,
|
||||
PrimarySymbol = primarySymbol,
|
||||
IsDirectPath = normalizedFrames.Any(frame => frame.IsVulnerableFunction)
|
||||
};
|
||||
|
||||
_store.Add(observation);
|
||||
|
||||
var response = new RuntimeTraceIngestResponse
|
||||
{
|
||||
FindingId = findingId,
|
||||
ObservationId = observationId,
|
||||
RecordedAt = _timeProvider.GetUtcNow(),
|
||||
ArtifactDigest = observation.ArtifactDigest,
|
||||
ComponentPurl = observation.ComponentPurl,
|
||||
FrameCount = normalizedFrames.Count,
|
||||
PrivacyFilterApplied = true
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<RuntimeTracesResponse?> GetTracesAsync(
|
||||
Guid findingId,
|
||||
RuntimeTracesQueryOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var observations = _store.GetByFinding(findingId);
|
||||
if (observations.Count == 0)
|
||||
{
|
||||
return Task.FromResult<RuntimeTracesResponse?>(null);
|
||||
}
|
||||
|
||||
var grouped = observations
|
||||
.GroupBy(BuildTraceGroupingKey)
|
||||
.Select(group =>
|
||||
{
|
||||
var mostRecent = group
|
||||
.OrderByDescending(item => item.CapturedAt)
|
||||
.ThenBy(item => item.ObservationId, StringComparer.Ordinal)
|
||||
.First();
|
||||
|
||||
return new FunctionTrace
|
||||
{
|
||||
Id = ComputeTraceId(findingId, group.Key),
|
||||
VulnerableFunction = mostRecent.PrimarySymbol,
|
||||
IsDirectPath = group.Any(item => item.IsDirectPath),
|
||||
HitCount = group.LongCount(),
|
||||
FirstSeen = group.Min(item => item.CapturedAt),
|
||||
LastSeen = group.Max(item => item.CapturedAt),
|
||||
ContainerId = mostRecent.ContainerId,
|
||||
ContainerName = mostRecent.ContainerName,
|
||||
CallPath = mostRecent.CallPath
|
||||
};
|
||||
});
|
||||
|
||||
var sorted = string.Equals(options.SortBy, "recent", StringComparison.OrdinalIgnoreCase)
|
||||
? grouped
|
||||
.OrderByDescending(trace => trace.LastSeen)
|
||||
.ThenByDescending(trace => trace.HitCount)
|
||||
.ThenBy(trace => trace.Id, StringComparer.Ordinal)
|
||||
: grouped
|
||||
.OrderByDescending(trace => trace.HitCount)
|
||||
.ThenByDescending(trace => trace.LastSeen)
|
||||
.ThenBy(trace => trace.Id, StringComparer.Ordinal);
|
||||
|
||||
var limit = Math.Clamp(options.Limit, 1, 200);
|
||||
var traces = sorted.Take(limit).ToList();
|
||||
var lastHit = observations.Max(item => item.CapturedAt);
|
||||
|
||||
var response = new RuntimeTracesResponse
|
||||
{
|
||||
FindingId = findingId,
|
||||
CollectionActive = true,
|
||||
CollectionStarted = observations.Min(item => item.CapturedAt),
|
||||
Summary = new ObservationSummary
|
||||
{
|
||||
TotalHits = observations.Count,
|
||||
UniquePaths = traces.Count,
|
||||
Posture = ContractRuntimePosture.EbpfDeep,
|
||||
LastHit = lastHit,
|
||||
DirectPathObserved = observations.Any(item => item.IsDirectPath),
|
||||
ProductionTraffic = observations.Any(item =>
|
||||
item.Metadata.TryGetValue("environment", out var environment) &&
|
||||
string.Equals(environment, "prod", StringComparison.OrdinalIgnoreCase)),
|
||||
ContainerCount = observations
|
||||
.Select(item => item.ContainerId)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Count()
|
||||
},
|
||||
Traces = traces
|
||||
};
|
||||
|
||||
return Task.FromResult<RuntimeTracesResponse?>(response);
|
||||
}
|
||||
|
||||
public Task<RtsScoreResponse?> GetRtsScoreAsync(Guid findingId, CancellationToken ct)
|
||||
{
|
||||
var observations = _store.GetByFinding(findingId);
|
||||
if (observations.Count == 0)
|
||||
{
|
||||
return Task.FromResult<RtsScoreResponse?>(null);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var lastSeen = observations.Max(item => item.CapturedAt);
|
||||
var ageHours = Math.Max(0, (now - lastSeen).TotalHours);
|
||||
var recencyFactor = Math.Exp(-ageHours / 48d);
|
||||
var observationScore = Math.Min(1d, Math.Log10(observations.Count + 1));
|
||||
var qualityFactor = observations.Count(item => item.CallPath.Count >= 1) / (double)observations.Count;
|
||||
var totalScore = Math.Round((observationScore + recencyFactor + qualityFactor) / 3d, 4);
|
||||
|
||||
var response = new RtsScoreResponse
|
||||
{
|
||||
FindingId = findingId,
|
||||
Score = new RtsScore
|
||||
{
|
||||
Score = totalScore,
|
||||
ComputedAt = now,
|
||||
Breakdown = new RtsBreakdown
|
||||
{
|
||||
ObservationScore = Math.Round(observationScore, 4),
|
||||
RecencyFactor = Math.Round(recencyFactor, 4),
|
||||
QualityFactor = Math.Round(qualityFactor, 4)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Task.FromResult<RtsScoreResponse?>(response);
|
||||
}
|
||||
|
||||
private static string BuildTraceGroupingKey(RuntimeTraceObservation observation)
|
||||
{
|
||||
return $"{observation.ComponentPurl}|{observation.PrimarySymbol}";
|
||||
}
|
||||
|
||||
private static string ComputeTraceId(Guid findingId, string key)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{findingId:D}|{key}"));
|
||||
return Convert.ToHexString(bytes.AsSpan(0, 12)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeObservationId(
|
||||
Guid findingId,
|
||||
RuntimeTraceIngestRequest request,
|
||||
IReadOnlyList<StackFrame> frames)
|
||||
{
|
||||
var builder = new StringBuilder()
|
||||
.Append(findingId.ToString("D"))
|
||||
.Append('|')
|
||||
.Append(request.CapturedAt.ToUniversalTime().ToString("O"))
|
||||
.Append('|')
|
||||
.Append(request.ArtifactDigest.Trim())
|
||||
.Append('|')
|
||||
.Append(request.ComponentPurl.Trim());
|
||||
|
||||
foreach (var frame in frames)
|
||||
{
|
||||
builder.Append('|')
|
||||
.Append(frame.Symbol)
|
||||
.Append(':')
|
||||
.Append(frame.Line?.ToString() ?? string.Empty)
|
||||
.Append(':')
|
||||
.Append(frame.IsVulnerableFunction ? '1' : '0');
|
||||
}
|
||||
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(bytes.AsSpan(0, 12)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string SanitizeValue(string value)
|
||||
=> AddressPattern.Replace(value, "<redacted>");
|
||||
}
|
||||
|
||||
internal sealed class InMemoryRuntimeTimelineService : IRuntimeTimelineService
|
||||
{
|
||||
private readonly IRuntimeTraceStore _store;
|
||||
|
||||
public InMemoryRuntimeTimelineService(IRuntimeTraceStore store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public Task<RuntimeTimeline?> GetTimelineAsync(Guid findingId, TimelineOptions options, CancellationToken ct)
|
||||
{
|
||||
var all = _store.GetByFinding(findingId);
|
||||
if (all.Count == 0)
|
||||
{
|
||||
return Task.FromResult<RuntimeTimeline?>(null);
|
||||
}
|
||||
|
||||
var bucketSize = options.BucketSize <= TimeSpan.Zero
|
||||
? TimeSpan.FromHours(1)
|
||||
: options.BucketSize;
|
||||
|
||||
var windowStart = options.WindowStart ?? all.Min(item => item.CapturedAt);
|
||||
var windowEnd = options.WindowEnd ?? all.Max(item => item.CapturedAt).Add(bucketSize);
|
||||
if (windowEnd <= windowStart)
|
||||
{
|
||||
windowEnd = windowStart.Add(bucketSize);
|
||||
}
|
||||
|
||||
var observations = all
|
||||
.Where(item => item.CapturedAt >= windowStart && item.CapturedAt <= windowEnd)
|
||||
.OrderBy(item => item.CapturedAt)
|
||||
.ThenBy(item => item.ObservationId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (observations.Count == 0)
|
||||
{
|
||||
return Task.FromResult<RuntimeTimeline?>(null);
|
||||
}
|
||||
|
||||
var hotCounts = observations
|
||||
.GroupBy(item => item.PrimarySymbol, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||
|
||||
var events = observations
|
||||
.Select(item => new TimelineEvent
|
||||
{
|
||||
Timestamp = item.CapturedAt,
|
||||
Type = item.IsDirectPath ? TimelineEventType.VulnerableFunctionCalled : TimelineEventType.ComponentLoaded,
|
||||
Description = item.IsDirectPath
|
||||
? $"Observed vulnerable symbol {item.PrimarySymbol}"
|
||||
: $"Observed runtime symbol {item.PrimarySymbol}",
|
||||
Significance = item.IsDirectPath ? EventSignificance.Critical : EventSignificance.Medium,
|
||||
EvidenceDigest = item.ArtifactDigest,
|
||||
Details = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["component_purl"] = item.ComponentPurl,
|
||||
["artifact_digest"] = item.ArtifactDigest,
|
||||
["container_id"] = item.ContainerId ?? string.Empty,
|
||||
["hot_symbol_hits"] = hotCounts[item.PrimarySymbol].ToString()
|
||||
}
|
||||
})
|
||||
.OrderBy(item => item.Timestamp)
|
||||
.ThenBy(item => item.EvidenceDigest, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var buckets = BuildBuckets(observations, windowStart, windowEnd, bucketSize);
|
||||
var posture = observations.Any(item => item.IsDirectPath)
|
||||
? TimelineRuntimePosture.Contradicts
|
||||
: TimelineRuntimePosture.Supports;
|
||||
var explanation = observations.Any(item => item.IsDirectPath)
|
||||
? "Runtime observations include vulnerable function execution."
|
||||
: "Runtime observations did not include vulnerable function execution.";
|
||||
|
||||
var latest = observations
|
||||
.OrderByDescending(item => item.CapturedAt)
|
||||
.ThenBy(item => item.ObservationId, StringComparer.Ordinal)
|
||||
.First();
|
||||
|
||||
var timeline = new RuntimeTimeline
|
||||
{
|
||||
FindingId = findingId,
|
||||
ComponentPurl = latest.ComponentPurl,
|
||||
WindowStart = windowStart,
|
||||
WindowEnd = windowEnd,
|
||||
Posture = posture,
|
||||
PostureExplanation = explanation,
|
||||
Buckets = buckets,
|
||||
Events = events,
|
||||
SessionDigests = observations
|
||||
.Select(item => item.ArtifactDigest)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(item => item, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
|
||||
return Task.FromResult<RuntimeTimeline?>(timeline);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TimelineBucket> BuildBuckets(
|
||||
IReadOnlyList<RuntimeTraceObservation> observations,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset end,
|
||||
TimeSpan bucketSize)
|
||||
{
|
||||
var buckets = new List<TimelineBucket>();
|
||||
var cursor = start;
|
||||
|
||||
while (cursor < end)
|
||||
{
|
||||
var bucketEnd = cursor.Add(bucketSize);
|
||||
if (bucketEnd > end)
|
||||
{
|
||||
bucketEnd = end;
|
||||
}
|
||||
|
||||
var bucketObservations = observations
|
||||
.Where(item => item.CapturedAt >= cursor && item.CapturedAt < bucketEnd)
|
||||
.ToList();
|
||||
|
||||
var byType = bucketObservations.Count == 0
|
||||
? Array.Empty<ObservationTypeSummary>()
|
||||
: new[]
|
||||
{
|
||||
new ObservationTypeSummary
|
||||
{
|
||||
Type = bucketObservations.Any(item => item.IsDirectPath)
|
||||
? ObservationType.SymbolResolution
|
||||
: ObservationType.LibraryLoad,
|
||||
Count = bucketObservations.Count
|
||||
}
|
||||
};
|
||||
|
||||
buckets.Add(new TimelineBucket
|
||||
{
|
||||
Start = cursor,
|
||||
End = bucketEnd,
|
||||
ObservationCount = bucketObservations.Count,
|
||||
ByType = byType,
|
||||
ComponentLoaded = bucketObservations.Count > 0,
|
||||
VulnerableCodeExecuted = bucketObservations.Count == 0
|
||||
? null
|
||||
: bucketObservations.Any(item => item.IsDirectPath)
|
||||
});
|
||||
|
||||
cursor = bucketEnd;
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RuntimeTraceObservation
|
||||
{
|
||||
public required string ObservationId { get; init; }
|
||||
public required Guid FindingId { get; init; }
|
||||
public required DateTimeOffset CapturedAt { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string ComponentPurl { get; init; }
|
||||
public required string PrimarySymbol { get; init; }
|
||||
public required bool IsDirectPath { get; init; }
|
||||
public required IReadOnlyList<StackFrame> CallPath { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
|
||||
public string? ContainerId { get; init; }
|
||||
public string? ContainerName { get; init; }
|
||||
}
|
||||
@@ -14,6 +14,26 @@ namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
/// </summary>
|
||||
public sealed class NullRuntimeTracesService : IRuntimeTracesService
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public Task<RuntimeTraceIngestResponse> IngestTraceAsync(
|
||||
Guid findingId,
|
||||
RuntimeTraceIngestRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var response = new RuntimeTraceIngestResponse
|
||||
{
|
||||
FindingId = findingId,
|
||||
ObservationId = "runtime-disabled",
|
||||
RecordedAt = request.CapturedAt,
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
ComponentPurl = request.ComponentPurl,
|
||||
FrameCount = request.Frames.Count,
|
||||
PrivacyFilterApplied = false
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<RuntimeTracesResponse?> GetTracesAsync(
|
||||
Guid findingId,
|
||||
|
||||
@@ -10,3 +10,10 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0345-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
|
||||
| LEDGER-TESTS-0001 | DOING | Stabilize Findings Ledger WebService test harness (config/auth + stubs). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| QA-FINDINGS-VERIFY-001 | DONE | `admin-audit-trails` run-001 verification completed and terminalized as `not_implemented`; parity gaps documented in `docs/implplan/SPRINT_20260211_029_Findings_unchecked_feature_verification.md`. |
|
||||
| QA-FINDINGS-VERIFY-002 | DONE | `attested-reduction-scoring-in-findings-ledger` run-001 verification completed and terminalized as `not_implemented`; see `docs/implplan/SPRINT_20260211_029_Findings_unchecked_feature_verification.md`. |
|
||||
| QA-FINDINGS-VERIFY-003 | DONE | `cvss-vex-sorting` run-001 verification completed and terminalized as `not_implemented`; see `docs/implplan/SPRINT_20260211_029_Findings_unchecked_feature_verification.md`. |
|
||||
| QA-FINDINGS-VERIFY-004 | DONE | `findings-ledger-with-append-only-events` run-001 verified with Tier 0/1/2 evidence and terminalized as `done`; see `docs/implplan/SPRINT_20260211_029_Findings_unchecked_feature_verification.md`. |
|
||||
| QA-FINDINGS-VERIFY-005 | DONE | `ledger-projections` run-001 terminalized as `not_implemented`; out-of-order sequence handling gap documented in `docs/implplan/SPRINT_20260211_029_Findings_unchecked_feature_verification.md`. |
|
||||
| QA-FINDINGS-VERIFY-006 | DONE | `ledger-replay-determinism` run-002 passed Tier 0/1/2 including replay harness CLI pass/fail checks; dossier moved to `docs/features/checked/findings/ledger-replay-determinism.md`. |
|
||||
| QA-FINDINGS-VERIFY-007 | DONE | `merkle-anchoring-for-audit-integrity` run-001 Tier 0/1/2 evidence reconciled and terminalized as `done`; dossier retained in `docs/features/checked/findings/merkle-anchoring-for-audit-integrity.md`. |
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Integration;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class FeatureVerificationProbeTests : IClassFixture<FindingsLedgerWebApplicationFactory>
|
||||
{
|
||||
private readonly FindingsLedgerWebApplicationFactory _factory;
|
||||
|
||||
public FeatureVerificationProbeTests(FindingsLedgerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Feature verification probe captures request/response evidence")]
|
||||
public async Task CaptureFeatureProbeEvidence()
|
||||
{
|
||||
var scenario = Environment.GetEnvironmentVariable("STELLA_FEATURE_PROBE_SCENARIO");
|
||||
var outputPath = Environment.GetEnvironmentVariable("STELLA_FEATURE_PROBE_OUT");
|
||||
|
||||
// Keep this test no-op for normal test runs unless explicitly invoked for feature verification.
|
||||
if (string.IsNullOrWhiteSpace(scenario) || string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var authedClient = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
using var anonClient = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
|
||||
authedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
|
||||
authedClient.DefaultRequestHeaders.Add("X-Scopes", "findings:read findings:write");
|
||||
authedClient.DefaultRequestHeaders.Add("X-Tenant-Id", "11111111-1111-1111-1111-111111111111");
|
||||
authedClient.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-qa");
|
||||
|
||||
var requests = BuildScenario(scenario);
|
||||
var capturedAt = DateTimeOffset.UtcNow;
|
||||
var results = new List<ProbeResult>(requests.Count);
|
||||
|
||||
foreach (var req in requests)
|
||||
{
|
||||
var client = req.Auth ? authedClient : anonClient;
|
||||
using var message = new HttpRequestMessage(new HttpMethod(req.Method), req.Path);
|
||||
if (req.JsonBody is not null)
|
||||
{
|
||||
message.Content = new StringContent(req.JsonBody, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
var requestAt = DateTimeOffset.UtcNow;
|
||||
using var response = await client.SendAsync(message);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var snippet = body.Length > 600 ? body[..600] : body;
|
||||
|
||||
results.Add(new ProbeResult(
|
||||
req.Description,
|
||||
req.Method,
|
||||
req.Path,
|
||||
req.ExpectedStatus,
|
||||
(int)response.StatusCode,
|
||||
req.Assertion,
|
||||
(int)response.StatusCode == req.ExpectedStatus ? "pass" : "fail",
|
||||
requestAt.ToString("O"),
|
||||
snippet));
|
||||
}
|
||||
|
||||
var artifact = new ProbeArtifact(
|
||||
Type: "api",
|
||||
Module: "api",
|
||||
Feature: scenario,
|
||||
BaseUrl: "in-memory-testserver",
|
||||
CapturedAtUtc: capturedAt.ToString("O"),
|
||||
Requests: results,
|
||||
Verdict: results.All(r => string.Equals(r.Result, "pass", StringComparison.Ordinal)) ? "pass" : "fail");
|
||||
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(artifact, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(outputPath, json);
|
||||
|
||||
Assert.All(results, r => Assert.Equal("pass", r.Result));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ProbeRequest> BuildScenario(string scenario)
|
||||
{
|
||||
if (string.Equals(scenario, "policy-trace-panel", StringComparison.Ordinal))
|
||||
{
|
||||
return
|
||||
[
|
||||
new("List finding summaries with auth", "GET", "/api/v1/findings/summaries", true, 200, null, "authorized summaries query succeeds"),
|
||||
new("Invalid finding id returns bad request", "GET", "/api/v1/findings/not-a-guid/summary", true, 400, null, "invalid GUID is rejected"),
|
||||
new("Unknown finding summary returns not found", "GET", $"/api/v1/findings/{Guid.NewGuid():D}/summary", true, 404, null, "unknown finding returns 404"),
|
||||
new("Unknown finding evidence graph returns not found", "GET", $"/api/v1/findings/{Guid.NewGuid():D}/evidence-graph", true, 404, null, "unknown graph returns 404"),
|
||||
new("Unauthorized summaries request is rejected", "GET", "/api/v1/findings/summaries", false, 401, null, "missing token returns 401")
|
||||
];
|
||||
}
|
||||
|
||||
if (string.Equals(scenario, "score-api-endpoints", StringComparison.Ordinal))
|
||||
{
|
||||
return
|
||||
[
|
||||
new("Scoring policy endpoint returns active policy", "GET", "/api/v1/scoring/policy", true, 200, null, "authorized policy read succeeds"),
|
||||
new("Batch scoring rejects empty list", "POST", "/api/v1/findings/scores", true, 400, "{\"findingIds\":[]}", "empty batch rejected"),
|
||||
new("Cached score unknown finding returns not found", "GET", "/api/v1/findings/CVE-9999-0000%40pkg%3Anpm%2Fnone%401.0.0/score", true, 404, null, "unknown score returns 404"),
|
||||
new("Scoring policy without auth is rejected", "GET", "/api/v1/scoring/policy", false, 401, null, "missing token returns 401")
|
||||
];
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unknown feature probe scenario: {scenario}");
|
||||
}
|
||||
|
||||
private sealed record ProbeRequest(
|
||||
string Description,
|
||||
string Method,
|
||||
string Path,
|
||||
bool Auth,
|
||||
int ExpectedStatus,
|
||||
string? JsonBody,
|
||||
string Assertion);
|
||||
|
||||
private sealed record ProbeResult(
|
||||
string Description,
|
||||
string Method,
|
||||
string Path,
|
||||
int ExpectedStatus,
|
||||
int ActualStatus,
|
||||
string Assertion,
|
||||
string Result,
|
||||
string RequestCapturedAtUtc,
|
||||
string ResponseSnippet);
|
||||
|
||||
private sealed record ProbeArtifact(
|
||||
string Type,
|
||||
string Module,
|
||||
string Feature,
|
||||
string BaseUrl,
|
||||
string CapturedAtUtc,
|
||||
IReadOnlyList<ProbeResult> Requests,
|
||||
string Verdict);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user