save checkpoint: save features

This commit is contained in:
master
2026-02-12 10:27:23 +02:00
parent dca86e1248
commit 5bca406787
8837 changed files with 1796879 additions and 5294 deletions

View File

@@ -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"
});
});

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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. |

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>();

View File

@@ -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; }
}

View File

@@ -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,

View File

@@ -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`. |

View File

@@ -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);
}