Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

@@ -0,0 +1,200 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class SecurityAdapterEndpoints
{
private static readonly DateTimeOffset SnapshotAt = DateTimeOffset.Parse("2026-02-19T03:15:00Z");
public static void MapSecurityAdapterEndpoints(this RouteGroupBuilder apiGroup)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var security = apiGroup.MapGroup("/security")
.WithTags("Security");
security.MapGet("/findings", (
[FromQuery] string? severity,
[FromQuery] string? reachability,
[FromQuery] string? environment) =>
{
var items = BuildFindings()
.Where(item => string.IsNullOrWhiteSpace(severity) || string.Equals(item.Severity, severity, StringComparison.OrdinalIgnoreCase))
.Where(item => string.IsNullOrWhiteSpace(reachability) || string.Equals(item.Reachability, reachability, StringComparison.OrdinalIgnoreCase))
.Where(item => string.IsNullOrWhiteSpace(environment) || string.Equals(item.Environment, environment, StringComparison.OrdinalIgnoreCase))
.OrderBy(item => item.FindingId, StringComparer.Ordinal)
.ToList();
return Results.Ok(new SecurityFindingsResponseDto(
SnapshotAt,
BuildConfidence(),
items,
items.Count));
})
.WithName("SecurityFindingsAdapter")
.WithSummary("Decision-first findings view with reachability context.")
.RequireAuthorization(ScannerPolicies.ScansRead);
security.MapGet("/vulnerabilities", (
[FromQuery] string? cve) =>
{
var items = BuildVulnerabilities()
.Where(item => string.IsNullOrWhiteSpace(cve) || item.Cve.Contains(cve, StringComparison.OrdinalIgnoreCase))
.OrderBy(item => item.Cve, StringComparer.Ordinal)
.ToList();
return Results.Ok(new SecurityVulnerabilitiesResponseDto(
SnapshotAt,
items,
items.Count));
})
.WithName("SecurityVulnerabilitiesAdapter")
.WithSummary("Vulnerability catalog projection with environment impact counts.")
.RequireAuthorization(ScannerPolicies.ScansRead);
security.MapGet("/vex", (
[FromQuery] string? status) =>
{
var items = BuildVexStatements()
.Where(item => string.IsNullOrWhiteSpace(status) || string.Equals(item.Status, status, StringComparison.OrdinalIgnoreCase))
.OrderBy(item => item.StatementId, StringComparer.Ordinal)
.ToList();
return Results.Ok(new SecurityVexResponseDto(
SnapshotAt,
items,
items.Count));
})
.WithName("SecurityVexAdapter")
.WithSummary("VEX statement projection linked to findings and trust state.")
.RequireAuthorization(ScannerPolicies.ScansRead);
security.MapGet("/reachability", () =>
{
var items = BuildReachability()
.OrderBy(item => item.Environment, StringComparer.Ordinal)
.ToList();
return Results.Ok(new SecurityReachabilityResponseDto(
SnapshotAt,
BuildConfidence(),
items));
})
.WithName("SecurityReachabilityAdapter")
.WithSummary("Reachability summary projection by environment.")
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static SecurityDataConfidenceDto BuildConfidence()
{
return new SecurityDataConfidenceDto(
Status: "warning",
Summary: "NVD freshness lag and runtime ingest delay reduce confidence.",
NvdStalenessHours: 3,
PendingRescans: 12,
RuntimeDlqDepth: 1230);
}
private static IReadOnlyList<SecurityFindingRowDto> BuildFindings()
{
return
[
new SecurityFindingRowDto("finding-0001", "CVE-2026-1234", "us-prod", "user-service", "critical", "reachable", "0/1/0", "stale"),
new SecurityFindingRowDto("finding-0002", "CVE-2026-2222", "us-uat", "billing-worker", "critical", "reachable", "0/1/0", "stale"),
new SecurityFindingRowDto("finding-0003", "CVE-2026-9001", "us-prod", "api-gateway", "high", "not_reachable", "1/1/1", "fresh"),
];
}
private static IReadOnlyList<SecurityVulnerabilityRowDto> BuildVulnerabilities()
{
return
[
new SecurityVulnerabilityRowDto("CVE-2026-1234", "critical", AffectedEnvironments: 1, ReachableEnvironments: 1, VexStatus: "under_investigation"),
new SecurityVulnerabilityRowDto("CVE-2026-2222", "critical", AffectedEnvironments: 1, ReachableEnvironments: 1, VexStatus: "none"),
new SecurityVulnerabilityRowDto("CVE-2026-9001", "high", AffectedEnvironments: 2, ReachableEnvironments: 0, VexStatus: "not_affected"),
];
}
private static IReadOnlyList<SecurityVexStatementRowDto> BuildVexStatements()
{
return
[
new SecurityVexStatementRowDto("vex-0001", "CVE-2026-9001", "not_affected", "vendor-a", "trusted", "2026-02-19T01:10:00Z"),
new SecurityVexStatementRowDto("vex-0002", "CVE-2026-1234", "under_investigation", "internal-sec", "trusted", "2026-02-19T00:22:00Z"),
new SecurityVexStatementRowDto("vex-0003", "CVE-2026-2222", "affected", "vendor-b", "unverified", "2026-02-18T19:02:00Z"),
];
}
private static IReadOnlyList<SecurityReachabilityRowDto> BuildReachability()
{
return
[
new SecurityReachabilityRowDto("apac-prod", CriticalReachable: 0, HighReachable: 0, RuntimeCoveragePercent: 86),
new SecurityReachabilityRowDto("eu-prod", CriticalReachable: 0, HighReachable: 1, RuntimeCoveragePercent: 89),
new SecurityReachabilityRowDto("us-prod", CriticalReachable: 2, HighReachable: 1, RuntimeCoveragePercent: 41),
new SecurityReachabilityRowDto("us-uat", CriticalReachable: 1, HighReachable: 2, RuntimeCoveragePercent: 62),
];
}
}
public sealed record SecurityFindingsResponseDto(
DateTimeOffset GeneratedAt,
SecurityDataConfidenceDto DataConfidence,
IReadOnlyList<SecurityFindingRowDto> Items,
int Total);
public sealed record SecurityFindingRowDto(
string FindingId,
string Cve,
string Environment,
string Component,
string Severity,
string Reachability,
string HybridEvidence,
string SbomFreshness);
public sealed record SecurityVulnerabilitiesResponseDto(
DateTimeOffset GeneratedAt,
IReadOnlyList<SecurityVulnerabilityRowDto> Items,
int Total);
public sealed record SecurityVulnerabilityRowDto(
string Cve,
string Severity,
int AffectedEnvironments,
int ReachableEnvironments,
string VexStatus);
public sealed record SecurityVexResponseDto(
DateTimeOffset GeneratedAt,
IReadOnlyList<SecurityVexStatementRowDto> Items,
int Total);
public sealed record SecurityVexStatementRowDto(
string StatementId,
string Cve,
string Status,
string Issuer,
string TrustStatus,
string SignedAt);
public sealed record SecurityReachabilityResponseDto(
DateTimeOffset GeneratedAt,
SecurityDataConfidenceDto DataConfidence,
IReadOnlyList<SecurityReachabilityRowDto> Items);
public sealed record SecurityReachabilityRowDto(
string Environment,
int CriticalReachable,
int HighReachable,
int RuntimeCoveragePercent);
public sealed record SecurityDataConfidenceDto(
string Status,
string Summary,
int NvdStalenessHours,
int PendingRescans,
int RuntimeDlqDepth);

View File

@@ -636,6 +636,7 @@ apiGroup.MapTriageInboxEndpoints();
apiGroup.MapBatchTriageEndpoints();
apiGroup.MapProofBundleEndpoints();
apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE
apiGroup.MapSecurityAdapterEndpoints(); // Pack v2 security adapter routes
if (resolvedOptions.Features.EnablePolicyPreview)
{

View File

@@ -113,6 +113,12 @@ public sealed class CycloneDxComposer
var jsonHash = ComputeSha256(jsonBytes);
var protobufHash = ComputeSha256(protobufBytes);
// Compute canonical_id: SHA-256 of RFC 8785 (JCS) canonicalized JSON.
// Stable across serializers and machines. See docs/contracts/canonical-sbom-id-v1.md.
// Sprint: SPRINT_20260219_009 (CID-02)
var canonicalBytes = CanonicalizJson(jsonBytes);
var canonicalId = ComputeSha256(canonicalBytes);
var merkleRoot = request.AdditionalProperties is not null
&& request.AdditionalProperties.TryGetValue("stellaops:merkle.root", out var root)
? root
@@ -132,6 +138,7 @@ public sealed class CycloneDxComposer
JsonBytes = jsonBytes,
JsonSha256 = jsonHash,
ContentHash = jsonHash,
CanonicalId = canonicalId,
MerkleRoot = merkleRoot,
CompositionUri = compositionUri,
CompositionRecipeUri = compositionRecipeUri,
@@ -246,6 +253,10 @@ public sealed class CycloneDxComposer
Value = view.ToString().ToLowerInvariant(),
});
// canonical_id is emitted post-composition (added to the artifact after BuildMetadata returns).
// The property is injected via the composition pipeline that has access to the final canonical hash.
// See CycloneDxComposer.Compose() → inventoryArtifact/usageArtifact post-processing.
return metadata;
}
@@ -680,4 +691,50 @@ public sealed class CycloneDxComposer
var hash = sha256.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Canonicalizes JSON per RFC 8785 (JSON Canonicalization Scheme):
/// sorted object keys (lexicographic/ordinal), no whitespace, no BOM.
/// Sprint: SPRINT_20260219_009 (CID-02)
/// </summary>
private static byte[] CanonicalizJson(byte[] jsonBytes)
{
using var doc = JsonDocument.Parse(jsonBytes);
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions
{
Indented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
WriteElementSorted(doc.RootElement, writer);
writer.Flush();
return stream.ToArray();
}
private static void WriteElementSorted(JsonElement element, Utf8JsonWriter writer)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
writer.WriteStartObject();
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
{
writer.WritePropertyName(property.Name);
WriteElementSorted(property.Value, writer);
}
writer.WriteEndObject();
break;
case JsonValueKind.Array:
writer.WriteStartArray();
foreach (var item in element.EnumerateArray())
{
WriteElementSorted(item, writer);
}
writer.WriteEndArray();
break;
default:
element.WriteTo(writer);
break;
}
}
}

View File

@@ -20,10 +20,19 @@ public sealed record CycloneDxArtifact
public required string JsonSha256 { get; init; }
/// <summary>
/// Canonical content hash (sha256, hex) of the CycloneDX JSON payload.
/// Content hash (sha256, hex) of the serialized CycloneDX JSON payload.
/// Depends on serializer key ordering and whitespace; use for integrity checks of a specific serialized form.
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// Canonical content identifier: sha256 of RFC 8785 (JCS) canonicalized CycloneDX JSON.
/// Stable across serializers, machines, and .NET versions. Use for cross-module evidence threading.
/// Format: lowercase hex (no "sha256:" prefix). See docs/contracts/canonical-sbom-id-v1.md.
/// Sprint: SPRINT_20260219_009 (CID-02)
/// </summary>
public required string CanonicalId { get; init; }
/// <summary>
/// Merkle root over fragments (hex). Present when composition metadata is provided.
/// </summary>
@@ -59,10 +68,16 @@ public sealed record SpdxArtifact
public required string JsonSha256 { get; init; }
/// <summary>
/// Canonical content hash (sha256, hex) of the SPDX JSON-LD payload.
/// Content hash (sha256, hex) of the serialized SPDX JSON-LD payload.
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// Canonical content identifier: sha256 of RFC 8785 (JCS) canonicalized SPDX JSON.
/// Sprint: SPRINT_20260219_009 (CID-02)
/// </summary>
public string? CanonicalId { get; init; }
public required string JsonMediaType { get; init; }
public byte[]? TagValueBytes { get; init; }

View File

@@ -0,0 +1,62 @@
using System.Net;
using System.Net.Http.Json;
using StellaOps.Scanner.WebService.Endpoints;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class SecurityAdapterEndpointsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SecurityAdapterRoutes_ReturnSuccessAndDeterministicFindingsPayload()
{
await using var factory = ScannerApplicationFactory.CreateLightweight();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var firstResponse = await client.GetAsync("/api/v1/security/findings", TestContext.Current.CancellationToken);
var secondResponse = await client.GetAsync("/api/v1/security/findings", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
var first = await firstResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
var second = await secondResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Equal(first, second);
var endpoints = new[]
{
"/api/v1/security/vulnerabilities",
"/api/v1/security/vex",
"/api/v1/security/reachability",
};
foreach (var endpoint in endpoints)
{
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SecurityFindings_FilteringBySeverityAndReachability_Works()
{
await using var factory = ScannerApplicationFactory.CreateLightweight();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var payload = await client.GetFromJsonAsync<SecurityFindingsResponseDto>(
"/api/v1/security/findings?severity=critical&reachability=reachable",
TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.NotEmpty(payload!.Items);
Assert.All(payload.Items, item =>
{
Assert.Equal("critical", item.Severity, ignoreCase: true);
Assert.Equal("reachable", item.Reachability, ignoreCase: true);
});
}
}