Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -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);
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user