license switch agpl -> busl1, sprints work, new product advisories

This commit is contained in:
master
2026-01-20 15:32:20 +02:00
parent 4903395618
commit c32fff8f86
1835 changed files with 38630 additions and 4359 deletions

View File

@@ -1,5 +1,5 @@
// <copyright file="RekorEntryEventTests.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_20260112_007_ATTESTOR_rekor_entry_events (ATT-REKOR-004)
// </copyright>

View File

@@ -1,4 +1,4 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Text;

View File

@@ -1,4 +1,4 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Text.Json;

View File

@@ -1,4 +1,4 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;

View File

@@ -1,4 +1,4 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Text.Json;

View File

@@ -180,20 +180,16 @@ public sealed class HttpRekorTileClientTests
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(RekorLogVersion.V2, false, true)]
[InlineData(RekorLogVersion.V1, false, false)]
[InlineData(RekorLogVersion.V1, true, false)]
[InlineData(RekorLogVersion.Auto, false, false)]
[InlineData(RekorLogVersion.Auto, true, true)]
public void ShouldUseTileProofs_ReturnsExpected(RekorLogVersion version, bool preferTiles, bool expected)
[InlineData(RekorLogVersion.V2, true)]
[InlineData(RekorLogVersion.Auto, true)]
public void ShouldUseTileProofs_ReturnsExpected(RekorLogVersion version, bool expected)
{
// Arrange
var backend = new RekorBackend
{
Name = "test",
Url = new Uri("https://rekor.sigstore.dev"),
Version = version,
PreferTileProofs = preferTiles
Version = version
};
// Act

View File

@@ -40,15 +40,11 @@ public sealed class RekorBackendResolverTests
[Theory]
[InlineData("Auto", RekorLogVersion.Auto)]
[InlineData("auto", RekorLogVersion.Auto)]
[InlineData("V1", RekorLogVersion.V1)]
[InlineData("v1", RekorLogVersion.V1)]
[InlineData("1", RekorLogVersion.V1)]
[InlineData("V2", RekorLogVersion.V2)]
[InlineData("v2", RekorLogVersion.V2)]
[InlineData("2", RekorLogVersion.V2)]
[InlineData("", RekorLogVersion.Auto)]
[InlineData(null, RekorLogVersion.Auto)]
[InlineData("invalid", RekorLogVersion.Auto)]
public void ResolveBackend_ParsesVersionCorrectly(string? versionString, RekorLogVersion expected)
{
var options = new AttestorOptions
@@ -68,6 +64,55 @@ public sealed class RekorBackendResolverTests
backend.Version.Should().Be(expected);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("V1")]
[InlineData("v1")]
[InlineData("1")]
public void ResolveBackend_V1Rejected(string versionString)
{
var options = new AttestorOptions
{
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.sigstore.dev",
Version = versionString
}
}
};
var action = () => RekorBackendResolver.ResolveBackend(options, "primary", allowFallbackToPrimary: false);
action.Should().Throw<InvalidOperationException>()
.WithMessage("Rekor v1 is no longer supported. Use Auto or V2.");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("invalid")]
[InlineData("v3")]
public void ResolveBackend_InvalidVersionRejected(string versionString)
{
var options = new AttestorOptions
{
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.sigstore.dev",
Version = versionString
}
}
};
var action = () => RekorBackendResolver.ResolveBackend(options, "primary", allowFallbackToPrimary: false);
action.Should().Throw<InvalidOperationException>()
.WithMessage($"Unsupported Rekor version '{versionString}'. Use Auto or V2.");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveBackend_WithTileBaseUrl_SetsProperty()
@@ -112,41 +157,17 @@ public sealed class RekorBackendResolverTests
backend.LogId.Should().Be(RekorBackend.SigstoreProductionLogId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveBackend_WithPreferTileProofs_SetsProperty()
{
var options = new AttestorOptions
{
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.sigstore.dev",
PreferTileProofs = true
}
}
};
var backend = RekorBackendResolver.ResolveBackend(options, "primary", allowFallbackToPrimary: false);
backend.PreferTileProofs.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(RekorLogVersion.V2, false, true)]
[InlineData(RekorLogVersion.V1, true, false)]
[InlineData(RekorLogVersion.Auto, true, true)]
[InlineData(RekorLogVersion.Auto, false, false)]
public void ShouldUseTileProofs_ReturnsCorrectValue(RekorLogVersion version, bool preferTileProofs, bool expected)
[InlineData(RekorLogVersion.V2, true)]
[InlineData(RekorLogVersion.Auto, true)]
public void ShouldUseTileProofs_ReturnsCorrectValue(RekorLogVersion version, bool expected)
{
var backend = new RekorBackend
{
Name = "test",
Url = new Uri("https://rekor.sigstore.dev"),
Version = version,
PreferTileProofs = preferTileProofs
Version = version
};
var result = RekorBackendResolver.ShouldUseTileProofs(backend);

View File

@@ -1,4 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-License-Identifier: BUSL-1.1
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using System;

View File

@@ -1,4 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-License-Identifier: BUSL-1.1
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using FluentAssertions;

View File

@@ -1,4 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-License-Identifier: BUSL-1.1
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using System;

View File

@@ -1,4 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) StellaOps Contributors
using System.Text.Json;

View File

@@ -1,4 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) StellaOps Contributors
using System.Text.Json;

View File

@@ -0,0 +1,276 @@
// -----------------------------------------------------------------------------
// CycloneDxFeatureGenerationTests.cs
// Sprint: SPRINT_20260119_013_Attestor_cyclonedx_1.7_generation
// Task: TASK-013-009 - CycloneDX 1.7 feature tests
// Description: Validates CycloneDX 1.7 writer output for new sections.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.StandardPredicates.Models;
using StellaOps.Attestor.StandardPredicates.Parsers;
using StellaOps.Attestor.StandardPredicates.Writers;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Attestor.StandardPredicates.Tests;
public sealed class CycloneDxFeatureGenerationTests
{
private readonly CycloneDxWriter _writer = new();
private readonly CycloneDxPredicateParser _parser =
new(NullLogger<CycloneDxPredicateParser>.Instance);
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Write_FullFeatureSet_EmitsBomLevelSections()
{
var document = CycloneDxTestData.CreateFullDocument();
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var root = json.RootElement;
root.GetProperty("specVersion").GetString().Should().Be("1.7");
root.GetProperty("serialNumber").GetString()
.Should().StartWith("urn:sha256:");
root.TryGetProperty("services", out var services).Should().BeTrue();
services.GetArrayLength().Should().Be(1);
services[0].GetProperty("endpoints")[0].GetString()
.Should().Be("https://api.example.com/a");
root.TryGetProperty("formulation", out var formulation).Should().BeTrue();
formulation.GetArrayLength().Should().Be(1);
formulation[0].GetProperty("workflows")[0].GetProperty("tasks").GetArrayLength()
.Should().Be(1);
root.TryGetProperty("annotations", out var annotations).Should().BeTrue();
annotations.GetArrayLength().Should().Be(1);
root.TryGetProperty("compositions", out var compositions).Should().BeTrue();
compositions.GetArrayLength().Should().Be(1);
root.TryGetProperty("declarations", out var declarations).Should().BeTrue();
declarations.GetProperty("attestations").GetArrayLength().Should().Be(1);
root.TryGetProperty("definitions", out var definitions).Should().BeTrue();
definitions.GetProperty("standards").GetArrayLength().Should().Be(1);
root.TryGetProperty("signature", out var signature).Should().BeTrue();
signature.GetProperty("algorithm").GetString().Should().Be("RS256");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Write_ComponentExtensions_EmitNewFields()
{
var document = CycloneDxTestData.CreateFullDocument();
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var component = FindComponent(json.RootElement, "component-a");
component.GetProperty("scope").GetString().Should().Be("required");
component.GetProperty("modified").GetBoolean().Should().BeTrue();
component.GetProperty("pedigree").ValueKind.Should().Be(JsonValueKind.Object);
component.GetProperty("swid").ValueKind.Should().Be(JsonValueKind.Object);
component.GetProperty("evidence").ValueKind.Should().Be(JsonValueKind.Object);
component.GetProperty("releaseNotes").ValueKind.Should().Be(JsonValueKind.Object);
component.GetProperty("modelCard").ValueKind.Should().Be(JsonValueKind.Object);
component.GetProperty("cryptoProperties").ValueKind.Should().Be(JsonValueKind.Object);
component.GetProperty("signature").ValueKind.Should().Be(JsonValueKind.Object);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Write_CompositionAggregates_MapToSpecValues()
{
var aggregates = Enum.GetValues<SbomCompositionAggregate>();
var compositions = aggregates
.Select((aggregate, index) => new SbomComposition
{
BomRef = $"composition-{index}",
Aggregate = aggregate
})
.ToImmutableArray();
var document = new SbomDocument
{
Name = "composition-bom",
Version = "1",
Timestamp = CycloneDxTestData.FixedTimestamp,
Components =
[
new SbomComponent
{
BomRef = "component-a",
Name = "component-a",
Version = "1.0.0",
Type = SbomComponentType.Library
}
],
Compositions = compositions
};
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var actual = json.RootElement.GetProperty("compositions")
.EnumerateArray()
.ToDictionary(
entry => entry.GetProperty("bom-ref").GetString() ?? string.Empty,
entry => entry.GetProperty("aggregate").GetString() ?? string.Empty,
StringComparer.Ordinal);
for (var i = 0; i < aggregates.Length; i++)
{
var expected = ExpectedAggregate(aggregates[i]);
actual[$"composition-{i}"].Should().Be(expected);
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Write_CryptoProperties_AssetTypesMapped()
{
var components = new[]
{
new SbomComponent
{
BomRef = "crypto-algorithm",
Name = "crypto-algorithm",
Version = "1.0.0",
Type = SbomComponentType.Library,
CryptoProperties = new SbomCryptoProperties
{
AssetType = SbomCryptoAssetType.Algorithm
}
},
new SbomComponent
{
BomRef = "crypto-certificate",
Name = "crypto-certificate",
Version = "1.0.0",
Type = SbomComponentType.Library,
CryptoProperties = new SbomCryptoProperties
{
AssetType = SbomCryptoAssetType.Certificate,
CertificateProperties = new SbomCryptoCertificateProperties
{
SerialNumber = "1234"
}
}
},
new SbomComponent
{
BomRef = "crypto-protocol",
Name = "crypto-protocol",
Version = "1.0.0",
Type = SbomComponentType.Library,
CryptoProperties = new SbomCryptoProperties
{
AssetType = SbomCryptoAssetType.Protocol,
ProtocolProperties = new SbomCryptoProtocolProperties
{
Type = "tls",
Version = "1.3"
}
}
},
new SbomComponent
{
BomRef = "crypto-material",
Name = "crypto-material",
Version = "1.0.0",
Type = SbomComponentType.Library,
CryptoProperties = new SbomCryptoProperties
{
AssetType = SbomCryptoAssetType.RelatedCryptoMaterial,
RelatedCryptoMaterialProperties = new SbomRelatedCryptoMaterialProperties
{
Type = "key",
Id = "key-1"
}
}
}
};
var document = new SbomDocument
{
Name = "crypto-bom",
Version = "1",
Timestamp = CycloneDxTestData.FixedTimestamp,
Components = [.. components]
};
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
FindComponent(json.RootElement, "crypto-algorithm")
.GetProperty("cryptoProperties").GetProperty("assetType").GetString()
.Should().Be("algorithm");
FindComponent(json.RootElement, "crypto-certificate")
.GetProperty("cryptoProperties").GetProperty("assetType").GetString()
.Should().Be("certificate");
FindComponent(json.RootElement, "crypto-protocol")
.GetProperty("cryptoProperties").GetProperty("assetType").GetString()
.Should().Be("protocol");
FindComponent(json.RootElement, "crypto-material")
.GetProperty("cryptoProperties").GetProperty("assetType").GetString()
.Should().Be("related-crypto-material");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RoundTrip_GeneratedBomMatchesParserHash()
{
var document = CycloneDxTestData.CreateFullDocument();
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var extraction = _parser.ExtractSbom(json.RootElement);
extraction.Should().NotBeNull();
extraction!.SbomSha256.Should().Be(result.GoldenHash);
}
private static JsonElement FindComponent(JsonElement root, string bomRef)
{
foreach (var component in root.GetProperty("components").EnumerateArray())
{
if (component.TryGetProperty("bom-ref", out var reference) &&
string.Equals(reference.GetString(), bomRef, StringComparison.Ordinal))
{
return component;
}
}
throw new InvalidOperationException($"Component '{bomRef}' not found.");
}
private static string ExpectedAggregate(SbomCompositionAggregate aggregate)
{
return aggregate switch
{
SbomCompositionAggregate.Complete => "complete",
SbomCompositionAggregate.Incomplete => "incomplete",
SbomCompositionAggregate.IncompleteFirstPartyOnly =>
"incomplete_first_party_only",
SbomCompositionAggregate.IncompleteFirstPartyProprietaryOnly =>
"incomplete_first_party_proprietary_only",
SbomCompositionAggregate.IncompleteFirstPartyOpensourceOnly =>
"incomplete_first_party_opensource_only",
SbomCompositionAggregate.IncompleteThirdPartyOnly =>
"incomplete_third_party_only",
SbomCompositionAggregate.IncompleteThirdPartyProprietaryOnly =>
"incomplete_third_party_proprietary_only",
SbomCompositionAggregate.IncompleteThirdPartyOpensourceOnly =>
"incomplete_third_party_opensource_only",
SbomCompositionAggregate.Unknown => "unknown",
SbomCompositionAggregate.NotSpecified => "not_specified",
_ => "unknown"
};
}
}

View File

@@ -0,0 +1,66 @@
// -----------------------------------------------------------------------------
// CycloneDxSchemaValidationTests.cs
// Sprint: SPRINT_20260119_013_Attestor_cyclonedx_1.7_generation
// Task: TASK-013-010 - Schema validation coverage
// Description: Validates CycloneDX 1.7 output against stored schema.
// -----------------------------------------------------------------------------
using System.Text.Json;
using FluentAssertions;
using Json.Schema;
using StellaOps.Attestor.StandardPredicates.Writers;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Attestor.StandardPredicates.Tests;
public sealed class CycloneDxSchemaValidationTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SchemaFile_ValidatesGeneratedBom()
{
var schema = LoadSchemaFromDocs();
var writer = new CycloneDxWriter();
var document = CycloneDxTestData.CreateMinimalDocument();
var result = writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var evaluation = schema.Evaluate(json.RootElement, new EvaluationOptions
{
OutputFormat = OutputFormat.List,
RequireFormatValidation = true
});
evaluation.IsValid.Should().BeTrue();
}
private static JsonSchema LoadSchemaFromDocs()
{
var root = FindRepoRoot();
var schemaPath = Path.Combine(root, "docs", "schemas", "cyclonedx-bom-1.7.schema.json");
File.Exists(schemaPath).Should().BeTrue($"schema file should exist at '{schemaPath}'");
var schemaText = File.ReadAllText(schemaPath);
return JsonSchema.FromText(schemaText, new BuildOptions
{
SchemaRegistry = new SchemaRegistry()
});
}
private static string FindRepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var docs = Path.Combine(directory.FullName, "docs");
var src = Path.Combine(directory.FullName, "src");
if (Directory.Exists(docs) && Directory.Exists(src))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Repository root not found.");
}
}

View File

@@ -0,0 +1,630 @@
// -----------------------------------------------------------------------------
// CycloneDxTestData.cs
// Sprint: SPRINT_20260119_013_Attestor_cyclonedx_1.7_generation
// Task: TASK-013-009 - CycloneDX 1.7 feature fixtures
// Description: Deterministic CycloneDX 1.7 test fixtures.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Attestor.StandardPredicates.Models;
namespace StellaOps.Attestor.StandardPredicates.Tests;
internal static class CycloneDxTestData
{
internal static readonly DateTimeOffset FixedTimestamp =
new(2026, 1, 20, 12, 0, 0, TimeSpan.Zero);
internal static SbomDocument CreateFullDocument()
{
var componentSignature = new SbomSignature
{
Algorithm = SbomSignatureAlgorithm.ES256,
KeyId = "component-key",
PublicKey = new SbomJsonWebKey
{
KeyType = "EC",
Curve = "P-256",
X = "x-value",
Y = "y-value"
},
CertificatePath = ["component-cert"],
Value = "Y29tcC1zaWc="
};
var component = new SbomComponent
{
BomRef = "component-a",
Name = "component-a",
Version = "1.0.0",
Type = SbomComponentType.MachineLearningModel,
Description = "ml component",
Scope = SbomComponentScope.Required,
Modified = true,
Pedigree = new SbomComponentPedigree
{
Ancestors =
[
new SbomComponent
{
BomRef = "component-ancestor",
Name = "component-ancestor",
Version = "0.9.0",
Type = SbomComponentType.Library
}
],
Notes = "pedigree notes"
},
Swid = new SbomSwid
{
TagId = "swid-tag",
Name = "component-a",
Version = "1.0.0"
},
Evidence = new SbomComponentEvidence
{
Identity =
[
new SbomComponentIdentityEvidence
{
Field = "purl",
Confidence = 0.9,
ConcludedValue = "pkg:generic/component-a@1.0.0",
Methods =
[
new SbomComponentIdentityEvidenceMethod
{
Technique = "hash",
Confidence = 0.8,
Value = "sha256:abc"
}
],
Tools = ["scanner"]
}
],
Occurrences =
[
new SbomComponentEvidenceOccurrence
{
BomRef = "occ-1",
Location = "/opt/component-a",
Line = 12
}
],
Callstack = new SbomComponentEvidenceCallstack
{
Frames =
[
new SbomComponentCallstackFrame
{
Module = "module-a",
Function = "run",
Parameters = ["arg1"],
Line = 42
}
]
},
Licenses = [new SbomLicense { Id = "MIT" }],
Copyright = ["(c) 2026"]
},
ReleaseNotes = new SbomReleaseNotes
{
Type = "added",
Title = "release",
Description = "release notes",
Timestamp = FixedTimestamp,
Aliases = ["v1.0.0"],
Tags = ["stable"],
Resolves =
[
new SbomIssue
{
Type = "bug",
Id = "BUG-1",
Name = "issue",
Description = "fixed",
Source = new SbomIssueSource
{
Name = "tracker",
Url = "https://example.com/bugs"
}
}
],
Notes = [new SbomReleaseNote { Locale = "en-US", Text = "note" }],
Properties = [new SbomProperty { Name = "severity", Value = "low" }]
},
ModelCard = new SbomModelCard
{
BomRef = "modelcard-1",
ModelParameters = new SbomModelParameters
{
Approach = new SbomModelApproach { Type = "supervised" },
Task = "classification",
ArchitectureFamily = "transformer",
ModelArchitecture = "bert",
Datasets =
[
new SbomModelDataset { Reference = "dataset-ref" },
new SbomModelDataset
{
Data = new SbomComponentData
{
BomRef = "data-1",
Name = "dataset",
Type = "training"
}
}
],
Inputs = [new SbomModelInputOutput { Format = "text/plain" }],
Outputs = [new SbomModelInputOutput { Format = "text/plain" }]
},
QuantitativeAnalysis = new SbomQuantitativeAnalysis
{
PerformanceMetrics =
[
new SbomPerformanceMetric
{
Type = "accuracy",
Value = "0.95",
Slice = "overall",
ConfidenceInterval = new SbomPerformanceMetricConfidenceInterval
{
LowerBound = "0.94",
UpperBound = "0.96"
}
}
],
Graphics = new SbomGraphicsCollection
{
Description = "performance",
Collection = [new SbomGraphic { Name = "chart", Image = "chart.png" }]
}
},
Considerations = new SbomModelConsiderations
{
Users = ["analysts"],
UseCases = ["classification"],
TechnicalLimitations = ["limited data"],
PerformanceTradeoffs = ["latency"],
EthicalConsiderations =
[
new SbomRisk
{
Name = "bias",
MitigationStrategy = "review"
}
],
EnvironmentalConsiderations = new SbomEnvironmentalConsiderations
{
EnergyConsumptions =
[
new SbomEnergyConsumption
{
Activity = "training",
EnergyProviders =
[
new SbomEnergyProvider
{
BomRef = "energy-1",
Description = "solar",
Organization = new SbomOrganizationalEntity
{
Name = "energy-org"
},
EnergySource = "solar",
EnergyProvided = "100kwh"
}
]
}
],
Properties = [new SbomProperty { Name = "co2", Value = "low" }]
},
FairnessAssessments =
[
new SbomFairnessAssessment
{
GroupAtRisk = "group",
Benefits = "benefit",
Harms = "harm",
MitigationStrategy = "mitigate"
}
]
},
Properties = [new SbomProperty { Name = "model", Value = "v1" }]
},
CryptoProperties = new SbomCryptoProperties
{
AssetType = SbomCryptoAssetType.Algorithm,
AlgorithmProperties = new SbomCryptoAlgorithmProperties
{
Primitive = "aes",
AlgorithmFamily = "aes",
Mode = "gcm",
Padding = "none",
CryptoFunctions = ["encrypt", "decrypt"],
ClassicalSecurityLevel = 128,
NistQuantumSecurityLevel = 1,
KeySize = 256
},
Oid = "1.2.840.113549.1.1.1"
},
Signature = componentSignature,
Properties = ImmutableDictionary<string, string>.Empty
.Add("build", "release")
.Add("source", "unit-test")
};
var service = new SbomService
{
BomRef = "service-a",
Provider = new SbomOrganizationalEntity { Name = "service-org" },
Group = "services",
Name = "api-service",
Version = "2.0.0",
Description = "service description",
Endpoints = ["https://api.example.com/b", "https://api.example.com/a"],
Authenticated = true,
TrustBoundary = true,
TrustZone = "zone-a",
Data =
[
new SbomServiceData
{
Flow = "inbound",
Classification = "restricted",
Name = "payload",
Description = "service data",
Source = ["client"],
Destination = ["service"]
}
],
Services =
[
new SbomService
{
BomRef = "service-nested",
Name = "nested-service",
Endpoints = ["https://nested.example.com"]
}
],
Properties = [new SbomProperty { Name = "tier", Value = "gold" }],
Tags = ["backend", "api"],
Signature = new SbomSignature
{
Algorithm = SbomSignatureAlgorithm.HS256,
KeyId = "service-key",
PublicKey = new SbomJsonWebKey { KeyType = "RSA", Modulus = "mod", Exponent = "AQAB" },
Value = "c2VydmljZS1zaWc="
}
};
var formulation = new SbomFormulation
{
BomRef = "formulation-1",
Components = [component],
Services = [service],
Workflows =
[
new SbomWorkflow
{
BomRef = "workflow-1",
Uid = "workflow-uid",
Name = "workflow",
Description = "build workflow",
ResourceReferences = ["resource-b", "resource-a"],
Tasks =
[
new SbomTask
{
BomRef = "task-1",
Uid = "task-uid",
Name = "task",
TaskTypes = ["build"],
Steps =
[
new SbomStep
{
Name = "step",
Description = "run",
Commands = ["make build"]
}
],
Inputs =
[
new SbomWorkflowInput
{
Source = "source-a",
Target = "target-a",
Resource = "resource-a",
Parameters = [new SbomProperty { Name = "flag", Value = "on" }]
}
],
Outputs =
[
new SbomWorkflowOutput
{
Type = "artifact",
Source = "source-b",
Target = "target-b",
Resource = "resource-b",
Data = ["output-a"]
}
],
TimeStart = FixedTimestamp,
TimeEnd = FixedTimestamp.AddMinutes(10)
}
],
TaskDependencies = ["task-1"],
TaskTypes = ["build"],
Trigger = new SbomTrigger
{
BomRef = "trigger-1",
Uid = "trigger-uid",
Name = "trigger",
Description = "on commit",
Type = "event",
Event = "push",
Conditions = ["branch=main"],
TimeActivated = FixedTimestamp
},
Steps =
[
new SbomStep
{
Name = "workflow-step",
Description = "workflow step",
Commands = ["echo start"]
}
],
Inputs =
[
new SbomWorkflowInput
{
Source = "workflow-source",
Target = "workflow-target",
Resource = "workflow-resource",
Data = ["input-a"]
}
],
Outputs =
[
new SbomWorkflowOutput
{
Type = "result",
Source = "workflow-source",
Target = "workflow-target",
Data = ["output-b"]
}
],
TimeStart = FixedTimestamp,
TimeEnd = FixedTimestamp.AddMinutes(30)
}
],
Properties = [new SbomProperty { Name = "pipeline", Value = "ci" }]
};
var annotation = new SbomAnnotation
{
BomRef = "annotation-1",
Subjects = ["component-a"],
Annotator = new SbomAnnotationAnnotator
{
Organization = new SbomOrganizationalEntity { Name = "annotator-org" }
},
Timestamp = FixedTimestamp,
Text = "annotation text",
Signature = new SbomSignature
{
Algorithm = SbomSignatureAlgorithm.RS256,
KeyId = "annotation-key",
PublicKey = new SbomJsonWebKey { KeyType = "RSA", Modulus = "mod", Exponent = "AQAB" },
Value = "YW5ub3RhdGlvbi1zaWc="
}
};
var composition = new SbomComposition
{
BomRef = "composition-1",
Aggregate = SbomCompositionAggregate.IncompleteFirstPartyOnly,
Assemblies = ["component-a"],
Dependencies = ["component-a"]
};
var declarations = new SbomDeclaration
{
Assessors =
[
new SbomAssessor
{
BomRef = "assessor-1",
ThirdParty = true,
Organization = new SbomOrganizationalEntity { Name = "assessor-org" }
}
],
Attestations =
[
new SbomAttestation
{
Summary = "attestation",
Assessor = "assessor-1",
Map =
[
new SbomAttestationMap
{
Requirement = "req-1",
Claims = ["claim-1"],
Conformance = new SbomAttestationConformance
{
Score = 0.9,
Rationale = "meets requirement",
MitigationStrategies = ["review"]
}
}
]
}
],
Claims =
[
new SbomClaim
{
BomRef = "claim-1",
Target = "component-a",
Predicate = "predicate",
MitigationStrategies = ["mitigation"],
Reasoning = "verified"
}
],
Evidence =
[
new SbomDeclarationEvidence
{
BomRef = "evidence-1",
PropertyName = "property",
Description = "evidence entry",
Data = "evidence-data",
Created = FixedTimestamp,
Author = new SbomOrganizationalContact { Name = "author" },
Reviewer = new SbomOrganizationalContact { Name = "reviewer" }
}
],
Targets = new SbomDeclarationTargets
{
Organizations = [new SbomOrganizationalEntity { Name = "target-org" }],
Components = [component],
Services = [service]
},
Affirmation = new SbomAffirmation
{
Statement = "affirmed",
Signatories =
[
new SbomSignatory
{
Name = "signer",
Role = "reviewer",
Organization = new SbomOrganizationalEntity { Name = "signer-org" },
ExternalReference = new SbomExternalReference
{
Type = "website",
Url = "https://example.com/signers"
}
}
]
},
Signature = new SbomSignature
{
Algorithm = SbomSignatureAlgorithm.RS256,
KeyId = "declaration-key",
PublicKey = new SbomJsonWebKey { KeyType = "RSA", Modulus = "mod", Exponent = "AQAB" },
Value = "ZGVjbGFyYXRpb24="
}
};
var definitions = new SbomDefinition
{
Standards =
[
new SbomStandard
{
BomRef = "standard-1",
Name = "Standard",
Version = "1.0",
Description = "standard description",
Owner = new SbomOrganizationalEntity { Name = "standards-org" },
Requirements =
[
new SbomRequirement
{
BomRef = "req-1",
Identifier = "REQ-1",
Title = "Requirement",
Text = "Requirement text",
Descriptions = ["Requirement description"]
}
],
ExternalReferences =
[
new SbomExternalReference
{
Type = "website",
Url = "https://example.com/standard"
}
],
Signature = new SbomSignature
{
Algorithm = SbomSignatureAlgorithm.RS384,
KeyId = "standard-key",
PublicKey = new SbomJsonWebKey { KeyType = "RSA", Modulus = "mod", Exponent = "AQAB" },
Value = "c3RhbmRhcmQ="
}
}
]
};
return new SbomDocument
{
Name = "full-bom",
Version = "1",
Timestamp = FixedTimestamp,
ArtifactDigest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
Metadata = new SbomMetadata
{
Tools = ["stella-writer"],
Authors = ["test@example.com"]
},
Components = [component],
Relationships =
[
new SbomRelationship
{
SourceRef = "component-a",
TargetRef = "component-a",
Type = SbomRelationshipType.DependsOn
}
],
Services = [service],
Formulation = [formulation],
Annotations = [annotation],
Compositions = [composition],
Declarations = declarations,
Definitions = definitions,
Signature = new SbomSignature
{
Algorithm = SbomSignatureAlgorithm.RS256,
KeyId = "bom-key",
PublicKey = new SbomJsonWebKey
{
KeyType = "RSA",
Modulus = "mod",
Exponent = "AQAB"
},
CertificatePath = ["bom-cert"],
Value = "Ym9tLXNpZw=="
}
};
}
internal static SbomDocument CreateMinimalDocument()
{
return new SbomDocument
{
Name = "schema-bom",
Version = "1",
Timestamp = FixedTimestamp,
ArtifactDigest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
Metadata = new SbomMetadata
{
Tools = ["stella-writer"]
},
Components =
[
new SbomComponent
{
BomRef = "component-a",
Name = "component-a",
Version = "1.0.0",
Type = SbomComponentType.Library
}
]
};
}
}

View File

@@ -5,6 +5,7 @@
// Description: Tests for deterministic serialNumber generation using artifact digest
// -----------------------------------------------------------------------------
using StellaOps.Attestor.StandardPredicates.Models;
using StellaOps.Attestor.StandardPredicates.Writers;
using Xunit;
using System.Text.Json;
@@ -33,8 +34,8 @@ public sealed class SerialNumberDerivationTests
var document = CreateDocument(artifactDigest);
// Act
var bytes = _writer.Write(document);
var json = Encoding.UTF8.GetString(bytes);
var result = _writer.Write(document);
var json = Encoding.UTF8.GetString(result.CanonicalBytes);
var parsed = JsonDocument.Parse(json);
var serialNumber = parsed.RootElement.GetProperty("serialNumber").GetString();
@@ -55,8 +56,8 @@ public sealed class SerialNumberDerivationTests
var document = CreateDocument(rawDigest);
// Act
var bytes = _writer.Write(document);
var json = Encoding.UTF8.GetString(bytes);
var result = _writer.Write(document);
var json = Encoding.UTF8.GetString(result.CanonicalBytes);
var parsed = JsonDocument.Parse(json);
var serialNumber = parsed.RootElement.GetProperty("serialNumber").GetString();
@@ -76,8 +77,8 @@ public sealed class SerialNumberDerivationTests
var document = CreateDocument(uppercaseDigest);
// Act
var bytes = _writer.Write(document);
var json = Encoding.UTF8.GetString(bytes);
var result = _writer.Write(document);
var json = Encoding.UTF8.GetString(result.CanonicalBytes);
var parsed = JsonDocument.Parse(json);
var serialNumber = parsed.RootElement.GetProperty("serialNumber").GetString();
@@ -96,8 +97,8 @@ public sealed class SerialNumberDerivationTests
var document = CreateDocument(null);
// Act
var bytes = _writer.Write(document);
var json = Encoding.UTF8.GetString(bytes);
var result = _writer.Write(document);
var json = Encoding.UTF8.GetString(result.CanonicalBytes);
var parsed = JsonDocument.Parse(json);
var serialNumber = parsed.RootElement.GetProperty("serialNumber").GetString();
@@ -116,8 +117,8 @@ public sealed class SerialNumberDerivationTests
var document = CreateDocument("");
// Act
var bytes = _writer.Write(document);
var json = Encoding.UTF8.GetString(bytes);
var result = _writer.Write(document);
var json = Encoding.UTF8.GetString(result.CanonicalBytes);
var parsed = JsonDocument.Parse(json);
var serialNumber = parsed.RootElement.GetProperty("serialNumber").GetString();
@@ -137,8 +138,8 @@ public sealed class SerialNumberDerivationTests
var document = CreateDocument(shortDigest);
// Act
var bytes = _writer.Write(document);
var json = Encoding.UTF8.GetString(bytes);
var result = _writer.Write(document);
var json = Encoding.UTF8.GetString(result.CanonicalBytes);
var parsed = JsonDocument.Parse(json);
var serialNumber = parsed.RootElement.GetProperty("serialNumber").GetString();
@@ -158,8 +159,8 @@ public sealed class SerialNumberDerivationTests
var document = CreateDocument(invalidDigest);
// Act
var bytes = _writer.Write(document);
var json = Encoding.UTF8.GetString(bytes);
var result = _writer.Write(document);
var json = Encoding.UTF8.GetString(result.CanonicalBytes);
var parsed = JsonDocument.Parse(json);
var serialNumber = parsed.RootElement.GetProperty("serialNumber").GetString();
@@ -184,11 +185,11 @@ public sealed class SerialNumberDerivationTests
var doc2 = CreateDocument(artifactDigest);
// Act
var bytes1 = _writer.Write(doc1);
var bytes2 = _writer.Write(doc2);
var result1 = _writer.Write(doc1);
var result2 = _writer.Write(doc2);
var json1 = Encoding.UTF8.GetString(bytes1);
var json2 = Encoding.UTF8.GetString(bytes2);
var json1 = Encoding.UTF8.GetString(result1.CanonicalBytes);
var json2 = Encoding.UTF8.GetString(result2.CanonicalBytes);
var parsed1 = JsonDocument.Parse(json1);
var parsed2 = JsonDocument.Parse(json2);
@@ -214,11 +215,11 @@ public sealed class SerialNumberDerivationTests
var doc2 = CreateDocument(digest2);
// Act
var bytes1 = _writer.Write(doc1);
var bytes2 = _writer.Write(doc2);
var result1 = _writer.Write(doc1);
var result2 = _writer.Write(doc2);
var json1 = Encoding.UTF8.GetString(bytes1);
var json2 = Encoding.UTF8.GetString(bytes2);
var json1 = Encoding.UTF8.GetString(result1.CanonicalBytes);
var json2 = Encoding.UTF8.GetString(result2.CanonicalBytes);
var parsed1 = JsonDocument.Parse(json1);
var parsed2 = JsonDocument.Parse(json2);
@@ -246,8 +247,8 @@ public sealed class SerialNumberDerivationTests
// Act
for (var i = 0; i < 100; i++)
{
var bytes = _writer.Write(document);
var json = Encoding.UTF8.GetString(bytes);
var result = _writer.Write(document);
var json = Encoding.UTF8.GetString(result.CanonicalBytes);
var parsed = JsonDocument.Parse(json);
var serialNumber = parsed.RootElement.GetProperty("serialNumber").GetString()!;
serialNumbers.Add(serialNumber);
@@ -268,8 +269,12 @@ public sealed class SerialNumberDerivationTests
{
Name = "test-app",
Version = "1.0.0",
CreatedAt = new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero),
Timestamp = new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero),
ArtifactDigest = artifactDigest,
Metadata = new SbomMetadata
{
Tools = ["stella-scanner@1.0.0"]
},
Components =
[
new SbomComponent
@@ -277,14 +282,9 @@ public sealed class SerialNumberDerivationTests
BomRef = "lodash",
Name = "lodash",
Version = "4.17.21",
Type = "library"
Type = SbomComponentType.Library
}
],
Tool = new SbomTool
{
Name = "stella-scanner",
Version = "1.0.0"
}
]
};
}

View File

@@ -5,6 +5,7 @@
// Description: Tests proving deterministic SPDX output
// -----------------------------------------------------------------------------
using System.Text.Json;
using StellaOps.Attestor.StandardPredicates.Models;
using StellaOps.Attestor.StandardPredicates.Writers;
using Xunit;
@@ -133,11 +134,137 @@ public sealed class SpdxDeterminismTests
var document = CreateTestDocument("context-test", "1.0.0");
var result = _writer.Write(document);
var json = System.Text.Encoding.UTF8.GetString(result.CanonicalBytes);
var json = System.Text.Encoding.UTF8.GetString(result.CanonicalBytes);
Assert.Contains("@context", json);
Assert.Contains("spdx.org", json);
}
/// <summary>
/// Test Case 6: External references are serialized for packages.
/// </summary>
[Fact]
public void ExternalReferences_AreSerialized()
{
var component = CreateComponent("pkg:npm/ext@1.0.0", "ext")
with
{
ExternalReferences =
[
new SbomExternalReference
{
Type = "website",
Url = "https://example.com/ext",
Comment = "home"
}
]
};
var document = CreateDocumentWithComponents("ext-doc", [component]);
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var graph = json.RootElement.GetProperty("@graph");
var package = graph.EnumerateArray()
.First(element => element.TryGetProperty("@type", out var type) &&
type.GetString() == "software_Package" &&
element.GetProperty("name").GetString() == "ext");
var externalRef = package.GetProperty("externalRef")[0];
Assert.Equal("AltWebPage", externalRef.GetProperty("externalRefType").GetString());
Assert.Equal("https://example.com/ext", externalRef.GetProperty("locator")[0].GetString());
Assert.Equal("home", externalRef.GetProperty("comment").GetString());
}
/// <summary>
/// Test Case 7: External identifiers include locator and issuing authority.
/// </summary>
[Fact]
public void ExternalIdentifiers_AreSerialized()
{
var component = CreateComponent("pkg:npm/extid@1.0.0", "extid")
with
{
ExternalIdentifiers =
[
new SbomExternalIdentifier
{
Type = "cve",
Identifier = "CVE-2024-1234",
Locator = "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-1234",
IssuingAuthority = "mitre",
Comment = "primary"
}
]
};
var document = CreateDocumentWithComponents("extid-doc", [component]);
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var graph = json.RootElement.GetProperty("@graph");
var package = graph.EnumerateArray()
.First(element => element.TryGetProperty("@type", out var type) &&
type.GetString() == "software_Package" &&
element.GetProperty("name").GetString() == "extid");
var identifier = package.GetProperty("externalIdentifier")
.EnumerateArray()
.First(entry => entry.GetProperty("identifier").GetString() == "CVE-2024-1234");
Assert.Equal("Cve", identifier.GetProperty("externalIdentifierType").GetString());
Assert.Equal("https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-1234",
identifier.GetProperty("identifierLocator").GetString());
Assert.Equal("mitre", identifier.GetProperty("issuingAuthority").GetString());
Assert.Equal("primary", identifier.GetProperty("comment").GetString());
}
/// <summary>
/// Test Case 8: verifiedUsing includes signature integrity methods.
/// </summary>
[Fact]
public void VerifiedUsing_IncludesSignature()
{
var component = CreateComponent("pkg:npm/signed@1.0.0", "signed")
with
{
Signature = new SbomSignature
{
Algorithm = SbomSignatureAlgorithm.ES256,
KeyId = "key-1",
PublicKey = new SbomJsonWebKey
{
KeyType = "RSA",
Modulus = "modulus",
Exponent = "AQAB"
},
Value = "sigvalue"
}
};
var document = CreateDocumentWithComponents("signed-doc", [component]);
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var graph = json.RootElement.GetProperty("@graph");
var package = graph.EnumerateArray()
.First(element => element.TryGetProperty("@type", out var type) &&
type.GetString() == "software_Package" &&
element.GetProperty("name").GetString() == "signed");
var signature = package.GetProperty("verifiedUsing")
.EnumerateArray()
.First(entry => entry.GetProperty("@type").GetString() == "Signature");
Assert.Equal("ES256", signature.GetProperty("algorithm").GetString());
Assert.Equal("sigvalue", signature.GetProperty("signature").GetString());
Assert.Equal("key-1", signature.GetProperty("keyId").GetString());
var publicKey = signature.GetProperty("publicKey");
Assert.Equal("RSA", publicKey.GetProperty("kty").GetString());
Assert.Equal("modulus", publicKey.GetProperty("n").GetString());
Assert.Equal("AQAB", publicKey.GetProperty("e").GetString());
}
private static SbomDocument CreateTestDocument(string name, string version)
{
return new SbomDocument

View File

@@ -0,0 +1,117 @@
using System.Linq;
using System.Text.Json;
using StellaOps.Attestor.StandardPredicates.Models;
using StellaOps.Attestor.StandardPredicates.Writers;
using Xunit;
namespace StellaOps.Attestor.StandardPredicates.Tests;
public sealed class SpdxWriterSecurityProfileTests
{
private const string SecurityProfileUri =
"https://spdx.org/rdf/3.0.1/terms/Security/ProfileIdentifierType/security";
private readonly SpdxWriter _writer = new();
[Fact]
public void VulnerabilityElements_AreSerialized()
{
var component = new SbomComponent
{
BomRef = "app",
Name = "app",
Version = "1.0.0"
};
var vulnerability = new SbomVulnerability
{
Id = "CVE-2026-0001",
Source = "NVD",
Summary = "Example vulnerability",
Description = "Details",
PublishedTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
ModifiedTime = new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero),
AffectedRefs = ["app"],
Assessments =
[
new SbomVulnerabilityAssessment
{
Type = SbomVulnerabilityAssessmentType.CvssV3,
TargetRef = "app",
Score = 9.1,
Vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
Comment = "critical"
},
new SbomVulnerabilityAssessment
{
Type = SbomVulnerabilityAssessmentType.Epss,
TargetRef = "app",
Score = 0.42,
Comment = "epss"
},
new SbomVulnerabilityAssessment
{
Type = SbomVulnerabilityAssessmentType.VexAffected,
TargetRef = "app",
Comment = "affected"
}
]
};
var document = new SbomDocument
{
Name = "security-doc",
Version = "1.0.0",
Timestamp = new DateTimeOffset(2026, 1, 1, 8, 0, 0, TimeSpan.Zero),
Components = [component],
Vulnerabilities = [vulnerability]
};
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var graph = json.RootElement.GetProperty("@graph");
var docElement = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "SpdxDocument");
var profiles = docElement.GetProperty("creationInfo").GetProperty("profile")
.EnumerateArray()
.Select(value => value.GetString())
.ToArray();
Assert.Contains(SecurityProfileUri, profiles);
var vulnElement = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "security_Vulnerability");
Assert.Equal("CVE-2026-0001", vulnElement.GetProperty("name").GetString());
var vulnIdentifier = vulnElement.GetProperty("externalIdentifier")[0];
Assert.Equal("Cve", vulnIdentifier.GetProperty("externalIdentifierType").GetString());
Assert.Equal("CVE-2026-0001", vulnIdentifier.GetProperty("identifier").GetString());
var affectsRelationship = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "Relationship" &&
element.GetProperty("relationshipType").GetString() == "Affects");
Assert.Equal(BuildElementId("vuln:CVE-2026-0001"), affectsRelationship.GetProperty("from").GetString());
Assert.Equal(BuildElementId("app"), affectsRelationship.GetProperty("to")[0].GetString());
var cvssAssessment = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "security_CvssV3VulnAssessmentRelationship");
var cvssScore = cvssAssessment.GetProperty("security_score").GetDouble();
Assert.InRange(cvssScore, 9.09, 9.11);
Assert.Equal("Critical", cvssAssessment.GetProperty("security_severity").GetString());
var epssAssessment = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "security_EpssVulnAssessmentRelationship");
var epssScore = epssAssessment.GetProperty("security_probability").GetDouble();
Assert.InRange(epssScore, 0.419, 0.421);
var vexAssessment = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "security_VexAffectedVulnAssessmentRelationship");
Assert.Equal("affected", vexAssessment.GetProperty("security_statusNotes").GetString());
}
private static string BuildElementId(string reference)
{
return "urn:stellaops:sbom:element:" + Uri.EscapeDataString(reference);
}
}

View File

@@ -0,0 +1,190 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Attestor.StandardPredicates.Models;
using StellaOps.Attestor.StandardPredicates.Writers;
using Xunit;
namespace StellaOps.Attestor.StandardPredicates.Tests;
public sealed class SpdxWriterSoftwareProfileTests
{
private readonly SpdxWriter _writer = new();
[Fact]
public void PackageFields_AreSerialized()
{
var component = new SbomComponent
{
BomRef = "pkg",
Name = "pkg",
Version = "1.2.3",
Purl = "pkg:npm/pkg@1.2.3",
DownloadLocation = "https://example.com/pkg.tgz",
HomePage = "https://example.com/pkg",
SourceInfo = "git+https://example.com/pkg.git",
PrimaryPurpose = "library",
AdditionalPurposes = ["service", "documentation", "service"],
ContentIdentifier = "sha256:abc123",
CopyrightText = "(c) Example",
AttributionText = ["Line B", "Line A"],
OriginatedBy = "urn:stellaops:agent:person:dev",
SuppliedBy = "urn:stellaops:agent:org:example",
BuiltTime = new DateTimeOffset(2026, 1, 1, 10, 0, 0, TimeSpan.Zero),
ReleaseTime = new DateTimeOffset(2026, 1, 2, 9, 0, 0, TimeSpan.Zero),
ValidUntilTime = new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero)
};
var document = new SbomDocument
{
Name = "pkg-doc",
Version = "1.0.0",
Timestamp = new DateTimeOffset(2026, 1, 1, 8, 0, 0, TimeSpan.Zero),
Components = [component]
};
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var graph = json.RootElement.GetProperty("@graph");
var package = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "software_Package" &&
element.GetProperty("name").GetString() == "pkg");
Assert.Equal("https://example.com/pkg.tgz", package.GetProperty("downloadLocation").GetString());
Assert.Equal("https://example.com/pkg", package.GetProperty("homePage").GetString());
Assert.Equal("git+https://example.com/pkg.git", package.GetProperty("sourceInfo").GetString());
Assert.Equal("library", package.GetProperty("primaryPurpose").GetString());
Assert.Equal("sha256:abc123", package.GetProperty("contentIdentifier").GetString());
Assert.Equal("2026-01-01T10:00:00Z", package.GetProperty("builtTime").GetString());
var additionalPurpose = package.GetProperty("additionalPurpose")
.EnumerateArray()
.Select(value => value.GetString())
.ToArray();
Assert.Equal(new[] { "documentation", "service" }, additionalPurpose);
var attribution = package.GetProperty("attributionText")
.EnumerateArray()
.Select(value => value.GetString())
.ToArray();
Assert.Equal(new[] { "Line A", "Line B" }, attribution);
}
[Fact]
public void FileAndSnippetElements_AreSerialized()
{
var fileComponent = new SbomComponent
{
BomRef = "file1",
Name = "app.cs",
Type = SbomComponentType.File,
FileName = "app.cs",
FileKind = "text",
ContentType = "text/plain"
};
var snippet = new SbomSnippet
{
BomRef = "snippet1",
FromFileRef = "file1",
Name = "snippet",
Description = "example snippet",
ByteRange = new SbomRange { Start = 10, End = 20 },
LineRange = new SbomRange { Start = 1, End = 2 }
};
var document = new SbomDocument
{
Name = "snippet-doc",
Version = "1.0.0",
Timestamp = new DateTimeOffset(2026, 1, 1, 8, 0, 0, TimeSpan.Zero),
Components = [fileComponent],
Snippets = [snippet]
};
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var graph = json.RootElement.GetProperty("@graph");
var file = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "software_File");
Assert.Equal("app.cs", file.GetProperty("fileName").GetString());
Assert.Equal("text", file.GetProperty("fileKind").GetString());
Assert.Equal("text/plain", file.GetProperty("contentType").GetString());
var snippetElement = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "software_Snippet");
Assert.Equal(BuildElementId("file1"), snippetElement.GetProperty("snippetFromFile").GetString());
Assert.Equal(10, snippetElement.GetProperty("byteRange").GetProperty("start").GetInt32());
Assert.Equal(20, snippetElement.GetProperty("byteRange").GetProperty("end").GetInt32());
Assert.Equal(1, snippetElement.GetProperty("lineRange").GetProperty("start").GetInt32());
Assert.Equal(2, snippetElement.GetProperty("lineRange").GetProperty("end").GetInt32());
}
[Fact]
public void BuildProfileElements_AreSerialized()
{
var component = new SbomComponent
{
BomRef = "app",
Name = "app",
Version = "2.0.0"
};
var build = new SbomBuild
{
BomRef = "build-123",
BuildId = "build-123",
BuildType = "ci",
BuildStartTime = new DateTimeOffset(2026, 1, 2, 12, 0, 0, TimeSpan.Zero),
BuildEndTime = new DateTimeOffset(2026, 1, 2, 12, 30, 0, TimeSpan.Zero),
ConfigSourceEntrypoint = "Dockerfile",
ConfigSourceDigest = "sha256:deadbeef",
ConfigSourceUri = "https://example.com/build/Dockerfile",
Environment = ImmutableDictionary.CreateRange(new Dictionary<string, string>
{
["CI"] = "true",
["OS"] = "linux"
}),
Parameters = ImmutableDictionary.CreateRange(new Dictionary<string, string>
{
["configuration"] = "Release"
}),
ProducedRefs = ["app"]
};
var document = new SbomDocument
{
Name = "build-doc",
Version = "1.0.0",
Timestamp = new DateTimeOffset(2026, 1, 2, 8, 0, 0, TimeSpan.Zero),
Components = [component],
Builds = [build]
};
var result = _writer.Write(document);
using var json = JsonDocument.Parse(result.CanonicalBytes);
var graph = json.RootElement.GetProperty("@graph");
var buildElement = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "build_Build");
Assert.Equal("build-123", buildElement.GetProperty("buildId").GetString());
Assert.Equal("ci", buildElement.GetProperty("buildType").GetString());
Assert.Equal("2026-01-02T12:00:00Z", buildElement.GetProperty("buildStartTime").GetString());
Assert.Equal("Dockerfile", buildElement.GetProperty("configSourceEntrypoint").GetString());
Assert.Equal("true", buildElement.GetProperty("environment").GetProperty("CI").GetString());
var relationship = graph.EnumerateArray()
.First(element => element.GetProperty("@type").GetString() == "Relationship" &&
element.GetProperty("relationshipType").GetString() == "OutputOf");
Assert.Equal(BuildElementId("build:build-123"), relationship.GetProperty("from").GetString());
Assert.Equal(BuildElementId("app"), relationship.GetProperty("to")[0].GetString());
}
private static string BuildElementId(string reference)
{
return "urn:stellaops:sbom:element:" + Uri.EscapeDataString(reference);
}
}

View File

@@ -1,7 +1,7 @@
# Attestor StandardPredicates Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260113_001_002_ATTESTOR_binary_diff_predicate.md`.
Source of truth: `docs/implplan/SPRINT_20260119_013_Attestor_cyclonedx_1.7_generation.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
@@ -9,3 +9,6 @@ Source of truth: `docs/implplan/SPRINT_20260113_001_002_ATTESTOR_binary_diff_pre
| AUDIT-0065-T | DONE | Revalidated 2026-01-06. |
| AUDIT-0065-A | DONE | Waived after revalidation 2026-01-06. |
| BINARYDIFF-TESTS-0001 | DONE | Add unit tests for BinaryDiff predicate, serializer, signer, and verifier. |
| ATT-004 | DONE | Timestamp extension roundtrip tests for CycloneDX/SPDX predicates. |
| TASK-013-009 | DONE | Added CycloneDX 1.7 feature, determinism, and round-trip tests. |
| TASK-013-010 | DONE | Added CycloneDX 1.7 schema validation test. |

View File

@@ -0,0 +1,79 @@
using System.Text.Json;
using FluentAssertions;
using StellaOps.Attestor.StandardPredicates;
using StellaOps.Attestor.StandardPredicates.Writers;
using Xunit;
namespace StellaOps.Attestor.StandardPredicates.Tests;
public sealed class TimestampExtensionTests
{
[Fact]
public void CycloneDxTimestampExtension_RoundTripsMetadata()
{
var baseDoc = new
{
bomFormat = "CycloneDX",
specVersion = "1.6",
metadata = new { timestamp = "2026-01-19T12:00:00Z" }
};
var input = JsonSerializer.SerializeToUtf8Bytes(baseDoc);
var metadata = CreateMetadata();
var updated = CycloneDxTimestampExtension.AddTimestampMetadata(input, metadata);
var extracted = CycloneDxTimestampExtension.ExtractTimestampMetadata(updated);
extracted.Should().NotBeNull();
extracted!.TsaUrl.Should().Be(metadata.TsaUrl);
extracted.TokenDigest.Should().Be(metadata.TokenDigest);
extracted.DigestAlgorithm.Should().Be(metadata.DigestAlgorithm);
extracted.GenerationTime.Should().Be(metadata.GenerationTime);
extracted.PolicyOid.Should().Be(metadata.PolicyOid);
extracted.SerialNumber.Should().Be(metadata.SerialNumber);
extracted.TsaName.Should().Be(metadata.TsaName);
extracted.HasStapledRevocation.Should().BeTrue();
extracted.IsQualified.Should().BeTrue();
}
[Fact]
public void SpdxTimestampExtension_RoundTripsMetadata()
{
var baseDoc = new
{
spdxVersion = "SPDX-3.0",
dataLicense = "CC0-1.0",
SPDXID = "SPDXRef-DOCUMENT"
};
var input = JsonSerializer.SerializeToUtf8Bytes(baseDoc);
var metadata = CreateMetadata();
var updated = SpdxTimestampExtension.AddTimestampAnnotation(input, metadata);
var extracted = SpdxTimestampExtension.ExtractTimestampMetadata(updated);
extracted.Should().NotBeNull();
extracted!.TsaUrl.Should().Be(metadata.TsaUrl);
extracted.TokenDigest.Should().Be(metadata.TokenDigest);
extracted.DigestAlgorithm.Should().Be(metadata.DigestAlgorithm);
extracted.GenerationTime.Should().Be(metadata.GenerationTime);
extracted.PolicyOid.Should().Be(metadata.PolicyOid);
extracted.TsaName.Should().Be(metadata.TsaName);
extracted.HasStapledRevocation.Should().BeTrue();
extracted.IsQualified.Should().BeTrue();
}
private static Rfc3161TimestampMetadata CreateMetadata()
{
return new Rfc3161TimestampMetadata
{
TsaUrl = "https://tsa.example.test",
TokenDigest = "abc123",
DigestAlgorithm = "SHA256",
GenerationTime = new DateTimeOffset(2026, 1, 19, 12, 0, 0, TimeSpan.Zero),
PolicyOid = "1.2.3.4",
SerialNumber = "01",
TsaName = "Example TSA",
HasStapledRevocation = true,
IsQualified = true
};
}
}