This commit is contained in:
StellaOps Bot
2025-12-18 20:37:27 +02:00
parent f85d53888c
commit 6410a6d082
17 changed files with 454 additions and 131 deletions

View File

@@ -21,9 +21,9 @@
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- |
| 1 | PROOF-CBOR-3105-001 | DOING | ProofSpine endpoints | Scanner · WebService | Add `Accept: application/cbor` support to ProofSpine endpoints with deterministic encoding. |
| 2 | PROOF-CBOR-3105-002 | TODO | Encoder helper | Scanner · WebService | Add a shared CBOR encoder helper (JSON→CBOR) with stable key ordering. |
| 3 | PROOF-CBOR-3105-003 | TODO | Integration tests | Scanner · QA | Add endpoint tests validating CBOR content-type and decoding key fields. |
| 4 | PROOF-CBOR-3105-004 | TODO | Close bookkeeping | Scanner · WebService | Update local `TASKS.md`, sprint status, and execution log with evidence (test run). |
| 2 | PROOF-CBOR-3105-002 | DOING | Encoder helper | Scanner · WebService | Add a shared CBOR encoder helper (JSON→CBOR) with stable key ordering. |
| 3 | PROOF-CBOR-3105-003 | DOING | Integration tests | Scanner · QA | Add endpoint tests validating CBOR content-type and decoding key fields. |
| 4 | PROOF-CBOR-3105-004 | DOING | Close bookkeeping | Scanner · WebService | Update local `TASKS.md`, sprint status, and execution log with evidence (test run). |
## Decisions & Risks
- **Decision:** CBOR payload shape matches JSON DTO shape (same property names).
@@ -33,4 +33,4 @@
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2025-12-18 | Sprint created; started PROOF-CBOR-3105-001. | Agent |
| 2025-12-18 | Started PROOF-CBOR-3105-002..004. | Agent |

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Routing;
using StellaOps.Replay.Core;
using StellaOps.Scanner.ProofSpine;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Serialization;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
@@ -17,6 +18,7 @@ internal static class ProofSpineEndpoints
spines.MapGet("/{spineId}", HandleGetSpineAsync)
.WithName("scanner.spines.get")
.Produces<ProofSpineResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status200OK, contentType: CborNegotiation.ContentType)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
@@ -24,15 +26,18 @@ internal static class ProofSpineEndpoints
scans.MapGet("/{scanId}/spines", HandleListSpinesAsync)
.WithName("scanner.spines.list-by-scan")
.Produces<ProofSpineListResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status200OK, contentType: CborNegotiation.ContentType)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleGetSpineAsync(
HttpRequest request,
string spineId,
IProofSpineRepository repository,
ProofSpineVerifier verifier,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(verifier);
@@ -93,19 +98,36 @@ internal static class ProofSpineEndpoints
}
};
if (CborNegotiation.AcceptsCbor(request))
{
return Results.Bytes(
DeterministicCborSerializer.Serialize(dto),
contentType: CborNegotiation.ContentType);
}
return Results.Ok(dto);
}
private static async Task<IResult> HandleListSpinesAsync(
HttpRequest request,
string scanId,
IProofSpineRepository repository,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(repository);
if (string.IsNullOrWhiteSpace(scanId))
{
return Results.Ok(new ProofSpineListResponseDto { Items = Array.Empty<ProofSpineSummaryDto>(), Total = 0 });
var empty = new ProofSpineListResponseDto { Items = Array.Empty<ProofSpineSummaryDto>(), Total = 0 };
if (CborNegotiation.AcceptsCbor(request))
{
return Results.Bytes(
DeterministicCborSerializer.Serialize(empty),
contentType: CborNegotiation.ContentType);
}
return Results.Ok(empty);
}
var summaries = await repository.GetSummariesByScanRunAsync(scanId, cancellationToken).ConfigureAwait(false);
@@ -119,11 +141,20 @@ internal static class ProofSpineEndpoints
CreatedAt = summary.CreatedAt
}).ToArray();
return Results.Ok(new ProofSpineListResponseDto
var response = new ProofSpineListResponseDto
{
Items = items,
Total = items.Length
});
};
if (CborNegotiation.AcceptsCbor(request))
{
return Results.Bytes(
DeterministicCborSerializer.Serialize(response),
contentType: CborNegotiation.ContentType);
}
return Results.Ok(response);
}
private static DsseEnvelopeDto MapEnvelope(DsseEnvelope envelope)
@@ -163,4 +194,3 @@ internal static class ProofSpineEndpoints
return "/" + trimmed;
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Scanner.WebService.Serialization;
internal static class CborNegotiation
{
public const string ContentType = "application/cbor";
public static bool AcceptsCbor(HttpRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (!request.Headers.TryGetValue("Accept", out var values))
{
return false;
}
foreach (var value in values)
{
if (value.Contains(ContentType, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,108 @@
using System.Collections.Generic;
using System.Formats.Cbor;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.WebService.Serialization;
internal static class DeterministicCborSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
public static byte[] Serialize<T>(T value)
{
using var document = JsonSerializer.SerializeToDocument(value, JsonOptions);
var writer = new CborWriter(CborConformanceMode.Canonical);
WriteElement(writer, document.RootElement);
return writer.Encode();
}
private static void WriteElement(CborWriter writer, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
WriteObject(writer, element);
return;
case JsonValueKind.Array:
writer.WriteStartArray(element.GetArrayLength());
foreach (var item in element.EnumerateArray())
{
WriteElement(writer, item);
}
writer.WriteEndArray();
return;
case JsonValueKind.String:
writer.WriteTextString(element.GetString() ?? string.Empty);
return;
case JsonValueKind.Number:
if (element.TryGetInt64(out var int64))
{
writer.WriteInt64(int64);
return;
}
writer.WriteDouble(element.GetDouble());
return;
case JsonValueKind.True:
writer.WriteBoolean(true);
return;
case JsonValueKind.False:
writer.WriteBoolean(false);
return;
case JsonValueKind.Null:
case JsonValueKind.Undefined:
writer.WriteNull();
return;
default:
writer.WriteNull();
return;
}
}
private static void WriteObject(CborWriter writer, JsonElement element)
{
var properties = new List<(byte[] KeyBytes, string Key, JsonElement Value)>();
foreach (var property in element.EnumerateObject())
{
var keyBytes = Encoding.UTF8.GetBytes(property.Name);
properties.Add((keyBytes, property.Name, property.Value));
}
properties.Sort(static (left, right) =>
{
var lengthCompare = left.KeyBytes.Length.CompareTo(right.KeyBytes.Length);
if (lengthCompare != 0)
{
return lengthCompare;
}
for (var i = 0; i < left.KeyBytes.Length; i++)
{
var byteCompare = left.KeyBytes[i].CompareTo(right.KeyBytes[i]);
if (byteCompare != 0)
{
return byteCompare;
}
}
return 0;
});
writer.WriteStartMap(properties.Count);
foreach (var (_, key, value) in properties)
{
writer.WriteTextString(key);
WriteElement(writer, value);
}
writer.WriteEndMap();
}
}

View File

@@ -404,7 +404,7 @@ public sealed class EpssSignalJob : BackgroundService
return EpssSignalEventTypes.RiskSpike;
}
if (change.Flags.HasFlag(EpssChangeFlags.DroppedLow))
if (change.Flags.HasFlag(EpssChangeFlags.BigJumpDown))
{
return EpssSignalEventTypes.DroppedLow;
}

View File

@@ -124,7 +124,7 @@ public sealed class EpssPriorityCalculator
var flags = EpssChangeFlags.None;
// NEW_SCORED: first time we have EPSS data
if (!oldScore.HasValue && newScore > 0)
if (!oldScore.HasValue)
{
flags |= EpssChangeFlags.NewScored;
}
@@ -133,31 +133,37 @@ public sealed class EpssPriorityCalculator
{
var delta = newScore - oldScore.Value;
// BIG_JUMP: significant score increase
if (delta >= _options.BigJumpDelta)
{
flags |= EpssChangeFlags.BigJump;
}
// DROPPED_LOW: significant score decrease
if (delta <= -_options.DroppedLowDelta)
{
flags |= EpssChangeFlags.DroppedLow;
}
}
// CROSSED_HIGH: moved into or out of high priority
var oldBand = Calculate(oldScore, oldPercentile).Band;
var newBand = Calculate(newScore, newPercentile).Band;
if (oldBand != newBand)
{
// Crossed into critical or high
if ((newBand == EpssPriorityBand.Critical || newBand == EpssPriorityBand.High) &&
oldBand != EpssPriorityBand.Critical && oldBand != EpssPriorityBand.High)
if (oldScore.Value < _options.HighScore && newScore >= _options.HighScore)
{
flags |= EpssChangeFlags.CrossedHigh;
}
if (oldScore.Value >= _options.HighScore && newScore < _options.HighScore)
{
flags |= EpssChangeFlags.CrossedLow;
}
if (delta > _options.BigJumpDelta)
{
flags |= EpssChangeFlags.BigJumpUp;
}
if (delta < -_options.DroppedLowDelta)
{
flags |= EpssChangeFlags.BigJumpDown;
}
}
if ((oldPercentile is null || oldPercentile < _options.HighPercentile)
&& newPercentile >= _options.HighPercentile)
{
flags |= EpssChangeFlags.TopPercentile;
}
if (oldPercentile is not null && oldPercentile >= _options.HighPercentile
&& newPercentile < _options.HighPercentile)
{
flags |= EpssChangeFlags.LeftTopPercentile;
}
return flags;
@@ -174,14 +180,23 @@ public enum EpssChangeFlags
None = 0,
/// <summary>CVE was scored for the first time.</summary>
NewScored = 1 << 0,
NewScored = 1,
/// <summary>Score crossed into high priority band.</summary>
CrossedHigh = 1 << 1,
CrossedHigh = 2,
/// <summary>Score crossed below the high score threshold.</summary>
CrossedLow = 4,
/// <summary>Score increased significantly (above BigJumpDelta).</summary>
BigJump = 1 << 2,
BigJumpUp = 8,
/// <summary>Score dropped significantly (above DroppedLowDelta).</summary>
DroppedLow = 1 << 3
/// <summary>Score decreased significantly (above DroppedLowDelta).</summary>
BigJumpDown = 16,
/// <summary>Entered the top percentile band.</summary>
TopPercentile = 32,
/// <summary>Left the top percentile band.</summary>
LeftTopPercentile = 64
}

View File

@@ -8,6 +8,7 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Scanner.Core.Epss;
@@ -58,7 +59,7 @@ public sealed class CachingEpssProvider : IEpssProvider
{
var cacheResult = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value is not null)
if (cacheResult.HasValue && cacheResult.Value is not null)
{
_logger.LogDebug("Cache hit for EPSS score: {CveId}", cveId);
return MapFromCacheEntry(cacheResult.Value, fromCache: true);
@@ -119,7 +120,7 @@ public sealed class CachingEpssProvider : IEpssProvider
var cacheKey = BuildCacheKey(cveId);
var cacheResult = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value is not null)
if (cacheResult.HasValue && cacheResult.Value is not null)
{
var evidence = MapFromCacheEntry(cacheResult.Value, fromCache: true);
found.Add(evidence);
@@ -214,7 +215,7 @@ public sealed class CachingEpssProvider : IEpssProvider
{
var cacheResult = await _cache.GetAsync(ModelDateCacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value?.ModelDate is not null)
if (cacheResult.HasValue && cacheResult.Value is not null)
{
return cacheResult.Value.ModelDate;
}

View File

@@ -5,6 +5,8 @@
// Description: Deterministic EPSS delta flag computation (mirrors SQL function).
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Epss;
namespace StellaOps.Scanner.Storage.Epss;
public static class EpssChangeDetector
@@ -72,4 +74,3 @@ public readonly record struct EpssChangeThresholds(
double HighScore,
double HighPercentile,
double BigJumpDelta);

View File

@@ -1,36 +0,0 @@
// -----------------------------------------------------------------------------
// EpssChangeFlags.cs
// Sprint: SPRINT_3410_0001_0001_epss_ingestion_storage
// Task: EPSS-3410-008
// Description: Flag bitmask for EPSS change detection.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Epss;
[Flags]
public enum EpssChangeFlags
{
None = 0,
/// <summary>0x01 - CVE newly scored (not in previous snapshot).</summary>
NewScored = 1,
/// <summary>0x02 - Crossed above the high score threshold.</summary>
CrossedHigh = 2,
/// <summary>0x04 - Crossed below the high score threshold.</summary>
CrossedLow = 4,
/// <summary>0x08 - Score increased by more than the big jump delta.</summary>
BigJumpUp = 8,
/// <summary>0x10 - Score decreased by more than the big jump delta.</summary>
BigJumpDown = 16,
/// <summary>0x20 - Entered the top percentile band.</summary>
TopPercentile = 32,
/// <summary>0x40 - Left the top percentile band.</summary>
LeftTopPercentile = 64
}

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;

View File

@@ -44,7 +44,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
RETURNING raw_id, ingestion_ts
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var result = await connection.QueryFirstOrDefaultAsync<(long raw_id, DateTimeOffset ingestion_ts)?>(sql, new
{
@@ -88,7 +88,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<RawRow?>(sql, new
{
AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue)
@@ -112,7 +112,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
ORDER BY asof_date DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<RawRow>(sql, new
{
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
@@ -134,7 +134,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<RawRow?>(sql);
return row.HasValue ? MapToRaw(row.Value) : null;
@@ -149,7 +149,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new
{
AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue),
@@ -173,7 +173,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<RawRow>(sql, new
{
ModelVersion = modelVersion,
@@ -187,7 +187,7 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
{
var sql = $"SELECT {SchemaName}.prune_epss_raw(@RetentionDays)";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>(sql, new { RetentionDays = retentionDays });
}

View File

@@ -505,14 +505,17 @@ public sealed class PostgresEpssRepository : IEpssRepository
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<ChangeRow>(sql, new
var rows = await connection.QueryAsync<ChangeRow>(new CommandDefinition(
sql,
new
{
ModelDate = modelDate,
Flags = flags.HasValue ? (int)flags.Value : 0,
Limit = limit
});
},
cancellationToken: cancellationToken)).ConfigureAwait(false);
return rows.Select(r => new EpssChangeRecord
{

View File

@@ -46,7 +46,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
RETURNING signal_id, created_at
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(signal.TenantId.ToString("D"), cancellationToken);
var result = await connection.QueryFirstOrDefaultAsync<(long signal_id, DateTimeOffset created_at)?>(sql, new
{
@@ -104,11 +104,13 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
ON CONFLICT (tenant_id, dedupe_key) DO NOTHING
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var inserted = 0;
foreach (var tenantGroup in signalList.GroupBy(signal => signal.TenantId))
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantGroup.Key.ToString("D"), cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var inserted = 0;
foreach (var signal in signalList)
foreach (var signal in tenantGroup)
{
var affected = await connection.ExecuteAsync(sql, new
{
@@ -132,6 +134,8 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
}
await transaction.CommitAsync(cancellationToken);
}
return inserted;
}
@@ -159,7 +163,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
@@ -190,7 +194,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
@@ -222,7 +226,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
@@ -246,7 +250,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
WHERE tenant_id = @TenantId
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<ConfigRow?>(sql, new { TenantId = tenantId });
@@ -277,7 +281,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
RETURNING config_id, created_at, updated_at
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(config.TenantId.ToString("D"), cancellationToken);
var result = await connection.QueryFirstAsync<(Guid config_id, DateTimeOffset created_at, DateTimeOffset updated_at)>(sql, new
{
@@ -302,7 +306,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
{
var sql = $"SELECT {SchemaName}.prune_epss_signals(@RetentionDays)";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>(sql, new { RetentionDays = retentionDays });
}
@@ -317,7 +321,7 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
WHERE tenant_id = @TenantId AND dedupe_key = @DedupeKey
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<SignalRow?>(sql, new { TenantId = tenantId, DedupeKey = dedupeKey });
return row.HasValue ? MapToSignal(row.Value) : null;

View File

@@ -38,7 +38,7 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
AND cve_id LIKE 'CVE-%'
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var cves = await connection.QueryAsync<string>(sql, new { TenantId = tenantId });
return new HashSet<string>(cves, StringComparer.OrdinalIgnoreCase);
@@ -57,7 +57,7 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new { TenantId = tenantId, CveId = cveId });
}
@@ -79,7 +79,7 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
AND cve_id = ANY(@CveIds)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
var observed = await connection.QueryAsync<string>(sql, new
{
TenantId = tenantId,
@@ -99,7 +99,7 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
AND cve_id LIKE 'CVE-%'
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var tenants = await connection.QueryAsync<Guid>(sql);
return tenants.ToList();
@@ -122,7 +122,7 @@ public sealed class PostgresObservedCveRepository : IObservedCveRepository
GROUP BY cve_id, tenant_id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<(string cve_id, Guid tenant_id)>(sql, new
{
CveIds = cveList.ToArray()

View File

@@ -1,5 +1,8 @@
using System.Collections.Generic;
using System.Formats.Cbor;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
@@ -10,6 +13,8 @@ namespace StellaOps.Scanner.WebService.Tests;
public sealed class ProofSpineEndpointsTests
{
private const string CborContentType = "application/cbor";
[Fact]
public async Task GetSpine_ReturnsSpine_WithVerification()
{
@@ -49,6 +54,42 @@ public sealed class ProofSpineEndpointsTests
Assert.True(body.TryGetProperty("verification", out _));
}
[Fact]
public async Task GetSpine_ReturnsCbor_WhenAcceptHeaderRequestsCbor()
{
await using var factory = new ScannerApplicationFactory();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
var spine = await builder
.ForArtifact("sha256:feedface")
.ForVulnerability("CVE-2025-1001")
.WithPolicyProfile("default")
.WithScanRun("scan-cbor-001")
.AddSbomSlice("sha256:sbom", new[] { "pkg:a", "pkg:b" }, toolId: "sbom", toolVersion: "1.0.0")
.BuildAsync();
await repository.SaveAsync(spine);
var client = factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/spines/{spine.SpineId}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(CborContentType));
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(CborContentType, response.Content.Headers.ContentType?.MediaType);
var cbor = await response.Content.ReadAsByteArrayAsync();
var decoded = ReadRootMap(cbor);
Assert.Equal(spine.SpineId, decoded["spineId"]);
Assert.True(decoded.ContainsKey("verification"));
Assert.True(((List<object?>)decoded["segments"]!).Count > 0);
}
[Fact]
public async Task ListSpinesByScan_ReturnsSummaries_WithSegmentCount()
{
@@ -87,6 +128,44 @@ public sealed class ProofSpineEndpointsTests
Assert.True(items[0].GetProperty("segmentCount").GetInt32() > 0);
}
[Fact]
public async Task ListSpinesByScan_ReturnsCbor_WhenAcceptHeaderRequestsCbor()
{
await using var factory = new ScannerApplicationFactory();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
var spine = await builder
.ForArtifact("sha256:feedface")
.ForVulnerability("CVE-2025-1002")
.WithPolicyProfile("default")
.WithScanRun("scan-cbor-002")
.AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0")
.BuildAsync();
await repository.SaveAsync(spine);
var client = factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/scans/scan-cbor-002/spines");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(CborContentType));
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(CborContentType, response.Content.Headers.ContentType?.MediaType);
var cbor = await response.Content.ReadAsByteArrayAsync();
var decoded = ReadRootMap(cbor);
var items = (List<object?>)decoded["items"]!;
Assert.Single(items);
var first = (Dictionary<string, object?>)items[0]!;
Assert.Equal(spine.SpineId, first["spineId"]);
Assert.True((int)first["segmentCount"]! > 0);
}
[Fact]
public async Task GetSpine_ReturnsInvalidStatus_WhenSegmentTampered()
{
@@ -125,4 +204,90 @@ public sealed class ProofSpineEndpointsTests
var segments = body.GetProperty("segments");
Assert.Equal("invalid", segments[0].GetProperty("status").GetString());
}
private static Dictionary<string, object?> ReadRootMap(byte[] cbor)
{
var reader = new CborReader(cbor, CborConformanceMode.Canonical);
return ReadMap(reader);
}
private static Dictionary<string, object?> ReadMap(CborReader reader)
{
var length = reader.ReadStartMap();
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
if (length is not null)
{
for (var i = 0; i < length.Value; i++)
{
var key = reader.ReadTextString();
result[key] = ReadValue(reader);
}
reader.ReadEndMap();
return result;
}
while (reader.PeekState() != CborReaderState.EndMap)
{
var key = reader.ReadTextString();
result[key] = ReadValue(reader);
}
reader.ReadEndMap();
return result;
}
private static List<object?> ReadArray(CborReader reader)
{
var length = reader.ReadStartArray();
var result = new List<object?>();
if (length is not null)
{
for (var i = 0; i < length.Value; i++)
{
result.Add(ReadValue(reader));
}
reader.ReadEndArray();
return result;
}
while (reader.PeekState() != CborReaderState.EndArray)
{
result.Add(ReadValue(reader));
}
reader.ReadEndArray();
return result;
}
private static object? ReadValue(CborReader reader)
{
switch (reader.PeekState())
{
case CborReaderState.StartMap:
return ReadMap(reader);
case CborReaderState.StartArray:
return ReadArray(reader);
case CborReaderState.TextString:
return reader.ReadTextString();
case CborReaderState.UnsignedInteger:
return (int)reader.ReadUInt64();
case CborReaderState.NegativeInteger:
return (int)reader.ReadInt64();
case CborReaderState.DoublePrecisionFloat:
return reader.ReadDouble();
case CborReaderState.SinglePrecisionFloat:
return reader.ReadSingle();
case CborReaderState.HalfPrecisionFloat:
return reader.ReadHalf();
case CborReaderState.Boolean:
return reader.ReadBoolean();
case CborReaderState.Null:
reader.ReadNull();
return null;
default:
throw new InvalidOperationException($"Unexpected CBOR state {reader.PeekState()}");
}
}
}

View File

@@ -64,7 +64,8 @@
"payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJzdGF0dXMiOiJCbG9ja2VkIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwicmVhY2hhYmlsaXR5IjoicnVudGltZSJ9XSwiaXNzdWVzIjpbXX0=",
"signatures": [
{
"keyId": "test-key",
"keyid": "test-key",
"sig": "",
"algorithm": "hs256",
"signature": "signature-value"
}

View File

@@ -70,7 +70,8 @@
"payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJzdGF0dXMiOiJCbG9ja2VkIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwicmVhY2hhYmlsaXR5IjoicnVudGltZSJ9XSwiaXNzdWVzIjpbXX0=",
"signatures": [
{
"keyId": "test-key",
"keyid": "test-key",
"sig": "",
"algorithm": "hs256",
"signature": "signature-value"
}