Skip to content

Embedded provenance (ELF notes, Go buildinfo, cargo-auditable, SBOMs, attestations)

What an artifact already says about itself — its build identity, its ABI floor, its package of origin, its module graph — and why the layer above it, the one that signs those claims, is defined in terms the catalog's mutable artifacts cannot satisfy.

FieldValue
KindFormat conventions + linker options + toolchain features, plus an out-of-band attestation layer that never touches the artifact
LanguageC++ (lld), C (GNU ld, .NET host), Go (cmd/link, debug/buildinfo), Rust (cargo-auditable), C# (Microsoft.NET.HostModel), specification prose (UAPI, in-toto, SLSA)
LicenseApache-2.0-with-LLVM-exception (lld), GPL-3.0-or-later (GNU ld), BSD-3-Clause (Go), MIT OR Apache-2.0 (cargo-auditable), MIT (dotnet/runtime), CC-BY-4.0 (UAPI.8, SLSA), Apache-2.0 (in-toto, cosign)
Repositorygolang/go · rust-secure-code/cargo-auditable · dotnet/runtime · llvm/llvm-project · in-toto/attestation · slsa-framework/slsa · sigstore/cosign
DocumentationELF gABI, program header / notes · ld --build-id · UAPI.8 Package Metadata for Executable Files · runtime/debug.ReadBuildInfo · slsa.dev · in-toto.io · reproducible-builds.org
First release.note.ABI-tag: glibc 2.x era · build-id: Fedora 8, 2007 · Go module buildinfo: Go 1.12 (2019-02-25), inline format Go 1.18 (2022-03-15) · .NET single-file bundles: .NET Core 3.0 (2019-09-23), v2 header .NET 5 · cargo-auditable 0.1: 2022 · UAPI.8 v1.0 · in-toto Attestation v1: 2023 · SLSA v1.0: 2023-04-19
Axis profileMultiplicity 0 / Reflexivity 2 / Closure 1 / Mutability 0
Index anchoringMixed by design — header-anchored (ELF section/segment tables), stream-scanned (Go's magic, the .NET bundle marker), and out-of-band (every attestation)
Dispatch ownerConsumer — nothing in the system dispatches on provenance; it is read by whoever chooses to look, which is why it can be absent without anything breaking

Revisions surveyed: golang/go 620058f8 (2026-08-25), rust-secure-code/cargo-auditable e0932726 (2026-08-22), llvm/llvm-project 73802c2e (2026-06-04), dotnet/runtime 714432e0 (2026-08-26), systemd/systemd 9bb06d5c, uapi-group/specifications 1375569c, in-toto/attestation 2dcd055e (2026-08-24), slsa-framework/slsa 1686afeb (2026-08-06), sigstore/cosign 58aae9e1 (2026-08-19), android/apksig 184702d9 (2025-03-08), Linux e43ffb69. Platform: measurements taken on NixOS x86-64 against gh 2.96.0 (a Go 1.26.5 binary, 40 356 736 bytes) and ripgrep 15.1.0 (a cargo auditable-built binary, 6 557 168 bytes) from the local Nix store.


Overview

What it solves

Five questions get asked about a binary after it has left the machine that produced it, and each has grown its own answer inside the file:

QuestionMechanismWhere it livesWho wrote it
"Which build is this, exactly?"NT_GNU_BUILD_IDa PT_NOTE segmentthe linker, on an opt-in flag
"What is the minimum kernel/ABI it needs?"NT_GNU_ABI_TAGa PT_NOTE segmentthe C runtime's startup objects
"Which distribution package shipped it?".note.package / .pkgnote JSONan allocated note or COFF sectionthe distribution's build system
"What did it link against, and at what version?"Go buildinfo, .dep-v0, bundle manifesta data-segment blob, a non-allocated section, a footer manifestthe toolchain
"Was it built by whom it claims, from what?"SLSA / in-toto / sigstorenowhere in the file — a registry or a transparency logthe build platform

The first four are the subject of this page's format half. The fifth is the subject of its argument, because the attestation layer is specified in terms of an immutable subject digest, and this catalog's most interesting artifacts — SELF and self-httpd, redbean under -* — mutate themselves by design.

The line against the application-packaging tree is drawn as follows: how an SBOM is published, distributed, and consumed by a scanner is that tree's subject. What is inside the byte stream, and what a signature can therefore cover, is this one's.

Design philosophy

The single most load-bearing sentence in this entire cluster is in the GNU linker's own documentation of --build-id (ld Options):

"The md5, sha1, and xx styles produces an identifier that is always the same in an identical output file, but are almost certainly unique among all nonidentical output files. It is not intended to be compared as a checksum for the file's contents. A linked file may be changed later by other tools, but the build ID bit string identifying the original linked file does not change."

Read it twice. The build-id is an identity, not an integrity check, and it is explicitly specified to survive mutation of the file it names. That is the exact property a mutable artifact needs and the exact property a signature cannot have — and the whole tension of the signing problem is already latent in it.

lld says the same thing from the implementer's side, in a comment that names Fedora as the source of the requirement (lld/ELF/Writer.cpp):

cpp
// lld/ELF/Writer.cpp — writeBuildId()
// Fedora introduced build ID as "approximation of true uniqueness across all
// binaries that might be used by overlapping sets of people". It does not
// need some security goals that some hash algorithms strive to provide, e.g.
// (second-)preimage and collision resistance. In practice people use 'md5'
// and 'sha1' just for different lengths. Implement them with the more
// efficient BLAKE3.

--build-id=sha1 does not compute SHA-1 and --build-id=md5 does not compute MD5; both compute truncated BLAKE3, because the field was never an integrity claim and the length is the only part anyone depends on.

The UAPI.8 specification states the opposite design pressure — provenance that must be present in memory, not merely present in the file:

"This metadata is stored as a section in the executable file, so that it will be loaded into memory along with the text and data of the binary, and will be preserved in a core dump. This metadata can also be easily read from the file on disk, so it can be used to identify provenience of files, independently of any package management system, even if the file is renamed or copied."

And cargo-auditable's README is refreshingly blunt about what embedded provenance is not (README):

"Software Bills of Materials (SBOMs) do not prevent supply chain attacks. They cannot even be used to assess the impact of such an attack after it is discovered, because any malicious library worth its bytes will remove itself from the SBOM. This applies to nearly every language and build system, not just Rust and Cargo."

An unauthenticated self-description is a convenience for honest builds and worthless against a dishonest one. Everything in the attestation layer exists to close that gap, and it closes it by leaving the artifact entirely.


How it works

ELF notes: a typed blob table with no index and no schema

The ELF gABI defines a note as a length-prefixed triple — n_namesz, n_descsz, n_type, then the name and descriptor each padded to 4-byte alignment — packed end to end inside an SHT_NOTE section, which is in turn covered by a PT_NOTE program header so the loader maps it. There is no count, no offset table, and no registry of types: the only way to answer "which notes does this file carry?" is to walk every note in every PT_NOTE and decode it. (name, type) is the composite key, and it is unenforced.

./self-selfdb/examples/elf-note-buildid.d is the runnable companion: it opens /proc/self/exe, walks the program headers to each PT_NOTE, decodes NT_GNU_BUILD_ID, NT_GNU_ABI_TAG and any vendor note, and prints the debuginfod key derived from the build-id. Its recipe carries a deliberate flag:

sdl
lflags "--build-id"

with the comment that this is "a link option, not a property of the format, and a toolchain that does not ask for one produces an image that cannot name itself." That is the first structural fact of this page and it generalizes: every mechanism surveyed here is opt-in at build time, and absent by default in at least one major toolchain. --build-id is defaulted on by most distributions' compiler drivers and by none of the linkers.

How the identifier is actually computed matters for the signing argument. lld hashes the entire output buffer — not a semantic subset — through a two-level tree (lld/ELF/Writer.cpp):

cpp
// lld/ELF/Writer.cpp — computeHash()
// Computes a hash value of Data using a given hash function.
// In order to utilize multiple cores, we first split data into 1MB
// chunks, compute a hash for each chunk, and then compute a hash value
// of the hash values.

The output is therefore a hash-of-hashes over 1 MiB blocks, computed in parallel and finalized over the concatenated chunk digests — structurally a one-level Merkle tree, and not equal to any plain digest of the file. It is also computed over a buffer in which the note's own descriptor bytes are still zero, since writeBuildId() runs after the rest of the image is laid out and then patches the note in place. Both facts recur, in more careful form, in Authenticode and APK v2. Sizes are fixed by style: 8 bytes for xx/Fast, 16 for md5/uuid, 20 for sha1, arbitrary for 0xhexstring (SyntheticSections.h).

.note.package is the richest of the notes and the only one carrying a schema-ish payload. Per UAPI.8 it is a 4-byte-aligned, allocated, NUL-padded, read-only note with owner FDO and type 0xcafe1a7e, whose descriptor is a single JSON object with conventional keys (type, os, osVersion, name, version, architecture, osCpe, appCpe, debugInfoUrl), and the spec instructs parsers not to assume a fixed key set. GNU ld emits it from --package-metadata=JSON (ld Options) — again an opt-in flag, again nothing enforcing the content. The PE/COFF twin is a .pkgnote section with the same JSON.

Go buildinfo: a magic-delimited blob, located by a scan

Go embeds the richest self-description of any mainstream toolchain, and it does so without a section-name contract on most platforms. cmd/link synthesizes a symbol (data.go):

go
// src/cmd/link/internal/ld/data.go — (*Link).buildinfo()
s := ldr.CreateSymForUpdate("go:buildinfo", 0)
s.SetType(sym.SBUILDINFO)
s.SetAlign(16)

// The \xff is invalid UTF-8, meant to make it less likely
// to find one of these accidentally.
const prefix = "\xff Go buildinf:" // 14 bytes, plus 1 data byte filled in below

The 32-byte header is prefix (14 bytes), a pointer-size byte, a flags byte (bit 0 = big-endian, bit 1 = the post-Go-1.18 inline format), and 16 reserved bytes that used to hold two target pointers. Two varint-length-prefixed strings follow: the toolchain version, then the module info. The linker also emits a go:buildinfo.ref symbol in .rodata pointing at the blob, solely so that external linking with -Wl,--gc-sections does not garbage-collect the provenance.

The reader is debug/buildinfo, and it is the catalog's clearest example of a bounded stream scan standing in for an index. It sniffs the container by its first 16 bytes (ELF, PE, Mach-O, fat Mach-O, XCOFF, Plan 9 a.out), asks the container for a search window, then brute-forces:

go
// src/debug/buildinfo/buildinfo.go
const (
    buildInfoAlign      = 16
    buildInfoHeaderSize = 32
)
// Read segment or section to find the build info blob.
// On some platforms, the blob will be in its own section, and DataStart
// returns the address of that section. On others, it's somewhere in the
// data segment; the linker puts it near the beginning.

DataStart() returns .go.buildinfo on ELF and __go_buildinfo on Mach-O; searchMagic then reads the window in 1 MiB chunks, rejects any hit whose offset is not 16-byte aligned, and keeps going. Misaligned occurrences of the magic — in a string constant, say — are skipped rather than trusted, and because the magic must be 16-byte aligned it can never straddle a chunk boundary, which is why chunked reading is safe.

The module payload is framed by two 16-byte sentinels chosen for improbability (modload/build.go):

go
infoStart, _ = hex.DecodeString("3077af0c9274080241e1c107e6d618e6")
infoEnd, _   = hex.DecodeString("f932433186182072008242104116d8f2")

and the reader validates the framing with a length-and-position check (len(mod) >= 33 && mod[len(mod)-17] == '\n') before stripping 16 bytes from each end. If the check fails, the module info is discarded rather than reported as suspect — a silent degradation, not an error.

Measured on gh 2.96.0, the whole thing accounts for itself exactly:

FieldBytesValue
Header32ff 20 47 6f 20 62 75 69 6c 64 69 6e 66 3a 08 02 + 16 zero bytes
Version string (varint 0x08 + data)9go1.26.5
Module string (varint e5 3d + data)2 + 7909176 lines: 1 path, 1 mod, 166 dep, 1 build ×7 …
Total7952= the whole .go.buildinfo section (0x1f10)

The section sits at file offset 0x2521000, which is byte 0 of the read-write LOAD segment — "the linker puts it near the beginning" is literally true, so the scan terminates on the first chunk. Section flags are WA: allocated and writable. That single flag is what makes Go's provenance self-readable at runtime (see Reflexivity) and, incidentally, what makes it writable by the process that carries it.

The build settings are the part most people underestimate. BuildSetting is a free-form key/value list whose documented keys (runtime/debug/mod.go) include -buildmode, -compiler, -trimpath, every CGO_* variable, DefaultGODEBUG, GOARCH/GOOS/GOAMD64, GOFIPS140, and the VCS quartet vcs, vcs.revision, vcs.time, vcs.modified. The VCS stamp is conditional and the conditions are strict (load/pkg.go): -buildvcs enabled, a non-test package in a main module, the working directory and the module root in the same repository, and the VCS binary present on PATH — with -buildvcs=auto silently omitting the metadata when the tool is missing. The gh binary measured above carries -trimpath=true and no vcs.* settings at all, because it was built by Nix from a source tarball rather than a checkout. Provenance that depends on the build environment's shape is provenance that a hermetic build system deletes.

cargo-auditable: a compressed dependency graph in a section nothing maps

cargo auditable wraps rustc, calls cargo metadata (or reads Cargo's unstable SBOM precursor when CARGO_SBOM_PATH is set), serializes a VersionInfo to JSON, zlib-compresses it, and synthesizes a one-section object file that is handed to the linker (collect_audit_data.rs, object_file.rs):

rust
// cargo-auditable/src/object_file.rs — create_metadata_file()
let section = file.add_section(
    file.segment_name(StandardSegment::Data).to_vec(),
    b".dep-v0".to_vec(),
    SectionKind::ReadOnlyData,
);
if let BinaryFormat::Elf = file.format() {
    // Explicitly set no flags to avoid SHF_ALLOC default for data section.
    file.section_mut(section).flags = SectionFlags::Elf { sh_flags: 0 };
};

sh_flags = 0 means not SHF_ALLOC: the section exists in the file and is never mapped into the process. On Mach-O and PE a symbol is additionally emitted so the linker does not discard it. Extraction is by section name across four containers plus Wasm custom sections (auditable-extract), and the documented parser is five lines of Python (PARSING.md).

Measured on ripgrep 15.1.0 from the Nix store (NixOS builds its Rust packages with cargo auditable):

QuantityValue
.dep-v0 size in the binary713 bytes
Decompressed JSON3 265 bytes
Packages described42 (1 root, kinds runtime and build)
format revision1
Binary size6 557 168 bytes
Provenance as a fraction of the image0.0109 % (≈1 in 9 200)

The schema is where this becomes interesting for the catalog. Edges are integer indices into the packages array, not names — a normalized edge list, i.e. the database move — and the array's order is therefore load-bearing. Because cargo metadata's order is not stable, cargo-auditable has to manufacture one (auditable_from_metadata.rs):

rust
// cargo-auditable/src/auditable_from_metadata.rs
// This function is the simplest place to introduce sorting, since
// it contains enough data to distinguish between equal-looking packages
// and provide a stable sorting that might not be possible
// using the data from VersionInfo struct alone.
// …
// they're supplied by in arbitrary order by cargo-metadata anyway
// and the order even varies between executions.
packages.sort_unstable_by(|a, b| { … a.id.repr.cmp(&b.id.repr) });

package.dependencies.sort_unstable();

A canonical order was invented so the artifact would be byte-reproducible, and the price is that node identity is positional: renumber the array and every edge in the graph changes meaning. Hold that thought — it is exactly the rowid-under-VACUUM problem, arrived at independently by a project that never touched a database.

The format field's compatibility rule is a small piece of genuine self-description (cargo-auditable.schema.json): "Format revisions are backwards compatible. If an unknown format is encountered, it should be treated as the highest known preceding format." A consumer can decode a future artifact conservatively. Compare an ELF note type, where an unknown n_type carries no ordering information at all.

.NET single-file bundles: two indexes for one datum

A .NET single-file app is an apphost executable with every managed assembly and (optionally) the runtime appended after it, followed by a manifest (Manifest.cs):

text
AppHost
------------ Embedded Files -------------------
------------ Bundle Header --------------------
    MajorVersion / MinorVersion / NumEmbeddedFiles / ExtractionID
    DepsJson Location (Offset, Size)        [v2+]
    RuntimeConfigJson Location (Offset, Size) [v2+]
    Flags                                   [v2+]
- - - - - - Manifest Entries - - - - - - - - - -
    Series of FileEntries [Type, Name, Offset, Size]

That is a footer index in the ZIP tradition, and it is located the way redbean's is not. The apphost ships with a 40-byte placeholder: eight zero bytes followed by a 32-byte constant that is "SHA-256 for .net core bundle". dotnet publish finds the constant by scanning the file and writes the header offset into the eight bytes ahead of it (Bundler.cs):

csharp
int position = BinaryUtils.SearchInFile(accessor, BundleHeaderSignature);
if (position == -1)
    throw new PlaceHolderNotFoundInAppHostException(BundleHeaderSignature);
headerOffset = accessor.ReadInt64(position - sizeof(long));

The running host does not scan. It reads the same 40 bytes as a static volatile array compiled into its own data segment (bundle_marker.c):

c
// src/native/corehost/apphost/bundle_marker.c
volatile bundle_marker_data_t* marker = (volatile bundle_marker_data_t*)placeholder;
return marker->locator.bundle_header_offset;

One datum, two anchorings: stream-scanned for an external tool, header-anchored (as a symbol) for the artifact interrogating itself. That split is not an accident of implementation; it is the general shape of every mechanism on this page, and it is the reason the index anchoring row of this page's metadata table has to say "mixed".

BundleID is a content-derived name: SHA-256 over the contents of every embedded file in manifest order, base64url-encoded and truncated to 12 characters, with the manifest object frozen the moment the ID is taken ("It is forbidden to change Manifest state after it was written or BundleId was obtained"). It is used as the extraction directory name for files that must hit disk, which makes it a cache key over the closure — the same trick as a Nix store path, at a quarter of the entropy.

SBOMs and the attestation layer

Neither of the two dominant SBOM formats says where the document goes. CycloneDX specifies encodings (JSON, XML, protobuf), registered media types (application/vnd.cyclonedx+json) and conventional filenames (bom.json, *.cdx.json); SPDX likewise specifies a document and its serializations. Placement is out of scope for both. That absence is the finding: everything in the previous four subsections is a placement convention invented by a toolchain because the format specifications declined to have an opinion, which is why there are five incompatible ones.

The attestation layer above resolves the question by not embedding. An in-toto Statement binds a predicate to subjects identified purely by digest:

"subject … Set of software artifacts that the attestation applies to. Each element MUST have digest set. **Subjects are assumed to be _immutable, i.e. the artifacts identified by the subject SHOULD NOT change.** … IMPORTANT: Subject artifacts are matched purely by digest, regardless of content type."_

SLSA's attestation model repeats the assumption in its vocabulary — "Artifact: Immutable blob of data described by an attestation, usually identified by cryptographic content hash" — and cosign states the operational consequence (SIGNATURE_SPEC.md):

"Because signatures are detached, the payload MUST contain the digest of the image it references, in a well-known location."

cosign went further and withdrew the in-band option: SBOM attachments are deprecated in favour of attestations (SBOM_SPEC.md, warning dated 2024-02-22). The industry's answer to "where does the SBOM live?" converged on "in a registry, keyed by the artifact's digest" — an out-of-band index, which concepts.md classifies as a materialized view with exactly the staleness failure mode that implies.

The floor under all of it is reproducibility (reproducible-builds.org): "A build is reproducible if given the same source code, build environment and build instructions, any party can recreate bit-by-bit identical copies of all specified artifacts." SLSA is careful to separate that from verified reproducibility (FAQ) and to note that "Reproducible builds do not address source, dependency, or distribution threats." Note the direction of dependence: the whole tower rests on the artifact's bytes being a function of its inputs, which is precisely the property that a program storing its own state in its own image forfeits on the first request it serves.


Format identity and multiplicity

Multiplicity 0, and the zero is the point. Nothing here creates a second parse of the same bytes. A PT_NOTE descriptor, a zlib stream in .dep-v0, a .NET manifest and a Go buildinfo blob are all payloads carried opaquely by a host format that does not interpret themconcepts.md's container, defined there as "the absence of multiplicity purchased with a tax". The tax is visible and small (a section-table entry, a program header, 713 bytes), and it is paid for exactly the reason the UKI pays its much larger one: the incumbent parser already exists and will carry anything you name.

Three refinements are worth making, because they are where the identity question actually bites.

The host's tolerance decides the mechanism, not the payload's. ELF is suffix-tolerant and header-anchored: it declares its own extent through the section and program header tables, so a new payload must be named in one of those tables. That is why every ELF-based mechanism here is a section, and why none of them can simply be appended. The .NET bundle, whose host format is PE, is appended anyway — and needs a scanned marker to find it, because PE's header does not know the payload exists. The same structural rule that generates the ZIP-parasitic ecosystem generates the split between "named section" and "appended blob with a magic" seen on this page.

Two containers, one JSON. .note.package and .pkgnote are the same object in an ELF note and a COFF section (UAPI.8). This is the closest thing here to genuine multiplicity, and it is not: the bytes are not simultaneously both, they are re-encoded per container. The schema is portable, the placement is not — which is the same asymmetry the SBOM formats institutionalized by declining to specify placement at all.

The scanned mechanisms buy a parser differential. Go's \xff Go buildinf: prefix is invalid UTF-8 on purpose, is required to be 16-byte aligned, and is searched only inside a container-provided window; a misaligned hit is skipped and the search continues. .NET's marker is a 32-byte SHA-256 constant searched over the whole file with no alignment or window constraint, and the offset is read from the eight bytes before the match. A crafted file containing the constant earlier than the real marker will hand the reader an attacker-chosen int64. Both are exercises in making a scan safe enough; neither is an index. See parser differentials for the general form, and note that the failure here is benign only because nothing dispatches on provenance.

Index anchoring and random access

MechanismAnchoringCost of finding itConsumable from a ranged read?
NT_GNU_BUILD_IDHeader — PT_NOTE in the program headersehdr → phdr table → one note walk; a few hundred bytesYes, in two round trips
.note.ABI-tagHeader — same segmentsame walkYes
.note.packageHeader — allocated notesame walkYes
Go buildinfoStream-scanned inside a bounded windowcontainer sniff → DataStart() → chunked 16-byte-aligned searchYes, if .go.buildinfo exists; on platforms without the named section the window is the whole data segment
.dep-v0Header — section tableshdr table → name lookup → one readYes, three round trips
.NET bundle headerStream-scanned over the whole file, or a data-segment symbol at runtimeSearchInFile over an mmap of the entire executableNo — the scan has no bound
SLSA / in-toto / sigstoreOut-of-banda registry or transparency-log lookup keyed by the artifact's digestN/A — the artifact is not read

Two consequences follow, and they are the reason this page's metadata row says "mixed by design".

Header-anchored provenance is cheap over a network. A build-id is three bounded reads from an ELF file: 64 bytes of Elf64_Ehdr, e_phnum × 56 bytes of program headers, then the PT_NOTE extent — a few hundred bytes to identify a binary you have not downloaded, which is the same trick range-request access plays against footer-indexed formats and the same trick debuginfod relies on at the other end. The runnable companion deliberately reads the whole file instead, because it is reading /proc/self/exe, where a ranged read buys nothing.

Scanned provenance is not. .NET's SearchInFile has to touch every byte of a possibly 100 MiB self-contained bundle before it can say "not a bundle". Go's scan is bounded by the container's data segment, and the linker cooperates by placing the blob at its start — a convention doing an index's job, which is thesis 2 in miniature.

The out-of-band row is the interesting one. An attestation stored in a registry is a materialized view over a build that already happened, and it can go stale in a way none of the in-band mechanisms can: the artifact can be rebuilt, re-tagged, or moved, and the attestation's subject digest simply stops matching anything. In exchange it is the only mechanism here that can be revoked, updated after the fact, or authored by a party other than the toolchain. Nix's references graph sits in the same position and pays the same price.

Reflexivity and query surface

Score 2, and the justification is a single ELF flag.

MechanismMapped into the process?Self-readable without I/O?In a core dump?
NT_GNU_BUILD_IDYes — PT_NOTE is loadedYes, by walking one's own phdrs (via dl_iterate_phdr)Yes
.note.packageYes — allocated by specification, explicitly to reach core dumpsYesYes — the stated purpose
Go buildinfoYes — .go.buildinfo is WAYes — ReadBuildInfo calls runtime.modinfo(), a Go string in the imageYes
.NET bundle markerYes — a static volatile arrayYesYes
.dep-v0No — sh_flags = 0No — the process must re-open its own fileNo

runtime/debug.ReadBuildInfo performs no file I/O whatsoever (mod.go): it takes modinfo() from the runtime, strips the 16-byte sentinels, parses the text, and splices in runtime.Version() because the version is stored separately "mostly for historical reasons". A Go program can answer "which version of golang.org/x/net am I running?" in the middle of handling a request. A Rust program built with cargo auditable cannot answer the equivalent question without opening /proc/self/exe — and under binfmt_misc that path names the interpreter, not the file that was executed, which is the exact hazard the note-decoding companion documents and SELF has to work around.

That is the whole reflexivity story, and its ceiling is low: every one of these surfaces is a fixed menu. go version -m prints; rust-audit-info prints; readelf -n prints. There is no join. You cannot ask "which of my dependencies also appear in that other binary", "which notes exist across this tree", or "which packages are reachable from the root but only through a build-kind edge" without first exporting the data into something relational — which is precisely what sqlelf does for ELF and what SELF does by making the format relational in the first place. SELF's schema has a notes table (CREATE TABLE notes (kind TEXT, name TEXT, content BLOB)) and a self_meta row for build_id: the note walk becomes a SELECT, and the (name, type) composite key that ELF leaves unenforced becomes something a UNIQUE constraint could enforce.

This is thesis 1every binary format eventually reimplements a database, badly — with a fresh witness that is not ELF's symbol table. cargo-auditable's .dep-v0 is a normalized relational encoding: a node table (packages) and an edge list expressed as foreign keys (dependencies: [25, 30, 35]), with a sort order defined to make the keys stable and a format column for schema evolution. It is a two-table database, serialized as JSON, compressed, and stored in a section — because there was no relational substrate available and one was needed anyway. See code as a database for the same convergence arrived at from the source-code side.

The one query surface on this page that is genuinely general is the one outside the artifact: an attestation store keyed by digest, holding typed predicates, is a document database with a well-defined join key. The reflexivity the artifact lacks is available to anyone willing to give up in-band-ness.

Closure, dedup, and size model

Score 1, incidental — and the reason is worth stating precisely: these mechanisms name a closure, they do not carry one.

ArtifactProvenance payloadBinary sizeFractionWhat it describes
gh 2.96.0 (Go 1.26.5)7 952 B (.go.buildinfo)40 356 736 B0.0197 %166 modules + 7 build settings
ripgrep 15.1.0 (Rust)713 B (.dep-v0, zlib)6 557 168 B0.0109 %42 packages, runtime and build kinds
cargo-auditable's own claim"under 4 kB even on large dependency trees with 400+ entries" (README)"between 1/1000 and 1/10 000"
NT_GNU_BUILD_ID (sha1)36 B (16-byte note header + 20-byte descriptor)any~0one build

Four orders of magnitude separate the description of a closure from the closure. That asymmetry is why nobody has ever needed to deduplicate embedded provenance, and it is the sharpest available contrast with the artifacts this catalog is otherwise about: SELF reports 611.9 MiB for a 723-executable userland against 5.53 GiB under the AppImage model, and Nix exists to make a carried closure shareable. A named closure needs none of that machinery, and buys none of its guarantees — the names are only as good as the resolver that will later turn them into bytes, and cargo-auditable's own FAQ notes that format 0 and 1 "may still include slightly more dependencies than are actually used".

Two internal compression choices are worth noting because they are the format doing database work again. Go's module block is plain tab-separated text, repeated in full, with no interning: the 166 dep lines of gh restate github.com/ 100-odd times, and the 7 909-byte payload compresses trivially but is never compressed. cargo-auditable normalizes (indices, not names) and compresses (zlib level 7, chosen because it "complete[s] in a few milliseconds") and lands at 713 bytes for a comparable graph. Same information, one order of magnitude apart, entirely because one of them modelled the data as a graph and the other as a log.

What is not carried anywhere is the thing a closure would need to be actionable: no hashes of the dependencies as built, no store paths, no source digests. Go carries Module.Sum in its type but the field is documented as the module checksum, not the binary's; the ELF notes carry nothing of the kind. To go from "this binary says it used aho-corasick 1.1.3" to "these exact bytes" you must trust a registry — the content-addressed chunking and Nix closure pages are where that gap is actually closed.

Mutability, dispatch, and trust

Score 0 on the mutability axis, and this section is the page's payload, because the zero is not a property of provenance — it is an assumption baked into every layer above it that the catalog's mutable artifacts violate.

Nothing here is dispatched on. No kernel, loader, or shell inspects a build-id, a .note.package, or a .dep-v0 before deciding what a file is; the dispatch owner is the consumer, who may not look at all. That is why an absent build-id breaks nothing, why strip removing a note breaks nothing, and why a hostile toolchain can emit whatever it likes. Compare binfmt_misc, where the bytes at a fixed offset are the decision.

Nothing here is signed in band, either. Every mechanism surveyed is unauthenticated plaintext or unauthenticated compressed text sitting in a region the artifact's producer controls and any post-processor can rewrite. --build-id is documented as "not intended to be compared as a checksum". .note.package is JSON with no signature slot. Go's blob is in a writable segment. cargo-auditable's README says the quiet part out loud: an attacker who has compromised a dependency will simply delete the row that names it.

The obvious first design

Take SELF as the concrete mutable artifact, since it is the one with a schema. The source outline proposes, and this page is charged with developing, the following:

If an SBOM is a view and an attestation is a signed subset of rows, you need a canonical serialization — per-table Merkle roots over a canonical row encoding, with the signature covering only the immutable tables, is the obvious first design. Nobody has built it.

Spelled out against SELF's actual DDL, that is: partition the schema into code tables (self_meta, segments, needed, dynamic_entries, symbols, relocations, sections, notes) and state tables (a visits table, a handlers table, whatever the application adds); define a canonical byte encoding for a row; compute a Merkle root per code table; sign the tuple of roots plus the schema; store the signature somewhere the hash does not cover. VACUUM may rewrite every byte in the file and the roots do not move, because they are computed over rows, not pages. INSERTing into visits does not invalidate anything, because visits is not covered.

It is the right first design. It does not survive contact.

Four things that break it

1. Row order is not canonical in SQL. A table is a multiset; a SELECT without ORDER BY has no defined order, and a Merkle root over rows is a function of an order. So a sort key must be defined, per table, as part of the format — and SELF's schema does not have one to hand: dynamic_entries (tag TEXT, value ANY) and notes (kind TEXT, name TEXT, content BLOB) declare no key at all, and dynamic_entries legitimately holds repeated tags (DT_NEEDED-adjacent multiplicities, several INIT_ARRAY-family entries). Sorting by the full tuple resolves ties only if no two rows are identical, and identical rows are legal. The evidence that this is a real cost rather than a theoretical one is cargo-auditable, which hit the identical wall from the other direction: cargo metadata's order "varies between executions", so a sort had to be invented, and the comment concedes the sort key is a workaround for a type that "does not implement Ord".

2. Identity is positional, and positions move. SQLite's VACUUM is unambiguous (sqlite.org):

"The VACUUM command rebuilds the database file, repacking it into a minimal amount of disk space. … The VACUUM command may change the ROWIDs of entries in any tables that do not have an explicit INTEGER PRIMARY KEY. … The VACUUM command works by copying the contents of the database into a temporary database file and then overwriting the original with the contents of the temporary file."

In SELF's schema, segments, needed, symbols and relocations do declare INTEGER PRIMARY KEY, so their rowids are stable; dynamic_entries and notes do not, so theirs are not. Any canonical encoding that includes a rowid, or any other table that references one, is invalidated by an operation the format's own design documents as routine — strip is specified as DELETE + VACUUM. And this is the same defect as cargo-auditable's index-based edges: the moment a node's name is its position, re-serialization renumbers the graph. Two projects, no shared code, one bug class.

3. A signature over a subset does not bind the subset to the whole. Sign the code tables and leave the state tables free, and an attacker does not need to forge anything — they need only add. SELF's own design puts LD_PRELOAD in a row and handlers in a table; self-httpd makes request handling a SELECT against a table the running server writes. A signature over segments and symbols says nothing about a new preload row, a new trigger, or a new view shadowing exports. Binding the subset to the whole requires signing the schema — but sqlite_schema is itself a table that a legitimately mutating artifact appends to (a migration is a CREATE TABLE), so the covered set and the mutable set intersect. The honest form of the requirement is: the signature must enumerate what may change, not what may not, which is a closed-world assumption that a self-modifying artifact is precisely designed to escape.

4. The signature has nowhere to live. Put it in a table and it is inside the b-tree you are hashing; put it in SQLite's reserved-per-page region and VACUUM rewrites the pages; put it in the 100-byte header and there are 20 free bytes and no defined semantics for them. And even setting the signature aside, a whole-file hash of a SQLite database is not stable under logically-identical content: the file format puts a file change counter at offset 24 and a version-valid-for number at offset 92, the freelist and page ordering depend on write history, and two databases with byte-identical row sets routinely differ. A mutable artifact cannot be verified by a signature over its bytes — which is where concepts.md points at this page — and it turns out it cannot trivially be verified by a signature over its rows either.

How the deployed systems dodge it

Nobody solved this. Four ecosystems worked around it in four distinguishable ways, and the taxonomy is the useful output.

SystemStrategyMechanism
Authenticode (PE, and thus UKIs)Exclude the mutating regionHash everything except the CheckSum field, the certificate-table data directory entry, and the certificate table itself
APK Signature Scheme v2Normalize the self-referential fieldSign contents-before-signing-block, the central directory, and an EOCD whose CD offset has been rewritten
dm-verity / fs-verityGive up on semanticsA Merkle tree over fixed-size blocks, verified lazily on read; the file is then read-only
sigstore / SLSA / in-totoLeaveDetached signature over a digest, stored in a registry or transparency log

Authenticode is a canonicalization, not a hash. The algorithm, implemented plainly in systemd's pe_hash() so a UKI can be verified without Windows, hashes: everything up to the CheckSum field; everything between it and the certificate-table data directory entry; the rest of the headers and section table; then each section's raw data in file-offset order after an explicit sort; then any trailing data minus the certificate table; then zero-padding to an 8-byte multiple. Three exclusions and one imposed ordering — i.e. it defines a canonical serialization of a PE file precisely because the naive one is not signable. This is the closest deployed analogue to "per-table Merkle roots over a canonical row encoding", and it is instructive that the canonicalization is about a hundred lines and has already produced a hashing-denial-of-service fix.

APK v2 normalizes the field the signature's own insertion would break. Inserting the signing block moves the central directory, so the EOCD's CD offset changes as a consequence of signing. apksig handles it by hashing a modified copy (ApkSigningBlockUtils.java):

java
// For the purposes of verifying integrity, ZIP End of Central Directory (EoCD) must be
// treated as though its Central Directory offset points to the start of APK Signing Block.
// We thus modify the EoCD accordingly.
ZipUtils.setZipEocdCentralDirectoryOffset(modifiedEocd, beforeApkSigningBlock.size());

and the content digest itself is a domain-separated two-level tree:

"1. Each segment of contents is split into consecutive chunks of 1 MB in size. … 2. The digest of each chunk is computed over the concatenation of byte 0xa5, the chunk's length in bytes (uint32 little-endian) and the chunk's contents. 3. The output digest is computed over the concatenation of the byte 0x5a, the number of chunks (uint32 little-endian) and the concatenation of digests of chunks of all segments in-order."

The 0xa5/0x5a prefixes are leaf/interior domain separation — the thing lld's otherwise structurally identical build-id tree hash omits, because lld's is not a security construction. Anyone building a per-table Merkle root should copy the prefixes and the explicit length framing verbatim; in-toto's gitTree and dirHash digest algorithms are the same discipline applied to trees of files (<type> SP <size> NUL <content>; find | sort in the C locale), and their existence proves the attestation model already accepts a digest over a canonicalized semantic structure rather than over bytes. That is the escape hatch: a selfTables digest algorithm would be a legitimate in-toto DigestSet key, and nothing in the Statement spec forbids it.

fs-verity is the one that actually handles a large file cheaply, by refusing to handle mutation (Documentation/filesystems/fsverity.rst):

"fs-verity is essentially a way to hash a file in constant time, subject to the caveat that reads which would violate the hash will fail at runtime."

and

"After this, the file is made readonly, and all reads from the file are automatically verified against the file's Merkle tree. Reads of any corrupted data, including mmap reads, will fail."

FS_IOC_ENABLE_VERITY must run on an O_RDONLY descriptor with no writers, returning ETXTBSY otherwise, "to guarantee that no writable file descriptors will exist after verity is enabled". A verity Merkle tree over 4 KiB blocks is exactly the shape a SQLite database wants — SQLite's default page size is 4096 bytes, so pages and verity blocks align one-to-one, and demand-paged verification would preserve the mmap and page-sharing property that everything else in this catalog is judged on. It is also exactly the wrong granularity for a mutable artifact, which brings the argument to its actual conclusion.

What the tension actually is

Two Merkle granularities, and each is destroyed by the operation the other survives:

GranularitySurvives VACUUMSurvives INSERT into a covered tablePreserves demand-paged verificationBinds meaning
Pages (dm-verity / fs-verity shaped)No — every byte movesNo — the page is rewrittenYesNo — a page is not a row
Rows (per-table roots over canonical encoding)Yes — rows are order-defined, not offset-definedNo — the table's root changesNo — verification needs the decoded rowsYes

VACUUM rewrites pages while preserving rows. INSERT preserves pages while adding rows. There is no granularity that is invariant under both, and so choosing the Merkle granularity is choosing which operation you are willing to make expensive — a full re-sign after every VACUUM, or a per-transaction re-root of one table plus the loss of lazy page verification. Nobody has picked, which is why the open question is still open, and it is a sharper statement of the problem than "you need a canonical serialization": you need two, and they disagree.

The one primitive that survives the whole mess is the one the GNU linker documented at the top of this page. A build-id "does not change" when the file is changed later by other tools — a stable name for a lineage rather than a hash of a state. A mutable artifact can carry an immutable name, and everything an attestation wants to say ("this lineage was produced by this builder from these sources") can be said about the name. What it cannot say is "and the bytes in front of you are still that", which is the entire value proposition of a signature. Threat model develops what is left: a signed measurement of a legitimately-mutating file (IMA/EVM's answer), and whether a self-querying server can hold a read-only handle to its code tables and a writable one to its state tables, enforced below the application. That decomposition — code tables verity-protected and mapped read-only, state tables writable and unsigned — is the only design in view that gets both halves, and it needs the VFS seam to exist at all.

NOTE

What was not verified. No implementation of per-table Merkle roots over SQLite exists to measure; the design above is derived from the primitives, not observed. The claim that a page-aligned verity tree over a SELF database would preserve page sharing follows from fs-verity's documented mmap behaviour but has not been tested against a SELF binary, which does not yet have an mmap-based loader (milestone M4 is unstarted).


Strengths

  • Free identity. A 36-byte note gives every binary a stable, toolchain-computed name that survives stripping, copying, renaming, and later modification — and turns into a network-resolvable key for debug info through debuginfod (debug info and indexes).
  • Provenance in the core dump. .note.package is allocated by specification, so a crash report carries the distribution, version and debuginfod URL of the binary that produced it, with no package database and no network.
  • Self-interrogation without I/O, where the section is mapped. runtime/debug.ReadBuildInfo and the .NET bundle_marker read the artifact's provenance out of the artifact's own memory, mid-execution.
  • Astonishing information density. 713 bytes describes a 42-crate dependency graph in a 6.5 MB binary; the "under 4 kB for 400+ entries" claim holds because the payload is a normalized graph, not a log.
  • Reproducibility-compatible by construction. cargo-auditable sorts its output and embeds no timestamps precisely so the payload does not perturb bit-for-bit reproducibility; Go's blob is a pure function of the module graph plus recorded flags.
  • Format-agnostic extraction. .dep-v0 is read identically from ELF, Mach-O, PE and Wasm custom sections; the payload's meaning never depended on the container.
  • Forward-compatible versioning where anyone bothered. cargo-auditable's "treat an unknown format as the highest known preceding one" rule lets a 2026 tool decode a 2030 artifact conservatively.
  • The attestation layer composes with anything. Because it is keyed by digest and stored out of band, in-toto/SLSA/sigstore apply unchanged to an ELF, a container image, a Git commit, or a tarball — including artifacts whose format has no room for metadata at all.

Weaknesses

  • Every mechanism is opt-in and silently absent. --build-id is a link option, --package-metadata is a link option, -buildvcs=auto drops VCS data without a word when the VCS binary is missing, and cargo auditable is a wrapper you must remember to invoke.
  • Hermetic builds delete the most valuable field. The measured gh binary carries -trimpath=true and no vcs.revision, because it was built from a tarball — exactly the build style that provenance is supposed to reward.
  • Nothing is authenticated in band. All of it is attacker-writable plaintext in a region the producer controls, and a compromised dependency removes itself.
  • Five incompatible placement conventions, because both SBOM specifications declined to define one.
  • Non-allocated sections are invisible to the process that carries them. .dep-v0 cannot be read without re-opening /proc/self/exe, which under binfmt_misc names the wrong file.
  • The query surface is a fixed menu everywhere. No mechanism supports a join, a projection, or a question its printer was not written to answer.
  • Two of the mechanisms are unbounded scans. .NET's marker search reads the whole executable; a crafted earlier copy of the 32-byte constant redirects the header offset.
  • Positional identity is fragile in both encodings surveyed. cargo-auditable's edges are array indices; SQLite's rowids move under VACUUM for any table without an explicit INTEGER PRIMARY KEY — including two of SELF's.
  • The whole attestation tower assumes an immutable subject, stated normatively in the in-toto Statement spec and repeated in SLSA's model, and there is no defined behaviour for an artifact that legitimately changes.
  • Detached, digest-keyed storage can go stale in ways in-band metadata cannot — the artifact and its attestation are joined by a value that a rebuild silently invalidates.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Build-id is an identity, explicitly not a checksumIt must remain stable when other tools modify the file, so it can key debug info for the original linkIt authenticates nothing; two tools can disagree about a file that both call by the same name
lld implements md5/sha1 styles with truncated BLAKE3The field has no security goals, and only the length is depended onThe style name lies about the algorithm; anyone who assumed a real SHA-1 is wrong
Build-id is a chunked hash-of-hashes over the whole output bufferParallelizable across cores on large linksNot equal to any plain digest, and computed over the note's own not-yet-written bytes; no leaf/interior domain separation
.note.package is an allocated sectionIt must reach memory, and therefore core dumps, without a package database or a networkIt occupies resident memory in every process forever, for data most processes never read
cargo-auditable marks .dep-v0 non-SHF_ALLOCCosts no runtime memory and no page faultsThe process cannot read its own dependency list; self-inspection needs /proc/self/exe
cargo-auditable encodes edges as array indices and sorts the arrayCompact, and byte-reproducible despite cargo metadata's unstable orderingNode identity is positional; any re-serialization renumbers the entire graph
Go locates its blob by a 16-byte-aligned magic scanWorks across six container formats with one code path and no per-format section-name contractUnbounded on containers without the named section; correctness depends on the linker's placement convention
Go stores the module graph as repeated tab-separated textTrivially parseable by anything, including a human reading stringsAn order of magnitude larger than the same graph normalized and compressed
Go's VCS stamping is conditional and fails openAvoids build errors in the many legitimate no-repository casesThe single most valuable field is silently absent exactly where builds are most hermetic
.NET uses a scanned marker externally and a data symbol internallyOne datum readable both by an offline tool and by the running hostTwo anchorings to keep in sync; the external one is an unbounded whole-file scan with an attacker-influenceable result
SPDX and CycloneDX define a document and not a placementKeeps the formats independent of every container and toolchainFive incompatible in-binary conventions grew in the vacuum
in-toto binds attestations to immutable subject digestsPredicate-agnostic storage and lookup, and one join key for every artifact typeMutable artifacts are outside the model entirely, normatively so
cosign deprecated SBOM attachments in favour of attestationsAn unsigned attachment adds no trust; an attestation is authenticated and typedProvenance now lives out of band and can be lost, stale, or simply never fetched
Authenticode excludes three regions and imposes a section orderingA signature cannot cover itself, and PE section order is not canonical"Is this the file I signed?" becomes a hundred-line algorithm with its own denial-of-service surface
APK v2 normalizes the EOCD's central-directory offset before hashingThe act of signing moves the central directory; the field must be canonicalized, not excludedVerifiers must reimplement the normalization exactly, or reject valid APKs
fs-verity hashes blocks, not semantics, and then freezes the fileConstant-time enablement, lazy verification, mmap reads coveredThe artifact becomes read-only; nothing in the tree survives a VACUUM-style rewrite

Sources