Project node-addon-slsa
Expand description
node-addon-slsa
Verifies that an npm package and its prebuilt native addon binary were
produced by the same GitHub Actions workflow run. Uses sigstore for
npm provenance and the Rekor transparency log for binary
verification. Aborts npm install with a SECURITY error if any check
fails.
No authentication required. No GITHUB_TOKEN.
Private repositories: the reusable
publish.yamlworkflow logs repository name, workflow paths, commit SHAs, and run URLs to the public Rekor transparency log. Source code stays private.
Threat model
Trusts GitHub Actions (build environment, attestation authority) and the sigstore public-good instance (Fulcio CA, Rekor). If either is compromised, verification may pass for malicious artifacts.
Protected
| Threat | Mitigation |
|---|---|
| Tampered npm package | sigstore provenance verification |
| Tampered GitHub release | Rekor transparency log + sigstore |
| Mismatched artifacts | Same workflow run check via Run Invocation URI |
| Man-in-the-middle on download | SHA-256 hash verified against signed attestation |
| Path traversal via addon.path | Resolved path must stay within package directory |
Not protected
- Compromised CI workflow — attestations will be valid for malicious code. This tool verifies provenance, not intent.
- Compromised maintainer account — write access to the repository allows producing legitimately attested malicious builds.
- Dependency confusion — verifies a single package, not its transitive dependency tree.
- Version
0.0.0— verification is skipped (local development). Never publish0.0.0to npm.
Setup
1. package.json
{
"name": "my-native-addon",
"version": "1.0.0",
"repository": {
"url": "git+https://github.com/owner/repo.git"
},
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"addon": {
"path": "./dist/my_addon.node",
"manifest": "./dist/slsa-manifest.json",
"attestWorkflow": "release.yaml"
},
"scripts": {
"postinstall": "slsa wget",
"pack-addon": "slsa pack"
},
"dependencies": {
"node-addon-slsa": "0.7.1"
}
}
addon.path— where the addon is installed (relative to package root).addon.attestWorkflow— filename (no path) of the GitHub Actions workflow in your repo that mints provenance attestations (the one that runsattest-addon— see CI workflow). The verifier pins the Fulcio Build Signer URI to<repo>/.github/workflows/<attestWorkflow>@<40-hex>; attestations minted by any other workflow in the same repo (including a malicious new one) are rejected.addon.manifest— path to the generated SLSA manifest inside the published tarball. The manifest carries each platform/arch binary's download URL, sidecar sigstore bundle URL, and SHA-256; the publish workflow produces it, so do not commit it by hand.postinstall—slsa wgetreads the manifest, downloads the binary for the current platform/arch, and verifies its provenance. Pair withrequireAddon: pnpm ≥ 10 blockspostinstallscripts by default, so consumers may never run this hook.pack-addon—slsa packgzip-compresses the binary for release.repository— github.com URL (HTTPS, SSH, with or without.git). Determines the expected source repository for attestation checks.
2. CI workflow
env:
RELEASE_BASE_URL: "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/"
jobs:
# Must be ONE file named exactly as `addon.attestWorkflow` in
# package.json — the install-time verifier pins attestations to this
# exact workflow file. Don't rename it without bumping a release.
build-addon:
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-15, windows-2025]
runs-on: ${{ matrix.os }}
permissions:
contents: write # release upload
id-token: write # sigstore OIDC
attestations: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ... set up toolchain, build native addon, then:
- name: Compress binary for release
run: npx slsa pack
- name: Attest addon (public-good sigstore)
id: attest
uses: vadimpiven/node-addon-slsa/.github/actions/attest-addon@<commit-sha>
with:
binary: dist/my_addon-v*.node.gz
url-prefix: ${{ env.RELEASE_BASE_URL }}
- name: Upload binary and sidecar to release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
BINARY_PATH: ${{ steps.attest.outputs.binary-path }}
BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }}
# `--clobber` so re-running a single failed matrix cell overwrites
# any partial assets from the previous attempt instead of 422-ing.
run: gh release upload "${{ github.ref_name }}" --clobber "$BINARY_PATH" "$BUNDLE_PATH"
pack-tarball:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ... set up Node / pnpm, build JS, then:
- run: npm pack
- name: Upload pre-packed tarball
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: my-tarball # any name; passed to publish.yaml below
path: ./*.tgz
if-no-files-found: error
retention-days: 1
publish:
needs: [build-addon, pack-tarball]
uses: vadimpiven/node-addon-slsa/.github/workflows/publish.yaml@<commit-sha>
permissions:
id-token: write # npm trusted publishing
with:
tarball-artifact: my-tarball # must match the upload-artifact name
addons-artifact-pattern: slsa-addons-*
release-base-url: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/
Pin every third-party action to a commit SHA with a trailing # vX.Y.Z
comment, not a mutable tag — SHAs are immutable and audit-friendly.
Flow: each matrix runner builds its .node.gz, calls attest-addon to
hash the local binary, mint a public-good sigstore bundle covering the
future public URL, and upload a per-binary descriptor as a GHA artifact
named slsa-addons-<platform>-<arch>. The same step uploads the
binary and its .sigstore sidecar to the caller's distribution
(GitHub Releases, S3, R2 — anywhere public). publish.yaml then
downloads every matching descriptor artifact, validates each
descriptor's url against release-base-url (the trust anchor),
aggregates them into the addon URL map, re-fetches both binary and
bundle from their public URLs (with CDN-propagation retries), runs the
full sigstore verify chain (TUF → Fulcio → Rekor inclusion), pins the
Fulcio Build Signer URI to the caller's attestWorkflow, writes the
SLSA manifest into the tarball, and publishes to npm via trusted
publishing. At install time slsa wget re-fetches the binary, its
bundle, and runs the same chain — no token required because bundles
inherit the binary's auth model.
If publish fails after the GitHub release has been finalized
(immutable releases), retry only the publish job from the GHA UI:
the public binary URLs are stable, the pre-packed tarball and
descriptor artifacts persist, and publish.yaml is idempotent (npm
rejects duplicate version publishes). Do not modify or delete the
release.
3. Loading the addon
import { requireAddon } from "node-addon-slsa";
type MyAddon = { greet(name: string): string };
export const addon = await requireAddon<MyAddon>();
Walks up from the caller's file to the enclosing package.json, then
downloads and provenance-verifies the binary if missing. Subsequent
calls are a stat plus require — safe to invoke at module load.
Tdefaults tounknown; supply the addon's type at the call site.- Pass
{ from: import.meta.url }when the caller lives outside the consuming package (e.g. a re-export wrapper). RequireAddonOptionsextendsVerifyOptions; see error handling for failure modes.
API reference
CLI
| Command / Option | Purpose |
|---|---|
slsa wget |
Download, verify, and install the native addon |
slsa pack [output-template] |
Gzip-compress the native addon. Template tokens: {version}/{platform}/{arch} |
--help, -h |
Show usage information |
SLSA_DEBUG=1 |
Debug logging to stderr |
Programmatic API
import { verifyPackage, requireAddon, isProvenanceError } from "node-addon-slsa";
import type {
VerifyPackageOptions,
PackageProvenance,
VerifyOptions,
} from "node-addon-slsa";
// Verify the installed package's manifest attestation via Sigstore/Rekor.
// Returns a handle for verifying individual addon binaries.
const provenance: PackageProvenance = await verifyPackage({
packageName: "my-native-addon",
repo: "owner/repo",
});
// Verify a binary you've already hashed.
await provenance.verifyAddonBySha256(hexHash);
// Or hash-and-verify a file in one call.
await provenance.verifyAddonFromFile("/path/to/addon.node.gz");
// Runtime loader: verify-on-demand, then require the addon.
// Supply the addon's type as T (defaults to `unknown`).
const addon = await requireAddon<MyAddon>();
Options
All options have sensible defaults. Pass only what you need:
await verifyPackage({
packageName: "my-native-addon",
repo: "owner/repo",
// All below are optional:
cwd: process.cwd(), // resolution base; defaults to process.cwd()
refPattern: /^refs\/tags\/v?1\./, // RegExp or exact-match string
timeoutMs: 60_000, // per-request HTTP timeout (default: 30s)
maxBinaryBytes: 256 * 1024 * 1024, // per-binary size cap (default: 256 MiB)
maxBinarySeconds: 300, // per-binary download timeout (default: 300s)
bundleFetchRetryDelays: [2000, 5000, 10000, 15000], // retry ms for sidecar 404s
trustMaterial, // pre-loaded via loadTrustMaterial()
dispatcher, // custom undici Dispatcher
signal, // AbortSignal
});
Error handling
ProvenanceError— verification failed (tampered artifact, mismatched provenance, missing/invalid sigstore bundle). Do not retry. Thekindfield is reserved for future fine-grained discrimination; currently"other"covers every failure mode.Error— transient issue (network timeout, service unavailable). Safe to retry.
try {
await provenance.verifyAddonBySha256(sha256);
} catch (err) {
if (isProvenanceError(err)) {
// Security failure — do not use this package version
} else {
// Transient — safe to retry
}
}
Advanced: node-addon-slsa/advanced
Heavy callers verifying many packages in one process can preload trust material once and inject a verifier:
import { verifyPackage } from "node-addon-slsa";
import { loadTrustMaterial, createBundleVerifier } from "node-addon-slsa/advanced";
const verifier = createBundleVerifier(await loadTrustMaterial());
for (const name of packages) {
const p = await verifyPackage({ packageName: name, repo: "owner/repo", verifier });
await p.verifyAddonFromFile(`/path/to/${name}/dist/addon.node.gz`);
}
Requirements
- Node.js
>=22.12.0 - npm package published via the reusable
vadimpiven/node-addon-slsa/.github/workflows/publish.yamlworkflow (handles both npm provenance and per-addon Rekor attestations)
Namespaces§
Type Aliases§
- PackageProvenance
Provenance handle returned by verifyPackage.
- ProvenanceErrorKind
Discriminator for programmatic dispatch on provenance failures. Kept as a union so future causes can be added without breaking the catch shape — currently just one case.
- RequireAddonOptions
Options for requireAddon. Extends VerifyOptions with a single extra field identifying which package's addon to load.
- VerifyOptions
Consumer-side verification options. All fields optional — defaults apply to the common case. Escape hatches are for heavy callers (reusing a verifier across calls) and slow networks (timeouts / retries).
- VerifyPackageOptions
Options for verifyPackage.
Functions§
- isProvenanceError
Public API barrel for
node-addon-slsa. Consumers import from here; everything re-exported is covered by semver.- requireAddon
Returns the native addon, running the
slsa wgetflow (verify + download) first when the binary is missing on disk.- verifyPackage
Verify an installed npm package's SLSA manifest and return a handle for per-addon provenance verification. Manifest-level checks run once; the returned handle reuses them across every addon file the caller feeds in, so call
verifyPackageonce andverifyAddonFromFilefor each.nodebinary the host is about to load.
Classes§
- ProvenanceError
Thrown when provenance verification detects a security issue. The message is prefixed with
SECURITY:and includes remediation advice.kindlets callers dispatch without regex-matching the message.
Interfaces§
- Dispatcher
Dispatcher is the core API used to dispatch requests.