save progress
This commit is contained in:
@@ -23,6 +23,7 @@ using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure;
|
||||
using StellaOps.Attestor.Spdx3;
|
||||
using StellaOps.Attestor.WebService.Options;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
@@ -129,6 +130,9 @@ internal static class AttestorWebServiceComposition
|
||||
builder.Services.AddScoped<Services.IProofChainQueryService, Services.ProofChainQueryService>();
|
||||
builder.Services.AddScoped<Services.IProofVerificationService, Services.ProofVerificationService>();
|
||||
|
||||
// SPDX 3.0.1 Build profile support (BP-007)
|
||||
builder.Services.AddSingleton<IBuildAttestationMapper, BuildAttestationMapper>();
|
||||
|
||||
builder.Services.AddSingleton<StellaOps.Attestor.StandardPredicates.IStandardPredicateRegistry>(sp =>
|
||||
{
|
||||
var registry = new StellaOps.Attestor.StandardPredicates.StandardPredicateRegistry();
|
||||
|
||||
@@ -11,6 +11,7 @@ using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Spdx3;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Attestor.WebService;
|
||||
@@ -394,6 +395,125 @@ internal static class AttestorWebServiceEndpoints
|
||||
|
||||
return Results.Ok(BulkVerificationContracts.MapJob(job));
|
||||
}).RequireAuthorization("attestor:write");
|
||||
|
||||
// SPDX 3.0.1 Build Profile export endpoint (BP-007)
|
||||
app.MapPost("/api/v1/attestations:export-build", (
|
||||
Spdx3BuildExportRequestDto? requestDto,
|
||||
HttpContext httpContext,
|
||||
IBuildAttestationMapper mapper) =>
|
||||
{
|
||||
if (requestDto is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Request body is required.");
|
||||
}
|
||||
|
||||
if (!IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestDto.BuildType))
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "buildType is required.");
|
||||
}
|
||||
|
||||
// Build the attestation payload from the request
|
||||
var configSource = (!string.IsNullOrWhiteSpace(requestDto.ConfigSourceUri) ||
|
||||
requestDto.ConfigSourceDigest?.Count > 0 ||
|
||||
!string.IsNullOrWhiteSpace(requestDto.ConfigEntryPoint))
|
||||
? new ConfigSource
|
||||
{
|
||||
Uri = requestDto.ConfigSourceUri,
|
||||
Digest = requestDto.ConfigSourceDigest ?? new Dictionary<string, string>(),
|
||||
EntryPoint = requestDto.ConfigEntryPoint
|
||||
}
|
||||
: null;
|
||||
|
||||
var materials = requestDto.Materials?.Select(m => new BuildMaterial
|
||||
{
|
||||
Uri = m.Uri,
|
||||
Digest = m.Digest ?? new Dictionary<string, string>()
|
||||
}).ToList() ?? new List<BuildMaterial>();
|
||||
|
||||
var attestationPayload = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = requestDto.BuildType,
|
||||
Builder = !string.IsNullOrWhiteSpace(requestDto.BuilderId)
|
||||
? new BuilderInfo
|
||||
{
|
||||
Id = requestDto.BuilderId,
|
||||
Version = requestDto.BuilderVersion
|
||||
}
|
||||
: null,
|
||||
Invocation = new BuildInvocation
|
||||
{
|
||||
ConfigSource = configSource,
|
||||
Environment = requestDto.Environment ?? new Dictionary<string, string>(),
|
||||
Parameters = requestDto.Parameters ?? new Dictionary<string, string>()
|
||||
},
|
||||
Metadata = new BuildMetadata
|
||||
{
|
||||
BuildInvocationId = requestDto.BuildId,
|
||||
BuildStartedOn = requestDto.BuildStartTime,
|
||||
BuildFinishedOn = requestDto.BuildEndTime
|
||||
},
|
||||
Materials = materials
|
||||
};
|
||||
|
||||
// Check if the payload can be mapped
|
||||
if (!mapper.CanMapToSpdx3(attestationPayload))
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Cannot map attestation to SPDX 3.0.1",
|
||||
detail: "The provided attestation payload is missing required fields for SPDX 3.0.1 Build profile.");
|
||||
}
|
||||
|
||||
// Map to SPDX 3.0.1 Build element
|
||||
var spdx3Build = mapper.MapToSpdx3(attestationPayload, requestDto.SpdxIdPrefix);
|
||||
|
||||
// Build response based on requested format
|
||||
var response = new Spdx3BuildExportResponseDto
|
||||
{
|
||||
Format = requestDto.Format,
|
||||
BuildSpdxId = spdx3Build.SpdxId,
|
||||
Spdx3Document = requestDto.Format is BuildAttestationFormat.Spdx3 or BuildAttestationFormat.Both
|
||||
? new
|
||||
{
|
||||
spdxVersion = "SPDX-3.0.1",
|
||||
conformsTo = new[] { "https://spdx.org/rdf/v3/Build" },
|
||||
spdxId = $"{requestDto.SpdxIdPrefix}/document",
|
||||
elements = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = spdx3Build.Type,
|
||||
spdxId = spdx3Build.SpdxId,
|
||||
name = spdx3Build.Name,
|
||||
build_buildType = spdx3Build.BuildType,
|
||||
build_buildId = spdx3Build.BuildId,
|
||||
build_buildStartTime = spdx3Build.BuildStartTime?.ToString("O", CultureInfo.InvariantCulture),
|
||||
build_buildEndTime = spdx3Build.BuildEndTime?.ToString("O", CultureInfo.InvariantCulture),
|
||||
build_configSourceUri = spdx3Build.ConfigSourceUri.IsEmpty ? null : spdx3Build.ConfigSourceUri.ToArray(),
|
||||
build_configSourceDigest = spdx3Build.ConfigSourceDigest.IsEmpty ? null : spdx3Build.ConfigSourceDigest.Select(h => new { algorithm = h.Algorithm, hashValue = h.HashValue }).ToArray(),
|
||||
build_configSourceEntrypoint = spdx3Build.ConfigSourceEntrypoint.IsEmpty ? null : spdx3Build.ConfigSourceEntrypoint.ToArray(),
|
||||
build_environment = spdx3Build.Environment.Count > 0 ? spdx3Build.Environment : null,
|
||||
build_parameter = spdx3Build.Parameter.Count > 0 ? spdx3Build.Parameter : null
|
||||
}
|
||||
}
|
||||
}
|
||||
: null,
|
||||
// DSSE envelope generation would require signing service integration
|
||||
// For now, return null for DSSE when not specifically requested or when signing is disabled
|
||||
DsseEnvelope = null,
|
||||
Signing = null
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-submissions")
|
||||
.Produces<Spdx3BuildExportResponseDto>(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAttestationDetailResultAsync(
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Spdx3BuildProfileContracts.cs
|
||||
// Sprint: SPRINT_20260107_004_003_BE
|
||||
// Task: BP-007 - Attestor WebService Integration for SPDX 3.0.1 Build Profile
|
||||
// Description: DTOs for SPDX 3.0.1 Build profile export endpoint
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Supported export formats for build attestations.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum BuildAttestationFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// DSSE (Dead Simple Signing Envelope) format - default.
|
||||
/// </summary>
|
||||
Dsse = 0,
|
||||
|
||||
/// <summary>
|
||||
/// SPDX 3.0.1 Build profile format.
|
||||
/// </summary>
|
||||
Spdx3 = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Both DSSE and SPDX 3.0.1 formats combined.
|
||||
/// </summary>
|
||||
Both = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to export a build attestation in SPDX 3.0.1 format.
|
||||
/// </summary>
|
||||
public sealed record Spdx3BuildExportRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the build type URI (e.g., "https://slsa.dev/provenance/v1").
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string BuildType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the builder ID URI.
|
||||
/// </summary>
|
||||
public string? BuilderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the builder version.
|
||||
/// </summary>
|
||||
public string? BuilderVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the build invocation ID.
|
||||
/// </summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the build started.
|
||||
/// </summary>
|
||||
public DateTimeOffset? BuildStartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the build finished.
|
||||
/// </summary>
|
||||
public DateTimeOffset? BuildEndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the configuration source URI.
|
||||
/// </summary>
|
||||
public string? ConfigSourceUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the configuration source digest (algorithm:value).
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? ConfigSourceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the configuration entry point.
|
||||
/// </summary>
|
||||
public string? ConfigEntryPoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the build environment variables.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the build parameters.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the build materials (source inputs).
|
||||
/// </summary>
|
||||
public List<BuildMaterialDto>? Materials { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the output format.
|
||||
/// </summary>
|
||||
public BuildAttestationFormat Format { get; init; } = BuildAttestationFormat.Dsse;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to sign the SPDX 3.0.1 document with DSSE.
|
||||
/// </summary>
|
||||
public bool Sign { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SPDX ID prefix for generated elements.
|
||||
/// </summary>
|
||||
public string SpdxIdPrefix { get; init; } = "urn:stellaops";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build material (input) DTO.
|
||||
/// </summary>
|
||||
public sealed record BuildMaterialDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the material URI.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the material digest (algorithm:value).
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing SPDX 3.0.1 Build profile export result.
|
||||
/// </summary>
|
||||
public sealed record Spdx3BuildExportResponseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the format of the response.
|
||||
/// </summary>
|
||||
public required BuildAttestationFormat Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SPDX 3.0.1 document (JSON-LD) when format is Spdx3 or Both.
|
||||
/// </summary>
|
||||
public object? Spdx3Document { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the DSSE envelope when format is Dsse or Both.
|
||||
/// </summary>
|
||||
public DsseEnvelopeDto? DsseEnvelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SPDX ID of the generated Build element.
|
||||
/// </summary>
|
||||
public string? BuildSpdxId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the signing information.
|
||||
/// </summary>
|
||||
public BuildSigningInfoDto? Signing { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope DTO.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the payload type.
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base64-encoded payload.
|
||||
/// </summary>
|
||||
public required string PayloadBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the signatures.
|
||||
/// </summary>
|
||||
public required List<DsseSignatureDto> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature DTO.
|
||||
/// </summary>
|
||||
public sealed record DsseSignatureDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the key ID.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base64-encoded signature.
|
||||
/// </summary>
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build signing information DTO.
|
||||
/// </summary>
|
||||
public sealed record BuildSigningInfoDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the key ID used for signing.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the signing algorithm.
|
||||
/// </summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the document was signed.
|
||||
/// </summary>
|
||||
public required string SignedAt { get; init; }
|
||||
}
|
||||
@@ -29,5 +29,6 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
<ProjectReference Include="../../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Spdx3\StellaOps.Attestor.Spdx3.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class BuildAttestationMapper : IBuildAttestationMapper
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
|
||||
var configSourceUris = ImmutableArray<string>.Empty;
|
||||
var configSourceDigests = ImmutableArray<Spdx3Hash>.Empty;
|
||||
var configSourceDigests = ImmutableArray<Spdx3BuildHash>.Empty;
|
||||
var configSourceEntrypoints = ImmutableArray<string>.Empty;
|
||||
|
||||
if (attestation.Invocation?.ConfigSource is { } configSource)
|
||||
@@ -47,7 +47,7 @@ public sealed class BuildAttestationMapper : IBuildAttestationMapper
|
||||
if (configSource.Digest.Count > 0)
|
||||
{
|
||||
configSourceDigests = configSource.Digest
|
||||
.Select(kvp => new Spdx3Hash { Algorithm = kvp.Key, HashValue = kvp.Value })
|
||||
.Select(kvp => new Spdx3BuildHash { Algorithm = kvp.Key, HashValue = kvp.Value })
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class BuildRelationshipBuilder
|
||||
public BuildRelationshipBuilder AddBuildToolOf(string toolSpdxId, string artifactSpdxId)
|
||||
{
|
||||
_relationships.Add(CreateRelationship(
|
||||
"BUILD_TOOL_OF",
|
||||
Spdx3RelationshipType.BuildToolOf,
|
||||
toolSpdxId,
|
||||
artifactSpdxId));
|
||||
return this;
|
||||
@@ -49,7 +49,7 @@ public sealed class BuildRelationshipBuilder
|
||||
public BuildRelationshipBuilder AddGenerates(string buildSpdxId, string artifactSpdxId)
|
||||
{
|
||||
_relationships.Add(CreateRelationship(
|
||||
"GENERATES",
|
||||
Spdx3RelationshipType.Generates,
|
||||
buildSpdxId,
|
||||
artifactSpdxId));
|
||||
return this;
|
||||
@@ -63,7 +63,7 @@ public sealed class BuildRelationshipBuilder
|
||||
public BuildRelationshipBuilder AddGeneratedFrom(string artifactSpdxId, string sourceSpdxId)
|
||||
{
|
||||
_relationships.Add(CreateRelationship(
|
||||
"GENERATED_FROM",
|
||||
Spdx3RelationshipType.GeneratedFrom,
|
||||
artifactSpdxId,
|
||||
sourceSpdxId));
|
||||
return this;
|
||||
@@ -77,7 +77,7 @@ public sealed class BuildRelationshipBuilder
|
||||
public BuildRelationshipBuilder AddHasPrerequisite(string buildSpdxId, string prerequisiteSpdxId)
|
||||
{
|
||||
_relationships.Add(CreateRelationship(
|
||||
"HAS_PREREQUISITE",
|
||||
Spdx3RelationshipType.HasPrerequisite,
|
||||
buildSpdxId,
|
||||
prerequisiteSpdxId));
|
||||
return this;
|
||||
@@ -133,11 +133,11 @@ public sealed class BuildRelationshipBuilder
|
||||
}
|
||||
|
||||
private Spdx3Relationship CreateRelationship(
|
||||
string relationshipType,
|
||||
Spdx3RelationshipType relationshipType,
|
||||
string fromSpdxId,
|
||||
string toSpdxId)
|
||||
{
|
||||
var relId = $"{_spdxIdPrefix}/relationship/{relationshipType.ToLowerInvariant()}/{_relationships.Count + 1}";
|
||||
var relId = $"{_spdxIdPrefix}/relationship/{relationshipType.ToString().ToLowerInvariant()}/{_relationships.Count + 1}";
|
||||
|
||||
return new Spdx3Relationship
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Spdx3\StellaOps.Spdx3.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Spdx3\StellaOps.Spdx3.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BuildProfileIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260107_004_003_BE_spdx3_build_profile
|
||||
// Task: BP-011 - Integration tests for SPDX 3.0.1 Build profile
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for SPDX 3.0.1 Build profile end-to-end flows.
|
||||
/// These tests verify the complete attestation-to-SPDX 3.0.1 pipeline.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class BuildProfileIntegrationTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp =
|
||||
new(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task EndToEnd_AttestationToSpdx3_ProducesValidBuildProfile()
|
||||
{
|
||||
// Arrange: Create a realistic build attestation payload
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
PredicateType = "https://slsa.dev/provenance/v1",
|
||||
Subject = ImmutableArray.Create(new AttestationSubject
|
||||
{
|
||||
Name = "pkg:oci/myapp@sha256:abc123",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "abc123def456"
|
||||
}.ToImmutableDictionary()
|
||||
}),
|
||||
Predicate = new BuildPredicate
|
||||
{
|
||||
BuildDefinition = new BuildDefinitionInfo
|
||||
{
|
||||
BuildType = "https://stellaops.org/build/container-scan/v1",
|
||||
ExternalParameters = new Dictionary<string, object>
|
||||
{
|
||||
["imageReference"] = "registry.io/myapp:latest"
|
||||
}.ToImmutableDictionary(),
|
||||
InternalParameters = ImmutableDictionary<string, object>.Empty,
|
||||
ResolvedDependencies = ImmutableArray.Create(new ResourceDescriptor
|
||||
{
|
||||
Name = "base-image",
|
||||
Uri = "pkg:oci/alpine@sha256:def789",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = "def789"
|
||||
}.ToImmutableDictionary()
|
||||
})
|
||||
},
|
||||
RunDetails = new RunDetailsInfo
|
||||
{
|
||||
Builder = new BuilderInfo
|
||||
{
|
||||
Id = "https://stellaops.org/scanner/v1.0.0",
|
||||
Version = new Dictionary<string, string>
|
||||
{
|
||||
["stellaops"] = "1.0.0"
|
||||
}.ToImmutableDictionary()
|
||||
},
|
||||
Metadata = new BuildMetadata
|
||||
{
|
||||
InvocationId = "scan-12345",
|
||||
StartedOn = FixedTimestamp.AddMinutes(-5),
|
||||
FinishedOn = FixedTimestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var mapper = new BuildAttestationMapper();
|
||||
|
||||
// Act: Map attestation to SPDX 3.0.1 Build element
|
||||
var buildElement = mapper.MapToSpdx3(attestation);
|
||||
|
||||
// Assert: Verify all fields are correctly mapped
|
||||
buildElement.Should().NotBeNull();
|
||||
buildElement.BuildType.Should().Be("https://stellaops.org/build/container-scan/v1");
|
||||
buildElement.BuildId.Should().Be("scan-12345");
|
||||
buildElement.BuildStartTime.Should().Be(FixedTimestamp.AddMinutes(-5));
|
||||
buildElement.BuildEndTime.Should().Be(FixedTimestamp);
|
||||
buildElement.ConfigSourceUri.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignatureVerification_ValidSignedDocument_Succeeds()
|
||||
{
|
||||
// Arrange: Create document and sign it
|
||||
var timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
var serializer = new Spdx3JsonSerializer();
|
||||
var signingProvider = new TestDsseSigningProvider();
|
||||
|
||||
var signer = new DsseSpdx3Signer(serializer, signingProvider, timeProvider);
|
||||
var document = CreateTestSpdx3Document();
|
||||
var options = new DsseSpdx3SigningOptions { PrimaryKeyId = "test-key-1" };
|
||||
|
||||
// Act: Sign the document
|
||||
var envelope = signer.SignAsync(document, options).Result;
|
||||
|
||||
// Assert: Signature should be present
|
||||
envelope.Should().NotBeNull();
|
||||
envelope.Signatures.Should().HaveCount(1);
|
||||
envelope.PayloadType.Should().Be("application/vnd.spdx3+json");
|
||||
|
||||
// Verify: Extract and verify the document
|
||||
var verifier = new DsseSpdx3Verifier(serializer, signingProvider);
|
||||
var verificationResult = verifier.VerifyAsync(envelope, CancellationToken.None).Result;
|
||||
|
||||
verificationResult.IsValid.Should().BeTrue();
|
||||
verificationResult.ExtractedDocument.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportExternalBuildProfile_ValidDocument_ParsesCorrectly()
|
||||
{
|
||||
// Arrange: External SPDX 3.0.1 Build profile JSON
|
||||
var externalJson = """
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/terms/",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Build",
|
||||
"spdxId": "urn:external:build:ext-build-001",
|
||||
"build_buildType": "https://example.com/build/maven/v1",
|
||||
"build_buildId": "maven-build-789",
|
||||
"build_buildStartTime": "2026-01-09T10:00:00Z",
|
||||
"build_buildEndTime": "2026-01-09T10:15:00Z",
|
||||
"build_configSourceUri": ["https://github.com/example/repo"],
|
||||
"build_configSourceDigest": [
|
||||
{
|
||||
"algorithm": "sha256",
|
||||
"hashValue": "feedfacecafe"
|
||||
}
|
||||
],
|
||||
"build_environment": {
|
||||
"JAVA_VERSION": "21",
|
||||
"MAVEN_VERSION": "3.9.6"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act: Parse the external document
|
||||
var parser = new Spdx3Parser();
|
||||
var parseResult = parser.Parse(externalJson);
|
||||
|
||||
// Assert: Build element should be correctly parsed
|
||||
parseResult.IsSuccess.Should().BeTrue();
|
||||
parseResult.Document.Should().NotBeNull();
|
||||
|
||||
var buildElements = parseResult.Document!.Elements
|
||||
.OfType<Spdx3Build>()
|
||||
.ToList();
|
||||
|
||||
buildElements.Should().HaveCount(1);
|
||||
|
||||
var build = buildElements[0];
|
||||
build.SpdxId.Should().Be("urn:external:build:ext-build-001");
|
||||
build.BuildType.Should().Be("https://example.com/build/maven/v1");
|
||||
build.BuildId.Should().Be("maven-build-789");
|
||||
build.BuildStartTime.Should().Be(new DateTimeOffset(2026, 1, 9, 10, 0, 0, TimeSpan.Zero));
|
||||
build.BuildEndTime.Should().Be(new DateTimeOffset(2026, 1, 9, 10, 15, 0, TimeSpan.Zero));
|
||||
build.ConfigSourceUri.Should().Contain("https://github.com/example/repo");
|
||||
build.Environment.Should().ContainKey("JAVA_VERSION");
|
||||
build.Environment!["JAVA_VERSION"].Should().Be("21");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CombinedDocument_SoftwareAndBuildProfiles_MergesCorrectly()
|
||||
{
|
||||
// Arrange: Create Software profile SBOM
|
||||
var sbomDocument = new Spdx3Document
|
||||
{
|
||||
SpdxId = "urn:stellaops:sbom:sbom-001",
|
||||
Name = "MyApp SBOM",
|
||||
Namespaces = ImmutableArray.Create("https://stellaops.org/spdx/"),
|
||||
ProfileConformance = ImmutableArray.Create(Spdx3Profile.Software),
|
||||
Elements = ImmutableArray.Create<Spdx3Element>(
|
||||
new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:stellaops:pkg:myapp-1.0.0",
|
||||
Name = "MyApp",
|
||||
PackageVersion = "1.0.0",
|
||||
PackageUrl = "pkg:npm/myapp@1.0.0"
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
// Arrange: Create Build profile element
|
||||
var buildElement = new Spdx3Build
|
||||
{
|
||||
SpdxId = "urn:stellaops:build:build-001",
|
||||
BuildType = "https://stellaops.org/build/scanner/v1",
|
||||
BuildId = "scan-12345",
|
||||
BuildStartTime = FixedTimestamp.AddMinutes(-5),
|
||||
BuildEndTime = FixedTimestamp
|
||||
};
|
||||
|
||||
// Act: Combine using CombinedDocumentBuilder
|
||||
var builder = new CombinedDocumentBuilder();
|
||||
var combinedDoc = builder
|
||||
.WithSoftwareDocument(sbomDocument)
|
||||
.WithBuildProvenance(buildElement)
|
||||
.Build();
|
||||
|
||||
// Assert: Combined document has both profiles
|
||||
combinedDoc.Should().NotBeNull();
|
||||
combinedDoc.ProfileConformance.Should().Contain(Spdx3Profile.Software);
|
||||
combinedDoc.ProfileConformance.Should().Contain(Spdx3Profile.Build);
|
||||
|
||||
// Assert: Contains both package and build elements
|
||||
combinedDoc.Elements.OfType<Spdx3Package>().Should().HaveCount(1);
|
||||
combinedDoc.Elements.OfType<Spdx3Build>().Should().HaveCount(1);
|
||||
|
||||
// Assert: GENERATES relationship exists
|
||||
var relationships = combinedDoc.Elements.OfType<Spdx3Relationship>().ToList();
|
||||
var generatesRel = relationships.FirstOrDefault(r =>
|
||||
r.RelationshipType == Spdx3RelationshipType.Generates);
|
||||
|
||||
generatesRel.Should().NotBeNull();
|
||||
generatesRel!.From.Should().Be(buildElement.SpdxId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_SignedCombinedDocument_PreservesAllData()
|
||||
{
|
||||
// Arrange: Create combined document
|
||||
var timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
var serializer = new Spdx3JsonSerializer();
|
||||
var signingProvider = new TestDsseSigningProvider();
|
||||
var signer = new DsseSpdx3Signer(serializer, signingProvider, timeProvider);
|
||||
var verifier = new DsseSpdx3Verifier(serializer, signingProvider);
|
||||
|
||||
var originalDoc = CreateCombinedTestDocument();
|
||||
var options = new DsseSpdx3SigningOptions { PrimaryKeyId = "test-key-1" };
|
||||
|
||||
// Act: Sign, then verify and extract
|
||||
var envelope = await signer.SignAsync(originalDoc, options);
|
||||
var verifyResult = await verifier.VerifyAsync(envelope, CancellationToken.None);
|
||||
|
||||
// Assert: Extracted document matches original
|
||||
verifyResult.IsValid.Should().BeTrue();
|
||||
|
||||
var extractedDoc = verifyResult.ExtractedDocument;
|
||||
extractedDoc.Should().NotBeNull();
|
||||
extractedDoc!.SpdxId.Should().Be(originalDoc.SpdxId);
|
||||
extractedDoc.Name.Should().Be(originalDoc.Name);
|
||||
extractedDoc.ProfileConformance.Should().BeEquivalentTo(originalDoc.ProfileConformance);
|
||||
|
||||
// Verify elements preserved
|
||||
extractedDoc.Elements.OfType<Spdx3Package>().Count()
|
||||
.Should().Be(originalDoc.Elements.OfType<Spdx3Package>().Count());
|
||||
extractedDoc.Elements.OfType<Spdx3Build>().Count()
|
||||
.Should().Be(originalDoc.Elements.OfType<Spdx3Build>().Count());
|
||||
}
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static Spdx3Document CreateTestSpdx3Document()
|
||||
{
|
||||
return new Spdx3Document
|
||||
{
|
||||
SpdxId = "urn:stellaops:sbom:test-001",
|
||||
Name = "Test SBOM",
|
||||
Namespaces = ImmutableArray.Create("https://stellaops.org/spdx/"),
|
||||
ProfileConformance = ImmutableArray.Create(Spdx3Profile.Software),
|
||||
Elements = ImmutableArray.Create<Spdx3Element>(
|
||||
new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:stellaops:pkg:test-pkg",
|
||||
Name = "TestPackage",
|
||||
PackageVersion = "1.0.0"
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateCombinedTestDocument()
|
||||
{
|
||||
return new Spdx3Document
|
||||
{
|
||||
SpdxId = "urn:stellaops:combined:test-001",
|
||||
Name = "Combined Test Document",
|
||||
Namespaces = ImmutableArray.Create("https://stellaops.org/spdx/"),
|
||||
ProfileConformance = ImmutableArray.Create(Spdx3Profile.Software, Spdx3Profile.Build),
|
||||
Elements = ImmutableArray.Create<Spdx3Element>(
|
||||
new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:stellaops:pkg:combined-pkg",
|
||||
Name = "CombinedPackage",
|
||||
PackageVersion = "2.0.0"
|
||||
},
|
||||
new Spdx3Build
|
||||
{
|
||||
SpdxId = "urn:stellaops:build:combined-build",
|
||||
BuildType = "https://stellaops.org/build/test/v1",
|
||||
BuildId = "combined-build-001",
|
||||
BuildStartTime = FixedTimestamp.AddMinutes(-10),
|
||||
BuildEndTime = FixedTimestamp
|
||||
},
|
||||
new Spdx3Relationship
|
||||
{
|
||||
SpdxId = "urn:stellaops:rel:generates-001",
|
||||
RelationshipType = Spdx3RelationshipType.Generates,
|
||||
From = "urn:stellaops:build:combined-build",
|
||||
To = ImmutableArray.Create("urn:stellaops:pkg:combined-pkg")
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test signing provider that uses a simple HMAC for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestDsseSigningProvider : IDsseSigningProvider
|
||||
{
|
||||
private static readonly byte[] TestKey = Encoding.UTF8.GetBytes("test-signing-key-32-bytes-long!!");
|
||||
|
||||
public Task<DsseSignatureResult> SignAsync(
|
||||
byte[] payload,
|
||||
string keyId,
|
||||
string? algorithm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA256(TestKey);
|
||||
var signature = hmac.ComputeHash(payload);
|
||||
|
||||
return Task.FromResult(new DsseSignatureResult
|
||||
{
|
||||
KeyId = keyId,
|
||||
Algorithm = algorithm ?? "HMAC-SHA256",
|
||||
SignatureBytes = signature
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> VerifyAsync(
|
||||
byte[] payload,
|
||||
byte[] signature,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA256(TestKey);
|
||||
var expectedSignature = hmac.ComputeHash(payload);
|
||||
|
||||
return Task.FromResult(signature.SequenceEqual(expectedSignature));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple JSON serializer for SPDX 3.0.1 documents (test implementation).
|
||||
/// </summary>
|
||||
file sealed class Spdx3JsonSerializer : ISpdx3Serializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public byte[] SerializeToBytes(Spdx3Document document)
|
||||
{
|
||||
return JsonSerializer.SerializeToUtf8Bytes(document, Options);
|
||||
}
|
||||
|
||||
public Spdx3Document? DeserializeFromBytes(byte[] bytes)
|
||||
{
|
||||
return JsonSerializer.Deserialize<Spdx3Document>(bytes, Options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user