Supply-chain security of the build and release pipeline¶
This page is about how disarm itself is built and published: which jobs can publish to which registry, what code runs next to those credentials, and what the repository does to keep the two apart. What a consumer can verify about a published artifact (PEP 740 attestations, the SBOM, the dependency policy gate) is in SECURITY.md.
It was written after the September 2026 compromise of MemTensor's npm and PyPI packages, and the last section lists what the repository owner has to configure on the registries and in GitHub, because a workflow file cannot do it.
The attack this responds to¶
On 23 September 2026 malicious versions of two MemTensor packages were published:
@memtensor/memos-cloud-openclaw-plugin on npm (0.1.21, 0.1.23 and 0.1.25) and
MemoryOS on PyPI (2.0.34). As reported by SafeDep, StepSecurity, Aikido and The
Hacker News:
- Infection vector. The attacker obtained the npm and PyPI publish tokens from MemTensor's own GitHub Actions release pipelines, by pushing commits that made the release job hand its token to the attacker before the job published anything. The maintainers' issue report notes that the npm payload existed only in the published artifacts, with no matching tag or commit on the default branch.
- Payload. A Go implant,
sckit(also called the "supplychain.local worm"), bundled as per-platform binaries and started in the background whenever the package loads. The npm variant addslib/sckit.js, which spawns the binary detached, passes it the process environment, and explicitly readsNPM_TOKENandNODE_AUTH_TOKEN. - What it steals. Credentials from developer machines and from CI jobs, collected
from the home directory: cloud, source-hosting, package-registry and developer-tool
secrets. They go to servers under
skyleen[.]fr. - Propagation. It carries templates to install itself into npm packages, Python packages and GitHub Actions workflows, and copies itself into whatever repositories and packages the stolen credentials reach.
Sources: SafeDep, SafeDep on the sckit framework, StepSecurity, The Hacker News, and the maintainers' issue report.
The general lesson is older than this worm: a job that holds a publish credential must not run code it did not write. That code can be a pushed workflow change, a dependency's install hook or build script, a poisoned cache, a moved action tag, or a string from an event payload spliced into a shell script.
Where the credentials are¶
| Registry | Workflow and job | Credential | Environment |
|---|---|---|---|
PyPI (disarm) |
publish.yml / publish |
OIDC trusted publishing, id-token: write |
pypi |
PyPI (translit-rs shim) |
publish-translit-rs.yml / publish |
OIDC trusted publishing | pypi |
| crates.io | publish.yml / publish-crate |
OIDC trusted publishing when configured, else the CRATES_IO_API_TOKEN_001 secret |
crates-io |
| npm | publish-node.yml / publish |
OIDC trusted publishing, id-token: write |
npm |
| RubyGems | publish-ruby.yml / publish |
OIDC trusted publishing, id-token: write |
rubygems |
| Maven Central | publish-java.yml / publish |
MAVEN_GPG_PRIVATE_KEY, MAVEN_GPG_PASSPHRASE, CENTRAL_PORTAL_USERNAME, CENTRAL_PORTAL_PASSWORD |
maven-central |
| GitHub Release assets | publish.yml / sbom-attach |
GITHUB_TOKEN, contents: write |
none |
| Docs site | docs.yml / build-deploy |
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID |
none |
Two more jobs can change the repository without publishing anything:
perf-gate.yml's perf-record pushes to the perf-results branch, and
dependabot-auto-merge.yml arms auto-merge on Dependabot's pull requests. The rest
read the repository and write, at most, metadata beside it: code-scanning results
(CodeQL), an issue (the RustSec audit and the nightly Hypothesis run), a cache deletion
(cache-cleanup.yml), or a preview comment (docs.yml).
What the workflows enforce¶
Publish jobs build nothing. The PyPI, RubyGems, npm and translit-rs publish jobs
download artifacts that other, credential-free jobs built. publish-crate runs
cargo publish --no-verify after a separate verify-crate job has built the package,
so no dependency's build script or proc-macro runs beside the token. The npm publish
job still installs the Node toolchain to emit the loader and types, with
npm ci --ignore-scripts, so no dependency's install hook runs while an OIDC token can
be requested.
No caches in the credential and artifact jobs that can do without. A restored
target/ can carry compiled build scripts and a restored package cache can carry
anything its writer put there. The crates.io, SBOM and npm release jobs restore no
cache. The Ruby gem builds still do; see the last section.
Every action is pinned to a full commit SHA, with the version in a comment, and
Dependabot's github-actions ecosystem proposes the updates.
Least-privilege tokens. Every workflow defaults to contents: read, and a write
permission is granted to the one job that needs it. Where a job used to build or run
code and write in the same breath, it is now two jobs: the perf measurement and the
SBOM generation run read-only and hand their output to a separate job that only writes.
docs.yml is the remaining exception; see the last section.
No persisted checkout token. Every actions/checkout sets
persist-credentials: false, except perf-record, which pushes and runs nothing else.
No template injection. Event and ref data reach run: scripts through env:,
never by ${{ }} expansion inside the script text.
Validated binaries. The Gradle wrapper jar is checked against Gradle's published
checksums before ./gradlew runs. Downloaded tools (TLC, actionlint) are checked
against a pinned SHA-256, and the docs build, the perf comparators and the
workflow-lint tools install from hash-locked requirements files.
A gate that keeps it that way. The workflow-lint job in ci.yml runs actionlint
(with shellcheck over every run: script) and zizmor at medium severity and above, and
the required All checks passed check fails if either reports anything. Both tools are
pinned and zizmor runs offline, so the gate changes only when a workflow does. A
deliberate exception is an inline # zizmor: ignore[<audit>] comment with its reason.
What the owner has to configure¶
None of this can be done from a workflow file. Items are ordered by how much they reduce exposure.
1. Restrict the publishing Environments to release refs¶
This is the control that addresses the MemTensor vector directly. A repository secret is available to a workflow run on any branch of this repository, and a Trusted Publisher configured without an environment accepts a token from its workflow file on any branch, including a branch someone pushes with a modified copy of that file. A secret or publisher bound to an Environment with deployment rules is not.
In Settings, Environments, for each of pypi, crates-io, npm, rubygems and
maven-central:
- Deployment branches and tags: "Selected branches and tags", with a tag rule
v*and a branch rulemain. The release trigger runs on the tag;mainkeeps the documentedworkflow_dispatchrecovery path in RELEASING.md working. - Required reviewers: optional. It adds one approval click per registry per
release, and it is the strongest control for
crates-ioandmaven-centralwhile they still use long-lived secrets.
The npm environment is new with this change; GitHub creates it, without rules, on the
first run that references it.
Then protect the tags themselves: a tag ruleset targeting v* that restricts
creation, update and deletion to the maintainers, so a deployment rule on v* means
something.
2. Move the long-lived secrets into their Environments¶
Under Settings, Secrets and variables, Actions, recreate as environment secrets and delete the repository-level copies:
CRATES_IO_API_TOKEN_001intocrates-io(until step 3 removes it);MAVEN_GPG_PRIVATE_KEY,MAVEN_GPG_PASSPHRASE,CENTRAL_PORTAL_USERNAMEandCENTRAL_PORTAL_PASSWORDintomaven-central.
A repository secret is readable by every workflow in the repository; an environment secret only by jobs that name the environment and pass its deployment rules. The jobs already name them, so no workflow change is needed. Maven Central offers no trusted publishing, so the Maven secrets stay, and this is their main protection.
CLOUDFLARE_API_TOKEN is the one secret that has to stay at repository level while
docs.yml deploys pull-request previews, because preview deploys run on pull-request
branches. Scope the token in Cloudflare to the disarm-docs Pages project only.
3. Configure crates.io trusted publishing, then delete the token¶
On crates.io, crate disarm, Settings, Trusted Publishing, Add:
| Field | Value |
|---|---|
| Publisher | GitHub |
| Repository owner | raeq |
| Repository name | disarm |
| Workflow filename | publish.yml |
| Environment | crates-io |
publish-crate already tries the OIDC exchange first
(rust-lang/crates-io-auth-action) and falls back to the secret only when the exchange
fails. After the next release has published through trusted publishing (its log shows
the exchange step succeeding), delete CRATES_IO_API_TOKEN_001 from GitHub and revoke
the token on crates.io under Account Settings, API Tokens. From then on a failed
exchange fails the publish instead of falling back. If crates.io offers a setting to
require trusted publishing for the crate, enable it.
4. Check the Trusted Publishers that already exist¶
| Registry and project | Owner | Repository | Workflow | Environment |
|---|---|---|---|---|
PyPI disarm |
raeq |
disarm |
publish.yml |
pypi |
PyPI translit-rs |
raeq |
disarm |
publish-translit-rs.yml |
pypi |
npm disarm |
raeq |
disarm |
publish-node.yml |
npm |
RubyGems disarm |
raeq |
disarm |
publish-ruby.yml |
rubygems |
A publisher with an empty environment accepts a token from any job in that workflow
file, so filling it in is what ties the registry to the Environment rules in step 1.
The npm publisher is the one to check first: until now its job had no environment, so
the field is probably empty. If it names a different environment, set it to npm,
because the job now runs in npm and a mismatch fails the publish.
Then remove the token paths the publishers replaced: on npm, under the package's Settings, Publishing access, choose "Require two-factor authentication and disallow tokens"; on PyPI and RubyGems, revoke any API token with upload or push scope for these projects.
5. Repository settings¶
- Settings, Actions, General:
- Workflow permissions: "Read repository contents and packages permissions". Every workflow here sets its own permissions, so this changes nothing today and protects a future workflow that forgets to.
- Leave "Allow GitHub Actions to create and approve pull requests" off.
dependabot-auto-merge.ymlarms auto-merge and approves nothing. - Fork pull request workflows: "Require approval for all external contributors".
- Actions permissions: allow only actions pinned to a full-length commit SHA, if the
option is offered. The
workflow-lintjob enforces the same rule on pull requests; the setting enforces it on every run. - Settings, Code security: secret scanning with push protection, Dependabot alerts and Dependabot security updates.
Considered and not done¶
- Egress filtering (
step-security/harden-runner). Blocking outbound traffic from the publish jobs would stop an exfiltration like sckit's outright. It was not added: audit mode blocks nothing, and block mode needs an allow-list learned from a few audited releases, which a change made without running a release cannot supply. It would also put a third-party action, running withsudo, first in exactly the jobs this page is protecting. It is a reasonable next step once someone can watch two releases in audit mode and build the allow-list from what they see. - Committing
Cargo.lockandGemfile.lock. Without a lockfile every CI run and every release resolves the newest matching version of each dependency at that moment, so a dependency version published an hour before a release is built into it, and Dependabot's cooldown does not apply. Committing the lockfiles, and building with--locked, fixes that. It is a policy change for a library crate, with effects on the MSRV hold in.github/dependabot.yml, the audit jobs and the SBOM, so it is proposed here rather than made. - Signing the Maven artifacts outside Gradle. The Maven publish job runs Gradle,
and its plugins, with the signing key in the environment. Moving signing into a
separate job with plain
gpgwould take the key out of the build. It changes the publishing flow, so it is left for a change that can be tested against the Central Portal. - Hash-locking the remaining
pip installs (maturin, the test extras). They run in jobs with no secrets and a read-only token, where the build and docs requirements files already set the pattern to follow. distributionSha256Sumfor the Gradle wrapper, so the downloaded distribution is checked as well as the wrapper jar. Regenerate the wrapper with./gradlew wrapper --gradle-version <v> --gradle-distribution-sha256-sum <sha>, using the checksum Gradle publishes for that distribution.- Splitting
docs.yml. One job builds the site from the pull request (mkdocs runs the repository's own hooks) and then deploys it with the Cloudflare token and apull-requests: writetoken. Fork and Dependabot pull requests get neither, and the docs requirements are hash-locked, so the exposure is a same-repository branch or a compromised hook. A build job that uploadssite/and a deploy job that only downloads it would close it.cloudflare/wrangler-actionalso installswranglerfrom npm at run time; itswranglerVersioninput can pin it. - Dropping the bundler and cargo caches from the Ruby gem builds
(
publish-ruby.yml,cross-gemsandsource-gem). These jobs hold no credential, but their output ships.bundler-cache: trueis also what runsbundle installthere, so removing it needs an explicit install step and a release to test it on. - A checksum for the Lean toolchain tarball in
formal.yml, as the TLC download already has. It runs in a job with no secrets. - Build-provenance attestations for the gem, the JAR and the crate
(
actions/attest-build-provenance). PyPI and npm already carry provenance; the other three registries do not verify it, so the benefit is a GitHub-side record only.