Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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>";
|
||||
}
|
||||
}
|
||||
@@ -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('/');
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user