A cross-platform UI framework for .NET
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

740 lines
36 KiB

#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Nuke.Common.IO;
using Nuke.Common.Tooling;
using static Serilog.Log;
// Generates one CycloneDX SBOM per published NuGet package, using the already-restored
// solution (obj/project.assets.json) so component versions are the ones actually resolved
// into the build, rather than the floating PackageReference ranges GitHub's own dependency
// graph reports.
//
// Numerge (see numerge.json) merges several source projects' packed output into a handful of
// the final NuGet packages (e.g. "Avalonia" absorbs Avalonia.Base, Avalonia.Controls, the
// build tasks/analyzers, etc). That merge happens on the built .nupkg files, not via MSBuild
// ProjectReferences, so it can't be discovered by pointing CycloneDX at a single project with
// -rs/--recursive. Instead this compares the intermediate and final package sets to work out
// which source projects were folded into which final package, generates a BOM per constituent
// project, and unions their components (de-duplicated by purl) into one BOM per final package.
public static class SbomGenerator
{
public static void Generate(
Tool cycloneDx,
AbsolutePath rootDirectory,
AbsolutePath nugetRoot,
AbsolutePath nugetIntermediateRoot,
AbsolutePath numergeConfigPath,
AbsolutePath outputDirectory,
string version)
{
outputDirectory.CreateOrCleanDirectory();
// Read each final package's id exactly once here (opening/unzipping a nupkg to parse its
// nuspec isn't free) and reuse the id->path map when locating each package's own nupkg
// below, rather than re-scanning every nupkg per final package.
var finalPackagePathsById = nugetRoot.GlobFiles("*.nupkg")
.GroupBy(p => ReadPackageId((string)p))
.ToDictionary(g => g.Key, g => g.First());
var finalPackageIds = finalPackagePathsById.Keys.ToHashSet();
var intermediatePackageIds = nugetIntermediateRoot.GlobFiles("*.nupkg")
.Select(p => ReadPackageId((string)p)).Distinct();
var numerge = Numerge.MergeConfiguration.LoadFile(numergeConfigPath);
var explicitParentByChild = numerge.Packages
.SelectMany(p => p.Merge.Select(c => (Parent: p.Id, Child: c.Id)))
.ToDictionary(x => x.Child, x => x.Parent);
var mergeAllParent = numerge.Packages.FirstOrDefault(p => p.MergeAll)?.Id;
// Every final package starts out as its own sole constituent; leftover intermediate
// packages (ones that never shipped standalone) get assigned to whichever final
// package absorbed them, per numerge.json.
var constituentProjectIdsByFinalId = finalPackageIds.ToDictionary(id => id, id => new List<string> { id });
foreach (var id in intermediatePackageIds.Where(id => !finalPackageIds.Contains(id)))
{
var owner = explicitParentByChild.TryGetValue(id, out var explicitOwner) ? explicitOwner : mergeAllParent;
if (owner is not null && constituentProjectIdsByFinalId.TryGetValue(owner, out var siblings))
siblings.Add(id);
else
Warning($"SBOM: couldn't determine which published package absorbs intermediate package '{id}' - it will be missing from all generated SBOMs.");
}
foreach (var (finalId, projectIds) in constituentProjectIdsByFinalId)
GenerateForPackage(cycloneDx, rootDirectory, finalPackagePathsById, outputDirectory, version, finalId, projectIds);
}
static void GenerateForPackage(Tool cycloneDx, AbsolutePath rootDirectory,
IReadOnlyDictionary<string, AbsolutePath> finalPackagePathsById,
AbsolutePath outputDirectory, string version, string finalId, List<string> projectIds)
{
JsonObject? merged = null;
var seenComponentKeys = new HashSet<string>();
var scannedProjectDirs = new List<AbsolutePath>();
foreach (var projectId in projectIds)
{
var project = rootDirectory.GlobFiles($"src/**/{projectId}.csproj")
.Concat(rootDirectory.GlobFiles($"packages/**/{projectId}.csproj"))
.FirstOrDefault();
if (project is null)
{
Warning($"SBOM: couldn't locate source project for '{projectId}', skipping it in the SBOM for '{finalId}'.");
continue;
}
scannedProjectDirs.Add(project.Parent);
var tempBom = outputDirectory / $"_{projectId}.tmp.json";
cycloneDx(
$"\"{project}\" -o \"{outputDirectory}\" -fn \"{tempBom.Name}\" -F Json -dpr -ed -sn \"{finalId}\" -sv \"{version}\"",
workingDirectory: rootDirectory);
var doc = JsonNode.Parse(File.ReadAllText(tempBom))!.AsObject();
File.Delete(tempBom);
var components = doc["components"]?.AsArray() ?? new JsonArray();
if (merged is null)
{
merged = doc;
foreach (var component in components)
seenComponentKeys.Add(ComponentKey(component));
}
else
{
var target = merged["components"]?.AsArray() ?? (JsonArray)(merged["components"] = new JsonArray());
foreach (var component in components)
{
if (seenComponentKeys.Add(ComponentKey(component)))
target.Add(component!.DeepClone());
}
// Every constituent project's own -sn/-sv override makes its root component (and
// therefore its dependency-graph "ref") identical to the final package's, so merging
// by ref correctly unions all constituents' dependsOn edges onto that shared root
// instead of silently keeping only the first project's edges.
MergeDependencyGraph(merged, doc["dependencies"]?.AsArray() ?? new JsonArray());
}
}
if (merged is null)
{
Warning($"SBOM: no source projects could be scanned for '{finalId}', no SBOM was generated for it.");
return;
}
// cyclonedx-dotnet only sees the MSBuild/NuGet graph. Some projects also bundle a
// Bun/npm-built webapp directly into their published package (e.g. Avalonia.Browser's
// staticwebassets, Avalonia.DesignerSupport's embedded previewer) - scan those separately
// so their shipped JS dependencies aren't silently absent from the SBOM.
var rootRef = merged["metadata"]?["component"]?["bom-ref"]?.GetValue<string>();
foreach (var projectDir in scannedProjectDirs)
AddNpmComponents(merged, seenComponentKeys, projectDir, rootRef);
// The final .nupkg carries the authoritative publisher/license/repository metadata and the
// actual shipped binaries; use it to flesh out the thin root component cyclonedx-dotnet
// emits and to verify nothing ships that the dependency scan didn't already account for.
var finalNupkg = finalPackagePathsById.GetValueOrDefault(finalId);
if (finalNupkg is not null)
{
var nuspec = ReadNuspecMetadata((string)finalNupkg);
EnrichRootComponent(merged, nuspec);
AddPackageContentComponents(merged, seenComponentKeys, (string)finalNupkg, finalId, projectIds, nuspec);
}
else
{
Warning($"SBOM: couldn't find the built .nupkg for '{finalId}' - root metadata and package-content verification were skipped.");
}
// cyclonedx-dotnet leaves dependsOn edges pointing at packages it excluded as dev
// dependencies (e.g. analyzers stripped by -ed), which dangle once the component is gone
// (upstream bug CycloneDX/cyclonedx-dotnet#761, still reproducing in 6.2.0). Drop those so
// the graph only references components actually present in the SBOM.
PruneDanglingDependencyEdges(merged);
var sbomJson = merged.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(outputDirectory / $"{finalId}.{version}.cdx.json", sbomJson);
// Embed the SBOM inside the shipped .nupkg so it travels with the package, in addition to
// the standalone copy written above (which CI publishes as the SBOM artifact) - belt and
// suspenders: consumers who only ever see the package still get its bill of materials.
if (finalNupkg is not null)
EmbedSbomInPackage(finalNupkg, sbomJson);
}
// The path inside the .nupkg where the CycloneDX SBOM is embedded. Mirrors the _manifest/
// layout Microsoft.Sbom.Targets uses for its SPDX manifest, but keeps CycloneDX's recognised
// *.cdx.json filename so tools that scan for that pattern still find it once unpacked.
const string EmbeddedSbomEntryPath = "_manifest/cyclonedx/bom.cdx.json";
// Adds the generated SBOM as a new part inside the shipped package. Must run before the package
// is signed - a NuGet signature covers the whole archive, so adding a part afterwards would
// invalidate it. That holds here: these packages are signed server-side by nuget.org on push,
// which happens after this build step.
static void EmbedSbomInPackage(AbsolutePath nupkgPath, string sbomJson)
{
using var file = File.Open(nupkgPath, FileMode.Open, FileAccess.ReadWrite);
using var zip = new ZipArchive(file, ZipArchiveMode.Update);
if (zip.Entries.Any(e => e.FullName.EndsWith(".signature.p7s", StringComparison.OrdinalIgnoreCase)))
{
Warning($"SBOM: '{nupkgPath.Name}' is already signed - skipping embed so its signature stays valid.");
return;
}
// Re-embedding (e.g. a re-run over the same output) should replace, not stack duplicates.
zip.GetEntry(EmbeddedSbomEntryPath)?.Delete();
using (var entryStream = zip.CreateEntry(EmbeddedSbomEntryPath).Open())
using (var writer = new StreamWriter(entryStream))
writer.Write(sbomJson);
EnsureJsonContentTypeRegistered(zip);
}
// A .nupkg is an OPC package: every part's extension must be declared in [Content_Types].xml or
// strict OPC readers - including NuGet's own signature verification - reject the package. The
// SBOM is a .json part, so register that extension before (or as) we add it.
static void EnsureJsonContentTypeRegistered(ZipArchive zip)
{
const string contentTypesEntryName = "[Content_Types].xml";
XNamespace ns = "http://schemas.openxmlformats.org/package/2006/content-types";
var entry = zip.GetEntry(contentTypesEntryName);
if (entry is null)
return; // not a well-formed OPC package; don't fabricate one
XDocument doc;
using (var read = entry.Open())
doc = XDocument.Load(read);
var alreadyRegistered = doc.Root!.Elements(ns + "Default")
.Any(d => string.Equals((string?)d.Attribute("Extension"), "json", StringComparison.OrdinalIgnoreCase));
if (alreadyRegistered)
return;
doc.Root.Add(new XElement(ns + "Default",
new XAttribute("Extension", "json"),
new XAttribute("ContentType", "application/json")));
entry.Delete();
using var write = zip.CreateEntry(contentTypesEntryName).Open();
doc.Save(write);
}
static void PruneDanglingDependencyEdges(JsonObject merged)
{
var deps = merged["dependencies"]?.AsArray();
if (deps is null)
return;
var known = new HashSet<string>();
var rootRef = merged["metadata"]?["component"]?["bom-ref"]?.GetValue<string>();
if (rootRef is not null)
known.Add(rootRef);
foreach (var component in merged["components"]?.AsArray() ?? new JsonArray())
{
if (component?["bom-ref"]?.GetValue<string>() is { } bomRef)
known.Add(bomRef);
if (component?["purl"]?.GetValue<string>() is { } purl)
known.Add(purl);
}
foreach (var node in deps.OfType<JsonObject>())
{
var dependsOn = node["dependsOn"]?.AsArray();
if (dependsOn is null)
continue;
var kept = new JsonArray();
foreach (var edge in dependsOn)
if (known.Contains(edge!.GetValue<string>()))
kept.Add(edge.GetValue<string>());
node["dependsOn"] = kept;
}
}
static void MergeDependencyGraph(JsonObject target, JsonArray incoming)
{
var targetDeps = target["dependencies"]?.AsArray() ?? (JsonArray)(target["dependencies"] = new JsonArray());
var byRef = targetDeps.OfType<JsonObject>().ToDictionary(d => d["ref"]!.GetValue<string>());
foreach (var node in incoming.OfType<JsonObject>())
{
var nodeRef = node["ref"]!.GetValue<string>();
var dependsOn = node["dependsOn"]?.AsArray().Select(x => x!.GetValue<string>()) ?? Enumerable.Empty<string>();
if (!byRef.TryGetValue(nodeRef, out var existing))
{
existing = node.DeepClone().AsObject();
targetDeps.Add(existing);
byRef[nodeRef] = existing;
}
var existingDependsOn = existing["dependsOn"]?.AsArray() ?? (JsonArray)(existing["dependsOn"] = new JsonArray());
var seen = existingDependsOn.Select(x => x!.GetValue<string>()).ToHashSet();
foreach (var dep in dependsOn)
if (seen.Add(dep))
existingDependsOn.Add(dep);
}
}
// Scans <projectDir>/**/webapp/package.json for production "dependencies" (deliberately
// ignoring devDependencies, which never ship) and adds them - plus their transitive
// dependencies, walked through the installed node_modules - as fully-formed components:
// bom-ref, resolved version, license, and dependency-graph edges, matching the shape of the
// NuGet components cyclonedx-dotnet emits so npm packages aren't second-class SBOM entries.
// Versions come from the actually-installed node_modules (same rationale as reading
// project.assets.json rather than trusting floating ranges).
static void AddNpmComponents(JsonObject merged, HashSet<string> seenComponentKeys, AbsolutePath projectDir,
string? rootRef)
{
foreach (string packageJsonPath in projectDir.GlobFiles("**/webapp/package.json"))
{
var packageJson = JsonNode.Parse(File.ReadAllText(packageJsonPath))!.AsObject();
var nodeModules = ((AbsolutePath)packageJsonPath).Parent / "node_modules";
var dependencies = packageJson["dependencies"]?.AsObject() ?? new JsonObject();
// The webapp is bundled into the shipped package, so its direct production
// dependencies are direct dependencies of the final NuGet package.
foreach (var (name, rangeNode) in dependencies)
{
if (IsTypeOnlyPackage(name))
continue;
var purl = AddNpmComponentTree(merged, seenComponentKeys, nodeModules, name,
rangeNode!.GetValue<string>(), nodeModules);
if (rootRef is not null)
AddDependsOn(merged, rootRef, purl);
}
}
}
// Adds the component for (name, range) and, recursively, everything it depends on, returning
// its purl. The component/graph node/subtree are materialised only the first time a purl is
// seen (which also breaks any dependency cycles); repeat encounters just return the purl so
// the caller can still record its own edge to it.
static string AddNpmComponentTree(JsonObject merged, HashSet<string> seenComponentKeys,
AbsolutePath topLevelNodeModules, string name, string declaredRange, AbsolutePath parentNodeModules)
{
var (purl, componentVersion, installedDir) =
ResolveNpmComponent(name, declaredRange, parentNodeModules, topLevelNodeModules);
if (!seenComponentKeys.Add(purl))
return purl;
var installed = installedDir is not null && File.Exists(installedDir / "package.json")
? JsonNode.Parse(File.ReadAllText(installedDir / "package.json"))!.AsObject()
: null;
var component = new JsonObject
{
["type"] = "library",
["bom-ref"] = purl,
["name"] = name,
["version"] = componentVersion,
["purl"] = purl
};
// No hashes: npm's verifiable hashes live in the (binary bun) lockfile, not the installed
// tree, and a hash of the unpacked directory wouldn't be checkable against a registry.
var licenses = installed is null ? null : BuildLicenses(installed);
if (licenses is not null)
component["licenses"] = licenses;
var target = merged["components"]?.AsArray() ?? (JsonArray)(merged["components"] = new JsonArray());
target.Add(component);
var node = new JsonObject { ["ref"] = purl, ["dependsOn"] = new JsonArray() };
(merged["dependencies"]?.AsArray() ?? (JsonArray)(merged["dependencies"] = new JsonArray())).Add(node);
var childDeps = installed?["dependencies"]?.AsObject() ?? new JsonObject();
var childNodeModules = installedDir is not null ? installedDir / "node_modules" : parentNodeModules;
var dependsOn = node["dependsOn"]!.AsArray();
foreach (var (childName, childRange) in childDeps)
{
if (IsTypeOnlyPackage(childName))
continue;
var childPurl = AddNpmComponentTree(merged, seenComponentKeys, topLevelNodeModules, childName,
childRange!.GetValue<string>(), childNodeModules);
dependsOn.Add(childPurl);
}
return purl;
}
// @types/* packages are TypeScript declaration stubs: esbuild strips them at build time, so
// they're never part of the shipped bytes and don't belong in a scope-of-delivery SBOM.
static bool IsTypeOnlyPackage(string name) => name.StartsWith("@types/", StringComparison.Ordinal);
static void AddDependsOn(JsonObject merged, string fromRef, string toPurl)
{
var deps = merged["dependencies"]?.AsArray() ?? (JsonArray)(merged["dependencies"] = new JsonArray());
var node = deps.OfType<JsonObject>().FirstOrDefault(d => d["ref"]?.GetValue<string>() == fromRef);
if (node is null)
{
node = new JsonObject { ["ref"] = fromRef, ["dependsOn"] = new JsonArray() };
deps.Add(node);
}
var dependsOn = node["dependsOn"]?.AsArray() ?? (JsonArray)(node["dependsOn"] = new JsonArray());
if (!dependsOn.Any(x => x!.GetValue<string>() == toPurl))
dependsOn.Add(toPurl);
}
static JsonArray? BuildLicenses(JsonObject installedPackageJson)
{
// Modern npm: "license" is an SPDX id or expression. Legacy: "license"/"licenses" objects.
if (installedPackageJson["license"] is JsonValue licenseValue && licenseValue.TryGetValue(out string? spdx))
{
var licenses = SpdxToLicenses(spdx);
if (licenses is not null)
return licenses;
}
var legacy = (installedPackageJson["license"] as JsonObject)?["type"]?.GetValue<string>()
?? (installedPackageJson["licenses"] as JsonArray)?.OfType<JsonObject>()
.FirstOrDefault()?["type"]?.GetValue<string>();
return legacy is null
? null
: new JsonArray(new JsonObject { ["license"] = new JsonObject { ["name"] = legacy } });
}
// Turns an SPDX string into a CycloneDX licenses array: a single license id becomes a
// {license:{id}} entry, a compound SPDX expression becomes an {expression} entry.
static JsonArray? SpdxToLicenses(string? spdx)
{
if (string.IsNullOrWhiteSpace(spdx))
return null;
var isExpression = spdx.IndexOf(" OR ", StringComparison.Ordinal) >= 0
|| spdx.IndexOf(" AND ", StringComparison.Ordinal) >= 0
|| spdx.IndexOf(" WITH ", StringComparison.Ordinal) >= 0;
return new JsonArray(isExpression
? new JsonObject { ["expression"] = spdx }
: new JsonObject { ["license"] = new JsonObject { ["id"] = spdx } });
}
static (string Purl, string Version, AbsolutePath? InstalledDir) ResolveNpmComponent(
string name, string declaredRange, AbsolutePath parentNodeModules, AbsolutePath topLevelNodeModules)
{
// npm/bun hoist most packages to the top level but may nest a conflicting version under
// the depending package, so prefer the nested copy and fall back to the hoisted one.
var installedDir = new[] { parentNodeModules / name, topLevelNodeModules / name }
.FirstOrDefault(d => File.Exists(d / "package.json"));
if (declaredRange.StartsWith("github:") || declaredRange.StartsWith("git") || declaredRange.Contains("://"))
{
if (TryParseGitHubDependency(declaredRange, out var owner, out var repo, out var reference))
return ($"pkg:github/{owner}/{repo}@{reference}", reference, installedDir);
// A non-GitHub git/URL specifier (GitLab/Bitbucket, a raw tarball URL, ...). We have no
// provider-specific purl for it, so degrade to a generic component - preferring the
// version installed on disk - rather than throwing and failing the whole release over a
// single dependency we can't classify precisely.
var resolvedVersion = installedDir is not null
? JsonNode.Parse(File.ReadAllText(installedDir / "package.json"))!["version"]?.GetValue<string>()
: null;
resolvedVersion ??= declaredRange;
Warning($"SBOM: npm dependency '{name}' uses an unrecognised git/URL specifier '{declaredRange}' - recording it as a generic component with version '{resolvedVersion}'.");
return ($"pkg:generic/{EncodeNpmName(name)}@{resolvedVersion}", resolvedVersion, installedDir);
}
if (installedDir is null)
{
Warning($"SBOM: npm dependency '{name}' isn't installed near {parentNodeModules} - recording its declared range '{declaredRange}' instead of a resolved version.");
return ($"pkg:npm/{EncodeNpmName(name)}@{declaredRange}", declaredRange, null);
}
var installedVersion = JsonNode.Parse(File.ReadAllText(installedDir / "package.json"))!["version"]?.GetValue<string>();
if (installedVersion is null)
{
// package.json without a "version" is valid for private packages; don't let it NRE.
Warning($"SBOM: npm dependency '{name}' installed at {installedDir} has no version in its package.json - recording its declared range '{declaredRange}' instead.");
installedVersion = declaredRange;
}
return ($"pkg:npm/{EncodeNpmName(name)}@{installedVersion}", installedVersion, installedDir);
}
static string EncodeNpmName(string name) => name.StartsWith("@") ? $"%40{name[1..]}" : name;
static bool TryParseGitHubDependency(string spec, out string owner, out string repo, out string reference)
{
owner = repo = "";
var hashIndex = spec.IndexOf('#');
reference = hashIndex >= 0 ? spec[(hashIndex + 1)..] : "HEAD";
var withoutRef = hashIndex >= 0 ? spec[..hashIndex] : spec;
var match = Regex.Match(withoutRef, @"github(?:\.com)?[:/]+([^/]+)/([^/#]+?)(?:\.git)?$");
if (!match.Success)
return false;
owner = match.Groups[1].Value;
repo = match.Groups[2].Value;
return true;
}
// Prefer purl (or bom-ref) as the identity; both encode version. Fall back to name+version so
// distinct versions of the same package aren't collapsed into one, and to a fresh GUID when
// there's nothing identifiable to key on (treating it as unique rather than deduping blindly).
static string ComponentKey(JsonNode? component) =>
component?["purl"]?.GetValue<string>()
?? component?["bom-ref"]?.GetValue<string>()
?? (component?["name"]?.GetValue<string>() is { } name
? $"{name}@{component?["version"]?.GetValue<string>()}"
: Guid.NewGuid().ToString());
// Fills in the root component with the publisher, licence, description and repository details
// from the shipped .nuspec - cyclonedx-dotnet only emits type/name/version, which is far short
// of the manufacturer/provenance information a CRA-facing SBOM is expected to carry.
static void EnrichRootComponent(JsonObject merged, NuspecMetadata meta)
{
var component = merged["metadata"]?["component"]?.AsObject();
if (component is null)
return;
// These are shipped libraries, not applications (cyclonedx-dotnet's default type).
component["type"] = "library";
component["purl"] = $"pkg:nuget/{meta.Id}@{meta.Version}";
if (meta.Description is not null)
component["description"] = meta.Description;
if (meta.Copyright is not null)
component["copyright"] = meta.Copyright;
JsonObject? supplier = null;
if (meta.Authors is not null)
{
component["publisher"] = meta.Authors;
component["author"] = meta.Authors;
supplier = new JsonObject { ["name"] = meta.Authors };
if (meta.ProjectUrl is not null)
supplier["url"] = new JsonArray(meta.ProjectUrl);
component["supplier"] = supplier;
}
var licenses = SpdxToLicenses(meta.LicenseExpression ?? meta.LicenseId);
// A file license has no SPDX id; record it by name rather than emitting an invalid id.
if (licenses is null && meta.LicenseFile is not null)
licenses = new JsonArray(new JsonObject
{ ["license"] = new JsonObject { ["name"] = Path.GetFileName(meta.LicenseFile) } });
if (licenses is not null)
component["licenses"] = licenses;
var externalReferences = new JsonArray();
if (meta.ProjectUrl is not null)
externalReferences.Add(new JsonObject { ["url"] = meta.ProjectUrl, ["type"] = "website" });
if (meta.RepositoryUrl is not null)
externalReferences.Add(new JsonObject { ["url"] = meta.RepositoryUrl, ["type"] = "vcs" });
if (externalReferences.Count > 0)
component["externalReferences"] = externalReferences;
// Also record the manufacturer at the document level (the entity that supplied the BOM).
if (supplier is not null && merged["metadata"] is JsonObject metadata)
metadata["supplier"] = supplier.DeepClone();
}
// Cross-checks what the package actually ships against the components derived from the
// dependency graph. Avalonia's own merged modules (Numerge folds several projects' assemblies
// into one package) are recorded as manufacturer-supplied components with a verifiable SHA-512
// of the shipped bytes; any third-party binary that no restored dependency accounts for is
// added and flagged, so a future bundling regression can't silently escape the SBOM.
//
// These shipped binaries are *constituents* of the package, not dependencies of it: the package
// is made up of them (Component C bundles D and E, in CycloneDX's terms), so they're modelled as
// a CycloneDX "assembly" via a top-level `compositions` entry rather than with a dependsOn edge
// from the root - "contains" and "depends on" are different relationships, and an assembly does
// not imply a dependency. They stay flat top-level components (not nested subcomponents) so their
// per-assembly hashes remain visible to scanners that ignore nested components.
static void AddPackageContentComponents(JsonObject merged, HashSet<string> seenComponentKeys,
string nupkgPath, string finalId, List<string> constituentProjectIds, NuspecMetadata meta)
{
var productNames = new HashSet<string>(constituentProjectIds, StringComparer.OrdinalIgnoreCase) { finalId };
var representedNames = (merged["components"]?.AsArray() ?? new JsonArray())
.Select(c => c?["name"]?.GetValue<string>())
.Where(n => n is not null)
.ToHashSet(StringComparer.OrdinalIgnoreCase)!;
var supplier = meta.Authors is null ? null : new JsonObject { ["name"] = meta.Authors };
var target = merged["components"]?.AsArray() ?? (JsonArray)(merged["components"] = new JsonArray());
var rootRef = merged["metadata"]?["component"]?["bom-ref"]?.GetValue<string>();
// The bundled first-party assemblies share the package's licence; cyclonedx-dotnet only puts
// it on the root component, so carry it onto them too (same derivation as EnrichRootComponent).
var productLicenses = SpdxToLicenses(meta.LicenseExpression ?? meta.LicenseId);
if (productLicenses is null && meta.LicenseFile is not null)
productLicenses = new JsonArray(new JsonObject
{ ["license"] = new JsonObject { ["name"] = Path.GetFileName(meta.LicenseFile) } });
// bom-refs of every shipped binary added below; declared as a complete assembly at the end.
var assemblyRefs = new List<string>();
using var file = File.Open(nupkgPath, FileMode.Open, FileAccess.Read);
using var zip = new ZipArchive(file, ZipArchiveMode.Read);
foreach (var entry in zip.Entries)
{
var path = entry.FullName;
// Reference assemblies under ref/ are compile-time surface, not shipped runtime code;
// the real implementation lives under lib/ and is scanned there.
if (!IsShippedBinary(path) || path.StartsWith("ref/", StringComparison.OrdinalIgnoreCase))
continue;
var bytes = ReadEntry(entry);
var assemblyName = TryReadAssemblyName(bytes, out var assemblyVersion);
var simpleName = assemblyName ?? Path.GetFileNameWithoutExtension(path);
// Third-party binaries already represented by a NuGet/npm component need no duplicate.
if (assemblyName is not null && representedNames.Contains(simpleName))
continue;
var isProduct = productNames.Contains(simpleName)
|| simpleName.StartsWith("Avalonia.", StringComparison.OrdinalIgnoreCase)
|| simpleName.Equals("Avalonia", StringComparison.OrdinalIgnoreCase);
// The package's primary assembly is the root component itself - don't list it as its own subcomponent.
if (isProduct && simpleName.Equals(finalId, StringComparison.OrdinalIgnoreCase))
continue;
var version = assemblyVersion ?? meta.Version;
var bomRef = $"binary:{simpleName}@{version}";
if (!seenComponentKeys.Add(bomRef))
continue;
if (!isProduct)
Warning($"SBOM: package '{finalId}' ships '{path}' ({simpleName}) which no restored dependency accounts for - added from package contents, please verify its provenance.");
var component = new JsonObject
{
["type"] = "library",
["bom-ref"] = bomRef,
["name"] = simpleName,
["version"] = version,
["scope"] = "required",
["hashes"] = new JsonArray(new JsonObject
{
["alg"] = "SHA-512",
["content"] = Convert.ToHexString(SHA512.HashData(bytes))
}),
["properties"] = new JsonArray(new JsonObject
{
["name"] = "avalonia:packagePath",
["value"] = path
})
};
if (isProduct)
{
if (supplier is not null)
component["supplier"] = supplier.DeepClone();
if (productLicenses is not null)
component["licenses"] = productLicenses.DeepClone();
}
target.Add(component);
assemblyRefs.Add(bomRef);
}
// Declare the package's assembly: the root component consists of exactly these bundled
// binaries. We've read every shipped binary out of the .nupkg, so the enumeration is
// complete (third-party dependencies remain in the dependency graph, as they should).
if (assemblyRefs.Count > 0)
{
var assemblies = new JsonArray();
if (rootRef is not null)
assemblies.Add(rootRef);
foreach (var bomRef in assemblyRefs)
assemblies.Add(bomRef);
var compositions = merged["compositions"]?.AsArray()
?? (JsonArray)(merged["compositions"] = new JsonArray());
compositions.Add(new JsonObject
{
["aggregate"] = "complete",
["assemblies"] = assemblies
});
}
}
static bool IsShippedBinary(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext is ".dll" or ".so" or ".dylib" or ".wasm" or ".node" or ".a";
}
static byte[] ReadEntry(ZipArchiveEntry entry)
{
using var stream = entry.Open();
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
// Returns the managed assembly's simple name (and version), or null for native / non-managed binaries.
static string? TryReadAssemblyName(byte[] bytes, out string? version)
{
version = null;
try
{
using var pe = new PEReader(new MemoryStream(bytes));
if (!pe.HasMetadata)
return null;
var reader = pe.GetMetadataReader();
if (!reader.IsAssembly)
return null;
var assembly = reader.GetAssemblyDefinition();
version = assembly.Version.ToString();
return reader.GetString(assembly.Name);
}
catch (BadImageFormatException)
{
return null;
}
}
class NuspecMetadata
{
public string Id = "";
public string Version = "";
public string? Authors;
public string? LicenseId;
public string? LicenseExpression;
public string? LicenseFile;
public string? ProjectUrl;
public string? RepositoryUrl;
public string? Description;
public string? Copyright;
}
static NuspecMetadata ReadNuspecMetadata(string nupkgPath)
{
using var file = File.Open(nupkgPath, FileMode.Open, FileAccess.Read);
using var zip = new ZipArchive(file, ZipArchiveMode.Read);
var nuspecEntry = zip.Entries.First(e => e.FullName.EndsWith(".nuspec", StringComparison.Ordinal) && e.FullName == e.Name);
using var nuspecStream = nuspecEntry.Open();
var metadata = XDocument.Load(nuspecStream).Root!
.Elements().First(x => x.Name.LocalName == "metadata");
string? Value(string name) => metadata.Elements().FirstOrDefault(x => x.Name.LocalName == name)?.Value;
var license = metadata.Elements().FirstOrDefault(x => x.Name.LocalName == "license");
var repository = metadata.Elements().FirstOrDefault(x => x.Name.LocalName == "repository");
return new NuspecMetadata
{
Id = Value("id") ?? "",
Version = Value("version") ?? "",
Authors = Value("authors"),
// A nuspec <license> is either type="expression" (an SPDX expression) or type="file"
// (a path to a bundled licence file); only the former is a valid SPDX id/expression.
LicenseExpression = license?.Attribute("type")?.Value == "expression" ? license.Value : null,
LicenseFile = license?.Attribute("type")?.Value == "file" ? license.Value : null,
LicenseId = license?.Attribute("type")?.Value is "expression" or "file" ? null : license?.Value,
ProjectUrl = Value("projectUrl"),
RepositoryUrl = repository?.Attribute("url")?.Value,
Description = Value("description"),
Copyright = Value("copyright")
};
}
public static string ReadPackageId(string nupkgPath) => ReadNuspecMetadata(nupkgPath).Id;
}