SBOM for Swift Packages: Supply-Chain Audit as a Build Artifact

SBOM for Swift Packages: Supply-Chain Audit as a Build Artifact

⚠️ Speculative Architecture & Preview: This article discusses future system iterations (e.g., iOS 27, Xcode 27) as conceptual planning and architectural design patterns. Technical details represent previews and proposals rather than finalized APIs.

SBOM for Swift Packages: Supply-Chain Audit as a Build Artifact

$ sw_vers
ProductName:    macOS
ProductVersion: 26.4
$ swift package show-dependencies --format json | jq '.dependencies | length'
0
# …and that is the wrong number, because this command reads the
# *manifest* the way you wrote it, and the graph you actually
# shipped lives in the build — traits resolved, conditions applied,
# products pruned to what the binary needed.
$ swift build --build-system swiftbuild --sbom-spec cyclonedx
… 47 packages · 214 dependency edges · one CycloneDX document. ✓

Before Swift 6.4, a software bill of materials for a Swift project was a spreadsheet with a Package.resolved pasted into a third-party scanner, reviewed quarterly, and wrong by the end of the sprint. SwiftPM SBOM generation (SE-0509, implemented in the 6.4 toolchain) changes the frame: the inventory is now a deterministic build output — CycloneDX 1.7 or SPDX 3.0 JSON emitted by the same command that produced the binary. That is the difference between a compliance chore and a build artifact, and it is the cleanest Swift supply chain audit primitive the ecosystem has shipped.

Wireframe flow diagram showing a stack of package nodes converging through a converter block into a folded-corner document artifact, which splits into two smaller document outputs that route into a validation diamond and an archive box.

The Verdict

  • Wire SBOM generation into CI now. Enterprise customers and regulators increasingly demand provenance before they accept a binary. Emitting it per-build is a few flags; producing it retroactively after an incident means reconstructing a historical graph that may no longer resolve.
  • Accuracy is a build-graph property, not a manifest property. swift package generate-sbom alone reads the resolved package graph and does not apply build-time conditionals. Only swift build --build-system swiftbuild emits an SBOM that reflects what the artifact actually contains.
  • An SBOM that claims completeness and under-reports is a liability, not a feature. An auditor who finds an undeclared translated dependency discounts the entire document and your whole security posture with it.
  • Treat the SBOM as inventory, not attestation. Hashes and signing are still future work (tracked in the SwiftPM backlog); the document proves the dependency graph at build time, not artifact integrity at runtime.

The Spreadsheet-With-Confidence Era

The pre-6.4 tooling was not bad; it was structurally blind. External generators read Package.swift and Package.resolved, which describe what could be built. They cannot see what was built:

  • Product relationships are missing. The manifest says packages depend on packages; the SBOM needs products to depend on products, because the executable links _CryptoExtras, not the whole swift-crypto package. Third-party scanners that flatted the manifest produced inventory at package granularity and no useful dependency edges.
  • Traits and conditions are invisible. A dependency gated behind a trait or an OS-specific #if appears in the resolved graph even when the shipping build does not compile it. Scanning the manifest reports phantom components and phantom risk.
  • The spreadsheet rots. The audit that is accurate the day it is generated drifts the first time anyone runs swift package update. A quarterly review cycle is three months of known-unknowns.

The failure mode this creates is defensive but worthless: a “clean” report that nobody trusts and an incident response that starts with “reconstruct the graph we actually shipped on Tuesday.” SE-0509 exists because both of those problems are data-freshness problems, and data-freshness is solved by making the artifact the source of truth.

What SE-0509 Actually Emits

SwiftPM generates CycloneDX and SPDX documents from a three-layer pipeline: an extractor that reads the resolved modules graph and optionally the Swift Build dependency graph, converters that map it into CycloneDX 1.7 or SPDX 3.0 JSON, and a validator that checks the output against the embedded JSON schemas. Two entry points share the same flags:

# Build and emit in one step — the build graph is available, so the
# SBOM honours build-time conditionals. We started calling this in
# a post-build script with --build-system native and got a warning;
# the warning is the tool telling you the document may be lying.
swift build --build-system swiftbuild --sbom-spec cyclonedx --sbom-spec spdx

# Manifest-only inventory, no build. Deterministic and cheap, but it
# cannot know which #if-targeted dependencies were actually compiled.
swift package generate-sbom --sbom-spec cyclonedx --product MyApp

Components are annotated with the information a consumer actually needs to triage a CVE: the PURL (pkg:swift/github.com/apple/swift-asn1@1.5.0), the entity type (Swift package or product), the component type (application when it ships an executable, library otherwise), and scope (required vs test). Repository URL and commit SHA ride along as pedigree — which is what makes the document a provenance record rather than a list. A CycloneDX component reads like this:

{
  "bom-ref": "swift-crypto:Crypto",
  "name": "Crypto",
  "purl": "pkg:swift/github.com/apple/swift-crypto:Crypto@3.6.0",
  "scope": "required",
  "type": "library",
  "version": "3.6.0",
  "properties": [{ "name": "swift-entity", "value": "swift-product" }]
}

Two filters keep the document scoped to the question being asked. --sbom-filter package drops product-level entities for a quieter CVE triage surface (packages are the unit you update); --sbom-filter product keeps only what ships at runtime. The default (all) is the full graph, which is the right artifact to archive and the wrong one to eyeball.

The Accuracy Gap: Package Graph vs Build Graph

This is the detail that makes the feature either reliable or dangerous, so it is worth being explicit. Package resolution happens at swift package update, and by the time resolution is done the graph already reflects trait selection — but OS-specific and build-conditioned dependencies are only evaluated when a build runs. The SBOM command therefore has two data sources:

  • Swift Build’s dependency graph — the actual compilation units, after conditionals are applied. Only reachable through swift build --build-system swiftbuild (the Swift 6.4 default), and the only source whose output matches the artifact.
  • The resolved modules graph — complete but pre-conditional. Used by swift package generate-sbom and by a build that opts back to the native system (which emits a warning to that effect, as its last line).

There is also a documented edge case worth defending in CI: if the artifact is built, the dependency graph changes, and then an SBOM is generated, the document will not reflect the built artifact. Both the warning and the edge case point the same direction — generate the SBOM in the same invocation that produces the binary, not in a separate pipeline stage that re-resolves.

Wiring SBOM Generation Into CI

The command line is three flags, but the deployment decision is where infrastructure teams get control. SBOM generation can be forced centrally with environment variables, so security teams do not have to modify every team’s swift build invocation:

# After a successful Xcode 27 / Swift 6.4 build, emit and archive.
- name: Emit and upload SBOM(s)
  run: |
    swift build --build-system swiftbuild \
      --sbom-spec cyclonedx \
      --sbom-spec spdx \
      --sbom-output-dir .build/sboms
  env:
    SWIFTPM_BUILD_SBOM_SPEC: "cyclonedx,spdx"
    SWIFTPM_BUILD_SBOM_OUTPUT_DIR: ".build/sboms"
- name: Archive SBOM as build artifact
  uses: actions/upload-artifact@v4
  with:
    name: sboms-${{ github.sha }}
    path: .build/sboms

The CI rules that matter:

  1. Fail the build on SBOM failure. The default behavior already returns an error; --sbom-warning-only is for rollout periods when you are proving the feature, not for steady state. A silent gap in provenance is how the incident-postmortem SBOM gets invented from memory.
  2. Archive the artifact by commit SHA. Re-runs do not overwrite previous SBOMs — timestamps are appended so multi-flag builds keep all of them. That preserves history but means retention is your problem; prune on a schedule and pin exactly one validated document per shipped binary.
  3. Feed it into a vulnerability tracker (OWASP Dependency-Track is the reference consumer) keyed on PURL, so the “is dependency X vulnerable” question has a deterministic answer per build instead of a quarterly scan.
  4. Use --product for leaf apps, filters for the rest. A per-product SBOM is the honest unit for a release artifact; a package-only filter is what you want to land in a dependency update ticket.

The Hidden Cost of an SBOM That Isn’t

The honest trade-off is that the feature is a build artifact, which means it is only as good as the discipline around it. Three costs are easy to miss:

  • False assurance is the expensive failure. A manifest-derived SBOM that includes a phantom platform-specific dependency looks comprehensive; a build-graph SBOM that omits an undeclared translated dependency looks clean. Both are wrong. The edge case compounds this: generate after a re-resolve and the document silently disagrees with what you shipped. The mitigation is mechanical — same invocation, --build-system swiftbuild — but only if someone owns it in the pipeline.
  • It is inventory, not integrity. Hashes, SBOM signing, and embedding SBOM data in the binary are all tracked but unimplemented. You cannot yet prove that a given artifact corresponds to a given document, so for regulated customers treat the SBOM as the dependency-relationship truth and keep your normal code-signing and notarization story for the binary.
  • License compliance is not included yet. SwiftPM’s license identification is explicitly a future feature; the safest path is specifying the license in each package’s manifest and treating the SBOM’s component list as the hook for your existing license tooling rather than the tooling itself. And a graph of 60 packages and 350 edges takes well under 5 seconds to emit both formats — the cost that justifies not offloading this is correctness, never runtime.

The retrofit equation is the real argument. Wiring the flags in today touches one YAML file and a SWIFTPM_BUILD_SBOM_SPEC variable. Producing the same document after a supply-chain incident requires reconstructing a Package.resolved that no longer resolves, correlating it with the binary a customer is holding, and explaining to a regulator why the audit trail was a spreadsheet. The former is a five-minute PR; the latter is a forensic project. Emit it now.

When Not to Bother

The verdict is directional, so the exceptions are worth naming. A single-target app with zero dependencies generates a trivial SBOM; the value arrives only at the first transitive dependency. Prototype packages and scratch toolchains need not wire the flags at all. And swift package generate-sbom on its own is the right call only when Swift Build is unavailable — wherever a build is happening, prefer the same-invocation path. In every other case, the deterministic build output is strictly better than the spreadsheet.

Ready for more depth?

Master these concepts with our structured technical roadmap.

View Roadmap