Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -0,0 +1,483 @@
// -----------------------------------------------------------------------------
// BundlesController.cs
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
// Task: 0010-0012 - Create bundle API endpoints
// Description: API endpoints for attestation bundle management
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Attestor.Bundling.Abstractions;
using StellaOps.Attestor.Bundling.Models;
namespace StellaOps.Attestor.WebService.Controllers;
/// <summary>
/// API endpoints for attestation bundle management.
/// Bundles aggregate attestations for a time period with optional org-key signing.
/// </summary>
[ApiController]
[Route("api/v1/bundles")]
[Produces("application/json")]
[Authorize]
public class BundlesController : ControllerBase
{
private readonly IAttestationBundler _bundler;
private readonly ILogger<BundlesController> _logger;
/// <summary>
/// Create a new BundlesController.
/// </summary>
public BundlesController(
IAttestationBundler bundler,
ILogger<BundlesController> logger)
{
_bundler = bundler ?? throw new ArgumentNullException(nameof(bundler));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Create a new attestation bundle for a time period.
/// </summary>
/// <param name="request">Bundle creation parameters.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created bundle metadata.</returns>
[HttpPost]
[ProducesResponseType(typeof(BundleCreatedResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<BundleCreatedResponse>> CreateBundleAsync(
[FromBody] CreateBundleRequest request,
CancellationToken ct = default)
{
if (request.PeriodEnd <= request.PeriodStart)
{
return BadRequest(new ProblemDetails
{
Title = "Invalid period",
Detail = "periodEnd must be after periodStart",
Status = StatusCodes.Status400BadRequest
});
}
_logger.LogInformation(
"Creating bundle for period {Start} to {End}",
request.PeriodStart,
request.PeriodEnd);
try
{
var creationRequest = new BundleCreationRequest(
request.PeriodStart,
request.PeriodEnd,
request.TenantId,
request.SignWithOrgKey,
request.OrgKeyId);
var bundle = await _bundler.CreateBundleAsync(creationRequest, ct);
var response = new BundleCreatedResponse
{
BundleId = bundle.Metadata.BundleId,
Status = "created",
AttestationCount = bundle.Attestations.Count,
PeriodStart = bundle.Metadata.PeriodStart,
PeriodEnd = bundle.Metadata.PeriodEnd,
CreatedAt = bundle.Metadata.CreatedAt,
HasOrgSignature = bundle.OrgSignature != null
};
return CreatedAtAction(
nameof(GetBundleAsync),
new { bundleId = bundle.Metadata.BundleId },
response);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to create bundle");
return BadRequest(new ProblemDetails
{
Title = "Bundle creation failed",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
}
/// <summary>
/// Get bundle metadata by ID.
/// </summary>
/// <param name="bundleId">The bundle ID (sha256:...).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Bundle metadata.</returns>
[HttpGet("{bundleId}")]
[ProducesResponseType(typeof(BundleMetadataResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleMetadataResponse>> GetBundleAsync(
[FromRoute] string bundleId,
CancellationToken ct = default)
{
if (!IsValidBundleId(bundleId))
{
return BadRequest(new ProblemDetails
{
Title = "Invalid bundle ID",
Detail = "Bundle ID must be in format sha256:<64-hex>",
Status = StatusCodes.Status400BadRequest
});
}
var bundle = await _bundler.GetBundleAsync(bundleId, ct);
if (bundle == null)
{
return NotFound(new ProblemDetails
{
Title = "Bundle not found",
Detail = $"No bundle found with ID {bundleId}",
Status = StatusCodes.Status404NotFound
});
}
return Ok(new BundleMetadataResponse
{
BundleId = bundle.Metadata.BundleId,
Version = bundle.Metadata.Version,
PeriodStart = bundle.Metadata.PeriodStart,
PeriodEnd = bundle.Metadata.PeriodEnd,
AttestationCount = bundle.Metadata.AttestationCount,
MerkleRoot = bundle.MerkleTree.Root,
OrgSignature = bundle.OrgSignature != null
? new OrgSignatureInfo
{
KeyId = bundle.OrgSignature.KeyId,
Algorithm = bundle.OrgSignature.Algorithm,
SignedAt = bundle.OrgSignature.SignedAt
}
: null,
CreatedAt = bundle.Metadata.CreatedAt
});
}
/// <summary>
/// List bundles with pagination.
/// </summary>
/// <param name="periodStart">Optional start of period filter.</param>
/// <param name="periodEnd">Optional end of period filter.</param>
/// <param name="tenantId">Optional tenant filter.</param>
/// <param name="limit">Maximum results (default 20).</param>
/// <param name="cursor">Pagination cursor.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Paginated list of bundles.</returns>
[HttpGet]
[ProducesResponseType(typeof(BundleListResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleListResponse>> ListBundlesAsync(
[FromQuery] DateTimeOffset? periodStart,
[FromQuery] DateTimeOffset? periodEnd,
[FromQuery] string? tenantId,
[FromQuery] int limit = 20,
[FromQuery] string? cursor = null,
CancellationToken ct = default)
{
var request = new BundleListRequest(
periodStart,
periodEnd,
tenantId,
Math.Clamp(limit, 1, 100),
cursor);
var result = await _bundler.ListBundlesAsync(request, ct);
var bundles = result.Bundles.Select(b => new BundleListItem
{
BundleId = b.BundleId,
PeriodStart = b.PeriodStart,
PeriodEnd = b.PeriodEnd,
AttestationCount = b.AttestationCount,
CreatedAt = b.CreatedAt,
HasOrgSignature = b.HasOrgSignature
}).ToList();
return Ok(new BundleListResponse
{
Bundles = bundles,
NextCursor = result.NextCursor
});
}
/// <summary>
/// Verify bundle integrity and signatures.
/// </summary>
/// <param name="bundleId">The bundle ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result.</returns>
[HttpPost("{bundleId}/verify")]
[ProducesResponseType(typeof(BundleVerifyResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleVerifyResponse>> VerifyBundleAsync(
[FromRoute] string bundleId,
CancellationToken ct = default)
{
if (!IsValidBundleId(bundleId))
{
return BadRequest(new ProblemDetails
{
Title = "Invalid bundle ID",
Detail = "Bundle ID must be in format sha256:<64-hex>",
Status = StatusCodes.Status400BadRequest
});
}
var bundle = await _bundler.GetBundleAsync(bundleId, ct);
if (bundle == null)
{
return NotFound(new ProblemDetails
{
Title = "Bundle not found",
Detail = $"No bundle found with ID {bundleId}",
Status = StatusCodes.Status404NotFound
});
}
var result = await _bundler.VerifyBundleAsync(bundle, ct);
return Ok(new BundleVerifyResponse
{
Valid = result.Valid,
MerkleRootVerified = result.MerkleRootVerified,
OrgSignatureVerified = result.OrgSignatureVerified,
AttestationsVerified = result.AttestationsVerified,
Issues = result.Issues.Select(i => new BundleIssueDto
{
Severity = i.Severity.ToString().ToLowerInvariant(),
Code = i.Code,
Message = i.Message,
EntryId = i.EntryId
}).ToList(),
VerifiedAt = result.VerifiedAt
});
}
/// <summary>
/// Get a specific attestation from a bundle.
/// </summary>
/// <param name="bundleId">The bundle ID.</param>
/// <param name="entryId">The attestation entry ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attestation.</returns>
[HttpGet("{bundleId}/attestations/{entryId}")]
[ProducesResponseType(typeof(BundledAttestation), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundledAttestation>> GetAttestationAsync(
[FromRoute] string bundleId,
[FromRoute] string entryId,
CancellationToken ct = default)
{
var bundle = await _bundler.GetBundleAsync(bundleId, ct);
if (bundle == null)
{
return NotFound(new ProblemDetails
{
Title = "Bundle not found",
Detail = $"No bundle found with ID {bundleId}",
Status = StatusCodes.Status404NotFound
});
}
var attestation = bundle.Attestations.FirstOrDefault(a =>
string.Equals(a.EntryId, entryId, StringComparison.OrdinalIgnoreCase));
if (attestation == null)
{
return NotFound(new ProblemDetails
{
Title = "Attestation not found",
Detail = $"No attestation found with entry ID {entryId} in bundle {bundleId}",
Status = StatusCodes.Status404NotFound
});
}
return Ok(attestation);
}
private static bool IsValidBundleId(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
if (!value.StartsWith("sha256:", StringComparison.Ordinal))
return false;
var hex = value.AsSpan()["sha256:".Length..];
if (hex.Length != 64)
return false;
foreach (var c in hex)
{
if (c is not ((>= '0' and <= '9') or (>= 'a' and <= 'f')))
return false;
}
return true;
}
}
#region DTOs
/// <summary>Request to create a bundle.</summary>
public sealed record CreateBundleRequest
{
/// <summary>Start of attestation collection period.</summary>
public required DateTimeOffset PeriodStart { get; init; }
/// <summary>End of attestation collection period.</summary>
public required DateTimeOffset PeriodEnd { get; init; }
/// <summary>Optional tenant ID filter.</summary>
public string? TenantId { get; init; }
/// <summary>Whether to sign with organization key.</summary>
public bool SignWithOrgKey { get; init; } = true;
/// <summary>Organization key ID to use (uses active key if not specified).</summary>
public string? OrgKeyId { get; init; }
}
/// <summary>Response after bundle creation.</summary>
public sealed record BundleCreatedResponse
{
/// <summary>The created bundle ID.</summary>
public required string BundleId { get; init; }
/// <summary>Creation status.</summary>
public required string Status { get; init; }
/// <summary>Number of attestations in the bundle.</summary>
public required int AttestationCount { get; init; }
/// <summary>Period start.</summary>
public required DateTimeOffset PeriodStart { get; init; }
/// <summary>Period end.</summary>
public required DateTimeOffset PeriodEnd { get; init; }
/// <summary>When the bundle was created.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>Whether the bundle has an org signature.</summary>
public required bool HasOrgSignature { get; init; }
}
/// <summary>Bundle metadata response.</summary>
public sealed record BundleMetadataResponse
{
/// <summary>Bundle ID.</summary>
public required string BundleId { get; init; }
/// <summary>Schema version.</summary>
public required string Version { get; init; }
/// <summary>Period start.</summary>
public required DateTimeOffset PeriodStart { get; init; }
/// <summary>Period end.</summary>
public required DateTimeOffset PeriodEnd { get; init; }
/// <summary>Number of attestations.</summary>
public required int AttestationCount { get; init; }
/// <summary>Merkle root.</summary>
public required string MerkleRoot { get; init; }
/// <summary>Org signature info if present.</summary>
public OrgSignatureInfo? OrgSignature { get; init; }
/// <summary>Creation timestamp.</summary>
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>Org signature info.</summary>
public sealed record OrgSignatureInfo
{
/// <summary>Key ID.</summary>
public required string KeyId { get; init; }
/// <summary>Algorithm.</summary>
public required string Algorithm { get; init; }
/// <summary>When signed.</summary>
public required DateTimeOffset SignedAt { get; init; }
}
/// <summary>Bundle list response.</summary>
public sealed record BundleListResponse
{
/// <summary>The bundles.</summary>
public required IReadOnlyList<BundleListItem> Bundles { get; init; }
/// <summary>Next page cursor.</summary>
public string? NextCursor { get; init; }
}
/// <summary>Bundle list item.</summary>
public sealed record BundleListItem
{
/// <summary>Bundle ID.</summary>
public required string BundleId { get; init; }
/// <summary>Period start.</summary>
public required DateTimeOffset PeriodStart { get; init; }
/// <summary>Period end.</summary>
public required DateTimeOffset PeriodEnd { get; init; }
/// <summary>Attestation count.</summary>
public required int AttestationCount { get; init; }
/// <summary>Creation time.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>Whether has org signature.</summary>
public required bool HasOrgSignature { get; init; }
}
/// <summary>Bundle verification response.</summary>
public sealed record BundleVerifyResponse
{
/// <summary>Overall validity.</summary>
public required bool Valid { get; init; }
/// <summary>Merkle root verified.</summary>
public required bool MerkleRootVerified { get; init; }
/// <summary>Org signature verified (if present).</summary>
public bool? OrgSignatureVerified { get; init; }
/// <summary>Number of attestations verified.</summary>
public required int AttestationsVerified { get; init; }
/// <summary>Issues found.</summary>
public required IReadOnlyList<BundleIssueDto> Issues { get; init; }
/// <summary>Verification timestamp.</summary>
public required DateTimeOffset VerifiedAt { get; init; }
}
/// <summary>Bundle issue DTO.</summary>
public sealed record BundleIssueDto
{
/// <summary>Issue severity.</summary>
public required string Severity { get; init; }
/// <summary>Issue code.</summary>
public required string Code { get; init; }
/// <summary>Issue message.</summary>
public required string Message { get; init; }
/// <summary>Related entry ID.</summary>
public string? EntryId { get; init; }
}
#endregion

View File

@@ -1,4 +1,4 @@
<?xml version='1.0' encoding='utf-8'?>
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -28,5 +28,6 @@
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
</ItemGroup>
</Project>