All posts
Security

Signed Is Not Safe: What an Artifact Signature Actually Proves

SolarWinds was signed. Correctly, by the vendor's own certificate, on an artifact the vendor genuinely produced. Every verification a customer could perform would have passed. Signing answers a much narrower question than most teams assume, and knowing which question changes what you build.

By Sharon Sahadevan··10 min read

"The image is signed" ends more security discussions than it should.

SolarWinds was signed. Correctly, with the vendor's own legitimate certificate, on an artifact the vendor genuinely produced and shipped through normal release channels. Up to 18,000 customers downloaded an affected version. Every signature verification any of them could have performed would have passed, because there was nothing wrong with the signature.

That is not a story about weak cryptography or poor key hygiene. It is a story about a question. Signing answers one, and most teams believe it answers a different one.

Three questions that get collapsed into one#

The word "verified" is doing too much work. There are three separable questions, answered by different mechanisms, and they fail independently.

Integrity. Has this changed since it was published? Answered by a digest.

Authenticity. Who published it? Answered by a signature.

Trustworthiness. Should I run it? Answered by policy, applied to claims about how the artifact came to exist.

A signature proves that the holder of a particular key asserted a particular digest, and that the bytes have not changed since. It proves nothing about the quality of the artifact, the intent of the signer, or whether the process that produced it was clean.

So a compromised build system signs malicious output with a completely valid signature. The signature is not lying. It is faithfully reporting that this organisation produced these bytes, which is exactly what it was designed to say and exactly the wrong thing to be reassured by.

Why the integrity mechanisms certified the compromise#

Look at where in the pipeline the attack happened.

SOURCE ──> DEPENDENCIES ──> BUILD ──> ARTIFACT ──> DEPLOY
                              │           │
                     compromise here   signature applied here

The SolarWinds source repository was not modified. Every source-stage control (code review, signed commits, branch protection, static analysis) was operating correctly and inspecting code that was never attacked. The malicious code was injected during compilation, after those controls ran, and before the signature was applied.

Which means the signature certified the compromised output. Not because anything failed, but because signing sits downstream of the build, and it attests to whatever the build produced.

This generalises into an uncomfortable rule: integrity mechanisms certify what was published, and cannot evaluate intent. Go's module checksum database, for example, permanently records the checksum first seen for a published module version, which is genuinely valuable against post-publication tampering. If an attacker publishes a backdoored version in the first place, the database faithfully guarantees that everyone receives the same backdoor.

Same shape, different layer.

Detection was accidental every time#

Look at four incidents and how each was actually found.

SolarWinds. Build-stage injection, valid signature. Found by a third-party investigation, months in.

Codecov. An error in their Docker image creation process let an attacker extract a credential for the storage bucket hosting their Bash Uploader, which was modified to exfiltrate environment variables from customer CI runs. It sat in production for about 65 days, from 31 January to 1 April 2021. It was found because one customer compared the shasum of the downloaded script against the one published on GitHub and noticed a discrepancy. That check was available to every customer and performed by one.

event-stream. A maintainer with no time accepted an offer of help and transferred publishing rights. The new maintainer added a dependency containing a payload targeting one specific downstream application. The malicious code was transitive, so nobody had chosen it.

xz-utils. A contributor built credibility over roughly two years, gained maintainer access, and introduced a backdoor into the release tarballs. Andres Freund found it while benchmarking PostgreSQL, because SSH logins were taking about 500ms instead of 100ms. He investigated, found Valgrind errors pointing at liblzma, and traced it from there.

Not one was caught by a scanner. Two were found by an individual noticing something odd. And the victims were not careless: these are well-run projects and companies whose failure was a trust model the whole industry shared.

The xz case contains the most useful detail. The malicious build machinery was present in the distributed release tarballs but not in equivalent form in the source repository. Anyone reading the Git history was looking at a different artifact than the one distributions were building from. It was defeated by a discrepancy between two representations of the same thing, which is precisely what technical controls are supposed to manufacture, so that detection does not depend on an engineer being curious about half a second of latency.

The question that actually matters#

If a signature says who, the question SolarWinds turns on is how.

Provenance is a claim about how an artifact was produced: which source revision, which builder, which inputs, which parameters.

Digest        "these are the bytes"
Signature     "and Acme's key asserted them"
Provenance    "and they were produced by builder X, from commit abc123,
               with these inputs, with no network access"

Only the third supports a policy like "reject anything not built by our build service, from a commit on a protected branch of a repository we own." That policy is what makes an injection at build time visible, because the artifact would not match the claim.

The pairing that does the work is the artifact digest plus the source commit digest. Together they answer the SolarWinds question directly: does this specific artifact correspond to a specific reviewed source revision? Provenance naming a repository but not a commit cannot answer it. Provenance naming a commit but not the artifact digest can be attached to anything.

Provenance the build could have written is worthless#

Here is where most implementations quietly fail.

The intuitive approach is a final pipeline step that writes a provenance document and signs it. It provides no security. If an earlier step is compromised, it can modify the artifact and it can equally modify the provenance, or read the signing key and produce its own. The attacker controls both the claim and the evidence.

Non-falsifiability requires the record to be produced by something outside the build's control: the build platform observing the build rather than participating in it.

This is exactly the line SLSA draws, and it is worth being precise about the current specification, because a lot of circulating material is out of date. SLSA v1.0 restructured into tracks. Only the Build track exists, and Level 4 from v0.1 was removed. The Build track runs L0 to L3, and each level is defined by the threat it addresses:

  • Build L1 addresses mistakes during release. Provenance exists.
  • Build L2 addresses tampering after the build. Provenance is signed by the platform.
  • Build L3 addresses tampering during the build. The provenance cannot be forged by the build itself.

The gap at L2 is worth reading carefully, because L2 is where many organisations stop. The specification is explicit that there is no minimum bound on the strength of the controls preventing tenant tampering, and build steps may still influence the provenance contents. So L2 protects against modification in a registry or in transit, and an attacker inside the build gets their output signed and shipped.

If your concern is the SolarWinds shape, L2 does not address it. L3 does, and it is a property of the build platform rather than something a tenant can configure into existence.

The gap almost nobody closes#

Suppose you do all of it. Signed artifacts, non-falsifiable provenance, SBOMs attached, everything bound to digests.

None of it protects you until something checks it before a deployment.

This is the most common failure in the entire field. Organisations sign every image, generate provenance for every build, attach an SBOM to every artifact, and then deploy without evaluating any of it. The pipeline emits claims into a void. An auditor sees signing enabled and marks it satisfied. An attacker who compromises the build produces exactly the same documents, correctly signed, and nothing rejects them.

Three questions expose it quickly:

What happens if an unsigned artifact is deployed? If it deploys, you have signing, not verification.

Which identity is required on the signature? If any valid signature passes, then an attacker who can sign anything passes. With public signing infrastructure this matters enormously: anyone can sign anything, by design, and the meaning comes entirely from whose identity is bound to it. Verification that does not pin the expected signer identity and issuer accepts a signature an attacker made thirty seconds ago and reports success.

What happens when the verification service is unavailable? If deployments proceed, the control is optional under exactly the conditions an attacker would create.

What "verified" should mean#

The useful output of all this is a sentence your team can write down and implement. Something shaped like:

An artifact is verified for production if it carries provenance issued by our build service, attesting it was built from a commit on a protected branch of a repository we own; that provenance is signed by an identity we recognise; checked at admission; with deployment refused if the check cannot be performed.

Every clause is a decision: which builder, which repositories, which identities, checked where, and what happens on failure. A programme without that sentence is generating artifacts and hoping. A programme with it has something to implement, test, and audit.

And note what the sentence does not claim. It says nothing about the code being good. A perfectly verified, Build L3 artifact can contain any vulnerability, any backdoor committed to the source, and any malicious dependency. Build integrity and code safety are different properties, and conflating them produces the belief that a high level makes dependency hygiene unnecessary.

The shift#

Traditional security asks whether an artifact was modified since the vendor released it. In a supply chain attack the answer is no, and that is not the question.

The questions that replace it are: what is actually inside this, where did each part come from, how was it built and by what, who is asserting that, and can I check their claim before it runs.

A signature answers one small piece of that. Treating it as the whole answer is how thousands of organisations installed an update they had every reason to trust.


This is the opening argument of Software Supply Chain Security, a course on proving what an artifact is and deciding whether to trust it: the threat model, dependency and source integrity, build system compromise, SLSA provenance, Sigstore signing and attestation, SBOMs and VEX, and verification at deploy time. Module 1, covering everything in this post plus the five stages where trust can be subverted, is free. Related reading: Mounting the Docker Socket Is Root on Your Host for a build pipeline handed far more privilege than its task requires, and Your Manifests Are in Git. That Is Not GitOps. for the same credential question on the deployment side.

More in Security