Skip to content

Record NativeAOT executables and bundled macOS binaries in package SBOMs - #6

Merged
grokys merged 1 commit into
mainfrom
sbom-native-executables
Jul 23, 2026
Merged

Record NativeAOT executables and bundled macOS binaries in package SBOMs#6
grokys merged 1 commit into
mainfrom
sbom-native-executables

Conversation

@grokys

@grokys grokys commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

AddPackageContentComponents cross-checks what a package actually ships against the components derived from its dependency graph, hashing each shipped binary. It selected those binaries by extension (.dll/.so/.dylib/.wasm/.node/.a), which misses exactly the binary that matters most for a NativeAOT tool package — the application itself.

Raised in review of AvaloniaUI.DeveloperSuite#1927: "Do we need SBOM for native tools? I.e. Parcel/DevTools packages are NAOT compiled executables."

The answer is yes — and more so than for a managed package, not less: the dependencies are statically linked into the image, so a consumer cannot inspect the binary to find them. But the question exposed that the evidence was missing.

What was wrong

1. The NativeAOT executable was never recorded. It ships as app.exe on Windows and extensionless on Linux and inside the macOS .app bundle. No component, no hash — while the compositions entry still declared aggregate: "complete", claiming the enumeration was exhaustive.

2. The macOS package recorded no shipped binaries at all. Its .app bundle ships re-zipped inside the .nupkg (that is what preserves the bundle structure and the executable permission bits), so every binary it delivers lives one level down. Windows and Linux packages recorded theirs; macOS recorded nothing.

3. A latent naming bug. Path.GetFileNameWithoutExtension treats the last dotted segment as an extension, so an extensionless AvaloniaUI.DeveloperTools would have been recorded as AvaloniaUI.

What changed

  • Shipped binaries are now also classified by executable-format magic — PE, ELF, and Mach-O including the universal (fat) wrapper — which spots the extensionless form no extension list can. .exe joins the extension list as a fast path. Only the first 4 bytes of an entry are inflated, so rejecting non-binary entries stays cheap.
  • EnumerateShippedBinaries descends into nested .zip entries, reaching the macOS bundle's contents. The ref/ exclusion is now applied only at the package root, since a ref/ directory inside a bundled app is that app's own layout.
  • Names come from a new BinaryName that strips only recognised binary extensions.

Verification

No test project exists here, so this was verified with a harness compiling this file and exercising the scan against a synthetic package covering all three platform layouts.

Recorded, as expected:

Entry Case
…/AvaloniaUI.DeveloperTools.zip/AvaloniaUI DeveloperTools.app/Contents/MacOS/AvaloniaUI.DeveloperTools nested zip + Mach-O + dotted extensionless name
…/AvaloniaUI.DeveloperTools.zip/…/MacOS/libHarfBuzzSharp.dylib nested zip, ordinary native lib
…/win/AvaloniaUI.DeveloperTools.exe PE executable
…/linux/AvaloniaUI.DeveloperTools extensionless ELF
…/linux/AvaloniaUI.DeveloperTools.Universal Mach-O universal wrapper
lib/net8.0/AvaloniaUI.Core.ToolRunner.dll ordinary managed assembly
runtimes/linux-x64/native/libSkiaSharp.so ordinary native library

Still skipped: ref/net8.0/*.dll, the .nuspec, README.md, icon.png, and directory entries.

BinaryName verified on: extensionless dotted (kept whole), .exe/.so/.dll (stripped), and a dotted name with an unrecognised extension (kept whole).

No regression on the managed path: PackLibraries_DiagnosticsSupport run end to end in AvaloniaUI.DeveloperSuite against this branch — 26 components, shipped-binary hash recorded, compositions intact, unchanged from before.

Not verified: a real NativeAOT .nupkg. Building one needs AVALONIA_TOOLS_LICENSE_KEY and a full Parcel packaging run, so the AOT path rests on the synthetic harness rather than a real package. Worth a look at the SBOMs the next DevTools/Parcel release publishes.

Note

This file is kept in sync with Avalonia's nukebuild/SbomGenerator.cs — this change should be mirrored there.

🤖 Generated with Claude Code

https://claude.ai/code/session_018E1GHyH9fht5jwsWxh1zcq

AddPackageContentComponents selected shipped binaries by extension
(.dll/.so/.dylib/.wasm/.node/.a), which misses exactly the binary that matters
most for a NativeAOT tool package: the application itself. It ships as app.exe
on Windows and extensionless on Linux and inside the macOS .app bundle, so the
package recorded no component and no hash for its primary deliverable, while
the compositions entry still claimed to be a complete assembly. It matters more
here than for a managed package, not less - the dependencies are statically
linked into the image, so a consumer cannot inspect the binary to find them.

Shipped binaries are now also classified by executable-format magic (PE, ELF,
Mach-O including the universal wrapper), which spots the extensionless form
that no extension list can. .exe joins the extension list as a fast path.

The scan also descends into nested .zip entries. The macOS tool package ships
its .app bundle re-zipped inside the .nupkg to preserve the bundle structure
and the executable permission bits, so every binary it ships lives one level
down; previously that package recorded no shipped binaries at all while the
Windows and Linux packages recorded theirs.

Names now come from BinaryName rather than Path.GetFileNameWithoutExtension,
which would truncate a dotted extensionless executable - AvaloniaUI.DeveloperTools
was recorded as "AvaloniaUI" - by treating the last dotted segment as an
extension. Only recognised binary extensions are stripped.

Verified against a synthetic package covering all three platform layouts: the
zipped macOS bundle, a Windows .exe, an extensionless dotted Linux executable
and a universal binary are all recorded, while ref/, the nuspec, README, icon
and directory entries are still skipped.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves SBOM completeness for NuGet tool packages by ensuring shipped binaries are detected even when they are extensionless NativeAOT executables and when macOS .app bundles are stored inside nested .zip entries within the .nupkg.

Changes:

  • Enumerate shipped binaries by executable-format magic (PE/ELF/Mach-O) in addition to extension matching, and include .exe as a binary extension.
  • Descend into nested .zip entries to detect binaries inside re-zipped macOS .app bundles.
  • Derive component names via BinaryName, which strips only recognized binary extensions to avoid truncating dotted extensionless executable names.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread nuke/SbomGenerator.cs
Comment thread nuke/SbomGenerator.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

nuke/SbomGenerator.cs:745

  • If a nested .zip entry is corrupt/invalid, new ZipArchive(...) will throw InvalidDataException whose default message typically doesn’t identify which package entry failed. This makes SBOM failures hard to diagnose; consider catching and rethrowing with path included (still failing the build, just with better context).
            if (Path.GetExtension(entry.FullName).Equals(".zip", StringComparison.OrdinalIgnoreCase))
            {
                // Buffered into memory because ZipArchive needs a seekable stream, which the
                // deflate stream of an entry is not.
                using var nestedBytes = new MemoryStream(ReadEntry(entry));

@grokys
grokys merged commit d7bfa89 into main Jul 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants