Ecosystems and references

For each kind of code a server runs, this page says what the attesterThe component on the machine being checked. It collects facts and reports them; it never decides the result. reads on the machine, which reference the verifierThe component that runs elsewhere, typically in CI: it sends a nonce, obtains references itself and compares. obtains, and what makes a check fail or leaves it unverifiable. It covers installed packages (npm, PyPI, RubyGems, Hex, Composer, Maven, NuGet), Go and Rust binaries, native binaries and release manifests, the official Node.js release, containers and OCIOpen Container Initiative: the standard image and registry formats. Manifests and layers are addressed by SHA-256 digest. images, and Debian and Ubuntu packages through their signed archives.

How a package check works#

Every installed-package ecosystem is a plugin with the same shape (ecosystems.INSTALLED):

SideFunctionDoes
Attesterdetect(root)Finds where packages are installed under a project root
Attesterscan(dir)Hashes what is installed there; reports facts only
VerifierreadLock(repoDir)Reads the lockfile at the deployed commit, from the verifier's own checkout; throws NoLockfileError when there is none
Verifiercompare({scan, lock, store, ...})Fetches the references the lockfile pins, checks their hashes, and compares

compare() returns the same shape for every ecosystem:

  • summary: counts by status: verified, bundled, patched, built, failed, unverifiable, error.
  • findings: one per package that did not verify, with status, package, path, reason, and up to 50 modified, missing and added files.
  • issues: other observations with a severity fail, warn or info.
  • passed: true only when nothing failed, nothing is unverifiable, nothing is error, every issue has severity warn or info, and the scan read every file (errors in a scan fail: what is not read is not compared).

The three non-passing statuses mean different things:

StatusMeaning
failedThe installed package contradicts its reference, or nothing pins it
unverifiableNo reference can exist for it (for example a package the lockfile pins without a hash)
errorThe reference exists but could not be fetched; retry

A reference whose content does not match the hash the lockfile pins is failed, not error: that is evidence of tampering somewhere.

The same loop works for every ecosystem:

JavaScript
const {ecosystems, ReleaseVerification} = require('attestium');

(async () => {
  const store = new ecosystems.ReferenceStore({cacheDir: './cache'});
  const release = new ReleaseVerification({cacheDir: './cache'});

  for (const install of ecosystems.detectInstalls('./app')) {
    const plugin = ecosystems[install.ecosystem];

    // Attester: hash what is installed.
    const scan = await plugin.scan(install.dir, {root: './app'});

    // Verifier: the lockfile at the deployed commit, from its own checkout.
    let lock = null;
    try {
      lock = plugin.readLock('./reference');
    } catch (error) {
      if (!(error instanceof ecosystems.NoLockfileError)) {
        throw error;
      }
    }

    const result = await plugin.compare({scan, lock, store, release});
    console.log(install.ecosystem, result.passed, result.summary);
    console.log(result.findings, result.issues);
  }
})();

ReferenceStore downloads references and caches their manifests in cacheDir. Its urls option replaces registry base URLs (pypi, pypiFiles, rubygems, hex, nuget, maven, packagist, crates, goproxy), for example for a mirror. Content-addressed references are checked by hash whatever the mirror.

npm (npm and pnpm)#

Attester readsnode_modules, in npm's nested or flat layout and pnpm's .pnpm store layout. Each package is hashed as a whole (a digest over its files); per-file hashes are sent only for packages the lockfile patches, allows to build, or pins to a GitHub commit, so a failure of another package does not name the changed file. Symbolic links are not followed; each is checked instead.
Referencepnpm-lock.yaml, npm-shrinkwrap.json or package-lock.json at the commit (the first found, or the one readLock(repoDir, {lockfile}) names). Each package is compared with the registry tarball whose SRI integrity the lockfile pins. A dependency pinned to a GitHub commit (codeload.github.com tarballs, git+https://github.com/...#<commit>) is compared with the archive GitHub generates for that commit.
Accounted forBundled dependencies, matched with the bundling package's tarball. pnpm patches (pnpm.patchedDependencies): the reference is the tarball with the patch applied, exactly. Packages allowed to run install scripts (pnpm.onlyBuiltDependencies) may add files but not change what they shipped. npm and pnpm rewriting a #!...\r\n first line of bin files to \n.
FailsFiles differ from the tarball; a package not in the lockfile or any bundle; an invalid package.json; a patch that does not apply; a link that does not resolve to an installed package; a link to a package other than the one the lockfile resolves its name to (see below); a non-bytecode file in a __pycache__ directory; with package-lock.json or npm-shrinkwrap.json, a package other than the one the lockfile pins at its path (Node.js resolves a name by path); in pnpm's store, a package other than the one its directory is named for; a bundled dependency outside the package that bundles it.
UnverifiableA package resolved from another git host, a directory or an unpinned tarball.
WarnsFiles in node_modules that belong to no package (nothing verified loads them); Python bytecode caches written when a build tool such as node-gyp ran Python.
Notepnpm links each name to a package in its store. The attester reports where each link points (packageLinks), and the verifier requires the package pnpm-lock.yaml resolves the name to: in the project's node_modules, the project's dependency (importer .); in .pnpm/<key>/node_modules, the dependency of the package installed there (any of its peer variants); elsewhere (hoisted links), any version the lockfile has for the name. An npm: alias names another package. A name the lockfile never declares must link to a package of that name. With package-lock.json, a link must name its package or an alias the lockfile declares.

Global packages next to the Node.js runtime (npm, corepack, pnpm, pm2) have no lockfile. ReleaseVerification#verifyGlobalPackage() compares npm and corepack with the copies inside the official Node.js archive when the installed version is the one the release shipped, and anything else with the registry tarball named by registry metadata.

To verify a project on the machine itself, without the evidence format:

JavaScript
const {ReleaseVerification} = require('attestium');

(async () => {
  const release = new ReleaseVerification({projectRoot: './app', cacheDir: './cache'});
  const result = await release.verifyModules();
  console.log(result.passed, result.details);
})();

PyPI#

Attester readsEach virtual environment (.venv, venv, env, .virtualenv, virtualenv with a pyvenv.cfg): every distribution's .dist-info, and every file its RECORD lists, hashed where it is installed (including console scripts in bin/). Files in site-packages that no RECORD claims are listed: Python runs .pth files and sitecustomize.py at start-up.
ReferenceThe first of uv.lock, pylock.toml, poetry.lock, Pipfile.lock, requirements.lock, requirements.txt (with --hash). The wheel whose tags match the installed WHEEL file is downloaded (from the lockfile's URL when it is on the pypi or pypiFiles host, otherwise found through the registry by its hash), checked against the pinned hash, and compared following the wheel install rules (.data directories, rewritten script interpreters, generated entry-point scripts).
FailsFiles differ from the pinned wheel; a package the lockfile does not pin; no pinned wheel matches the installed tags; missing METADATA or RECORD; a file in site-packages or the environment's bin/ that no package created (a RECORD line naming a directory does not account for what is in it); anything at the top of site-packages that is not a file, directory or link (a FIFO .pth file is read at start-up); a changed _virtualenv start-up hook, or one that cannot be checked.
UnverifiableThe lockfile pins no hashes for the package; the package was built on the server from a source distribution.
Infopip, setuptools and wheel installed by the environment tool and not in the lockfile, when they match their registry release, or, given the host's Debian or Ubuntu archive (compare({distro: {archive, arch}})), the distribution's patched wheel of that version (python3-pip-whl, which python3 -m venv installs).
WarnsBytecode (.pyc), which cannot be compared with anything. Install without compiling (uv's default, pip install --no-compile) and run with PYTHONDONTWRITEBYTECODE=1.

RubyGems (Bundler)#

Attester readsvendor/bundle/ruby/<version> or .bundle/ruby/<version>: every installed gem's files, its specification (a Ruby file RubyGems loads at start-up), compiled extensions, executable wrappers in bin/, RubyGems plugins.
ReferenceGemfile.lock (or gems.locked). Its CHECKSUMS section (Bundler 2.6 and later) pins each .gem file's SHA-256. The .gem is downloaded, checked, and its data.tar.gz compared with the installed directory.
FailsFiles differ from the gem; a gem not in the lockfile; a specification that is not a generated literal specification, or that differs from the gem's metadata; compiled extensions for a gem without extensions; Ruby source (.rb outside ext/) added to a gem with extensions; a bin/ file that is not RubyGems' wrapper for a verified gem; any installed RubyGems plugin (loaded into every Ruby process).
UnverifiableA gem installed from a git source.
WarnsA gem the lockfile has no checksum for, compared with the registry's checksum instead (run bundle lock --add-checksums); native extensions compiled on the server, whose output cannot be compared.

rubygems.compareGemDirectory({name, version, files, store}) compares one gem directory no lockfile pins, such as the bundler gem bundle exec puts on RUBYLIB, with the .gem the registry serves under the registry's published checksum.

Hex (Erlang, Elixir)#

Attester readsdeps/<name> next to mix.lock, with the metadata files Mix writes (.hex, .fetch).
Referencemix.lock pins each package's outer checksum, the SHA-256 of the tarball repo.hex.pm serves. The tarball is downloaded, checked, and its contents compared.
FailsFiles differ from the tarball; a dependency not in mix.lock; an entry in deps/ that is a link or special file instead of a directory.
UnverifiableA dependency fetched from git or a path; one from another Hex repository; a lock entry with only the inner checksum (update it with a current Mix).
NoteThe code that runs is compiled from these sources (_build, or a release). Verify it by reproducing the build.

Composer (PHP)#

Attester readsEvery package directory under vendor/ (vendor/<vendor>/<name>), with git blob ids, and the files Composer generates (the autoloader in vendor/composer, vendor/autoload.php, proxies in vendor/bin).
Referencecomposer.lock pins each package to a commit of its source repository. The commit's tree, read from a blobless clone (gitTrees.GitTrees), is the reference: a dist install holds the tree minus export-ignore files, a source install the whole tree.
FailsA file differs from the file at the commit, is added, or is missing; a package not in composer.lock; a file in vendor/ that belongs to no package.
Unverifiablecomposer.lock pins no source commit for the package.
WarnsThe generated autoloader, which runs first in every request, is not compared. Reproduce the build with vendor/composer/** and vendor/autoload.php as build outputs, and pass them as covered.

Git runs with hooks off, no prompts, no system or global configuration, and only https URLs.

Maven (Java)#

Attester readsDirectories of jars a JVM application loads: target/lib, target/dependency, lib, libs, Gradle's build/install/<app>/lib, and WEB-INF/lib of unpacked wars. Each jar is hashed, and its Maven coordinates read from META-INF/maven/.../pom.properties.
ReferenceJars are copied unchanged from the repository, so each jar's SHA-256 must be one the build pinned: gradle/verification-metadata.xml (Gradle dependency verification) or lockfile.json (maven-lockfile).
FailsA jar that differs from the one the build pinned; a jar the build does not pin that names no coordinates; a jar that differs from Maven Central's for its coordinates; links or other entries named like jars.
ErrorMaven Central could not be reached for an unpinned jar.
WarnsJars the build does not pin, compared with Maven Central instead (this trusts the registry, not the repository); other files next to the jars.
NoteThe application's own jar is build output: verify it by reproducing the build, and pass it as covered.

NuGet (.NET)#

Attester readsA published .NET application: the directory holding <app>.deps.json and <app>.runtimeconfig.json (a publish or out directory), hashed file by file.
Referencepackages.lock.json (RestorePackagesWithLockFile) pins each package's content hash, the SHA-512 of the .nupkg. Every locked package is downloaded and checked, and each assembly or native library in the published directory must be a file one of them ships, unchanged.
FailsA file that differs from the file of the same name in the locked packages (assemblies and native libraries are recognized whatever the case of their extension); a file no locked package ships and no build reproduces; no packages.lock.json; a locked package that could not be downloaded or checked.
WarnsBuild output that decides what the runtime loads (deps.json, runtimeconfig.json), and the application's own assembly and apphost, are verified only by reproducing the build.

Go binaries#

A Go binary (Go 1.18 and later) records the module it was built from, every dependency with its go.sum hash, and build settings. elf.goBuildInfo() reads that record from the file; ecosystems.go.compareBuildInfo() checks it against go.sum at the deployed commit.

JavaScript
const fs = require('node:fs');
const {execFileSync} = require('node:child_process');
const {elf, ecosystems} = require('attestium');

// Attester: the build information inside the running binary.
const info = elf.goBuildInfo(fs.readFileSync('./hello'));

// Verifier: go.mod, go.sum and the commit, from its own checkout.
const lock = ecosystems.go.readLock('./goref');
const commit = execFileSync('git', ['-C', './goref', 'rev-parse', 'HEAD'], {encoding: 'utf8'}).trim();

const result = ecosystems.go.compareBuildInfo({info, lock, commit, label: 'hello'});
console.log(info.goVersion, info.main, info.settings['vcs.revision']);
console.log(result.passed, result.summary, result.issues);
ResultWhen
FailsA dependency (after replacements) not in go.sum, or with another hash; built from another module; built from another commit (vcs.revision); built from a modified tree (vcs.modified=true)
UnverifiableA dependency replaced by a local directory
InfoNo commit recorded (-buildvcs=false); built without -trimpath, so it will not reproduce byte for byte
WarnsGo 1.17 or earlier, which stores no inline build information

The record is written by the build, so a binary could claim anything. The binary's own hash proves what it is: a reproduced build, an attested artifact, or a signed checksum. This check shows that the binary was built with the pinned dependencies, and flags a build from another commit or a modified tree.

Rust binaries#

A binary built with cargo-auditable records every crate compiled into it in a .dep-v0 section. elf.cargoAuditable() reads it; ecosystems.cargo.compareAuditable() checks it against Cargo.lock at the commit.

JavaScript
const fs = require('node:fs');
const {elf, ecosystems} = require('attestium');

// Attester: the crate list inside the binary (null when it has none).
const packages = elf.cargoAuditable(fs.readFileSync('./rustapp/target/debug/rustapp'));

// Verifier: Cargo.lock at the commit.
const lock = ecosystems.cargo.readLock('./rustapp');
const result = ecosystems.cargo.compareAuditable({packages, lock});
console.log(result.passed, result.summary, result.findings);

A crate fails when it is not in Cargo.lock, or comes from another source than the lockfile says. A registry crate whose lockfile entry has no checksum, or a git crate pinned to no commit, is unverifiable. As with Go, the binary's own hash is what proves it.

Native binaries#

A native binary (a database server, a proxy, a tool from a release page) is explained by one of:

  • its owning distribution package (see Debian and Ubuntu packages);
  • an entry in the project's published checksum list, preferably signed (checksums.fetchChecksums(), see Signatures);
  • a GitHub artifact attestation for its SHA-256 (attestations.verifyGithubAttestation());
  • a pinned SHA-256 in the verifier's configuration;
  • a reproduced build.

For your own releases deployed without git, publish a release manifest from CI and attest it. See Release manifests and SPEC.md.

Language runtimes#

The attester detects each process's runtime from its executable name and mapped libraries (libjvm.so is the JVM, whatever the launcher is called): Node.js, Python, JVM, Ruby, .NET, BEAM, PHP, Perl, Deno, Bun, or native. For each, ProcessIntegrity reports that runtime's code injection vectors: environment variables that load code (NODE_OPTIONS, PYTHONPATH, JAVA_TOOL_OPTIONS, RUBYOPT, DOTNET_STARTUP_HOOKS, ERL_FLAGS, PHP_INI_SCAN_DIR, PERL5OPT, and the dynamic linker's LD_PRELOAD and LD_AUDIT), command-line options that do the same, debugger and management ports, and attach mechanisms.

The runtime binary itself is explained like any executable. Node.js has an official release reference:

JavaScript
const {ReleaseVerification} = require('attestium');

(async () => {
  const release = new ReleaseVerification({cacheDir: './cache'});
  // In a verifier, pass the executable's sha256 and the version from the evidence.
  const result = await release.verifyNodeRelease();
  console.log(result.passed, result.details);
})();

verifyNodeRelease() downloads SHASUMS256.txt for the version, the official .tar.gz for the platform, checks the archive against the list, and compares bin/node inside it with the binary (on Windows, node.exe is listed directly). Set nodeKeyring to a keyring of the Node.js release keys to require a valid signature on SHASUMS256.txt (checked with gpgv). A binary repackaged by a distribution differs from the official one; explain it by its distribution package instead.

Other runtimes are explained by their distribution package, their project's signed checksums, or a pinned hash.

Containers and OCI images#

For a process in a container, the attester reports:

  • the container, from the process's cgroup (containers.containerOf(): Docker, containerd, CRI-O, Podman, Kubernetes);
  • the image the runtime says it started from (containers.inspectDocker(), or containers.inspectCri() through crictl);
  • mounts that bring in files from outside the image;
  • the overlay's writable layer: files written and files deleted (containers.walkUpper());
  • every file of the root filesystem, hashed through /proc/<pid>/root, other mounts left out (containers.walkRootfs()).

The verifier does not trust the runtime's claims. It fetches the image by digest from the registry, checks every manifest and layer against the digest that names it, applies the layers in order (whiteouts included), and compares the files:

JavaScript
const {containers, oci} = require('attestium');

(async () => {
  const pid = process.argv[2]; // a process inside the container

  // Attester: the container, the image the runtime names, the files it runs from.
  const {id} = containers.containerOf(pid);
  const inspected = await containers.inspectDocker(id);
  const rootfs = await containers.walkRootfs(pid);
  const repoDigest = inspected.image.repoDigests[0]; // registry/repository@sha256:...

  // Verifier: the image, fetched by digest; the platform manifest; every layer applied.
  const registry = new oci.Registry();
  const reference = oci.parseReference(repoDigest);
  const {manifest} = await registry.platformManifest(reference, inspected.platform);
  const expected = await oci.imageFiles(registry, reference, manifest);

  // Files the runtime writes into every container are not image files.
  const runtimeFiles = new Set(['.dockerenv', 'etc/hostname', 'etc/hosts', 'etc/resolv.conf']);
  const result = oci.compareRootfs(rootfs.files, expected, {ignore: file => runtimeFiles.has(file)});
  console.log(result); // {modified, missing, added, modeChanged}
})();

Pass credentials to oci.Registry for private registries ({token} or {username, password} per registry name), and endpoints to map a registry name to a mirror. A registry with no configured endpoint is a name taken from evidence: it, and the token service it names, is never reached at a private address (loopback, RFC 1918, link-local, cloud metadata) unless httpOptions.denyPrivateAddresses is false. Code in a volume or bind mount is not part of the image; explain it as a directory service or report it.

Debian and Ubuntu packages#

The runtime and the shared libraries a process maps usually come from the distribution. The reference is the distribution's signed archive:

  • The attester finds the owning package of each file in the dpkg database (distro.DpkgDatabase#ownerOf()), with its version, architecture, the path it is listed as (merged-/usr aliases included), and when it was installed.
  • The verifier checks the archive's InRelease signature with the distribution's keyring (gpgv, reading its status lines from a descriptor of their own, so a signature by a revoked or expired key, which gpgv accepts with exit status 0, is refused), follows the SHA-256 chain to the Packages index and to the .deb, extracts it with dpkg-deb, and compares the file.
JavaScript
const fs = require('node:fs');
const {distro, ecosystems, util} = require('attestium');

(async () => {
  const file = '/usr/lib/x86_64-linux-gnu/libc.so.6';

  // Attester: the owning package, from the dpkg database, and the file's hash.
  const owner = new distro.DpkgDatabase().ownerOf(file);
  const sha256 = util.sha256(fs.readFileSync(file));

  // Verifier: the same file inside that package, from the signed archive.
  const release = distro.osRelease();
  const archives = distro.defaultArchives(release, owner.arch);
  const store = new ecosystems.ReferenceStore({cacheDir: './cache'});
  const reference = new distro.ArchiveReference({archives, store});
  const files = await reference.files(owner.name, owner.version, owner.arch, owner.installedAt);

  const expected = files[owner.listedAs] ?? files[file];
  console.log(owner, expected === sha256 ? 'matches the archive' : 'differs from the archive');
})();

The dpkg database is the server's claim. The package it names must hold the file with the same contents, so a false claim fails. A file owned by no package is reported as unexplained.

defaultArchives() returns the release's main, updates and security suites, with the keyrings in /usr/share/keyrings (install debian-archive-keyring or ubuntu-keyring on the verifier). Archive indexes may be fetched over plain HTTP: their contents are authenticated by the signature and the hash chain, not by the transport.

A version that has since been superseded is no longer in the archive. ArchiveReference#files() then looks it up in the archive's snapshot service (snapshot.debian.org, snapshot.ubuntu.com) at the hour the package was installed, and a day later. When neither has it, it throws with code ENOTINARCHIVE: upgrade the package, or configure an archive snapshot that has it.

Edit this page on GitHub