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.
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