more audit work

This commit is contained in:
master
2026-01-08 10:21:51 +02:00
parent 43c02081ef
commit 51cf4bc16c
546 changed files with 36721 additions and 4003 deletions

View 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.

View File

@@ -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];
}
}

View File

@@ -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}";
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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>();
}

View File

@@ -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>

View 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. |

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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
};
}
}

View File

@@ -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 });
}
}

View File

@@ -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>

View File

@@ -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). |