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

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

View File

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

View File

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

View File

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