Safeguard
DevSecOps

You Tested a Different Artefact Than the One You Shipped

Tests pass against a build with debug assertions, development dependencies and verbose errors. You then ship something compiled differently, with a different dependency set, that behaves differently when something goes wrong.

Tomas Lindgren
Platform Engineer
5 min read

Your tests pass against a build with debug assertions, development dependencies, verbose errors and a permissive configuration. You then ship a different build, compiled with different flags, containing a different dependency set, behaving differently when something goes wrong.

The gap between those two artefacts is where a specific class of problem lives: things that are safe in the build you tested and not in the one you shipped, or present in the shipped one and never exercised.

This post is what differs and what to check. For whoever owns the release pipeline.

What is actually different

Dependencies. Development and test dependencies are excluded from a production build, which is correct. The failure is the reverse: a development-only package that ends up in the production dependency list because somebody installed it without the right flag. Now a test helper, a mock server or a debugging utility ships, and those are rarely written with hostile input in mind.

Error handling. Development returns stack traces and query text; production is supposed to return a generic message. If the switch is a single environment variable, you have one flag between an internal error message and a detailed disclosure, and nothing tests the production branch.

Debug endpoints and tooling. Profilers, hot reload, in-browser developer panels and admin utilities guarded by a build flag. The guard is usually correct and it is a condition nobody verifies after release.

Configuration defaults. Permissive settings that make development pleasant: relaxed cross-origin policy, disabled certificate validation against a local service, a seeded credential, a shortened token lifetime for convenience.

Optimisation and hardening flags. Compiled languages ship with different flags in release builds, and the security-relevant ones (stack protection, position independence, fortification) are sometimes attached to a build type nobody checked.

Source maps. Shipped to production they hand over readable source, including comments and internal names, which is a reconnaissance gift and occasionally reveals credentials in code that was never expected to be read.

The general rule

Test the artefact you will ship, not a rebuild of the same source.

That means the pipeline produces one artefact, that artefact is what runs in staging, and it is the same bytes promoted to production. If staging is rebuilt from source, you have tested a second artefact that resembles the first and you have no guarantee about the difference.

This is the same argument as pinning by digest rather than by tag, applied one step earlier.

Check what shipped

The useful checks run against the built artefact rather than the repository:

# Container: is a shell, package manager or compiler present?
docker run --rm --entrypoint sh your-image:tag -c \
  'command -v sh bash apk apt-get pip npm gcc 2>/dev/null'

# Container: what is actually installed versus what your manifest declares?
docker run --rm --entrypoint sh your-image:tag -c \
  'command -v dpkg >/dev/null && dpkg -l | wc -l'

# Web build: did source maps ship?
curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/static/main.js.map

# Web build: is the production error path actually generic?
curl -s https://app.example.com/api/does-not-exist | head -c 300

# Node: are dev dependencies present in the production install?
docker run --rm your-image:tag npm ls --omit=dev --all 2>&1 | grep -i "extraneous" | head

The source map check takes five seconds and finds something surprisingly often, because bundlers emit them by default and the deployment copies everything in the output directory.

Test the production configuration, at least once

The specific gap worth closing: a test suite that only ever runs in development configuration never exercises the code path that runs for your users.

Run a subset of your tests against the production build with production settings. Not everything, because some of it requires the permissive configuration to work at all, but enough to assert the things that differ: that errors are generic, that the debug routes return 404, that the permissive cross-origin rule is absent.

A handful of assertions, run against the real artefact, catches the flag that was flipped the wrong way.

Make the differences enumerable

The reason this class survives is that nobody can list how the two builds differ. The list lives across a build configuration, a dependency manifest, an environment file and a container definition, and no single person holds it.

Write it down once: every setting that differs between development and production, with the value in each and why. It is a page, it is the artefact a reviewer needs, and producing it usually finds two things that should not differ at all.

The concession

Some differences are necessary and good. Development should have verbose errors and hot reload, production should not, and building one artefact that behaves identically in both would be worse for everyone.

The argument is not for eliminating the differences but for knowing them and testing the production side of each. The failures here are not caused by having a production configuration, they are caused by it being the configuration nobody looks at, because looking at it is inconvenient precisely when it matters.

The implication

Everything you verified was verified against something slightly different from what your users received.

Run the source map check and the error-message check against your live site. Two commands, and they tell you whether the gap between tested and shipped is a gap you know about.

Never miss an update

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

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.