DevSecOps

Ninja Build Supply Chain Considerations

Ninja is a low-level build tool, not a package manager. That framing matters for understanding its supply chain properties and common misconceptions.

Nayan Dey
Senior Security Engineer
7 min read

Ninja gets lumped into conversations about build systems, and that framing tends to confuse supply chain discussions. Ninja is not a build system in the sense that Bazel or Buck2 is. Ninja is a minimalist execution engine -- you point it at a build.ninja file, and it runs the commands that file declares, in dependency order, as fast as the underlying system allows. The build.ninja file itself is almost always generated by a higher-level tool: CMake, GN, Meson, gyp, or a hand-written script.

This distinction matters. Ninja does exactly what you tell it to do, no more and no less. It does not verify inputs, resolve dependencies, sandbox actions, or produce attestations. If you want any of those guarantees, they have to come from the generator or from wrapping code around Ninja. This post covers what Ninja actually does for supply chain, what gaps exist, and how teams I have worked with fill them.

What Ninja Does and Does Not Guarantee

Ninja's documented goal is speed. The build.ninja format is designed for fast parsing; the dependency tracking uses mtime comparisons (or optionally, on-disk hashes via restat = 1) to avoid redundant work. The execution model is: read the build graph, find stale nodes, run their commands in parallel up to -j limit, update the log.

Here is a minimal build.ninja snippet:

rule cc
  command = gcc -MMD -MF $out.d -c $in -o $out
  description = CC $out
  deps = gcc
  depfile = $out.d

build obj/main.o: cc src/main.c
build obj/util.o: cc src/util.c

rule link
  command = gcc $in -o $out
build bin/app: link obj/main.o obj/util.o

From a supply chain perspective, note what is absent. There is no verification that gcc is a specific version. There is no sandbox around the cc rule. There is no input hash check -- Ninja compares mtimes by default. There is no output attestation.

This is by design. Ninja's authors explicitly chose to keep the tool minimal, on the theory that generators should supply the policy. In practice, most generators (including CMake) do not supply much policy either, and you end up with a build system that happily consumes whatever is on $PATH.

The mtime Problem

Ninja's default dependency tracking uses file modification times. If src/main.c has a newer mtime than obj/main.o, Ninja rebuilds obj/main.o. This is fast and reasonable in most cases. It is also exploitable.

Two scenarios:

  1. A malicious process modifies obj/main.o in place with an earlier mtime (via touch -t). Ninja will see the object is "up to date" and will not rebuild. The next link consumes the tampered object.
  2. A developer runs git checkout on an old commit. Mtimes reset to the checkout time, which is often newer than built objects. Ninja rebuilds unnecessarily, but more concerningly, stale inputs can look fresh.

The mitigation is restat = 1 on rules where integrity matters:

rule cc
  command = gcc -c $in -o $out
  restat = 1

With restat, Ninja compares the output content against the build log after running the command. If the output is byte-identical to the previous build, Ninja does not propagate a rebuild upward. More importantly, it catches the case where something external wrote to the output file.

Better still: feed Ninja a generator that produces content-addressed outputs. CMake 3.21+ supports CMAKE_JOB_POOLS and per-rule hashing; if you pair CMake with Ninja, enable -DCMAKE_COLOR_DIAGNOSTICS=ON -DCMAKE_EXPORT_COMPILE_COMMANDS=ON and use a wrapper that verifies object hashes.

Environment Inheritance

Ninja inherits the parent process's environment unmodified. Every rule runs with the same $PATH, $LD_LIBRARY_PATH, $CC, and so on. For supply chain, this means:

  • If a developer has a malicious tool earlier in $PATH, Ninja will run it instead of the intended compiler.
  • If $LD_LIBRARY_PATH points to an unexpected directory, the compiled binaries may link against unexpected libraries.
  • If $CC is a wrapper script, that wrapper runs in every cc rule.

I once audited a codebase where a developer had installed a ccache wrapper at ~/.local/bin/gcc that quietly rewrote -O2 to -O0 for faster iteration. The CI system inherited the same $PATH setup and shipped debug-optimized binaries to production for three weeks before anyone noticed. Ninja was not the bug, but Ninja also did not have any defense against it.

The fix is to run Ninja under a sanitized environment:

env -i PATH=/usr/bin:/bin HOME=$HOME ninja -C build

Or, better, have the generator produce absolute paths to tools rather than relying on $PATH resolution. Meson does this by default; CMake can be configured to do it with -DCMAKE_C_COMPILER=/usr/bin/gcc explicitly specified.

The Generator Is the Security Boundary

Because Ninja executes what it is given, the security posture of a Ninja-based build depends almost entirely on the generator. A Chromium build using GN + Ninja inherits whatever security properties GN provides. A firmware build using CMake + Ninja inherits CMake's (weaker) properties. A Meson build using Ninja inherits Meson's.

For supply chain posture, I recommend ranking:

  1. GN + Ninja: Strong. GN's toolchain definitions are explicit, and Chromium's discipline around sandboxing and reproducibility is best-in-class.
  2. Meson + Ninja: Good. Meson enforces explicit dependency declarations and supports wrap files with SHA256 integrity.
  3. CMake + Ninja: Variable. CMake is flexible enough to be safe, but the default configurations are not security-focused.

Regardless of generator, run Ninja with --verbose periodically to see the exact command lines being executed. I have caught generator bugs where a rule was shelling out to an unpinned tool that should have been declared as an input.

Reproducibility

Ninja's execution is deterministic given deterministic inputs. The build log (.ninja_log) records timing but not content hashes. For reproducible builds, you need to stamp out the usual suspects at the generator level:

  • Timestamps in object files: use -ffile-prefix-map=$(pwd)=. (GCC 8+) and SOURCE_DATE_EPOCH=<fixed-value>.
  • Build IDs: -Wl,--build-id=none to drop them, or -Wl,--build-id=sha1 for content-addressed IDs.
  • Randomized linker behaviors: -Wl,--no-undefined-version and LC_ALL=C to stabilize ordering.

For Ninja specifically, set NINJA_STATUS="" and LC_ALL=C to ensure the status output does not leak into build logs that might influence downstream tools. This is a small thing but has bitten me when a Makefile parsed Ninja's output.

Sandboxing Strategies

Ninja has no native sandbox. Teams that need sandboxing typically wrap Ninja invocations:

  • nix develop -c ninja for Nix-based isolation.
  • bubblewrap --ro-bind / / --dev /dev --proc /proc --unshare-all -- ninja for lightweight Linux sandboxing.
  • Docker containers for coarse-grained isolation in CI.

The wrapper approach has a subtle gotcha: Ninja's -j setting spawns parallel processes inside the sandbox. Make sure the sandbox's resource limits are set for the aggregate load, not per-process.

SBOMs from Ninja-Based Builds

Ninja does not know what a dependency is in the package-manager sense. It knows about file-level dependencies via depfile. To produce an SBOM, you have to go upstream to the generator and then further to the package manager (vcpkg, Conan, system libraries).

For CMake + Ninja projects, we use cmake --graphviz to extract the target dependency graph and combine it with vcpkg export --x-json for third-party dependencies. The result goes into a CycloneDX 1.5 SBOM that Safeguard ingests.

How Safeguard Helps

Safeguard handles Ninja-built projects by ingesting the generator-level dependency graph (CMake, GN, or Meson) alongside package-manager data from vcpkg, Conan, or system package databases to produce CycloneDX 1.5 SBOMs that cover both declared dependencies and system libraries linked at build time. Policy gates can enforce that Ninja invocations run under sanitized environments and that every build.ninja command uses absolute tool paths rather than $PATH lookup. For teams producing reproducible artifacts, Safeguard verifies that Ninja's output matches expected hashes across rebuilds and flags drift tied to environment or toolchain changes. That layered approach compensates for Ninja's intentional minimalism without asking the tool to grow features it was never designed for.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.