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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user