more audit work
This commit is contained in:
23
src/Attestor/__Libraries/StellaOps.Attestor.Spdx3/AGENTS.md
Normal file
23
src/Attestor/__Libraries/StellaOps.Attestor.Spdx3/AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Attestor SPDX3 Build Profile Charter
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/Attestor/__Libraries/StellaOps.Attestor.Spdx3/`.
|
||||
- Roles: backend engineer, QA automation.
|
||||
- Focus: mapping SLSA/in-toto build attestations to SPDX 3.0.1 Build profile elements.
|
||||
|
||||
## Required Reading
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/attestor/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`
|
||||
|
||||
## Working Agreement
|
||||
- Preserve deterministic IDs and ordering in SPDX outputs.
|
||||
- Use InvariantCulture for formatted timestamps and hashes.
|
||||
- Avoid Guid.NewGuid/DateTime.UtcNow in core logic; use injected providers.
|
||||
- Update the sprint tracker and local `TASKS.md` when work changes.
|
||||
|
||||
## Testing
|
||||
- Unit tests live in `src/Attestor/__Libraries/__Tests/StellaOps.Attestor.Spdx3.Tests/`.
|
||||
- Cover mapping, deterministic ID generation, and relationship ordering.
|
||||
@@ -0,0 +1,147 @@
|
||||
// <copyright file="BuildAttestationMapper.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Maps between SLSA/in-toto build attestations and SPDX 3.0.1 Build profile elements.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-004
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mapping Table (SLSA -> SPDX 3.0.1):
|
||||
/// | in-toto/SLSA | SPDX 3.0.1 Build |
|
||||
/// |--------------|------------------|
|
||||
/// | buildType | build_buildType |
|
||||
/// | builder.id | CreationInfo.createdBy (Agent) |
|
||||
/// | invocation.configSource.uri | build_configSourceUri |
|
||||
/// | invocation.environment | build_environment |
|
||||
/// | invocation.parameters | build_parameter |
|
||||
/// | metadata.buildStartedOn | build_buildStartTime |
|
||||
/// | metadata.buildFinishedOn | build_buildEndTime |
|
||||
/// | metadata.buildInvocationId | build_buildId |
|
||||
/// </remarks>
|
||||
public sealed class BuildAttestationMapper : IBuildAttestationMapper
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Spdx3Build MapToSpdx3(BuildAttestationPayload attestation, string spdxIdPrefix)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
|
||||
var configSourceUris = ImmutableArray<string>.Empty;
|
||||
var configSourceDigests = ImmutableArray<Spdx3Hash>.Empty;
|
||||
var configSourceEntrypoints = ImmutableArray<string>.Empty;
|
||||
|
||||
if (attestation.Invocation?.ConfigSource is { } configSource)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configSource.Uri))
|
||||
{
|
||||
configSourceUris = ImmutableArray.Create(configSource.Uri);
|
||||
}
|
||||
|
||||
if (configSource.Digest.Count > 0)
|
||||
{
|
||||
configSourceDigests = configSource.Digest
|
||||
.Select(kvp => new Spdx3Hash { Algorithm = kvp.Key, HashValue = kvp.Value })
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configSource.EntryPoint))
|
||||
{
|
||||
configSourceEntrypoints = ImmutableArray.Create(configSource.EntryPoint);
|
||||
}
|
||||
}
|
||||
|
||||
var environment = attestation.Invocation?.Environment.ToImmutableDictionary()
|
||||
?? ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var parameters = attestation.Invocation?.Parameters.ToImmutableDictionary()
|
||||
?? ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var buildId = attestation.Metadata?.BuildInvocationId
|
||||
?? GenerateBuildId(attestation);
|
||||
|
||||
return new Spdx3Build
|
||||
{
|
||||
SpdxId = GenerateSpdxId(spdxIdPrefix, buildId),
|
||||
Type = Spdx3Build.TypeName,
|
||||
Name = $"Build {buildId}",
|
||||
BuildType = attestation.BuildType,
|
||||
BuildId = buildId,
|
||||
BuildStartTime = attestation.Metadata?.BuildStartedOn,
|
||||
BuildEndTime = attestation.Metadata?.BuildFinishedOn,
|
||||
ConfigSourceUri = configSourceUris,
|
||||
ConfigSourceDigest = configSourceDigests,
|
||||
ConfigSourceEntrypoint = configSourceEntrypoints,
|
||||
Environment = environment,
|
||||
Parameter = parameters
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BuildAttestationPayload MapFromSpdx3(Spdx3Build build)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(build);
|
||||
|
||||
ConfigSource? configSource = null;
|
||||
if (build.ConfigSourceUri.Length > 0 || build.ConfigSourceDigest.Length > 0)
|
||||
{
|
||||
configSource = new ConfigSource
|
||||
{
|
||||
Uri = build.ConfigSourceUri.FirstOrDefault(),
|
||||
Digest = build.ConfigSourceDigest
|
||||
.ToDictionary(h => h.Algorithm, h => h.HashValue),
|
||||
EntryPoint = build.ConfigSourceEntrypoint.FirstOrDefault()
|
||||
};
|
||||
}
|
||||
|
||||
return new BuildAttestationPayload
|
||||
{
|
||||
BuildType = build.BuildType,
|
||||
Invocation = new BuildInvocation
|
||||
{
|
||||
ConfigSource = configSource,
|
||||
Environment = build.Environment,
|
||||
Parameters = build.Parameter
|
||||
},
|
||||
Metadata = new BuildMetadata
|
||||
{
|
||||
BuildInvocationId = build.BuildId,
|
||||
BuildStartedOn = build.BuildStartTime,
|
||||
BuildFinishedOn = build.BuildEndTime
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanMapToSpdx3(BuildAttestationPayload attestation)
|
||||
{
|
||||
if (attestation is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// buildType is required for SPDX 3.0.1 Build profile
|
||||
return !string.IsNullOrWhiteSpace(attestation.BuildType);
|
||||
}
|
||||
|
||||
private static string GenerateSpdxId(string prefix, string? buildId)
|
||||
{
|
||||
var id = buildId ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
return $"{prefix.TrimEnd('/')}/build/{id}";
|
||||
}
|
||||
|
||||
private static string GenerateBuildId(BuildAttestationPayload attestation)
|
||||
{
|
||||
// Generate a deterministic build ID from available information
|
||||
var input = $"{attestation.BuildType}:{attestation.Metadata?.BuildStartedOn:O}";
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(hash)[..16];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// <copyright file="BuildRelationshipBuilder.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Builds SPDX 3.0.1 relationships for Build profile elements.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-006
|
||||
/// </summary>
|
||||
public sealed class BuildRelationshipBuilder
|
||||
{
|
||||
private readonly string _spdxIdPrefix;
|
||||
private readonly List<Spdx3Relationship> _relationships = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BuildRelationshipBuilder"/> class.
|
||||
/// </summary>
|
||||
/// <param name="spdxIdPrefix">Prefix for generating relationship SPDX IDs.</param>
|
||||
public BuildRelationshipBuilder(string spdxIdPrefix)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
_spdxIdPrefix = spdxIdPrefix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a BUILD_TOOL_OF relationship (tool -> artifact).
|
||||
/// </summary>
|
||||
/// <param name="toolSpdxId">SPDX ID of the build tool.</param>
|
||||
/// <param name="artifactSpdxId">SPDX ID of the artifact built by the tool.</param>
|
||||
public BuildRelationshipBuilder AddBuildToolOf(string toolSpdxId, string artifactSpdxId)
|
||||
{
|
||||
_relationships.Add(CreateRelationship(
|
||||
"BUILD_TOOL_OF",
|
||||
toolSpdxId,
|
||||
artifactSpdxId));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a GENERATES relationship (build -> artifact).
|
||||
/// </summary>
|
||||
/// <param name="buildSpdxId">SPDX ID of the Build element.</param>
|
||||
/// <param name="artifactSpdxId">SPDX ID of the generated artifact.</param>
|
||||
public BuildRelationshipBuilder AddGenerates(string buildSpdxId, string artifactSpdxId)
|
||||
{
|
||||
_relationships.Add(CreateRelationship(
|
||||
"GENERATES",
|
||||
buildSpdxId,
|
||||
artifactSpdxId));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a GENERATED_FROM relationship (artifact -> source).
|
||||
/// </summary>
|
||||
/// <param name="artifactSpdxId">SPDX ID of the generated artifact.</param>
|
||||
/// <param name="sourceSpdxId">SPDX ID of the source material.</param>
|
||||
public BuildRelationshipBuilder AddGeneratedFrom(string artifactSpdxId, string sourceSpdxId)
|
||||
{
|
||||
_relationships.Add(CreateRelationship(
|
||||
"GENERATED_FROM",
|
||||
artifactSpdxId,
|
||||
sourceSpdxId));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a HAS_PREREQUISITE relationship (build -> dependency).
|
||||
/// </summary>
|
||||
/// <param name="buildSpdxId">SPDX ID of the Build element.</param>
|
||||
/// <param name="prerequisiteSpdxId">SPDX ID of the prerequisite material.</param>
|
||||
public BuildRelationshipBuilder AddHasPrerequisite(string buildSpdxId, string prerequisiteSpdxId)
|
||||
{
|
||||
_relationships.Add(CreateRelationship(
|
||||
"HAS_PREREQUISITE",
|
||||
buildSpdxId,
|
||||
prerequisiteSpdxId));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Links a Build element to its produced Package elements.
|
||||
/// </summary>
|
||||
/// <param name="build">The Build element.</param>
|
||||
/// <param name="packageSpdxIds">SPDX IDs of produced Package elements.</param>
|
||||
public BuildRelationshipBuilder LinkBuildToPackages(Spdx3Build build, IEnumerable<string> packageSpdxIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(build);
|
||||
ArgumentNullException.ThrowIfNull(packageSpdxIds);
|
||||
|
||||
foreach (var packageId in packageSpdxIds)
|
||||
{
|
||||
AddGenerates(build.SpdxId, packageId);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Links a Build element to its source materials.
|
||||
/// </summary>
|
||||
/// <param name="build">The Build element.</param>
|
||||
/// <param name="materials">Build materials (sources).</param>
|
||||
public BuildRelationshipBuilder LinkBuildToMaterials(
|
||||
Spdx3Build build,
|
||||
IEnumerable<BuildMaterial> materials)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(build);
|
||||
ArgumentNullException.ThrowIfNull(materials);
|
||||
|
||||
foreach (var material in materials)
|
||||
{
|
||||
// Create a source element SPDX ID from the material URI
|
||||
var materialSpdxId = GenerateMaterialSpdxId(material.Uri);
|
||||
AddHasPrerequisite(build.SpdxId, materialSpdxId);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the list of relationships.
|
||||
/// </summary>
|
||||
/// <returns>Immutable array of SPDX 3.0.1 relationships.</returns>
|
||||
public ImmutableArray<Spdx3Relationship> Build()
|
||||
{
|
||||
return _relationships.ToImmutableArray();
|
||||
}
|
||||
|
||||
private Spdx3Relationship CreateRelationship(
|
||||
string relationshipType,
|
||||
string fromSpdxId,
|
||||
string toSpdxId)
|
||||
{
|
||||
var relId = $"{_spdxIdPrefix}/relationship/{relationshipType.ToLowerInvariant()}/{_relationships.Count + 1}";
|
||||
|
||||
return new Spdx3Relationship
|
||||
{
|
||||
SpdxId = relId,
|
||||
Type = "Relationship",
|
||||
RelationshipType = relationshipType,
|
||||
From = fromSpdxId,
|
||||
To = ImmutableArray.Create(toSpdxId)
|
||||
};
|
||||
}
|
||||
|
||||
private string GenerateMaterialSpdxId(string materialUri)
|
||||
{
|
||||
// Generate a deterministic SPDX ID from the material URI
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(materialUri));
|
||||
var shortHash = Convert.ToHexStringLower(hash)[..12];
|
||||
return $"{_spdxIdPrefix}/material/{shortHash}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// <copyright file="CombinedDocumentBuilder.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Builds combined SPDX 3.0.1 documents containing multiple profiles (e.g., Software + Build).
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-008
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This builder merges elements from different profiles into a single coherent document,
|
||||
/// ensuring proper profile conformance declarations and cross-profile relationships.
|
||||
/// </remarks>
|
||||
public sealed class CombinedDocumentBuilder
|
||||
{
|
||||
private readonly List<Spdx3Element> _elements = new();
|
||||
private readonly HashSet<Spdx3ProfileIdentifier> _profiles = new();
|
||||
private readonly List<Spdx3CreationInfo> _creationInfos = new();
|
||||
private readonly List<Spdx3Relationship> _relationships = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private string? _documentSpdxId;
|
||||
private string? _documentName;
|
||||
private string? _rootElementId;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CombinedDocumentBuilder"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for timestamp generation.</param>
|
||||
public CombinedDocumentBuilder(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the document SPDX ID.
|
||||
/// </summary>
|
||||
/// <param name="spdxId">The document's unique IRI identifier.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedDocumentBuilder WithDocumentId(string spdxId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxId);
|
||||
_documentSpdxId = spdxId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the document name.
|
||||
/// </summary>
|
||||
/// <param name="name">Human-readable document name.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedDocumentBuilder WithName(string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
_documentName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds elements from a Software profile SBOM.
|
||||
/// </summary>
|
||||
/// <param name="sbom">The source SBOM document.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedDocumentBuilder WithSoftwareProfile(Spdx3Document sbom)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
|
||||
// Add all elements from the SBOM
|
||||
foreach (var element in sbom.Elements)
|
||||
{
|
||||
_elements.Add(element);
|
||||
}
|
||||
|
||||
// Add relationships
|
||||
foreach (var relationship in sbom.Relationships)
|
||||
{
|
||||
_relationships.Add(relationship);
|
||||
}
|
||||
|
||||
// Track root element from SBOM
|
||||
var root = sbom.GetRootPackage();
|
||||
if (root is not null && _rootElementId is null)
|
||||
{
|
||||
_rootElementId = root.SpdxId;
|
||||
}
|
||||
|
||||
// Add Software and Core profiles
|
||||
_profiles.Add(Spdx3ProfileIdentifier.Core);
|
||||
_profiles.Add(Spdx3ProfileIdentifier.Software);
|
||||
|
||||
// Preserve existing profile conformance
|
||||
foreach (var profile in sbom.Profiles)
|
||||
{
|
||||
_profiles.Add(profile);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a Build profile element with relationships to the SBOM.
|
||||
/// </summary>
|
||||
/// <param name="build">The Build element.</param>
|
||||
/// <param name="producedArtifactId">Optional ID of the artifact produced by this build.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedDocumentBuilder WithBuildProfile(Spdx3Build build, string? producedArtifactId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(build);
|
||||
|
||||
_elements.Add(build);
|
||||
_profiles.Add(Spdx3ProfileIdentifier.Core);
|
||||
_profiles.Add(Spdx3ProfileIdentifier.Build);
|
||||
|
||||
// Link build to root/produced artifact if specified
|
||||
var targetId = producedArtifactId ?? _rootElementId;
|
||||
if (targetId is not null)
|
||||
{
|
||||
var generatesRelationship = new Spdx3Relationship
|
||||
{
|
||||
SpdxId = $"{build.SpdxId}/relationship/generates",
|
||||
From = build.SpdxId,
|
||||
To = ImmutableArray.Create(targetId),
|
||||
RelationshipType = Spdx3RelationshipType.Generates
|
||||
};
|
||||
_relationships.Add(generatesRelationship);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a Build element mapped from an attestation.
|
||||
/// </summary>
|
||||
/// <param name="attestation">The source attestation.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for generating SPDX IDs.</param>
|
||||
/// <param name="producedArtifactId">Optional ID of the artifact produced by this build.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedDocumentBuilder WithBuildAttestation(
|
||||
BuildAttestationPayload attestation,
|
||||
string spdxIdPrefix,
|
||||
string? producedArtifactId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
|
||||
var mapper = new BuildAttestationMapper();
|
||||
var build = mapper.MapToSpdx3(attestation, spdxIdPrefix);
|
||||
|
||||
return WithBuildProfile(build, producedArtifactId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds creation information for the combined document.
|
||||
/// </summary>
|
||||
/// <param name="creationInfo">The creation information.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedDocumentBuilder WithCreationInfo(Spdx3CreationInfo creationInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(creationInfo);
|
||||
_creationInfos.Add(creationInfo);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an arbitrary element to the document.
|
||||
/// </summary>
|
||||
/// <param name="element">The element to add.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedDocumentBuilder WithElement(Spdx3Element element)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(element);
|
||||
_elements.Add(element);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a relationship to the document.
|
||||
/// </summary>
|
||||
/// <param name="relationship">The relationship to add.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedDocumentBuilder WithRelationship(Spdx3Relationship relationship)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(relationship);
|
||||
_relationships.Add(relationship);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the combined SPDX 3.0.1 document.
|
||||
/// </summary>
|
||||
/// <returns>The combined document.</returns>
|
||||
/// <exception cref="InvalidOperationException">If required fields are missing.</exception>
|
||||
public Spdx3Document Build()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_documentSpdxId))
|
||||
{
|
||||
throw new InvalidOperationException("Document SPDX ID is required. Call WithDocumentId().");
|
||||
}
|
||||
|
||||
// Create combined creation info if none provided
|
||||
if (_creationInfos.Count == 0)
|
||||
{
|
||||
var defaultCreationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
Id = $"{_documentSpdxId}/creationInfo",
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = _timeProvider.GetUtcNow(),
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
CreatedUsing = ImmutableArray.Create("StellaOps"),
|
||||
Profile = _profiles.ToImmutableArray(),
|
||||
DataLicense = Spdx3CreationInfo.Spdx301DataLicense
|
||||
};
|
||||
_creationInfos.Add(defaultCreationInfo);
|
||||
}
|
||||
|
||||
// Combine all elements including relationships
|
||||
var allElements = new List<Spdx3Element>(_elements);
|
||||
allElements.AddRange(_relationships);
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: allElements,
|
||||
creationInfos: _creationInfos,
|
||||
profiles: _profiles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new builder with the given time provider.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <returns>A new builder instance.</returns>
|
||||
public static CombinedDocumentBuilder Create(TimeProvider timeProvider)
|
||||
{
|
||||
return new CombinedDocumentBuilder(timeProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new builder using the system time provider.
|
||||
/// </summary>
|
||||
/// <returns>A new builder instance.</returns>
|
||||
public static CombinedDocumentBuilder Create()
|
||||
{
|
||||
return new CombinedDocumentBuilder(TimeProvider.System);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for combining SPDX 3.0.1 documents.
|
||||
/// </summary>
|
||||
public static class CombinedDocumentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Combines an SBOM with a build attestation into a single document.
|
||||
/// </summary>
|
||||
/// <param name="sbom">The source SBOM.</param>
|
||||
/// <param name="attestation">The build attestation.</param>
|
||||
/// <param name="documentId">The combined document ID.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for generated IDs.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <returns>The combined document.</returns>
|
||||
public static Spdx3Document WithBuildProvenance(
|
||||
this Spdx3Document sbom,
|
||||
BuildAttestationPayload attestation,
|
||||
string documentId,
|
||||
string spdxIdPrefix,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
return CombinedDocumentBuilder.Create(timeProvider ?? TimeProvider.System)
|
||||
.WithDocumentId(documentId)
|
||||
.WithName($"Combined SBOM and Build Provenance")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithBuildAttestation(attestation, spdxIdPrefix)
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
// <copyright file="DsseSpdx3Signer.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Signs SPDX 3.0.1 documents with DSSE (Dead Simple Signing Envelope).
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-005
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The DSSE envelope wraps the entire SPDX 3.0.1 document as the payload.
|
||||
/// This follows the same pattern as in-toto attestations, making the signed
|
||||
/// SPDX document verifiable with standard DSSE/in-toto verification tools.
|
||||
///
|
||||
/// Payload type: application/spdx+json
|
||||
/// </remarks>
|
||||
public sealed class DsseSpdx3Signer : IDsseSpdx3Signer
|
||||
{
|
||||
/// <summary>
|
||||
/// The DSSE payload type for SPDX 3.0.1 JSON-LD documents.
|
||||
/// </summary>
|
||||
public const string Spdx3PayloadType = "application/spdx+json";
|
||||
|
||||
/// <summary>
|
||||
/// The PAE (Pre-Authentication Encoding) prefix for DSSE v1.
|
||||
/// </summary>
|
||||
private const string PaePrefix = "DSSEv1";
|
||||
|
||||
private readonly ISpdx3Serializer _serializer;
|
||||
private readonly IDsseSigningProvider _signingProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DsseSpdx3Signer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="serializer">The SPDX 3.0.1 JSON-LD serializer.</param>
|
||||
/// <param name="signingProvider">The DSSE signing provider.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamp injection.</param>
|
||||
public DsseSpdx3Signer(
|
||||
ISpdx3Serializer serializer,
|
||||
IDsseSigningProvider signingProvider,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
|
||||
_signingProvider = signingProvider ?? throw new ArgumentNullException(nameof(signingProvider));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DsseSpdx3Envelope> SignAsync(
|
||||
Spdx3Document document,
|
||||
DsseSpdx3SigningOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
// Serialize the SPDX 3.0.1 document to canonical JSON
|
||||
var payloadBytes = _serializer.SerializeToBytes(document);
|
||||
|
||||
// Encode payload as base64url (RFC 4648 Section 5)
|
||||
var payloadBase64Url = ToBase64Url(payloadBytes);
|
||||
|
||||
// Build PAE (Pre-Authentication Encoding) for signing
|
||||
var paeBytes = BuildPae(Spdx3PayloadType, payloadBytes);
|
||||
|
||||
// Sign the PAE
|
||||
var signatures = new List<DsseSpdx3Signature>();
|
||||
var primarySignature = await _signingProvider
|
||||
.SignAsync(paeBytes, options.PrimaryKeyId, options.PrimaryAlgorithm, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
signatures.Add(new DsseSpdx3Signature
|
||||
{
|
||||
KeyId = primarySignature.KeyId,
|
||||
Sig = ToBase64Url(primarySignature.SignatureBytes)
|
||||
});
|
||||
|
||||
// Optional secondary signature (e.g., post-quantum algorithm)
|
||||
if (!string.IsNullOrWhiteSpace(options.SecondaryKeyId))
|
||||
{
|
||||
var secondarySignature = await _signingProvider
|
||||
.SignAsync(paeBytes, options.SecondaryKeyId, options.SecondaryAlgorithm, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
signatures.Add(new DsseSpdx3Signature
|
||||
{
|
||||
KeyId = secondarySignature.KeyId,
|
||||
Sig = ToBase64Url(secondarySignature.SignatureBytes)
|
||||
});
|
||||
}
|
||||
|
||||
return new DsseSpdx3Envelope
|
||||
{
|
||||
PayloadType = Spdx3PayloadType,
|
||||
Payload = payloadBase64Url,
|
||||
Signatures = signatures.ToImmutableArray(),
|
||||
SignedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DsseSpdx3Envelope> SignBuildProfileAsync(
|
||||
Spdx3Build build,
|
||||
Spdx3Document? associatedSbom,
|
||||
DsseSpdx3SigningOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(build);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
// Create a document containing the build element
|
||||
var elements = new List<Spdx3Element> { build };
|
||||
|
||||
// Include associated SBOM elements if provided
|
||||
if (associatedSbom is not null)
|
||||
{
|
||||
elements.AddRange(associatedSbom.Elements);
|
||||
}
|
||||
|
||||
var creationInfo = build.CreationInfo ?? new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = _timeProvider.GetUtcNow(),
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
Profile = ImmutableArray.Create(
|
||||
Spdx3ProfileIdentifier.Core,
|
||||
Spdx3ProfileIdentifier.Build)
|
||||
};
|
||||
|
||||
var profiles = ImmutableHashSet.Create(
|
||||
Spdx3ProfileIdentifier.Core,
|
||||
Spdx3ProfileIdentifier.Build);
|
||||
|
||||
var document = new Spdx3Document(
|
||||
elements: elements,
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: profiles);
|
||||
|
||||
return await SignAsync(document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> VerifyAsync(
|
||||
DsseSpdx3Envelope envelope,
|
||||
IReadOnlyList<DsseVerificationKey> trustedKeys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ArgumentNullException.ThrowIfNull(trustedKeys);
|
||||
|
||||
if (envelope.Signatures.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
var payloadBytes = FromBase64Url(envelope.Payload);
|
||||
|
||||
// Build PAE for verification
|
||||
var paeBytes = BuildPae(envelope.PayloadType, payloadBytes);
|
||||
|
||||
// Verify at least one signature from a trusted key
|
||||
foreach (var signature in envelope.Signatures)
|
||||
{
|
||||
var trustedKey = trustedKeys.FirstOrDefault(k => k.KeyId == signature.KeyId);
|
||||
if (trustedKey is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var signatureBytes = FromBase64Url(signature.Sig);
|
||||
var isValid = await _signingProvider
|
||||
.VerifyAsync(paeBytes, signatureBytes, trustedKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Spdx3Document? ExtractDocument(DsseSpdx3Envelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
if (envelope.PayloadType != Spdx3PayloadType)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var payloadBytes = FromBase64Url(envelope.Payload);
|
||||
return _serializer.Deserialize(payloadBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the Pre-Authentication Encoding (PAE) as per DSSE spec.
|
||||
/// PAE format: "DSSEv1" SP LEN(type) SP type SP LEN(payload) SP payload
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DSSE v1 PAE uses ASCII decimal for lengths and space as separator.
|
||||
/// This prevents length-extension attacks and ensures unambiguous parsing.
|
||||
/// </remarks>
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
// PAE = "DSSEv1" SP LEN(type) SP type SP LEN(payload) SP payload
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
var paeString = $"{PaePrefix} {typeBytes.Length} {payloadType} {payload.Length} ";
|
||||
var paePrefix = Encoding.UTF8.GetBytes(paeString);
|
||||
|
||||
var result = new byte[paePrefix.Length + payload.Length];
|
||||
Buffer.BlockCopy(paePrefix, 0, result, 0, paePrefix.Length);
|
||||
Buffer.BlockCopy(payload, 0, result, paePrefix.Length, payload.Length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts bytes to base64url encoding (RFC 4648 Section 5).
|
||||
/// </summary>
|
||||
private static string ToBase64Url(byte[] bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts base64url string back to bytes.
|
||||
/// </summary>
|
||||
private static byte[] FromBase64Url(string base64Url)
|
||||
{
|
||||
var base64 = base64Url
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
// Add padding if necessary
|
||||
var padding = (4 - (base64.Length % 4)) % 4;
|
||||
if (padding > 0)
|
||||
{
|
||||
base64 += new string('=', padding);
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signing SPDX 3.0.1 documents with DSSE.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-005
|
||||
/// </summary>
|
||||
public interface IDsseSpdx3Signer
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs an SPDX 3.0.1 document with DSSE.
|
||||
/// </summary>
|
||||
/// <param name="document">The SPDX 3.0.1 document to sign.</param>
|
||||
/// <param name="options">Signing options including key selection.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The DSSE envelope containing the signed document.</returns>
|
||||
Task<DsseSpdx3Envelope> SignAsync(
|
||||
Spdx3Document document,
|
||||
DsseSpdx3SigningOptions options,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Signs an SPDX 3.0.1 Build profile element with DSSE.
|
||||
/// </summary>
|
||||
/// <param name="build">The Build element to sign.</param>
|
||||
/// <param name="associatedSbom">Optional associated SBOM to include.</param>
|
||||
/// <param name="options">Signing options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The DSSE envelope containing the signed Build profile.</returns>
|
||||
Task<DsseSpdx3Envelope> SignBuildProfileAsync(
|
||||
Spdx3Build build,
|
||||
Spdx3Document? associatedSbom,
|
||||
DsseSpdx3SigningOptions options,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE-signed SPDX 3.0.1 envelope.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The envelope to verify.</param>
|
||||
/// <param name="trustedKeys">List of trusted verification keys.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the envelope is valid and signed by a trusted key.</returns>
|
||||
Task<bool> VerifyAsync(
|
||||
DsseSpdx3Envelope envelope,
|
||||
IReadOnlyList<DsseVerificationKey> trustedKeys,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the SPDX 3.0.1 document from a DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The envelope containing the signed document.</param>
|
||||
/// <returns>The extracted document, or null if extraction fails.</returns>
|
||||
Spdx3Document? ExtractDocument(DsseSpdx3Envelope envelope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope containing a signed SPDX 3.0.1 document.
|
||||
/// </summary>
|
||||
public sealed record DsseSpdx3Envelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the payload type (should be "application/spdx+json").
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base64url-encoded payload.
|
||||
/// </summary>
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the signatures over the PAE.
|
||||
/// </summary>
|
||||
public ImmutableArray<DsseSpdx3Signature> Signatures { get; init; } =
|
||||
ImmutableArray<DsseSpdx3Signature>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the document was signed.
|
||||
/// </summary>
|
||||
public DateTimeOffset SignedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signature within a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record DsseSpdx3Signature
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the key ID that produced this signature.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base64url-encoded signature value.
|
||||
/// </summary>
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for DSSE signing of SPDX 3.0.1 documents.
|
||||
/// </summary>
|
||||
public sealed record DsseSpdx3SigningOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the primary signing key ID.
|
||||
/// </summary>
|
||||
public required string PrimaryKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary signing algorithm (e.g., "ES256", "RS256").
|
||||
/// </summary>
|
||||
public string? PrimaryAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional secondary signing key ID (e.g., for PQ hybrid).
|
||||
/// </summary>
|
||||
public string? SecondaryKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional secondary signing algorithm.
|
||||
/// </summary>
|
||||
public string? SecondaryAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to include timestamps in the envelope.
|
||||
/// </summary>
|
||||
public bool IncludeTimestamp { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider interface for DSSE signing operations.
|
||||
/// </summary>
|
||||
public interface IDsseSigningProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs data with the specified key.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to sign (PAE bytes).</param>
|
||||
/// <param name="keyId">The key ID to use.</param>
|
||||
/// <param name="algorithm">Optional algorithm override.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The signature result.</returns>
|
||||
Task<DsseSignatureResult> SignAsync(
|
||||
byte[] data,
|
||||
string keyId,
|
||||
string? algorithm,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signature against the data.
|
||||
/// </summary>
|
||||
/// <param name="data">The original data (PAE bytes).</param>
|
||||
/// <param name="signature">The signature to verify.</param>
|
||||
/// <param name="key">The verification key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the signature is valid.</returns>
|
||||
Task<bool> VerifyAsync(
|
||||
byte[] data,
|
||||
byte[] signature,
|
||||
DsseVerificationKey key,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a DSSE signing operation.
|
||||
/// </summary>
|
||||
public sealed record DsseSignatureResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the key ID used for signing.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw signature bytes.
|
||||
/// </summary>
|
||||
public required byte[] SignatureBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the algorithm used.
|
||||
/// </summary>
|
||||
public string? Algorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A verification key for DSSE signature validation.
|
||||
/// </summary>
|
||||
public sealed record DsseVerificationKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the key ID.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the public key bytes.
|
||||
/// </summary>
|
||||
public required byte[] PublicKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the algorithm.
|
||||
/// </summary>
|
||||
public string? Algorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for SPDX 3.0.1 document serialization.
|
||||
/// </summary>
|
||||
public interface ISpdx3Serializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializes an SPDX 3.0.1 document to canonical JSON bytes.
|
||||
/// </summary>
|
||||
byte[] SerializeToBytes(Spdx3Document document);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes bytes to an SPDX 3.0.1 document.
|
||||
/// </summary>
|
||||
Spdx3Document? Deserialize(byte[] bytes);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// <copyright file="IBuildAttestationMapper.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Maps between SLSA/in-toto build attestations and SPDX 3.0.1 Build profile elements.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-003
|
||||
/// </summary>
|
||||
public interface IBuildAttestationMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps an in-toto/SLSA build attestation to an SPDX 3.0.1 Build element.
|
||||
/// </summary>
|
||||
/// <param name="attestation">The source build attestation.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for generating the SPDX ID.</param>
|
||||
/// <returns>The mapped SPDX 3.0.1 Build element.</returns>
|
||||
Spdx3Build MapToSpdx3(BuildAttestationPayload attestation, string spdxIdPrefix);
|
||||
|
||||
/// <summary>
|
||||
/// Maps an SPDX 3.0.1 Build element to an in-toto/SLSA build attestation payload.
|
||||
/// </summary>
|
||||
/// <param name="build">The source SPDX 3.0.1 Build element.</param>
|
||||
/// <returns>The mapped build attestation payload.</returns>
|
||||
BuildAttestationPayload MapFromSpdx3(Spdx3Build build);
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the attestation can be fully mapped to SPDX 3.0.1.
|
||||
/// </summary>
|
||||
/// <param name="attestation">The attestation to check.</param>
|
||||
/// <returns>True if all required fields can be mapped.</returns>
|
||||
bool CanMapToSpdx3(BuildAttestationPayload attestation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an in-toto/SLSA build attestation payload.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-003
|
||||
/// </summary>
|
||||
public sealed record BuildAttestationPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the predicate type (e.g., "https://slsa.dev/provenance/v1").
|
||||
/// </summary>
|
||||
public required string BuildType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the builder information.
|
||||
/// </summary>
|
||||
public BuilderInfo? Builder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the build invocation information.
|
||||
/// </summary>
|
||||
public BuildInvocation? Invocation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the build metadata.
|
||||
/// </summary>
|
||||
public BuildMetadata? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the build materials (source inputs).
|
||||
/// </summary>
|
||||
public IReadOnlyList<BuildMaterial> Materials { get; init; } = Array.Empty<BuildMaterial>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder information from SLSA provenance.
|
||||
/// </summary>
|
||||
public sealed record BuilderInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the builder ID (URI).
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the builder version.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build invocation information from SLSA provenance.
|
||||
/// </summary>
|
||||
public sealed record BuildInvocation
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the config source information.
|
||||
/// </summary>
|
||||
public ConfigSource? ConfigSource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the environment variables.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Environment { get; init; } =
|
||||
new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the build parameters.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Parameters { get; init; } =
|
||||
new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration source information.
|
||||
/// </summary>
|
||||
public sealed record ConfigSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the config source URI.
|
||||
/// </summary>
|
||||
public string? Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the digest of the config source.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } =
|
||||
new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the entry point within the config source.
|
||||
/// </summary>
|
||||
public string? EntryPoint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build metadata from SLSA provenance.
|
||||
/// </summary>
|
||||
public sealed record BuildMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the build invocation ID.
|
||||
/// </summary>
|
||||
public string? BuildInvocationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the build started.
|
||||
/// </summary>
|
||||
public DateTimeOffset? BuildStartedOn { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the build finished.
|
||||
/// </summary>
|
||||
public DateTimeOffset? BuildFinishedOn { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the build is reproducible.
|
||||
/// </summary>
|
||||
public bool? Reproducible { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build material (input) from SLSA provenance.
|
||||
/// </summary>
|
||||
public sealed record BuildMaterial
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the material URI.
|
||||
/// </summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the material digest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } =
|
||||
new Dictionary<string, string>();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Attestor.Spdx3</RootNamespace>
|
||||
<Description>SPDX 3.0.1 Build profile integration for StellaOps Attestor</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Spdx3\StellaOps.Spdx3.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
10
src/Attestor/__Libraries/StellaOps.Attestor.Spdx3/TASKS.md
Normal file
10
src/Attestor/__Libraries/StellaOps.Attestor.Spdx3/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Attestor SPDX3 Build Profile Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0848-M | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0848-T | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0848-A | TODO | Open findings; apply pending approval. |
|
||||
@@ -0,0 +1,19 @@
|
||||
# Attestor SPDX3 Build Profile Tests Charter
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/Attestor/__Libraries/__Tests/StellaOps.Attestor.Spdx3.Tests/`.
|
||||
- Roles: QA automation, backend engineer.
|
||||
- Focus: deterministic unit tests for SPDX3 build mapping and validation.
|
||||
|
||||
## Required Reading
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/attestor/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`
|
||||
|
||||
## Working Agreement
|
||||
- Use fixed timestamps and IDs in fixtures.
|
||||
- Avoid Random, Guid.NewGuid, DateTime.UtcNow in tests.
|
||||
- Cover error paths and deterministic ID generation.
|
||||
- Update `TASKS.md` and sprint tracker as statuses change.
|
||||
@@ -0,0 +1,176 @@
|
||||
// <copyright file="BuildAttestationMapperTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="BuildAttestationMapper"/>.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BuildAttestationMapperTests
|
||||
{
|
||||
private readonly BuildAttestationMapper _mapper = new();
|
||||
private const string SpdxIdPrefix = "https://stellaops.io/spdx/test";
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_WithFullAttestation_MapsAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
Builder = new BuilderInfo { Id = "https://github.com/actions/runner", Version = "2.300.0" },
|
||||
Invocation = new BuildInvocation
|
||||
{
|
||||
ConfigSource = new ConfigSource
|
||||
{
|
||||
Uri = "https://github.com/stellaops/app",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "abc123" },
|
||||
EntryPoint = ".github/workflows/build.yml"
|
||||
},
|
||||
Environment = new Dictionary<string, string> { ["CI"] = "true" },
|
||||
Parameters = new Dictionary<string, string> { ["target"] = "release" }
|
||||
},
|
||||
Metadata = new BuildMetadata
|
||||
{
|
||||
BuildInvocationId = "run-12345",
|
||||
BuildStartedOn = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
BuildFinishedOn = new DateTimeOffset(2026, 1, 7, 12, 5, 0, TimeSpan.Zero)
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var build = _mapper.MapToSpdx3(attestation, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
build.Should().NotBeNull();
|
||||
build.BuildType.Should().Be("https://slsa.dev/provenance/v1");
|
||||
build.BuildId.Should().Be("run-12345");
|
||||
build.BuildStartTime.Should().Be(attestation.Metadata.BuildStartedOn);
|
||||
build.BuildEndTime.Should().Be(attestation.Metadata.BuildFinishedOn);
|
||||
build.ConfigSourceUri.Should().ContainSingle().Which.Should().Be("https://github.com/stellaops/app");
|
||||
build.ConfigSourceDigest.Should().ContainSingle().Which.Algorithm.Should().Be("sha256");
|
||||
build.ConfigSourceEntrypoint.Should().ContainSingle().Which.Should().Be(".github/workflows/build.yml");
|
||||
build.Environment.Should().ContainKey("CI").WhoseValue.Should().Be("true");
|
||||
build.Parameter.Should().ContainKey("target").WhoseValue.Should().Be("release");
|
||||
build.SpdxId.Should().StartWith(SpdxIdPrefix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_WithMinimalAttestation_MapsRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://stellaops.org/build/scan/v1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var build = _mapper.MapToSpdx3(attestation, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
build.Should().NotBeNull();
|
||||
build.BuildType.Should().Be("https://stellaops.org/build/scan/v1");
|
||||
build.SpdxId.Should().StartWith(SpdxIdPrefix);
|
||||
build.ConfigSourceUri.Should().BeEmpty();
|
||||
build.Environment.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapFromSpdx3_WithFullBuild_MapsToAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123",
|
||||
BuildStartTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
BuildEndTime = new DateTimeOffset(2026, 1, 7, 12, 5, 0, TimeSpan.Zero),
|
||||
ConfigSourceUri = ImmutableArray.Create("https://github.com/stellaops/app"),
|
||||
ConfigSourceDigest = ImmutableArray.Create(Spdx3Hash.Sha256("abc123")),
|
||||
ConfigSourceEntrypoint = ImmutableArray.Create("Dockerfile"),
|
||||
Environment = ImmutableDictionary<string, string>.Empty.Add("CI", "true"),
|
||||
Parameter = ImmutableDictionary<string, string>.Empty.Add("target", "release")
|
||||
};
|
||||
|
||||
// Act
|
||||
var attestation = _mapper.MapFromSpdx3(build);
|
||||
|
||||
// Assert
|
||||
attestation.Should().NotBeNull();
|
||||
attestation.BuildType.Should().Be("https://slsa.dev/provenance/v1");
|
||||
attestation.Metadata!.BuildInvocationId.Should().Be("build-123");
|
||||
attestation.Metadata!.BuildStartedOn.Should().Be(build.BuildStartTime);
|
||||
attestation.Metadata!.BuildFinishedOn.Should().Be(build.BuildEndTime);
|
||||
attestation.Invocation!.ConfigSource!.Uri.Should().Be("https://github.com/stellaops/app");
|
||||
attestation.Invocation!.Environment.Should().ContainKey("CI");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanMapToSpdx3_WithValidAttestation_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://slsa.dev/provenance/v1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mapper.CanMapToSpdx3(attestation);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanMapToSpdx3_WithEmptyBuildType_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = ""
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mapper.CanMapToSpdx3(attestation);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanMapToSpdx3_WithNull_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = _mapper.CanMapToSpdx3(null!);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_GeneratesDeterministicSpdxId()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
Metadata = new BuildMetadata { BuildInvocationId = "fixed-id-123" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var build1 = _mapper.MapToSpdx3(attestation, SpdxIdPrefix);
|
||||
var build2 = _mapper.MapToSpdx3(attestation, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
build1.SpdxId.Should().Be(build2.SpdxId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// <copyright file="BuildProfileValidatorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="BuildProfileValidator"/>.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BuildProfileValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_WithValidBuild_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123",
|
||||
BuildStartTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
BuildEndTime = new DateTimeOffset(2026, 1, 7, 12, 5, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.ErrorsOnly.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMissingBuildType_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "",
|
||||
BuildId = "build-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ErrorsOnly.Should().ContainSingle()
|
||||
.Which.Field.Should().Be("buildType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithInvalidBuildTypeUri_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "not-a-uri",
|
||||
BuildId = "build-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ErrorsOnly.Should().ContainSingle()
|
||||
.Which.Message.Should().Contain("valid URI");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEndTimeBeforeStartTime_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123",
|
||||
BuildStartTime = new DateTimeOffset(2026, 1, 7, 12, 5, 0, TimeSpan.Zero),
|
||||
BuildEndTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero) // Before start
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ErrorsOnly.Should().ContainSingle()
|
||||
.Which.Field.Should().Be("buildEndTime");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMissingBuildId_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
|
||||
result.WarningsOnly.Should().ContainSingle()
|
||||
.Which.Field.Should().Be("buildId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithDigestWithoutUri_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123",
|
||||
ConfigSourceDigest = ImmutableArray.Create(Spdx3Hash.Sha256("abc123"))
|
||||
// Note: ConfigSourceUri is empty
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.WarningsOnly.Should().Contain(w => w.Field == "configSourceDigest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithUnknownHashAlgorithm_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/test/build/123",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123",
|
||||
ConfigSourceUri = ImmutableArray.Create("https://github.com/test/repo"),
|
||||
ConfigSourceDigest = ImmutableArray.Create(new Spdx3Hash
|
||||
{
|
||||
Algorithm = "unknown-algo",
|
||||
HashValue = "abc123"
|
||||
})
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.WarningsOnly.Should().Contain(w => w.Field == "configSourceDigest.algorithm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMissingSpdxId_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = BuildProfileValidator.Validate(build);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ErrorsOnly.Should().Contain(e => e.Field == "spdxId");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// <copyright file="CombinedDocumentBuilderTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Build;
|
||||
using StellaOps.Spdx3.Model.Software;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CombinedDocumentBuilder"/>.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CombinedDocumentBuilderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private static readonly DateTimeOffset FixedTimestamp =
|
||||
new(2026, 1, 8, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public CombinedDocumentBuilderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithSoftwareAndBuildProfiles_CreatesCombinedDocument()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var build = CreateTestBuild();
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithName("Combined SBOM and Build")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithBuildProfile(build)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.Should().NotBeNull();
|
||||
document.Profiles.Should().Contain(Spdx3ProfileIdentifier.Core);
|
||||
document.Profiles.Should().Contain(Spdx3ProfileIdentifier.Software);
|
||||
document.Profiles.Should().Contain(Spdx3ProfileIdentifier.Build);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithBuildProfile_CreatesGeneratesRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var build = CreateTestBuild();
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithBuildProfile(build)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
var relationships = document.Relationships.ToList();
|
||||
relationships.Should().Contain(r =>
|
||||
r.RelationshipType == Spdx3RelationshipType.Generates &&
|
||||
r.From == build.SpdxId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithBuildAttestation_MapsBuildFromAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
Metadata = new BuildMetadata
|
||||
{
|
||||
BuildInvocationId = "run-12345",
|
||||
BuildStartedOn = FixedTimestamp
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithBuildAttestation(attestation, "https://stellaops.io/spdx")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.Elements.Should().Contain(e => e is Spdx3Build);
|
||||
var buildElement = document.Elements.OfType<Spdx3Build>().First();
|
||||
buildElement.BuildType.Should().Be("https://slsa.dev/provenance/v1");
|
||||
buildElement.BuildId.Should().Be("run-12345");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithoutDocumentId_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
|
||||
// Act
|
||||
var act = () => CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithSoftwareProfile(sbom)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*Document SPDX ID is required*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CreatesDefaultCreationInfo()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/doc/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.CreationInfos.Should().HaveCount(1);
|
||||
var creationInfo = document.CreationInfos.First();
|
||||
creationInfo.SpecVersion.Should().Be(Spdx3CreationInfo.Spdx301Version);
|
||||
creationInfo.Created.Should().Be(FixedTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithCustomCreationInfo_UsesProvidedInfo()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var customCreationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
Id = "custom-creation-info",
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp.AddHours(-1),
|
||||
CreatedBy = ImmutableArray.Create("custom-author"),
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core)
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/doc/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithCreationInfo(customCreationInfo)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.CreationInfos.Should().Contain(customCreationInfo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithBuildProvenance_ExtensionMethod_CreatesCombinedDocument()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var attestation = new BuildAttestationPayload
|
||||
{
|
||||
BuildType = "https://stellaops.org/build/scan/v1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var combined = sbom.WithBuildProvenance(
|
||||
attestation,
|
||||
documentId: "https://stellaops.io/spdx/combined/ext-12345",
|
||||
spdxIdPrefix: "https://stellaops.io/spdx",
|
||||
timeProvider: _timeProvider);
|
||||
|
||||
// Assert
|
||||
combined.Should().NotBeNull();
|
||||
combined.Profiles.Should().Contain(Spdx3ProfileIdentifier.Build);
|
||||
combined.Elements.Should().Contain(e => e is Spdx3Build);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PreservesAllSbomElements()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbomWithMultiplePackages();
|
||||
|
||||
// Act
|
||||
var document = CombinedDocumentBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/doc/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
var packages = document.Packages.ToList();
|
||||
packages.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateTestSbom()
|
||||
{
|
||||
var creationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp.AddDays(-1),
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software)
|
||||
};
|
||||
|
||||
var rootPackage = new Spdx3Package
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/root",
|
||||
Type = "software_Package",
|
||||
Name = "test-root-package",
|
||||
PackageVersion = "1.0.0"
|
||||
};
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: new Spdx3Element[] { rootPackage },
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: new[] { Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software });
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateTestSbomWithMultiplePackages()
|
||||
{
|
||||
var creationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp.AddDays(-1),
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software)
|
||||
};
|
||||
|
||||
var packages = new Spdx3Package[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/root",
|
||||
Type = "software_Package",
|
||||
Name = "root-package",
|
||||
PackageVersion = "1.0.0"
|
||||
},
|
||||
new()
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/dep1",
|
||||
Type = "software_Package",
|
||||
Name = "dependency-1",
|
||||
PackageVersion = "2.0.0"
|
||||
},
|
||||
new()
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/dep2",
|
||||
Type = "software_Package",
|
||||
Name = "dependency-2",
|
||||
PackageVersion = "3.0.0"
|
||||
}
|
||||
};
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: packages,
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: new[] { Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software });
|
||||
}
|
||||
|
||||
private static Spdx3Build CreateTestBuild()
|
||||
{
|
||||
return new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/build/12345",
|
||||
Type = Spdx3Build.TypeName,
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-12345",
|
||||
BuildStartTime = FixedTimestamp.AddMinutes(-5),
|
||||
BuildEndTime = FixedTimestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// <copyright file="DsseSpdx3SignerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DsseSpdx3Signer"/>.
|
||||
/// Sprint: SPRINT_20260107_004_003 Task BP-005
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DsseSpdx3SignerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<ISpdx3Serializer> _serializerMock;
|
||||
private readonly Mock<IDsseSigningProvider> _signingProviderMock;
|
||||
private readonly DsseSpdx3Signer _signer;
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp =
|
||||
new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public DsseSpdx3SignerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
_serializerMock = new Mock<ISpdx3Serializer>();
|
||||
_signingProviderMock = new Mock<IDsseSigningProvider>();
|
||||
|
||||
_signer = new DsseSpdx3Signer(
|
||||
_serializerMock.Object,
|
||||
_signingProviderMock.Object,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithValidDocument_ReturnsEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var document = CreateTestDocument();
|
||||
var options = new DsseSpdx3SigningOptions { PrimaryKeyId = "key-123" };
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"test\":\"document\"}");
|
||||
|
||||
_serializerMock
|
||||
.Setup(s => s.SerializeToBytes(document))
|
||||
.Returns(payloadBytes);
|
||||
|
||||
_signingProviderMock
|
||||
.Setup(s => s.SignAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
"key-123",
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseSignatureResult
|
||||
{
|
||||
KeyId = "key-123",
|
||||
SignatureBytes = new byte[] { 0x01, 0x02, 0x03 }
|
||||
});
|
||||
|
||||
// Act
|
||||
var envelope = await _signer.SignAsync(document, options);
|
||||
|
||||
// Assert
|
||||
envelope.Should().NotBeNull();
|
||||
envelope.PayloadType.Should().Be(DsseSpdx3Signer.Spdx3PayloadType);
|
||||
envelope.Payload.Should().NotBeNullOrEmpty();
|
||||
envelope.Signatures.Should().HaveCount(1);
|
||||
envelope.Signatures[0].KeyId.Should().Be("key-123");
|
||||
envelope.SignedAt.Should().Be(FixedTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithSecondaryKey_ReturnsTwoSignatures()
|
||||
{
|
||||
// Arrange
|
||||
var document = CreateTestDocument();
|
||||
var options = new DsseSpdx3SigningOptions
|
||||
{
|
||||
PrimaryKeyId = "key-123",
|
||||
PrimaryAlgorithm = "ES256",
|
||||
SecondaryKeyId = "pq-key-456",
|
||||
SecondaryAlgorithm = "ML-DSA-65"
|
||||
};
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"test\":\"document\"}");
|
||||
|
||||
_serializerMock
|
||||
.Setup(s => s.SerializeToBytes(document))
|
||||
.Returns(payloadBytes);
|
||||
|
||||
_signingProviderMock
|
||||
.Setup(s => s.SignAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
"key-123",
|
||||
"ES256",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseSignatureResult
|
||||
{
|
||||
KeyId = "key-123",
|
||||
SignatureBytes = new byte[] { 0x01, 0x02, 0x03 },
|
||||
Algorithm = "ES256"
|
||||
});
|
||||
|
||||
_signingProviderMock
|
||||
.Setup(s => s.SignAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
"pq-key-456",
|
||||
"ML-DSA-65",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseSignatureResult
|
||||
{
|
||||
KeyId = "pq-key-456",
|
||||
SignatureBytes = new byte[] { 0x04, 0x05, 0x06 },
|
||||
Algorithm = "ML-DSA-65"
|
||||
});
|
||||
|
||||
// Act
|
||||
var envelope = await _signer.SignAsync(document, options);
|
||||
|
||||
// Assert
|
||||
envelope.Signatures.Should().HaveCount(2);
|
||||
envelope.Signatures[0].KeyId.Should().Be("key-123");
|
||||
envelope.Signatures[1].KeyId.Should().Be("pq-key-456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignBuildProfileAsync_CreatesBuildDocument()
|
||||
{
|
||||
// Arrange
|
||||
var build = new Spdx3Build
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/build/12345",
|
||||
Type = Spdx3Build.TypeName,
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
BuildId = "build-12345"
|
||||
};
|
||||
|
||||
var options = new DsseSpdx3SigningOptions { PrimaryKeyId = "key-123" };
|
||||
|
||||
_serializerMock
|
||||
.Setup(s => s.SerializeToBytes(It.IsAny<Spdx3Document>()))
|
||||
.Returns(Encoding.UTF8.GetBytes("{\"build\":\"test\"}"));
|
||||
|
||||
_signingProviderMock
|
||||
.Setup(s => s.SignAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseSignatureResult
|
||||
{
|
||||
KeyId = "key-123",
|
||||
SignatureBytes = new byte[] { 0x01, 0x02, 0x03 }
|
||||
});
|
||||
|
||||
// Act
|
||||
var envelope = await _signer.SignBuildProfileAsync(build, null, options);
|
||||
|
||||
// Assert
|
||||
envelope.Should().NotBeNull();
|
||||
envelope.PayloadType.Should().Be(DsseSpdx3Signer.Spdx3PayloadType);
|
||||
|
||||
_serializerMock.Verify(
|
||||
s => s.SerializeToBytes(It.Is<Spdx3Document>(d =>
|
||||
d.Elements.Any(e => e is Spdx3Build))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithValidSignature_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = new DsseSpdx3Envelope
|
||||
{
|
||||
PayloadType = DsseSpdx3Signer.Spdx3PayloadType,
|
||||
Payload = "eyJ0ZXN0IjoiZG9jdW1lbnQifQ", // base64url of {"test":"document"}
|
||||
Signatures = ImmutableArray.Create(new DsseSpdx3Signature
|
||||
{
|
||||
KeyId = "key-123",
|
||||
Sig = "AQID" // base64url of [0x01, 0x02, 0x03]
|
||||
})
|
||||
};
|
||||
|
||||
var trustedKeys = new List<DsseVerificationKey>
|
||||
{
|
||||
new() { KeyId = "key-123", PublicKey = new byte[] { 0x10, 0x20 } }
|
||||
};
|
||||
|
||||
_signingProviderMock
|
||||
.Setup(s => s.VerifyAsync(
|
||||
It.IsAny<byte[]>(),
|
||||
It.IsAny<byte[]>(),
|
||||
It.Is<DsseVerificationKey>(k => k.KeyId == "key-123"),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
// Act
|
||||
var result = await _signer.VerifyAsync(envelope, trustedKeys);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithUntrustedKey_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = new DsseSpdx3Envelope
|
||||
{
|
||||
PayloadType = DsseSpdx3Signer.Spdx3PayloadType,
|
||||
Payload = "eyJ0ZXN0IjoiZG9jdW1lbnQifQ",
|
||||
Signatures = ImmutableArray.Create(new DsseSpdx3Signature
|
||||
{
|
||||
KeyId = "untrusted-key",
|
||||
Sig = "AQID"
|
||||
})
|
||||
};
|
||||
|
||||
var trustedKeys = new List<DsseVerificationKey>
|
||||
{
|
||||
new() { KeyId = "key-123", PublicKey = new byte[] { 0x10, 0x20 } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _signer.VerifyAsync(envelope, trustedKeys);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractDocument_WithValidEnvelope_ReturnsDocument()
|
||||
{
|
||||
// Arrange
|
||||
var originalDocument = CreateTestDocument();
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"test\":\"document\"}");
|
||||
var payload = Convert.ToBase64String(payloadBytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
var envelope = new DsseSpdx3Envelope
|
||||
{
|
||||
PayloadType = DsseSpdx3Signer.Spdx3PayloadType,
|
||||
Payload = payload,
|
||||
Signatures = ImmutableArray<DsseSpdx3Signature>.Empty
|
||||
};
|
||||
|
||||
_serializerMock
|
||||
.Setup(s => s.Deserialize(It.IsAny<byte[]>()))
|
||||
.Returns(originalDocument);
|
||||
|
||||
// Act
|
||||
var extracted = _signer.ExtractDocument(envelope);
|
||||
|
||||
// Assert
|
||||
extracted.Should().NotBeNull();
|
||||
extracted.Should().Be(originalDocument);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractDocument_WithWrongPayloadType_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = new DsseSpdx3Envelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = "eyJ0ZXN0IjoiZG9jdW1lbnQifQ",
|
||||
Signatures = ImmutableArray<DsseSpdx3Signature>.Empty
|
||||
};
|
||||
|
||||
// Act
|
||||
var extracted = _signer.ExtractDocument(envelope);
|
||||
|
||||
// Assert
|
||||
extracted.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PayloadType_IsCorrectSpdxMediaType()
|
||||
{
|
||||
// Assert
|
||||
DsseSpdx3Signer.Spdx3PayloadType.Should().Be("application/spdx+json");
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateTestDocument()
|
||||
{
|
||||
var creationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp,
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Build)
|
||||
};
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: Array.Empty<Spdx3Element>(),
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: new[] { Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Build });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Spdx3\StellaOps.Attestor.Spdx3.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
# Attestor SPDX3 Build Profile Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0849-M | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0849-T | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0849-A | DONE | Waived (test project; revalidated 2026-01-08). |
|
||||
Reference in New Issue
Block a user