Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -6,14 +6,11 @@ using CycloneDX.Models;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Extension methods for CycloneDX 1.7 support.
/// Workaround for CycloneDX.Core not yet exposing SpecificationVersion.v1_7.
/// Helpers and media type constants for CycloneDX 1.7.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_5000_0001_0001 - Advisory Alignment (CycloneDX 1.7 Upgrade)
///
/// Once CycloneDX.Core adds v1_7 support, this extension can be removed
/// and the code can use SpecificationVersion.v1_7 directly.
/// Keep upgrade helpers for backward-compatibility with 1.6 inputs.
/// </remarks>
public static class CycloneDx17Extensions
{

View File

@@ -47,12 +47,38 @@ public sealed record CycloneDxArtifact
public required string ProtobufMediaType { get; init; }
}
public sealed record SpdxArtifact
{
public required SbomView View { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required byte[] JsonBytes { get; init; }
public required string JsonSha256 { get; init; }
/// <summary>
/// Canonical content hash (sha256, hex) of the SPDX JSON-LD payload.
/// </summary>
public required string ContentHash { get; init; }
public required string JsonMediaType { get; init; }
public byte[]? TagValueBytes { get; init; }
public string? TagValueSha256 { get; init; }
public string? TagValueMediaType { get; init; }
}
public sealed record SbomCompositionResult
{
public required CycloneDxArtifact Inventory { get; init; }
public CycloneDxArtifact? Usage { get; init; }
public SpdxArtifact? SpdxInventory { get; init; }
public required ComponentGraph Graph { get; init; }
/// <summary>

View File

@@ -0,0 +1,413 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Spdx;
using StellaOps.Scanner.Emit.Spdx.Models;
using StellaOps.Scanner.Emit.Spdx.Serialization;
namespace StellaOps.Scanner.Emit.Composition;
public interface ISpdxComposer
{
SpdxArtifact Compose(
SbomCompositionRequest request,
SpdxCompositionOptions options,
CancellationToken cancellationToken = default);
ValueTask<SpdxArtifact> ComposeAsync(
SbomCompositionRequest request,
SpdxCompositionOptions options,
CancellationToken cancellationToken = default);
}
public sealed record SpdxCompositionOptions
{
public string CreatorTool { get; init; } = "StellaOps-Scanner";
public string? CreatorOrganization { get; init; }
public string NamespaceBase { get; init; } = "https://stellaops.io/spdx";
public bool IncludeFiles { get; init; }
public bool IncludeSnippets { get; init; }
public bool IncludeTagValue { get; init; }
public SpdxLicenseListVersion LicenseListVersion { get; init; } = SpdxLicenseListVersion.V3_21;
public ImmutableArray<string> ProfileConformance { get; init; } = ImmutableArray.Create("core", "software");
}
public sealed class SpdxComposer : ISpdxComposer
{
private const string JsonMediaType = "application/spdx+json; version=3.0.1";
private const string TagValueMediaType = "text/spdx; version=2.3";
public SpdxArtifact Compose(
SbomCompositionRequest request,
SpdxCompositionOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(options);
var graph = ComponentGraphBuilder.Build(request.LayerFragments);
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
var idBuilder = new SpdxIdBuilder(options.NamespaceBase, request.Image.ImageDigest);
var licenseList = SpdxLicenseListProvider.Get(options.LicenseListVersion);
var creationInfo = BuildCreationInfo(request, options, generatedAt);
var document = BuildDocument(request, options, graph, idBuilder, creationInfo, licenseList);
var jsonBytes = SpdxJsonLdSerializer.Serialize(document);
var jsonHash = CanonJson.Sha256Hex(jsonBytes);
byte[]? tagBytes = null;
string? tagHash = null;
if (options.IncludeTagValue)
{
tagBytes = SpdxTagValueSerializer.Serialize(document);
tagHash = CanonJson.Sha256Hex(tagBytes);
}
return new SpdxArtifact
{
View = SbomView.Inventory,
GeneratedAt = generatedAt,
JsonBytes = jsonBytes,
JsonSha256 = jsonHash,
ContentHash = jsonHash,
JsonMediaType = JsonMediaType,
TagValueBytes = tagBytes,
TagValueSha256 = tagHash,
TagValueMediaType = tagBytes is null ? null : TagValueMediaType
};
}
public ValueTask<SpdxArtifact> ComposeAsync(
SbomCompositionRequest request,
SpdxCompositionOptions options,
CancellationToken cancellationToken = default)
=> ValueTask.FromResult(Compose(request, options, cancellationToken));
private static SpdxCreationInfo BuildCreationInfo(
SbomCompositionRequest request,
SpdxCompositionOptions options,
DateTimeOffset generatedAt)
{
var creators = ImmutableArray.CreateBuilder<string>();
var toolName = !string.IsNullOrWhiteSpace(request.GeneratorName)
? request.GeneratorName!.Trim()
: options.CreatorTool;
if (!string.IsNullOrWhiteSpace(toolName))
{
var toolLabel = !string.IsNullOrWhiteSpace(request.GeneratorVersion)
? $"{toolName}-{request.GeneratorVersion!.Trim()}"
: toolName;
creators.Add($"Tool: {toolLabel}");
}
if (!string.IsNullOrWhiteSpace(options.CreatorOrganization))
{
creators.Add($"Organization: {options.CreatorOrganization!.Trim()}");
}
return new SpdxCreationInfo
{
Created = generatedAt,
Creators = creators.ToImmutable(),
SpecVersion = SpdxDefaults.SpecVersion
};
}
private static SpdxDocument BuildDocument(
SbomCompositionRequest request,
SpdxCompositionOptions options,
ComponentGraph graph,
SpdxIdBuilder idBuilder,
SpdxCreationInfo creationInfo,
SpdxLicenseList licenseList)
{
var packages = new List<SpdxPackage>();
var packageIdMap = new Dictionary<string, string>(StringComparer.Ordinal);
var rootPackage = BuildRootPackage(request.Image, idBuilder);
packages.Add(rootPackage);
foreach (var component in graph.Components)
{
var package = BuildComponentPackage(component, idBuilder, licenseList);
packages.Add(package);
packageIdMap[component.Identity.Key] = package.SpdxId;
}
var rootElementIds = packages
.Select(static pkg => pkg.SpdxId)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var sbom = new SpdxSbom
{
SpdxId = idBuilder.SbomId,
Name = "software-sbom",
RootElements = new[] { rootPackage.SpdxId }.ToImmutableArray(),
Elements = rootElementIds,
SbomTypes = new[] { "build" }.ToImmutableArray()
};
var relationships = BuildRelationships(idBuilder, graph, rootPackage, packageIdMap);
var name = request.Image.ImageReference ?? request.Image.Repository ?? request.Image.ImageDigest;
return new SpdxDocument
{
DocumentNamespace = idBuilder.DocumentNamespace,
Name = $"SBOM for {name}",
CreationInfo = creationInfo,
Sbom = sbom,
Elements = packages.Cast<SpdxElement>().ToImmutableArray(),
Relationships = relationships,
ProfileConformance = options.ProfileConformance
};
}
private static ImmutableArray<SpdxRelationship> BuildRelationships(
SpdxIdBuilder idBuilder,
ComponentGraph graph,
SpdxPackage rootPackage,
IReadOnlyDictionary<string, string> packageIdMap)
{
var relationships = new List<SpdxRelationship>();
var documentId = idBuilder.DocumentNamespace;
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(documentId, "describes", rootPackage.SpdxId),
FromElement = documentId,
Type = SpdxRelationshipType.Describes,
ToElements = ImmutableArray.Create(rootPackage.SpdxId)
});
var dependencyTargets = new HashSet<string>(StringComparer.Ordinal);
foreach (var component in graph.Components)
{
foreach (var dependencyKey in component.Dependencies)
{
if (packageIdMap.ContainsKey(dependencyKey))
{
dependencyTargets.Add(dependencyKey);
}
}
}
var rootDependencies = graph.Components
.Where(component => !dependencyTargets.Contains(component.Identity.Key))
.OrderBy(component => component.Identity.Key, StringComparer.Ordinal)
.ToArray();
foreach (var component in rootDependencies)
{
if (!packageIdMap.TryGetValue(component.Identity.Key, out var targetId))
{
continue;
}
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(rootPackage.SpdxId, "dependsOn", targetId),
FromElement = rootPackage.SpdxId,
Type = SpdxRelationshipType.DependsOn,
ToElements = ImmutableArray.Create(targetId)
});
}
foreach (var component in graph.Components.OrderBy(component => component.Identity.Key, StringComparer.Ordinal))
{
if (!packageIdMap.TryGetValue(component.Identity.Key, out var fromId))
{
continue;
}
var deps = component.Dependencies
.Where(packageIdMap.ContainsKey)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray();
foreach (var depKey in deps)
{
var toId = packageIdMap[depKey];
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(fromId, "dependsOn", toId),
FromElement = fromId,
Type = SpdxRelationshipType.DependsOn,
ToElements = ImmutableArray.Create(toId)
});
}
}
return relationships
.OrderBy(rel => rel.FromElement, StringComparer.Ordinal)
.ThenBy(rel => rel.Type)
.ThenBy(rel => rel.ToElements.FirstOrDefault() ?? string.Empty, StringComparer.Ordinal)
.ToImmutableArray();
}
private static SpdxPackage BuildRootPackage(ImageArtifactDescriptor image, SpdxIdBuilder idBuilder)
{
var digest = image.ImageDigest;
var digestParts = digest.Split(':', 2, StringSplitOptions.TrimEntries);
var digestValue = digestParts.Length == 2 ? digestParts[1] : digest;
var checksums = ImmutableArray.Create(new SpdxChecksum
{
Algorithm = digestParts.Length == 2 ? digestParts[0].ToUpperInvariant() : "SHA256",
Value = digestValue
});
return new SpdxPackage
{
SpdxId = idBuilder.CreatePackageId($"image:{image.ImageDigest}"),
Name = image.ImageReference ?? image.Repository ?? image.ImageDigest,
Version = digestValue,
PackageUrl = BuildImagePurl(image),
DownloadLocation = "NOASSERTION",
PrimaryPurpose = "container",
Checksums = checksums
};
}
private static SpdxPackage BuildComponentPackage(
AggregatedComponent component,
SpdxIdBuilder idBuilder,
SpdxLicenseList licenseList)
{
var packageUrl = !string.IsNullOrWhiteSpace(component.Identity.Purl)
? component.Identity.Purl
: (component.Identity.Key.StartsWith("pkg:", StringComparison.Ordinal) ? component.Identity.Key : null);
var declared = BuildLicenseExpression(component.Metadata?.Licenses, licenseList);
return new SpdxPackage
{
SpdxId = idBuilder.CreatePackageId(component.Identity.Key),
Name = component.Identity.Name,
Version = component.Identity.Version,
PackageUrl = packageUrl,
DownloadLocation = "NOASSERTION",
PrimaryPurpose = MapPrimaryPurpose(component.Identity.ComponentType),
DeclaredLicense = declared
};
}
private static SpdxLicenseExpression? BuildLicenseExpression(
IReadOnlyList<string>? licenses,
SpdxLicenseList licenseList)
{
if (licenses is null || licenses.Count == 0)
{
return null;
}
var expressions = new List<SpdxLicenseExpression>();
foreach (var license in licenses)
{
if (string.IsNullOrWhiteSpace(license))
{
continue;
}
if (SpdxLicenseExpressionParser.TryParse(license, out var parsed, licenseList))
{
expressions.Add(parsed!);
continue;
}
expressions.Add(new SpdxSimpleLicense(ToLicenseRef(license)));
}
if (expressions.Count == 0)
{
return null;
}
var current = expressions[0];
for (var i = 1; i < expressions.Count; i++)
{
current = new SpdxDisjunctiveLicense(current, expressions[i]);
}
return current;
}
private static string ToLicenseRef(string license)
{
var normalized = new string(license
.Trim()
.Select(ch => char.IsLetterOrDigit(ch) || ch == '.' || ch == '-' ? ch : '-')
.ToArray());
if (normalized.StartsWith("LicenseRef-", StringComparison.Ordinal))
{
return normalized;
}
return $"LicenseRef-{normalized}";
}
private static string? MapPrimaryPurpose(string? type)
{
if (string.IsNullOrWhiteSpace(type))
{
return "library";
}
return type.Trim().ToLowerInvariant() switch
{
"application" => "application",
"framework" => "framework",
"container" => "container",
"operating-system" or "os" => "operatingSystem",
"device" => "device",
"firmware" => "firmware",
"file" => "file",
_ => "library"
};
}
private static string? BuildImagePurl(ImageArtifactDescriptor image)
{
if (string.IsNullOrWhiteSpace(image.Repository))
{
return null;
}
var repo = image.Repository.Trim();
var tag = string.IsNullOrWhiteSpace(image.Tag) ? null : image.Tag.Trim();
var digest = image.ImageDigest.Trim();
var builder = new System.Text.StringBuilder("pkg:oci/");
builder.Append(repo.Replace("/", "%2F", StringComparison.Ordinal));
if (!string.IsNullOrWhiteSpace(tag))
{
builder.Append('@').Append(tag);
}
builder.Append("?digest=").Append(Uri.EscapeDataString(digest));
if (!string.IsNullOrWhiteSpace(image.Architecture))
{
builder.Append("&arch=").Append(Uri.EscapeDataString(image.Architecture.Trim()));
}
return builder.ToString();
}
}

View File

@@ -88,6 +88,17 @@ public sealed class ScannerArtifactPackageBuilder
descriptors.Add(CreateDescriptor(ArtifactDocumentType.ImageBom, ArtifactDocumentFormat.CycloneDxProtobuf, composition.Usage.ProtobufMediaType, composition.Usage.ProtobufBytes, composition.Usage.ProtobufSha256, SbomView.Usage));
}
if (composition.SpdxInventory is not null)
{
descriptors.Add(CreateDescriptor(
ArtifactDocumentType.ImageBom,
ArtifactDocumentFormat.SpdxJson,
composition.SpdxInventory.JsonMediaType,
composition.SpdxInventory.JsonBytes,
composition.SpdxInventory.JsonSha256,
SbomView.Inventory));
}
descriptors.Add(CreateDescriptor(ArtifactDocumentType.Index, ArtifactDocumentFormat.BomIndex, "application/vnd.stellaops.bom-index.v1+binary", bomIndex.Bytes, bomIndex.Sha256, null));
descriptors.Add(CreateDescriptor(

View File

@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using CycloneDX.Models;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Spdx.Models;
namespace StellaOps.Scanner.Emit.Spdx.Conversion;
public sealed record SpdxConversionOptions
{
public string NamespaceBase { get; init; } = "https://stellaops.io/spdx";
}
public static class SpdxCycloneDxConverter
{
public static SpdxDocument FromCycloneDx(Bom bom, SpdxConversionOptions? options = null)
{
ArgumentNullException.ThrowIfNull(bom);
options ??= new SpdxConversionOptions();
var basis = bom.SerialNumber ?? bom.Metadata?.Component?.BomRef ?? "cyclonedx";
var namespaceHash = ScannerIdentifiers.CreateDeterministicHash(basis);
var creationInfo = new SpdxCreationInfo
{
Created = bom.Metadata?.Timestamp is { } timestamp
? new DateTimeOffset(timestamp, TimeSpan.Zero)
: ScannerTimestamps.UtcNow(),
Creators = ImmutableArray.Create("Tool: CycloneDX")
};
var idBuilder = new SpdxIdBuilder(options.NamespaceBase, namespaceHash);
var documentNamespace = idBuilder.DocumentNamespace;
var rootComponent = bom.Metadata?.Component;
var rootPackage = rootComponent is null
? new SpdxPackage
{
SpdxId = idBuilder.CreatePackageId("root"),
Name = "root",
DownloadLocation = "NOASSERTION",
PrimaryPurpose = "application"
}
: MapComponent(rootComponent, idBuilder);
var packages = new List<SpdxPackage> { rootPackage };
if (bom.Components is not null)
{
packages.AddRange(bom.Components.Select(component => MapComponent(component, idBuilder)));
}
var sbom = new SpdxSbom
{
SpdxId = idBuilder.SbomId,
Name = "software-sbom",
RootElements = ImmutableArray.Create(rootPackage.SpdxId),
Elements = packages.Select(package => package.SpdxId).OrderBy(id => id, StringComparer.Ordinal).ToImmutableArray(),
SbomTypes = ImmutableArray.Create("build")
};
var relationships = BuildRelationshipsFromCycloneDx(bom, idBuilder, packages);
return new SpdxDocument
{
DocumentNamespace = documentNamespace,
Name = "SPDX converted from CycloneDX",
CreationInfo = creationInfo,
Sbom = sbom,
Elements = packages.Cast<SpdxElement>().ToImmutableArray(),
Relationships = relationships
};
}
public static Bom ToCycloneDx(SpdxDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var rootId = document.Sbom.RootElements.FirstOrDefault();
var packages = document.Elements.OfType<SpdxPackage>().ToList();
var rootPackage = packages.FirstOrDefault(pkg => string.Equals(pkg.SpdxId, rootId, StringComparison.Ordinal))
?? packages.FirstOrDefault();
var bom = new Bom
{
SpecVersion = SpecificationVersion.v1_7,
Version = 1,
Metadata = new Metadata
{
Timestamp = document.CreationInfo.Created.UtcDateTime,
Component = rootPackage is null ? null : MapPackage(rootPackage)
}
};
bom.Components = packages
.Where(pkg => rootPackage is null || !string.Equals(pkg.SpdxId, rootPackage.SpdxId, StringComparison.Ordinal))
.Select(MapPackage)
.ToList();
bom.Dependencies = BuildDependenciesFromSpdx(document, packages);
return bom;
}
private static SpdxPackage MapComponent(Component component, SpdxIdBuilder idBuilder)
{
return new SpdxPackage
{
SpdxId = idBuilder.CreatePackageId(component.BomRef ?? component.Name ?? "component"),
Name = component.Name ?? component.BomRef ?? "component",
Version = component.Version,
PackageUrl = component.Purl,
DownloadLocation = "NOASSERTION",
PrimaryPurpose = component.Type.ToString().Replace("_", "-", StringComparison.Ordinal).ToLowerInvariant()
};
}
private static Component MapPackage(SpdxPackage package)
{
return new Component
{
BomRef = package.SpdxId,
Name = package.Name ?? package.SpdxId,
Version = package.Version,
Purl = package.PackageUrl,
Type = Component.Classification.Library
};
}
private static ImmutableArray<SpdxRelationship> BuildRelationshipsFromCycloneDx(
Bom bom,
SpdxIdBuilder idBuilder,
IReadOnlyList<SpdxPackage> packages)
{
var packageMap = packages.ToDictionary(pkg => pkg.SpdxId, StringComparer.Ordinal);
var relationships = new List<SpdxRelationship>();
if (bom.Dependencies is null)
{
return ImmutableArray<SpdxRelationship>.Empty;
}
foreach (var dependency in bom.Dependencies)
{
if (dependency.Dependencies is null || dependency.Ref is null)
{
continue;
}
foreach (var target in dependency.Dependencies.Where(dep => dep.Ref is not null))
{
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(dependency.Ref, "dependsOn", target.Ref!),
FromElement = dependency.Ref,
Type = SpdxRelationshipType.DependsOn,
ToElements = ImmutableArray.Create(target.Ref!)
});
}
}
return relationships.ToImmutableArray();
}
private static List<Dependency>? BuildDependenciesFromSpdx(
SpdxDocument document,
IReadOnlyList<SpdxPackage> packages)
{
var dependencies = new List<Dependency>();
var packageIds = packages.Select(pkg => pkg.SpdxId).ToHashSet(StringComparer.Ordinal);
foreach (var relationship in document.Relationships
.Where(rel => rel.Type == SpdxRelationshipType.DependsOn))
{
if (!packageIds.Contains(relationship.FromElement))
{
continue;
}
var targets = relationship.ToElements.Where(packageIds.Contains).ToList();
if (targets.Count == 0)
{
continue;
}
dependencies.Add(new Dependency
{
Ref = relationship.FromElement,
Dependencies = targets.Select(target => new Dependency { Ref = target }).ToList()
});
}
return dependencies.Count == 0 ? null : dependencies;
}
}

View File

@@ -0,0 +1,35 @@
namespace StellaOps.Scanner.Emit.Spdx.Models;
public abstract record SpdxLicenseExpression;
public sealed record SpdxSimpleLicense(string LicenseId) : SpdxLicenseExpression;
public sealed record SpdxConjunctiveLicense(
SpdxLicenseExpression Left,
SpdxLicenseExpression Right) : SpdxLicenseExpression;
public sealed record SpdxDisjunctiveLicense(
SpdxLicenseExpression Left,
SpdxLicenseExpression Right) : SpdxLicenseExpression;
public sealed record SpdxWithException(
SpdxLicenseExpression License,
string Exception) : SpdxLicenseExpression;
public sealed record SpdxNoneLicense : SpdxLicenseExpression
{
public static SpdxNoneLicense Instance { get; } = new();
private SpdxNoneLicense()
{
}
}
public sealed record SpdxNoAssertionLicense : SpdxLicenseExpression
{
public static SpdxNoAssertionLicense Instance { get; } = new();
private SpdxNoAssertionLicense()
{
}
}

View File

@@ -0,0 +1,406 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Emit.Spdx.Models;
public enum SpdxLicenseListVersion
{
V3_21
}
public sealed record SpdxLicenseList
{
public required string Version { get; init; }
public required ImmutableHashSet<string> LicenseIds { get; init; }
public required ImmutableHashSet<string> ExceptionIds { get; init; }
}
public static class SpdxLicenseListProvider
{
private const string LicenseResource = "StellaOps.Scanner.Emit.Spdx.Resources.spdx-license-list-3.21.json";
private const string ExceptionResource = "StellaOps.Scanner.Emit.Spdx.Resources.spdx-license-exceptions-3.21.json";
private static readonly Lazy<SpdxLicenseList> LicenseListV321 = new(LoadV321);
public static SpdxLicenseList Get(SpdxLicenseListVersion version)
=> version switch
{
SpdxLicenseListVersion.V3_21 => LicenseListV321.Value,
_ => LicenseListV321.Value,
};
private static SpdxLicenseList LoadV321()
{
var assembly = Assembly.GetExecutingAssembly();
var licenseIds = LoadLicenseIds(assembly, LicenseResource, "licenses", "licenseId");
var exceptionIds = LoadLicenseIds(assembly, ExceptionResource, "exceptions", "licenseExceptionId");
return new SpdxLicenseList
{
Version = "3.21",
LicenseIds = licenseIds,
ExceptionIds = exceptionIds,
};
}
private static ImmutableHashSet<string> LoadLicenseIds(
Assembly assembly,
string resourceName,
string arrayProperty,
string idProperty)
{
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Missing embedded resource: {resourceName}");
using var document = JsonDocument.Parse(stream);
if (!document.RootElement.TryGetProperty(arrayProperty, out var array) ||
array.ValueKind != JsonValueKind.Array)
{
return ImmutableHashSet<string>.Empty;
}
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
foreach (var entry in array.EnumerateArray())
{
if (entry.TryGetProperty(idProperty, out var idElement) &&
idElement.ValueKind == JsonValueKind.String &&
idElement.GetString() is { Length: > 0 } id)
{
builder.Add(id);
}
}
return builder.ToImmutable();
}
}
public static class SpdxLicenseExpressionParser
{
public static bool TryParse(string expression, out SpdxLicenseExpression? result, SpdxLicenseList? licenseList = null)
{
result = null;
if (string.IsNullOrWhiteSpace(expression))
{
return false;
}
try
{
result = Parse(expression, licenseList);
return true;
}
catch (FormatException)
{
return false;
}
}
public static SpdxLicenseExpression Parse(string expression, SpdxLicenseList? licenseList = null)
{
if (string.IsNullOrWhiteSpace(expression))
{
throw new FormatException("License expression is empty.");
}
var tokens = Tokenize(expression);
var parser = new Parser(tokens);
var parsed = parser.ParseExpression();
if (parser.HasMoreTokens)
{
throw new FormatException("Unexpected trailing tokens in license expression.");
}
if (licenseList is not null)
{
Validate(parsed, licenseList);
}
return parsed;
}
private static void Validate(SpdxLicenseExpression expression, SpdxLicenseList list)
{
switch (expression)
{
case SpdxSimpleLicense simple:
if (IsSpecial(simple.LicenseId) || IsLicenseRef(simple.LicenseId))
{
return;
}
if (!list.LicenseIds.Contains(simple.LicenseId))
{
throw new FormatException($"Unknown SPDX license identifier: {simple.LicenseId}");
}
break;
case SpdxWithException withException:
Validate(withException.License, list);
if (!list.ExceptionIds.Contains(withException.Exception))
{
throw new FormatException($"Unknown SPDX license exception: {withException.Exception}");
}
break;
case SpdxConjunctiveLicense conjunctive:
Validate(conjunctive.Left, list);
Validate(conjunctive.Right, list);
break;
case SpdxDisjunctiveLicense disjunctive:
Validate(disjunctive.Left, list);
Validate(disjunctive.Right, list);
break;
case SpdxNoneLicense:
case SpdxNoAssertionLicense:
break;
default:
throw new FormatException("Unsupported SPDX license expression node.");
}
}
private static bool IsSpecial(string licenseId)
=> string.Equals(licenseId, "NONE", StringComparison.Ordinal)
|| string.Equals(licenseId, "NOASSERTION", StringComparison.Ordinal);
private static bool IsLicenseRef(string licenseId)
=> licenseId.StartsWith("LicenseRef-", StringComparison.Ordinal)
|| licenseId.StartsWith("DocumentRef-", StringComparison.Ordinal);
private static List<Token> Tokenize(string expression)
{
var tokens = new List<Token>();
var buffer = new StringBuilder();
void Flush()
{
if (buffer.Length == 0)
{
return;
}
var value = buffer.ToString();
buffer.Clear();
tokens.Add(Token.From(value));
}
foreach (var ch in expression)
{
switch (ch)
{
case '(':
Flush();
tokens.Add(new Token(TokenType.OpenParen, "("));
break;
case ')':
Flush();
tokens.Add(new Token(TokenType.CloseParen, ")"));
break;
default:
if (char.IsWhiteSpace(ch))
{
Flush();
}
else
{
buffer.Append(ch);
}
break;
}
}
Flush();
return tokens;
}
private sealed class Parser
{
private readonly IReadOnlyList<Token> _tokens;
private int _index;
public Parser(IReadOnlyList<Token> tokens)
{
_tokens = tokens;
}
public bool HasMoreTokens => _index < _tokens.Count;
public SpdxLicenseExpression ParseExpression()
{
var left = ParseWith();
while (TryMatch(TokenType.And, out _) || TryMatch(TokenType.Or, out var op))
{
var right = ParseWith();
left = op!.Type == TokenType.And
? new SpdxConjunctiveLicense(left, right)
: new SpdxDisjunctiveLicense(left, right);
}
return left;
}
private SpdxLicenseExpression ParseWith()
{
var left = ParsePrimary();
if (TryMatch(TokenType.With, out var withToken))
{
var exception = Expect(TokenType.Identifier);
left = new SpdxWithException(left, exception.Value);
}
return left;
}
private SpdxLicenseExpression ParsePrimary()
{
if (TryMatch(TokenType.OpenParen, out _))
{
var inner = ParseExpression();
Expect(TokenType.CloseParen);
return inner;
}
var token = Expect(TokenType.Identifier);
if (string.Equals(token.Value, "NONE", StringComparison.OrdinalIgnoreCase))
{
return SpdxNoneLicense.Instance;
}
if (string.Equals(token.Value, "NOASSERTION", StringComparison.OrdinalIgnoreCase))
{
return SpdxNoAssertionLicense.Instance;
}
return new SpdxSimpleLicense(token.Value);
}
private bool TryMatch(TokenType type, out Token? token)
{
token = null;
if (_index >= _tokens.Count)
{
return false;
}
var candidate = _tokens[_index];
if (candidate.Type != type)
{
return false;
}
_index++;
token = candidate;
return true;
}
private Token Expect(TokenType type)
{
if (_index >= _tokens.Count)
{
throw new FormatException($"Expected {type} but reached end of expression.");
}
var token = _tokens[_index++];
if (token.Type != type)
{
throw new FormatException($"Expected {type} but found {token.Type}.");
}
return token;
}
}
private sealed record Token(TokenType Type, string Value)
{
public static Token From(string value)
{
var normalized = value.Trim();
if (string.Equals(normalized, "AND", StringComparison.OrdinalIgnoreCase))
{
return new Token(TokenType.And, "AND");
}
if (string.Equals(normalized, "OR", StringComparison.OrdinalIgnoreCase))
{
return new Token(TokenType.Or, "OR");
}
if (string.Equals(normalized, "WITH", StringComparison.OrdinalIgnoreCase))
{
return new Token(TokenType.With, "WITH");
}
return new Token(TokenType.Identifier, normalized);
}
}
private enum TokenType
{
Identifier,
And,
Or,
With,
OpenParen,
CloseParen
}
}
public static class SpdxLicenseExpressionRenderer
{
public static string Render(SpdxLicenseExpression expression)
{
return RenderInternal(expression, parentOperator: null);
}
private static string RenderInternal(SpdxLicenseExpression expression, SpdxBinaryOperator? parentOperator)
{
switch (expression)
{
case SpdxSimpleLicense simple:
return simple.LicenseId;
case SpdxNoneLicense:
return "NONE";
case SpdxNoAssertionLicense:
return "NOASSERTION";
case SpdxWithException withException:
var licenseText = RenderInternal(withException.License, parentOperator: null);
return $"{licenseText} WITH {withException.Exception}";
case SpdxConjunctiveLicense conjunctive:
return RenderBinary(conjunctive.Left, conjunctive.Right, "AND", SpdxBinaryOperator.And, parentOperator);
case SpdxDisjunctiveLicense disjunctive:
return RenderBinary(disjunctive.Left, disjunctive.Right, "OR", SpdxBinaryOperator.Or, parentOperator);
default:
throw new InvalidOperationException("Unsupported SPDX license expression node.");
}
}
private static string RenderBinary(
SpdxLicenseExpression left,
SpdxLicenseExpression right,
string op,
SpdxBinaryOperator current,
SpdxBinaryOperator? parent)
{
var leftText = RenderInternal(left, current);
var rightText = RenderInternal(right, current);
var text = $"{leftText} {op} {rightText}";
if (parent.HasValue && parent.Value != current)
{
return $"({text})";
}
return text;
}
private enum SpdxBinaryOperator
{
And,
Or
}
}

View File

@@ -0,0 +1,204 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Scanner.Emit.Spdx.Models;
public static class SpdxDefaults
{
public const string SpecVersion = "3.0.1";
public const string JsonLdContext = "https://spdx.org/rdf/3.0.1/spdx-context.jsonld";
public const string DocumentType = "SpdxDocument";
public const string SbomType = "software_Sbom";
public const string PackageType = "software_Package";
public const string FileType = "software_File";
public const string SnippetType = "software_Snippet";
public const string RelationshipType = "Relationship";
}
public sealed record SpdxDocument
{
public required string DocumentNamespace { get; init; }
public required string Name { get; init; }
public required SpdxCreationInfo CreationInfo { get; init; }
public required SpdxSbom Sbom { get; init; }
public ImmutableArray<SpdxElement> Elements { get; init; } = ImmutableArray<SpdxElement>.Empty;
public ImmutableArray<SpdxRelationship> Relationships { get; init; } = ImmutableArray<SpdxRelationship>.Empty;
public ImmutableArray<SpdxAnnotation> Annotations { get; init; } = ImmutableArray<SpdxAnnotation>.Empty;
public ImmutableArray<string> ProfileConformance { get; init; } = ImmutableArray<string>.Empty;
public string SpecVersion { get; init; } = SpdxDefaults.SpecVersion;
}
public sealed record SpdxCreationInfo
{
public required DateTimeOffset Created { get; init; }
public ImmutableArray<string> Creators { get; init; } = ImmutableArray<string>.Empty;
public ImmutableArray<string> CreatedUsing { get; init; } = ImmutableArray<string>.Empty;
public string SpecVersion { get; init; } = SpdxDefaults.SpecVersion;
}
public abstract record SpdxElement
{
public required string SpdxId { get; init; }
public string? Name { get; init; }
public string? Summary { get; init; }
public string? Description { get; init; }
public string? Comment { get; init; }
}
public sealed record SpdxSbom : SpdxElement
{
public ImmutableArray<string> RootElements { get; init; } = ImmutableArray<string>.Empty;
public ImmutableArray<string> Elements { get; init; } = ImmutableArray<string>.Empty;
public ImmutableArray<string> SbomTypes { get; init; } = ImmutableArray<string>.Empty;
}
public sealed record SpdxPackage : SpdxElement
{
public string? Version { get; init; }
public string? PackageUrl { get; init; }
public string? DownloadLocation { get; init; }
public string? PrimaryPurpose { get; init; }
public SpdxLicenseExpression? DeclaredLicense { get; init; }
public SpdxLicenseExpression? ConcludedLicense { get; init; }
public string? CopyrightText { get; init; }
public ImmutableArray<SpdxChecksum> Checksums { get; init; } = ImmutableArray<SpdxChecksum>.Empty;
public ImmutableArray<SpdxExternalRef> ExternalRefs { get; init; } = ImmutableArray<SpdxExternalRef>.Empty;
public SpdxPackageVerificationCode? VerificationCode { get; init; }
}
public sealed record SpdxFile : SpdxElement
{
public string? FileName { get; init; }
public SpdxLicenseExpression? ConcludedLicense { get; init; }
public string? CopyrightText { get; init; }
public ImmutableArray<SpdxChecksum> Checksums { get; init; } = ImmutableArray<SpdxChecksum>.Empty;
}
public sealed record SpdxSnippet : SpdxElement
{
public required string FromFileSpdxId { get; init; }
public long? ByteRangeStart { get; init; }
public long? ByteRangeEnd { get; init; }
public long? LineRangeStart { get; init; }
public long? LineRangeEnd { get; init; }
}
public sealed record SpdxRelationship
{
public required string SpdxId { get; init; }
public required string FromElement { get; init; }
public required SpdxRelationshipType Type { get; init; }
public required ImmutableArray<string> ToElements { get; init; }
}
public enum SpdxRelationshipType
{
Describes,
DependsOn,
Contains,
ContainedBy,
Other
}
public sealed record SpdxAnnotation
{
public required string SpdxId { get; init; }
public required string Annotator { get; init; }
public required DateTimeOffset AnnotatedAt { get; init; }
public required string AnnotationType { get; init; }
public required string Comment { get; init; }
}
public sealed record SpdxChecksum
{
public required string Algorithm { get; init; }
public required string Value { get; init; }
}
public sealed record SpdxExternalRef
{
public required string Category { get; init; }
public required string Type { get; init; }
public required string Locator { get; init; }
}
public sealed record SpdxPackageVerificationCode
{
public required string Value { get; init; }
public ImmutableArray<string> ExcludedFiles { get; init; } = ImmutableArray<string>.Empty;
}
public sealed record SpdxExtractedLicense
{
public required string LicenseId { get; init; }
public string? Name { get; init; }
public string? Text { get; init; }
public ImmutableArray<string> References { get; init; } = ImmutableArray<string>.Empty;
}
public sealed record SpdxVulnerability : SpdxElement
{
public string? Locator { get; init; }
public string? StatusNotes { get; init; }
public DateTimeOffset? PublishedTime { get; init; }
public DateTimeOffset? ModifiedTime { get; init; }
}
public sealed record SpdxVulnAssessment : SpdxElement
{
public string? Severity { get; init; }
public string? VectorString { get; init; }
public string? Score { get; init; }
}

View File

@@ -0,0 +1,643 @@
{
"licenseListVersion": "3.21",
"exceptions": [
{
"reference": "./389-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./389-exception.html",
"referenceNumber": 48,
"name": "389 Directory Server Exception",
"licenseExceptionId": "389-exception",
"seeAlso": [
"http://directory.fedoraproject.org/wiki/GPL_Exception_License_Text",
"https://web.archive.org/web/20080828121337/http://directory.fedoraproject.org/wiki/GPL_Exception_License_Text"
]
},
{
"reference": "./Asterisk-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Asterisk-exception.html",
"referenceNumber": 33,
"name": "Asterisk exception",
"licenseExceptionId": "Asterisk-exception",
"seeAlso": [
"https://github.com/asterisk/libpri/blob/7f91151e6bd10957c746c031c1f4a030e8146e9a/pri.c#L22",
"https://github.com/asterisk/libss7/blob/03e81bcd0d28ff25d4c77c78351ddadc82ff5c3f/ss7.c#L24"
]
},
{
"reference": "./Autoconf-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Autoconf-exception-2.0.html",
"referenceNumber": 42,
"name": "Autoconf exception 2.0",
"licenseExceptionId": "Autoconf-exception-2.0",
"seeAlso": [
"http://ac-archive.sourceforge.net/doc/copyright.html",
"http://ftp.gnu.org/gnu/autoconf/autoconf-2.59.tar.gz"
]
},
{
"reference": "./Autoconf-exception-3.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Autoconf-exception-3.0.html",
"referenceNumber": 41,
"name": "Autoconf exception 3.0",
"licenseExceptionId": "Autoconf-exception-3.0",
"seeAlso": [
"http://www.gnu.org/licenses/autoconf-exception-3.0.html"
]
},
{
"reference": "./Autoconf-exception-generic.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Autoconf-exception-generic.html",
"referenceNumber": 4,
"name": "Autoconf generic exception",
"licenseExceptionId": "Autoconf-exception-generic",
"seeAlso": [
"https://launchpad.net/ubuntu/precise/+source/xmltooling/+copyright",
"https://tracker.debian.org/media/packages/s/sipwitch/copyright-1.9.15-3",
"https://opensource.apple.com/source/launchd/launchd-258.1/launchd/compile.auto.html"
]
},
{
"reference": "./Autoconf-exception-macro.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Autoconf-exception-macro.html",
"referenceNumber": 19,
"name": "Autoconf macro exception",
"licenseExceptionId": "Autoconf-exception-macro",
"seeAlso": [
"https://github.com/freedesktop/xorg-macros/blob/39f07f7db58ebbf3dcb64a2bf9098ed5cf3d1223/xorg-macros.m4.in",
"https://www.gnu.org/software/autoconf-archive/ax_pthread.html",
"https://launchpad.net/ubuntu/precise/+source/xmltooling/+copyright"
]
},
{
"reference": "./Bison-exception-2.2.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Bison-exception-2.2.html",
"referenceNumber": 11,
"name": "Bison exception 2.2",
"licenseExceptionId": "Bison-exception-2.2",
"seeAlso": [
"http://git.savannah.gnu.org/cgit/bison.git/tree/data/yacc.c?id\u003d193d7c7054ba7197b0789e14965b739162319b5e#n141"
]
},
{
"reference": "./Bootloader-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Bootloader-exception.html",
"referenceNumber": 50,
"name": "Bootloader Distribution Exception",
"licenseExceptionId": "Bootloader-exception",
"seeAlso": [
"https://github.com/pyinstaller/pyinstaller/blob/develop/COPYING.txt"
]
},
{
"reference": "./Classpath-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Classpath-exception-2.0.html",
"referenceNumber": 36,
"name": "Classpath exception 2.0",
"licenseExceptionId": "Classpath-exception-2.0",
"seeAlso": [
"http://www.gnu.org/software/classpath/license.html",
"https://fedoraproject.org/wiki/Licensing/GPL_Classpath_Exception"
]
},
{
"reference": "./CLISP-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./CLISP-exception-2.0.html",
"referenceNumber": 9,
"name": "CLISP exception 2.0",
"licenseExceptionId": "CLISP-exception-2.0",
"seeAlso": [
"http://sourceforge.net/p/clisp/clisp/ci/default/tree/COPYRIGHT"
]
},
{
"reference": "./cryptsetup-OpenSSL-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./cryptsetup-OpenSSL-exception.html",
"referenceNumber": 39,
"name": "cryptsetup OpenSSL exception",
"licenseExceptionId": "cryptsetup-OpenSSL-exception",
"seeAlso": [
"https://gitlab.com/cryptsetup/cryptsetup/-/blob/main/COPYING",
"https://gitlab.nic.cz/datovka/datovka/-/blob/develop/COPYING",
"https://github.com/nbs-system/naxsi/blob/951123ad456bdf5ac94e8d8819342fe3d49bc002/naxsi_src/naxsi_raw.c",
"http://web.mit.edu/jgross/arch/amd64_deb60/bin/mosh"
]
},
{
"reference": "./DigiRule-FOSS-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./DigiRule-FOSS-exception.html",
"referenceNumber": 20,
"name": "DigiRule FOSS License Exception",
"licenseExceptionId": "DigiRule-FOSS-exception",
"seeAlso": [
"http://www.digirulesolutions.com/drupal/foss"
]
},
{
"reference": "./eCos-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./eCos-exception-2.0.html",
"referenceNumber": 38,
"name": "eCos exception 2.0",
"licenseExceptionId": "eCos-exception-2.0",
"seeAlso": [
"http://ecos.sourceware.org/license-overview.html"
]
},
{
"reference": "./Fawkes-Runtime-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Fawkes-Runtime-exception.html",
"referenceNumber": 8,
"name": "Fawkes Runtime Exception",
"licenseExceptionId": "Fawkes-Runtime-exception",
"seeAlso": [
"http://www.fawkesrobotics.org/about/license/"
]
},
{
"reference": "./FLTK-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./FLTK-exception.html",
"referenceNumber": 18,
"name": "FLTK exception",
"licenseExceptionId": "FLTK-exception",
"seeAlso": [
"http://www.fltk.org/COPYING.php"
]
},
{
"reference": "./Font-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Font-exception-2.0.html",
"referenceNumber": 7,
"name": "Font exception 2.0",
"licenseExceptionId": "Font-exception-2.0",
"seeAlso": [
"http://www.gnu.org/licenses/gpl-faq.html#FontException"
]
},
{
"reference": "./freertos-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./freertos-exception-2.0.html",
"referenceNumber": 47,
"name": "FreeRTOS Exception 2.0",
"licenseExceptionId": "freertos-exception-2.0",
"seeAlso": [
"https://web.archive.org/web/20060809182744/http://www.freertos.org/a00114.html"
]
},
{
"reference": "./GCC-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GCC-exception-2.0.html",
"referenceNumber": 54,
"name": "GCC Runtime Library exception 2.0",
"licenseExceptionId": "GCC-exception-2.0",
"seeAlso": [
"https://gcc.gnu.org/git/?p\u003dgcc.git;a\u003dblob;f\u003dgcc/libgcc1.c;h\u003d762f5143fc6eed57b6797c82710f3538aa52b40b;hb\u003dcb143a3ce4fb417c68f5fa2691a1b1b1053dfba9#l10"
]
},
{
"reference": "./GCC-exception-3.1.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GCC-exception-3.1.html",
"referenceNumber": 27,
"name": "GCC Runtime Library exception 3.1",
"licenseExceptionId": "GCC-exception-3.1",
"seeAlso": [
"http://www.gnu.org/licenses/gcc-exception-3.1.html"
]
},
{
"reference": "./GNAT-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GNAT-exception.html",
"referenceNumber": 13,
"name": "GNAT exception",
"licenseExceptionId": "GNAT-exception",
"seeAlso": [
"https://github.com/AdaCore/florist/blob/master/libsrc/posix-configurable_file_limits.adb"
]
},
{
"reference": "./gnu-javamail-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./gnu-javamail-exception.html",
"referenceNumber": 34,
"name": "GNU JavaMail exception",
"licenseExceptionId": "gnu-javamail-exception",
"seeAlso": [
"http://www.gnu.org/software/classpathx/javamail/javamail.html"
]
},
{
"reference": "./GPL-3.0-interface-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GPL-3.0-interface-exception.html",
"referenceNumber": 21,
"name": "GPL-3.0 Interface Exception",
"licenseExceptionId": "GPL-3.0-interface-exception",
"seeAlso": [
"https://www.gnu.org/licenses/gpl-faq.en.html#LinkingOverControlledInterface"
]
},
{
"reference": "./GPL-3.0-linking-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GPL-3.0-linking-exception.html",
"referenceNumber": 1,
"name": "GPL-3.0 Linking Exception",
"licenseExceptionId": "GPL-3.0-linking-exception",
"seeAlso": [
"https://www.gnu.org/licenses/gpl-faq.en.html#GPLIncompatibleLibs"
]
},
{
"reference": "./GPL-3.0-linking-source-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GPL-3.0-linking-source-exception.html",
"referenceNumber": 37,
"name": "GPL-3.0 Linking Exception (with Corresponding Source)",
"licenseExceptionId": "GPL-3.0-linking-source-exception",
"seeAlso": [
"https://www.gnu.org/licenses/gpl-faq.en.html#GPLIncompatibleLibs",
"https://github.com/mirror/wget/blob/master/src/http.c#L20"
]
},
{
"reference": "./GPL-CC-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GPL-CC-1.0.html",
"referenceNumber": 52,
"name": "GPL Cooperation Commitment 1.0",
"licenseExceptionId": "GPL-CC-1.0",
"seeAlso": [
"https://github.com/gplcc/gplcc/blob/master/Project/COMMITMENT",
"https://gplcc.github.io/gplcc/Project/README-PROJECT.html"
]
},
{
"reference": "./GStreamer-exception-2005.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GStreamer-exception-2005.html",
"referenceNumber": 35,
"name": "GStreamer Exception (2005)",
"licenseExceptionId": "GStreamer-exception-2005",
"seeAlso": [
"https://gstreamer.freedesktop.org/documentation/frequently-asked-questions/licensing.html?gi-language\u003dc#licensing-of-applications-using-gstreamer"
]
},
{
"reference": "./GStreamer-exception-2008.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./GStreamer-exception-2008.html",
"referenceNumber": 30,
"name": "GStreamer Exception (2008)",
"licenseExceptionId": "GStreamer-exception-2008",
"seeAlso": [
"https://gstreamer.freedesktop.org/documentation/frequently-asked-questions/licensing.html?gi-language\u003dc#licensing-of-applications-using-gstreamer"
]
},
{
"reference": "./i2p-gpl-java-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./i2p-gpl-java-exception.html",
"referenceNumber": 40,
"name": "i2p GPL+Java Exception",
"licenseExceptionId": "i2p-gpl-java-exception",
"seeAlso": [
"http://geti2p.net/en/get-involved/develop/licenses#java_exception"
]
},
{
"reference": "./KiCad-libraries-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./KiCad-libraries-exception.html",
"referenceNumber": 28,
"name": "KiCad Libraries Exception",
"licenseExceptionId": "KiCad-libraries-exception",
"seeAlso": [
"https://www.kicad.org/libraries/license/"
]
},
{
"reference": "./LGPL-3.0-linking-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./LGPL-3.0-linking-exception.html",
"referenceNumber": 2,
"name": "LGPL-3.0 Linking Exception",
"licenseExceptionId": "LGPL-3.0-linking-exception",
"seeAlso": [
"https://raw.githubusercontent.com/go-xmlpath/xmlpath/v2/LICENSE",
"https://github.com/goamz/goamz/blob/master/LICENSE",
"https://github.com/juju/errors/blob/master/LICENSE"
]
},
{
"reference": "./libpri-OpenH323-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./libpri-OpenH323-exception.html",
"referenceNumber": 32,
"name": "libpri OpenH323 exception",
"licenseExceptionId": "libpri-OpenH323-exception",
"seeAlso": [
"https://github.com/asterisk/libpri/blob/1.6.0/README#L19-L22"
]
},
{
"reference": "./Libtool-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Libtool-exception.html",
"referenceNumber": 17,
"name": "Libtool Exception",
"licenseExceptionId": "Libtool-exception",
"seeAlso": [
"http://git.savannah.gnu.org/cgit/libtool.git/tree/m4/libtool.m4"
]
},
{
"reference": "./Linux-syscall-note.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Linux-syscall-note.html",
"referenceNumber": 49,
"name": "Linux Syscall Note",
"licenseExceptionId": "Linux-syscall-note",
"seeAlso": [
"https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/COPYING"
]
},
{
"reference": "./LLGPL.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./LLGPL.html",
"referenceNumber": 3,
"name": "LLGPL Preamble",
"licenseExceptionId": "LLGPL",
"seeAlso": [
"http://opensource.franz.com/preamble.html"
]
},
{
"reference": "./LLVM-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./LLVM-exception.html",
"referenceNumber": 14,
"name": "LLVM Exception",
"licenseExceptionId": "LLVM-exception",
"seeAlso": [
"http://llvm.org/foundation/relicensing/LICENSE.txt"
]
},
{
"reference": "./LZMA-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./LZMA-exception.html",
"referenceNumber": 55,
"name": "LZMA exception",
"licenseExceptionId": "LZMA-exception",
"seeAlso": [
"http://nsis.sourceforge.net/Docs/AppendixI.html#I.6"
]
},
{
"reference": "./mif-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./mif-exception.html",
"referenceNumber": 53,
"name": "Macros and Inline Functions Exception",
"licenseExceptionId": "mif-exception",
"seeAlso": [
"http://www.scs.stanford.edu/histar/src/lib/cppsup/exception",
"http://dev.bertos.org/doxygen/",
"https://www.threadingbuildingblocks.org/licensing"
]
},
{
"reference": "./Nokia-Qt-exception-1.1.json",
"isDeprecatedLicenseId": true,
"detailsUrl": "./Nokia-Qt-exception-1.1.html",
"referenceNumber": 31,
"name": "Nokia Qt LGPL exception 1.1",
"licenseExceptionId": "Nokia-Qt-exception-1.1",
"seeAlso": [
"https://www.keepassx.org/dev/projects/keepassx/repository/revisions/b8dfb9cc4d5133e0f09cd7533d15a4f1c19a40f2/entry/LICENSE.NOKIA-LGPL-EXCEPTION"
]
},
{
"reference": "./OCaml-LGPL-linking-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./OCaml-LGPL-linking-exception.html",
"referenceNumber": 29,
"name": "OCaml LGPL Linking Exception",
"licenseExceptionId": "OCaml-LGPL-linking-exception",
"seeAlso": [
"https://caml.inria.fr/ocaml/license.en.html"
]
},
{
"reference": "./OCCT-exception-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./OCCT-exception-1.0.html",
"referenceNumber": 15,
"name": "Open CASCADE Exception 1.0",
"licenseExceptionId": "OCCT-exception-1.0",
"seeAlso": [
"http://www.opencascade.com/content/licensing"
]
},
{
"reference": "./OpenJDK-assembly-exception-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./OpenJDK-assembly-exception-1.0.html",
"referenceNumber": 24,
"name": "OpenJDK Assembly exception 1.0",
"licenseExceptionId": "OpenJDK-assembly-exception-1.0",
"seeAlso": [
"http://openjdk.java.net/legal/assembly-exception.html"
]
},
{
"reference": "./openvpn-openssl-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./openvpn-openssl-exception.html",
"referenceNumber": 43,
"name": "OpenVPN OpenSSL Exception",
"licenseExceptionId": "openvpn-openssl-exception",
"seeAlso": [
"http://openvpn.net/index.php/license.html"
]
},
{
"reference": "./PS-or-PDF-font-exception-20170817.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./PS-or-PDF-font-exception-20170817.html",
"referenceNumber": 45,
"name": "PS/PDF font exception (2017-08-17)",
"licenseExceptionId": "PS-or-PDF-font-exception-20170817",
"seeAlso": [
"https://github.com/ArtifexSoftware/urw-base35-fonts/blob/65962e27febc3883a17e651cdb23e783668c996f/LICENSE"
]
},
{
"reference": "./QPL-1.0-INRIA-2004-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./QPL-1.0-INRIA-2004-exception.html",
"referenceNumber": 44,
"name": "INRIA QPL 1.0 2004 variant exception",
"licenseExceptionId": "QPL-1.0-INRIA-2004-exception",
"seeAlso": [
"https://git.frama-c.com/pub/frama-c/-/blob/master/licenses/Q_MODIFIED_LICENSE",
"https://github.com/maranget/hevea/blob/master/LICENSE"
]
},
{
"reference": "./Qt-GPL-exception-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Qt-GPL-exception-1.0.html",
"referenceNumber": 10,
"name": "Qt GPL exception 1.0",
"licenseExceptionId": "Qt-GPL-exception-1.0",
"seeAlso": [
"http://code.qt.io/cgit/qt/qtbase.git/tree/LICENSE.GPL3-EXCEPT"
]
},
{
"reference": "./Qt-LGPL-exception-1.1.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Qt-LGPL-exception-1.1.html",
"referenceNumber": 16,
"name": "Qt LGPL exception 1.1",
"licenseExceptionId": "Qt-LGPL-exception-1.1",
"seeAlso": [
"http://code.qt.io/cgit/qt/qtbase.git/tree/LGPL_EXCEPTION.txt"
]
},
{
"reference": "./Qwt-exception-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Qwt-exception-1.0.html",
"referenceNumber": 51,
"name": "Qwt exception 1.0",
"licenseExceptionId": "Qwt-exception-1.0",
"seeAlso": [
"http://qwt.sourceforge.net/qwtlicense.html"
]
},
{
"reference": "./SHL-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./SHL-2.0.html",
"referenceNumber": 26,
"name": "Solderpad Hardware License v2.0",
"licenseExceptionId": "SHL-2.0",
"seeAlso": [
"https://solderpad.org/licenses/SHL-2.0/"
]
},
{
"reference": "./SHL-2.1.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./SHL-2.1.html",
"referenceNumber": 23,
"name": "Solderpad Hardware License v2.1",
"licenseExceptionId": "SHL-2.1",
"seeAlso": [
"https://solderpad.org/licenses/SHL-2.1/"
]
},
{
"reference": "./SWI-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./SWI-exception.html",
"referenceNumber": 22,
"name": "SWI exception",
"licenseExceptionId": "SWI-exception",
"seeAlso": [
"https://github.com/SWI-Prolog/packages-clpqr/blob/bfa80b9270274f0800120d5b8e6fef42ac2dc6a5/clpqr/class.pl"
]
},
{
"reference": "./Swift-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Swift-exception.html",
"referenceNumber": 46,
"name": "Swift Exception",
"licenseExceptionId": "Swift-exception",
"seeAlso": [
"https://swift.org/LICENSE.txt",
"https://github.com/apple/swift-package-manager/blob/7ab2275f447a5eb37497ed63a9340f8a6d1e488b/LICENSE.txt#L205"
]
},
{
"reference": "./u-boot-exception-2.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./u-boot-exception-2.0.html",
"referenceNumber": 5,
"name": "U-Boot exception 2.0",
"licenseExceptionId": "u-boot-exception-2.0",
"seeAlso": [
"http://git.denx.de/?p\u003du-boot.git;a\u003dblob;f\u003dLicenses/Exceptions"
]
},
{
"reference": "./Universal-FOSS-exception-1.0.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./Universal-FOSS-exception-1.0.html",
"referenceNumber": 12,
"name": "Universal FOSS Exception, Version 1.0",
"licenseExceptionId": "Universal-FOSS-exception-1.0",
"seeAlso": [
"https://oss.oracle.com/licenses/universal-foss-exception/"
]
},
{
"reference": "./vsftpd-openssl-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./vsftpd-openssl-exception.html",
"referenceNumber": 56,
"name": "vsftpd OpenSSL exception",
"licenseExceptionId": "vsftpd-openssl-exception",
"seeAlso": [
"https://git.stg.centos.org/source-git/vsftpd/blob/f727873674d9c9cd7afcae6677aa782eb54c8362/f/LICENSE",
"https://launchpad.net/debian/squeeze/+source/vsftpd/+copyright",
"https://github.com/richardcochran/vsftpd/blob/master/COPYING"
]
},
{
"reference": "./WxWindows-exception-3.1.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./WxWindows-exception-3.1.html",
"referenceNumber": 25,
"name": "WxWindows Library Exception 3.1",
"licenseExceptionId": "WxWindows-exception-3.1",
"seeAlso": [
"http://www.opensource.org/licenses/WXwindows"
]
},
{
"reference": "./x11vnc-openssl-exception.json",
"isDeprecatedLicenseId": false,
"detailsUrl": "./x11vnc-openssl-exception.html",
"referenceNumber": 6,
"name": "x11vnc OpenSSL Exception",
"licenseExceptionId": "x11vnc-openssl-exception",
"seeAlso": [
"https://github.com/LibVNC/x11vnc/blob/master/src/8to24.c#L22"
]
}
],
"releaseDate": "2023-06-18"
}

View File

@@ -0,0 +1,413 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Spdx.Models;
namespace StellaOps.Scanner.Emit.Spdx.Serialization;
public static class SpdxJsonLdSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static byte[] Serialize(SpdxDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var creationInfoId = "_:creationinfo";
var creatorNodes = BuildCreatorNodes(document, creationInfoId, document.CreationInfo.Creators);
var createdUsingNodes = BuildCreatorNodes(document, creationInfoId, document.CreationInfo.CreatedUsing);
var createdByRefs = creatorNodes
.Select(node => node.Reference)
.Distinct(StringComparer.Ordinal)
.OrderBy(reference => reference, StringComparer.Ordinal)
.ToArray();
var createdUsingRefs = createdUsingNodes
.Select(node => node.Reference)
.Distinct(StringComparer.Ordinal)
.OrderBy(reference => reference, StringComparer.Ordinal)
.ToArray();
var creationInfo = new Dictionary<string, object?>
{
["type"] = "CreationInfo",
["@id"] = creationInfoId,
["created"] = ScannerTimestamps.ToIso8601(document.CreationInfo.Created),
["specVersion"] = document.CreationInfo.SpecVersion
};
if (createdByRefs.Length > 0)
{
creationInfo["createdBy"] = createdByRefs;
}
if (createdUsingRefs.Length > 0)
{
creationInfo["createdUsing"] = createdUsingRefs;
}
var graph = new List<object>
{
creationInfo
};
foreach (var node in creatorNodes.Concat(createdUsingNodes).Select(entry => entry.Node))
{
graph.Add(node);
}
var documentId = document.DocumentNamespace;
var elementIds = BuildElementIds(document, creatorNodes, createdUsingNodes);
var profileConformance = document.ProfileConformance.IsDefaultOrEmpty
? new[] { "core", "software" }
: document.ProfileConformance.OrderBy(value => value, StringComparer.Ordinal).ToArray();
var documentNode = new Dictionary<string, object?>
{
["type"] = SpdxDefaults.DocumentType,
["spdxId"] = documentId,
["creationInfo"] = creationInfoId,
["rootElement"] = new[] { document.Sbom.SpdxId },
["element"] = elementIds,
["profileConformance"] = profileConformance
};
graph.Add(documentNode);
var sbomElementIds = document.Elements
.OfType<SpdxElement>()
.Select(element => element.SpdxId)
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
var sbomNode = new Dictionary<string, object?>
{
["type"] = SpdxDefaults.SbomType,
["spdxId"] = document.Sbom.SpdxId,
["creationInfo"] = creationInfoId,
["rootElement"] = document.Sbom.RootElements.OrderBy(id => id, StringComparer.Ordinal).ToArray(),
["element"] = sbomElementIds,
["software_sbomType"] = document.Sbom.SbomTypes.IsDefaultOrEmpty
? new[] { "build" }
: document.Sbom.SbomTypes.OrderBy(value => value, StringComparer.Ordinal).ToArray()
};
graph.Add(sbomNode);
foreach (var element in document.Elements.OrderBy(element => element.SpdxId, StringComparer.Ordinal))
{
switch (element)
{
case SpdxPackage package:
graph.Add(BuildPackageNode(package, creationInfoId));
break;
case SpdxFile file:
graph.Add(BuildFileNode(file, creationInfoId));
break;
case SpdxSnippet snippet:
graph.Add(BuildSnippetNode(snippet, creationInfoId));
break;
case SpdxVulnerability vulnerability:
graph.Add(BuildVulnerabilityNode(vulnerability, creationInfoId));
break;
case SpdxVulnAssessment assessment:
graph.Add(BuildVulnAssessmentNode(assessment, creationInfoId));
break;
}
}
foreach (var relationship in document.Relationships.OrderBy(relationship => relationship.SpdxId, StringComparer.Ordinal))
{
graph.Add(BuildRelationshipNode(relationship, creationInfoId));
}
var root = new Dictionary<string, object?>
{
["@context"] = SpdxDefaults.JsonLdContext,
["@graph"] = graph
};
return CanonJson.Canonicalize(root, JsonOptions);
}
private static string[] BuildElementIds(
SpdxDocument document,
IEnumerable<CreatorNode> creatorNodes,
IEnumerable<CreatorNode> createdUsingNodes)
{
var ids = new HashSet<string>(StringComparer.Ordinal)
{
document.Sbom.SpdxId
};
foreach (var element in document.Elements)
{
ids.Add(element.SpdxId);
}
foreach (var relationship in document.Relationships)
{
ids.Add(relationship.SpdxId);
}
foreach (var creator in creatorNodes.Concat(createdUsingNodes))
{
if (!string.IsNullOrWhiteSpace(creator.Reference))
{
ids.Add(creator.Reference);
}
}
return ids.OrderBy(id => id, StringComparer.Ordinal).ToArray();
}
private static IReadOnlyList<CreatorNode> BuildCreatorNodes(
SpdxDocument document,
string creationInfoId,
ImmutableArray<string> creators)
{
if (creators.IsDefaultOrEmpty)
{
return Array.Empty<CreatorNode>();
}
var nodes = new List<CreatorNode>();
foreach (var entry in creators)
{
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
var parsed = ParseCreator(entry);
if (parsed is null)
{
var fallbackName = entry.Trim();
var fallbackReference = CreateCreatorId(document.DocumentNamespace, "tool", fallbackName);
nodes.Add(new CreatorNode(fallbackReference, fallbackName, new Dictionary<string, object?>
{
["type"] = "Tool",
["spdxId"] = fallbackReference,
["name"] = fallbackName,
["creationInfo"] = creationInfoId
}));
continue;
}
var (type, name) = parsed.Value;
var reference = CreateCreatorId(document.DocumentNamespace, type, name);
var node = new Dictionary<string, object?>
{
["type"] = type,
["spdxId"] = reference,
["name"] = name,
["creationInfo"] = creationInfoId
};
nodes.Add(new CreatorNode(reference, name, node));
}
return nodes
.OrderBy(node => node.Reference, StringComparer.Ordinal)
.ToArray();
}
private static (string Type, string Name)? ParseCreator(string creator)
{
var trimmed = creator.Trim();
var splitIndex = trimmed.IndexOf(':');
if (splitIndex <= 0)
{
return null;
}
var prefix = trimmed[..splitIndex].Trim();
var name = trimmed[(splitIndex + 1)..].Trim();
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
return prefix.ToLowerInvariant() switch
{
"tool" => ("Tool", name),
"organization" => ("Organization", name),
"person" => ("Person", name),
_ => null
};
}
private static string CreateCreatorId(string documentNamespace, string type, string name)
{
var normalizedType = type.Trim().ToLowerInvariant();
var normalizedName = name.Trim();
return $"{documentNamespace}#{normalizedType}-{ScannerIdentifiers.CreateDeterministicHash(documentNamespace, normalizedType, normalizedName)}";
}
private static Dictionary<string, object?> BuildPackageNode(SpdxPackage package, string creationInfoId)
{
var node = new Dictionary<string, object?>
{
["type"] = SpdxDefaults.PackageType,
["spdxId"] = package.SpdxId,
["creationInfo"] = creationInfoId,
["name"] = package.Name ?? package.SpdxId
};
AddIfValue(node, "software_packageVersion", package.Version);
AddIfValue(node, "software_packageUrl", package.PackageUrl);
if (!string.Equals(package.DownloadLocation, "NOASSERTION", StringComparison.OrdinalIgnoreCase))
{
AddIfValue(node, "software_downloadLocation", package.DownloadLocation);
}
AddIfValue(node, "software_primaryPurpose", package.PrimaryPurpose);
AddIfValue(node, "software_copyrightText", package.CopyrightText);
if (package.DeclaredLicense is not null)
{
node["simplelicensing_licenseExpression"] = SpdxLicenseExpressionRenderer.Render(package.DeclaredLicense);
}
else if (package.ConcludedLicense is not null)
{
node["simplelicensing_licenseExpression"] = SpdxLicenseExpressionRenderer.Render(package.ConcludedLicense);
}
return node;
}
private static Dictionary<string, object?> BuildFileNode(SpdxFile file, string creationInfoId)
{
var node = new Dictionary<string, object?>
{
["type"] = SpdxDefaults.FileType,
["spdxId"] = file.SpdxId,
["creationInfo"] = creationInfoId,
["name"] = file.FileName ?? file.Name ?? file.SpdxId
};
AddIfValue(node, "software_copyrightText", file.CopyrightText);
if (file.ConcludedLicense is not null)
{
node["simplelicensing_licenseExpression"] = SpdxLicenseExpressionRenderer.Render(file.ConcludedLicense);
}
return node;
}
private static Dictionary<string, object?> BuildSnippetNode(SpdxSnippet snippet, string creationInfoId)
{
var node = new Dictionary<string, object?>
{
["type"] = SpdxDefaults.SnippetType,
["spdxId"] = snippet.SpdxId,
["creationInfo"] = creationInfoId,
["name"] = snippet.Name ?? snippet.SpdxId,
["software_snippetFromFile"] = snippet.FromFileSpdxId
};
return node;
}
private static Dictionary<string, object?> BuildVulnerabilityNode(SpdxVulnerability vulnerability, string creationInfoId)
{
var node = new Dictionary<string, object?>
{
["type"] = "security_Vulnerability",
["spdxId"] = vulnerability.SpdxId,
["creationInfo"] = creationInfoId,
["name"] = vulnerability.Name ?? vulnerability.SpdxId
};
AddIfValue(node, "security_locator", vulnerability.Locator);
AddIfValue(node, "security_statusNotes", vulnerability.StatusNotes);
AddIfValue(node, "security_publishedTime", vulnerability.PublishedTime);
AddIfValue(node, "security_modifiedTime", vulnerability.ModifiedTime);
return node;
}
private static Dictionary<string, object?> BuildVulnAssessmentNode(SpdxVulnAssessment assessment, string creationInfoId)
{
var node = new Dictionary<string, object?>
{
["type"] = "security_VulnAssessmentRelationship",
["spdxId"] = assessment.SpdxId,
["creationInfo"] = creationInfoId,
["name"] = assessment.Name ?? assessment.SpdxId
};
AddIfValue(node, "security_severity", assessment.Severity);
AddIfValue(node, "security_vectorString", assessment.VectorString);
AddIfValue(node, "security_score", assessment.Score);
return node;
}
private static Dictionary<string, object?> BuildRelationshipNode(SpdxRelationship relationship, string creationInfoId)
{
var node = new Dictionary<string, object?>
{
["type"] = SpdxDefaults.RelationshipType,
["spdxId"] = relationship.SpdxId,
["creationInfo"] = creationInfoId,
["from"] = relationship.FromElement,
["relationshipType"] = RelationshipTypeToString(relationship.Type),
["to"] = relationship.ToElements.ToArray()
};
return node;
}
private static void AddIfValue(Dictionary<string, object?> node, string key, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
node[key] = value;
}
private static void AddIfValue(Dictionary<string, object?> node, string key, long? value)
{
if (!value.HasValue)
{
return;
}
node[key] = value.Value;
}
private static void AddIfValue(Dictionary<string, object?> node, string key, DateTimeOffset? value)
{
if (!value.HasValue)
{
return;
}
node[key] = ScannerTimestamps.ToIso8601(value.Value);
}
private static string RelationshipTypeToString(SpdxRelationshipType type)
=> type switch
{
SpdxRelationshipType.Describes => "describes",
SpdxRelationshipType.DependsOn => "dependsOn",
SpdxRelationshipType.Contains => "contains",
SpdxRelationshipType.ContainedBy => "containedBy",
_ => "other"
};
private sealed record CreatorNode(string Reference, string Name, Dictionary<string, object?> Node);
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Spdx.Models;
namespace StellaOps.Scanner.Emit.Spdx.Serialization;
public sealed record SpdxTagValueOptions
{
public bool IncludeFiles { get; init; }
public bool IncludeSnippets { get; init; }
}
public static class SpdxTagValueSerializer
{
public static byte[] Serialize(SpdxDocument document, SpdxTagValueOptions? options = null)
{
ArgumentNullException.ThrowIfNull(document);
options ??= new SpdxTagValueOptions();
var builder = new StringBuilder();
builder.AppendLine("SPDXVersion: SPDX-2.3");
builder.AppendLine("DataLicense: CC0-1.0");
builder.AppendLine("SPDXID: SPDXRef-DOCUMENT");
builder.AppendLine($"DocumentName: {Escape(document.Name)}");
builder.AppendLine($"DocumentNamespace: {Escape(document.DocumentNamespace)}");
foreach (var creator in document.CreationInfo.Creators
.Where(static entry => !string.IsNullOrWhiteSpace(entry))
.OrderBy(entry => entry, StringComparer.Ordinal))
{
builder.AppendLine($"Creator: {Escape(creator)}");
}
builder.AppendLine($"Created: {ScannerTimestamps.ToIso8601(document.CreationInfo.Created)}");
builder.AppendLine();
var packages = document.Elements
.OfType<SpdxPackage>()
.OrderBy(pkg => pkg.SpdxId, StringComparer.Ordinal)
.ToImmutableArray();
foreach (var package in packages)
{
builder.AppendLine($"PackageName: {Escape(package.Name ?? package.SpdxId)}");
builder.AppendLine($"SPDXID: {Escape(package.SpdxId)}");
if (!string.IsNullOrWhiteSpace(package.Version))
{
builder.AppendLine($"PackageVersion: {Escape(package.Version)}");
}
builder.AppendLine($"PackageDownloadLocation: {Escape(package.DownloadLocation ?? "NOASSERTION")}");
if (package.DeclaredLicense is not null)
{
builder.AppendLine($"PackageLicenseDeclared: {SpdxLicenseExpressionRenderer.Render(package.DeclaredLicense)}");
}
else if (package.ConcludedLicense is not null)
{
builder.AppendLine($"PackageLicenseConcluded: {SpdxLicenseExpressionRenderer.Render(package.ConcludedLicense)}");
}
if (!string.IsNullOrWhiteSpace(package.PackageUrl))
{
builder.AppendLine($"ExternalRef: PACKAGE-MANAGER purl {Escape(package.PackageUrl)}");
}
if (!string.IsNullOrWhiteSpace(package.PrimaryPurpose))
{
builder.AppendLine($"PrimaryPackagePurpose: {Escape(package.PrimaryPurpose)}");
}
builder.AppendLine();
}
foreach (var relationship in document.Relationships
.OrderBy(rel => rel.FromElement, StringComparer.Ordinal)
.ThenBy(rel => rel.Type)
.ThenBy(rel => rel.ToElements.FirstOrDefault() ?? string.Empty, StringComparer.Ordinal))
{
foreach (var target in relationship.ToElements.OrderBy(id => id, StringComparer.Ordinal))
{
builder.AppendLine($"Relationship: {Escape(relationship.FromElement)} {RelationshipTypeToTagValue(relationship.Type)} {Escape(target)}");
}
}
return Encoding.UTF8.GetBytes(builder.ToString());
}
private static string RelationshipTypeToTagValue(SpdxRelationshipType type)
=> type switch
{
SpdxRelationshipType.Describes => "DESCRIBES",
SpdxRelationshipType.DependsOn => "DEPENDS_ON",
SpdxRelationshipType.Contains => "CONTAINS",
SpdxRelationshipType.ContainedBy => "CONTAINED_BY",
_ => "OTHER"
};
private static string Escape(string value)
{
if (!value.Contains('\n', StringComparison.Ordinal) && !value.Contains('\r', StringComparison.Ordinal))
{
return value.Trim();
}
var normalized = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n');
return $"<text>{normalized}</text>";
}
}

View File

@@ -0,0 +1,45 @@
using System;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Emit.Spdx;
internal sealed class SpdxIdBuilder
{
public SpdxIdBuilder(string namespaceBase, string imageDigest)
{
if (string.IsNullOrWhiteSpace(namespaceBase))
{
throw new ArgumentException("Namespace base is required.", nameof(namespaceBase));
}
var normalizedBase = TrimTrailingSlash(namespaceBase.Trim());
var normalizedDigest = ScannerIdentifiers.NormalizeDigest(imageDigest) ?? "unknown";
var digestValue = normalizedDigest.Split(':', 2, StringSplitOptions.TrimEntries)[^1];
DocumentNamespace = $"{normalizedBase}/image/{digestValue}";
}
public string DocumentNamespace { get; }
public string DocumentId => $"{DocumentNamespace}#document";
public string SbomId => $"{DocumentNamespace}#sbom";
public string CreationInfoId => "_:creationinfo";
public string CreatePackageId(string key)
=> $"{DocumentNamespace}#pkg-{ScannerIdentifiers.CreateDeterministicHash(DocumentNamespace, "pkg", key)}";
public string CreateRelationshipId(string from, string type, string to)
=> $"{DocumentNamespace}#rel-{ScannerIdentifiers.CreateDeterministicHash(DocumentNamespace, "rel", from, type, to)}";
public string CreateToolId(string name)
=> $"{DocumentNamespace}#tool-{ScannerIdentifiers.CreateDeterministicHash(DocumentNamespace, "tool", name)}";
public string CreateOrganizationId(string name)
=> $"{DocumentNamespace}#org-{ScannerIdentifiers.CreateDeterministicHash(DocumentNamespace, "org", name)}";
private static string TrimTrailingSlash(string value)
=> string.IsNullOrWhiteSpace(value)
? string.Empty
: value.Trim().TrimEnd('/');
}

View File

@@ -14,7 +14,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CycloneDX.Core" Version="10.0.2" />
<PackageReference Include="CycloneDX.Core" Version="11.0.0" />
<PackageReference Include="RoaringBitmap" Version="0.0.9" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Spdx/Resources/spdx-license-list-3.21.json" LogicalName="StellaOps.Scanner.Emit.Spdx.Resources.spdx-license-list-3.21.json" />
<EmbeddedResource Include="Spdx/Resources/spdx-license-exceptions-3.21.json" LogicalName="StellaOps.Scanner.Emit.Spdx.Resources.spdx-license-exceptions-3.21.json" />
</ItemGroup>
</Project>

View File

@@ -3,3 +3,4 @@
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `BSE-009` | `docs/implplan/SPRINT_3500_0012_0001_binary_sbom_emission.md` | DONE | Added end-to-end integration test coverage for native binary SBOM emission (emit → fragments → CycloneDX). |
| `SPRINT-3600-0002-T1` | `docs/implplan/SPRINT_3600_0002_0001_cyclonedx_1_7_upgrade.md` | DOING | Update CycloneDX packages and defaults to 1.7. |