Audit the iOS dependency supply chain on a cloud Mac

Audit the iOS dependency supply chain on a cloud Mac

A commit that built successfully yesterday may resolve to a different transitive dependency today. This is one of the hardest problems to trace in cloud Mac builds. The visible symptom may be nothing more than a compiler error, while the actual change could be a package revision, downloaded content, or a newly introduced script phase. Rerunning the job is not enough. Every build must be able to answer three questions: what was resolved, whether the content changed, and whether that change was reviewed.

Define the evidence to retain first

A dependency audit should cover both direct and transitive dependencies managed by SwiftPM and CocoaPods. Organize the evidence into three layers:

Layer Evidence Question answered
Declaration Package.swift, Podfile, Xcode project references What the team intends to include
Resolution Package.resolved, Podfile.lock Which versions or revisions were actually pinned
Build Bill of materials, file hashes, build logs What this build actually used

Package.resolved and Podfile.lock must be committed to version control. Do not ignore them in CI, and do not let the pipeline update them automatically and then continue with a release.

A lockfile is not a security verdict. It only provides stable input; reviews, hashes, and change records explain why that input is permitted to enter the build.

Work directories can persist on ZoomMini dedicated nodes, but audits must still treat files in the repository as the source of truth. The current state of a cache on one machine cannot serve as the only evidence.

Use a fixed resolution directory and resolve from a clean state

First, place the SwiftPM download directory inside the workspace so different runners do not use incomparable global caches. For a project that includes a workspace, run:

set -euo pipefail
ROOT="$(pwd)"
SPM_DIR="$ROOT/.audit/spm"
DERIVED_DIR="$ROOT/.audit/DerivedData"

rm -rf "$SPM_DIR" "$DERIVED_DIR"
mkdir -p "$SPM_DIR" "$DERIVED_DIR"

xcodebuild \
  -resolvePackageDependencies \
  -workspace App.xcworkspace \
  -scheme App \
  -clonedSourcePackagesDirPath "$SPM_DIR"

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -derivedDataPath "$DERIVED_DIR" \
  -clonedSourcePackagesDirPath "$SPM_DIR" \
  build

If the project only has a .xcodeproj, replace -workspace with -project. Immediately after resolution, check whether the repository was modified:

git diff --exit-code -- '**/Package.resolved' Podfile.lock

Stop the build if this command fails. Common causes include a developer updating dependencies without committing the lockfile, different tool versions rewriting the file format, or dependency constraints allowing a new transitive version.

Record the toolchain boundary

The audit record should also include xcodebuild -version, swift --version, ruby --version, and the versions of the dependency management tools. These details do not necessarily belong in the bill of materials, but they should be archived with the build logs. Otherwise, when identical lockfiles produce different resolution behavior, it is difficult to determine whether the difference came from the toolchain or the source code.

Generate a bill of materials from lockfiles

The following script scans the repository for Package.resolved files, supports the common top-level pins and object.pins structures, and records versions, branches, revisions, and lockfile hashes:

import glob
import hashlib
import json
import os

items = []

for path in glob.glob("**/Package.resolved", recursive=True):
    if "/.audit/" in f"/{path}":
        continue
    with open(path, "rb") as handle:
        raw = handle.read()
    data = json.loads(raw)
    pins = data.get("pins") or data.get("object", {}).get("pins", [])
    packages = []
    for pin in pins:
        state = pin.get("state", {})
        packages.append({
            "identity": pin.get("identity") or pin.get("package"),
            "location": pin.get("location") or pin.get("repositoryURL"),
            "version": state.get("version"),
            "branch": state.get("branch"),
            "revision": state.get("revision")
        })
    items.append({
        "file": path,
        "sha256": hashlib.sha256(raw).hexdigest(),
        "packages": sorted(packages, key=lambda item: item["identity"] or "")
    })

for path in glob.glob("**/Podfile.lock", recursive=True):
    with open(path, "rb") as handle:
        raw = handle.read()
    items.append({
        "file": path,
        "sha256": hashlib.sha256(raw).hexdigest()
    })

os.makedirs(".audit", exist_ok=True)
with open(".audit/dependency-manifest.json", "w") as handle:
    json.dump({"artifacts": items}, handle, indent=2, sort_keys=True)

After running the script, retain dependency-manifest.json as a build artifact. If the team decides to commit it to the repository, every dependency update commit must update it as well. A release job must never overwrite it silently.

Turn the diff check into a release gate

The gate should not simply reject every change. It should require each change to be explainable. One practical sequence is:

  1. Check whether declaration files and lockfiles changed together.
  2. Clear the isolated dependency directory and resolve again.
  3. Confirm that resolution did not rewrite any lockfiles.
  4. Generate a new bill of materials and compare it with the baseline.
  5. Review each new dependency’s license, maintenance source, and ability to execute scripts.
  6. After compilation and testing, retain the manifest, lockfile hashes, and toolchain versions.

Start with standard diff tools to create an automated gate:

python3 tools/dependency_manifest.py
diff -u audit-baseline/dependency-manifest.json .audit/dependency-manifest.json

When an update is permitted, do not disable the gate. Commit the new baseline on a separate branch, and ask reviewers to focus on newly added repository URLs, changes from version pins to branches, revision hash changes, and dependencies without version numbers.

Inspect script phases separately

Dependencies can execute shell scripts through build phases. When reviewing the Xcode project and generated dependency projects, verify whether any script accesses the network, reads credentials outside the workspace, modifies source directories, or writes environment variables to logs. The CI account should receive only the minimum permissions required to check out, resolve, and build the project.

Incident response and delivery checklist

If the lockfile is unchanged but the bill-of-materials hash differs, stop the release first and preserve the workspace, resolution logs, and download directory. Then determine whether files were regenerated, a dependency uses a mutable branch, content in an internal mirror was replaced, or the resolver version changed. Do not clear the cache first, because doing so destroys the most valuable forensic evidence.

Use the following checklist before routine deliveries:

  • Declaration files, lockfiles, and the bill of materials belong to the same commit.
  • Every SwiftPM dependency has an explicit version or immutable revision.
  • CocoaPods resolution matches Podfile.lock.
  • New dependencies have passed license and script-phase reviews.
  • A clean resolution produces no Git diff.
  • Build artifacts are linked to the manifest hash and toolchain versions.
  • CI does not expose unrelated tokens or private keys to dependency scripts.

The purpose of a dependency supply chain audit is not to produce a longer list. It is to establish a repeatable decision process: when inputs stay the same, the result can be verified; when inputs change, the release stops; and reviewers can see exactly where the change occurred.

Frequently asked questions

Do committed lockfiles remove the need for a dependency manifest?

No. Lockfiles pin resolution results, while a manifest normalizes names, versions, revisions, source fields, and hashes for review, retention, and comparison across builds.

Should the dependency gate run before or after the full Xcode build?

Run it after a clean dependency resolution but before the full build. This exposes uncommitted drift before the project is allowed to execute its build script phases.

What should a team do when a transitive dependency revision changes?

Pause the release, identify the parent dependency, review the revision and license, and test the new graph on an isolated branch before committing the lockfiles and audit record.

ZoomMini Cloud Mac

Choose dedicated physical nodes for builds, testing, and experiments

Two M4 configurations are available across nodes in Singapore, Tokyo, Seoul, and Hong Kong. Availability is confirmed by the result returned at checkout.

Choose a model and order