Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/pm/security-scanner-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ bun install

Consult your scanner's documentation for which environment variables to set and any other required configuration.

Bun runs the scanner in a separate process. That process gets the environment variables of your shell. The `--no-env-file` and `--env-file` flags of `bun install` and the other package manager commands also apply to the scanner:

- `--no-env-file`: Bun loads no [`.env` file](/runtime/environment-variables) for the scanner. `env = false` in `bunfig.toml` does the same.
- `--env-file`: Bun passes the values of the files you name to the scanner process as environment variables.

The scanner can still read a `.env` file itself. If your scanner takes its token from `.env`, set the token in your shell or name the file with `--env-file`.

### Authoring your own scanner

For a complete example with tests and CI setup, see the official template:
Expand Down
2 changes: 1 addition & 1 deletion docs/runtime/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ process.env.FOO = "hello";

## Manually specifying `.env` files

The `--env-file` flag overrides which `.env` files Bun loads. It works when running a file with `bun`, when running `package.json` scripts, and with `bun install` and the other package manager commands (where the loaded values are used for `$VARIABLE` substitution in `bunfig.toml`).
The `--env-file` flag overrides which `.env` files Bun loads. It works when running a file with `bun`, when running `package.json` scripts, and with `bun install` and the other package manager commands (where the loaded values are used for `$VARIABLE` substitution in `bunfig.toml`, and are passed to lifecycle scripts and the security scanner as environment variables).

```sh
bun --env-file=.env.1 src/index.ts
Expand Down
13 changes: 10 additions & 3 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1926,7 +1926,7 @@ pub fn init(
fs::EntriesOption::Err(e) => return Err(e.canonical_error.into()),
};

let env: &mut dot_env::Loader = if cli.env_files.is_empty() {
let (env, env_files): (&mut dot_env::Loader, options::EnvFiles) = if cli.env_files.is_empty() {
let env = new_env_loader();
env.load_process()?;
let env_suffix = default_env_file_suffix(env);
Expand All @@ -1944,11 +1944,17 @@ pub fn init(
)
};
env.load(&env_probe_keys, &[], env_suffix, skip_default_env)?;
env
let env_files = if skip_default_env {
options::EnvFiles::Disabled
} else {
options::EnvFiles::Default
};
(env, env_files)
} else {
// SAFETY: `load_explicit_env_files` stored this loader when `init()`
// started; nothing else holds a reference to it yet.
unsafe { &mut *holder::ENV_LOADER.load() }
let env = unsafe { &mut *holder::ENV_LOADER.load() };
(env, options::EnvFiles::Explicit)
};

initialize_store();
Expand Down Expand Up @@ -2008,6 +2014,7 @@ pub fn init(
max_concurrent_lifecycle_scripts: cli
.concurrent_scripts
.unwrap_or((cpu_count * 2) as usize),
env_files,
..Default::default()
};

Expand Down
16 changes: 16 additions & 0 deletions src/install/PackageManager/PackageManagerOptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ pub enum OfflineMode {
Offline,
}

/// Which `.env` files this install loaded.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub enum EnvFiles {
/// The default files of the project root.
#[default]
Default,
/// `--no-env-file` / bunfig `env = false`: none.
Disabled,
/// `--env-file`: only the named files.
Explicit,
}

// `string` fields are `[]const u8` borrowed from CLI args / bunfig config,
// which live for the process lifetime. There is no `deinit` on Options. Mapped to
// `&'static [u8]` per PORTING.md (no lifetime params on structs).
Expand Down Expand Up @@ -103,6 +115,9 @@ pub struct Options {
// Security scanner module path
pub security_scanner: Option<&'static [u8]>,

/// The security scanner child follows this: it is a `bun` that loads `.env` files itself.
pub(crate) env_files: EnvFiles,

// Minimum release age in ms (security feature)
// Only install packages published at least N ms ago
pub minimum_release_age_ms: Option<f64>,
Expand Down Expand Up @@ -178,6 +193,7 @@ impl Default for Options {
hoist: true,
offline: OfflineMode::Online,
security_scanner: None,
env_files: EnvFiles::Default,
minimum_release_age_ms: None,
minimum_release_age_excludes: None,
cpu: Npm::Architecture::CURRENT,
Expand Down
120 changes: 70 additions & 50 deletions src/install/PackageManager/security_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ use bstr::BStr;
// `bun_io::Loop` is the trait's nominal: `us_loop_t` on POSIX, `uv_loop_t`
// on Windows. The inherent `loop_()` projects `.uv_loop` from the uws wrapper
// on Windows so `BufferedReaderParent::loop_` returns the libuv loop directly.
use core::ffi::CStr;
use std::ffi::CString;

use crate::Error;
use crate::bun_fs::FileSystem;
use crate::bun_json::{Expr, ExprData};
Expand All @@ -29,14 +32,14 @@ use bun_ptr::{RefCount, RefPtr};
use bun_spawn::SpawnResultExt as _;
use bun_spawn::subprocess::{self, StdioResult};
use bun_spawn::{
self as spawn, Exited, Process, ProcessExit, ProcessExitKind, ProcessHandle, Rusage,
self as spawn, Exited, Process, ProcessExit, ProcessExitKind, ProcessHandle, Rusage, SpawnEnv,
SpawnOptions, Status, Stdio,
};
use bun_sys::{self, Fd, FdExt as _};

use crate::hoisted_install as HoistedInstall;
use crate::isolated_install as IsolatedInstall;
use crate::package_manager_real::package_manager_options::Do;
use crate::package_manager_real::package_manager_options::{Do, EnvFiles};

#[inline]
fn signal_name(raw: u8) -> &'static str {
Expand Down Expand Up @@ -255,6 +258,33 @@ pub fn perform_security_scan_for_all(
)
}

/// What the scanner child, a `bun -e` in the project root, is told about `.env` files.
enum ScannerEnv {
/// Nothing: it loads the default files itself.
Default,
/// `--no-env-file`.
NoEnvFile,
/// `--no-env-file`, and the environment with the `--env-file` values.
Resolved(bun_dotenv::NullDelimitedEnvMap),
}

impl ScannerEnv {
fn new(manager: &PackageManager) -> Result<Self, Error> {
Ok(match manager.options.env_files {
EnvFiles::Default => Self::Default,
EnvFiles::Disabled => Self::NoEnvFile,
EnvFiles::Explicit => {
let env = manager.env_mut();
// `--env-file` copies the environment before bunfig `[run] noOrphans` sets this.
if bun_io::parent_death_watchdog::is_enabled() {
env.map.put(b"BUN_FEATURE_FLAG_NO_ORPHANS", b"1")?;
}
Self::Resolved(env.map.create_null_delimited_env_map()?)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) Users of bun install --env-file with a scanner and one large env value now see the whole install abort with security scanner failed: E2BIG where the base install completed. security_scanner.rs:282 snapshots the entire loader map into envp and :1081 passes it as SpawnEnv::Strings, so a single KEY=VALUE over Linux MAX_ARG_STRLEN (128 KiB) makes execve fail. The base child inherited environ and loaded .env itself, so the value never reached execve. Fix: do not turn the scan into a hard install failure on an oversized env entry — skip or truncate the oversized value with a warning, or fall back to Inherit plus --env-file on the child argv.

Why this was flagged

Trigger: bun install --env-file=ci.env (or add/update/remove/pm scan) with [install.security] scanner configured and ci.env containing a value longer than MAX_ARG_STRLEN (128 KiB on Linux), e.g. a base64 blob or inlined key material; the author's own measurement shows a 200,000-byte value fails. ScannerEnv::new at security_scanner.rs:276-282 calls create_null_delimited_env_map() over the full loader map and spawn at :1079-1081 hands it to spawn_process_cstr as SpawnEnv::Strings, so execve returns E2BIG and attempt_security_scan_with_retry propagates it as an install error. On the base the scanner was spawned with the inherited environ and the oversized value stayed inside the parent's loader, so the install succeeded. The dismissal argued lifecycle scripts already fail on the same map (PackageManagerLifecycle.rs:399), but that only applies to projects that run lifecycle scripts; projects with --ignore-scripts or no scripts newly fail. Remedy: degrade quietly (warn and drop the oversized entry) instead of aborting the install.

Verification: Trigger: bun install run with --env-file=<file> where the file holds one KEY=VALUE over 128 KiB and [install.security] scanner is configured. security_scanner.rs:276-282 snapshots every loader entry; :1077-1083 passes it as SpawnEnv::Strings to execve, which fails with E2BIG, and install_with_manager.rs:2135-2143 calls Global::exit(1), aborting the whole install. The base passed bun_sys::environ_ptr(), so the install completed. No test pins it.

}
})
}
}

fn scan_installing_scanner_if_needed(
manager: &mut PackageManager,
security_scanner: &[u8],
Expand All @@ -263,13 +293,17 @@ fn scan_installing_scanner_if_needed(
command_ctx: CommandContext,
original_cwd: &[u8],
) -> Result<Option<SecurityScanResults>, Error> {
// Built once: the partial install below can add script variables to the loader.
let env = ScannerEnv::new(manager)?;

let result = attempt_security_scan(
manager,
security_scanner,
scan_all,
seeds,
command_ctx,
original_cwd,
&env,
)?;

match result {
Expand All @@ -288,6 +322,7 @@ fn scan_installing_scanner_if_needed(
command_ctx,
original_cwd,
true,
&env,
)?;
match retry_result {
ScanAttemptResult::Success(scan_results) => Ok(Some(scan_results)),
Expand Down Expand Up @@ -793,6 +828,7 @@ fn attempt_security_scan(
seeds: &[PackageID],
command_ctx: CommandContext,
original_cwd: &[u8],
env: &ScannerEnv,
) -> Result<ScanAttemptResult, Error> {
attempt_security_scan_with_retry(
manager,
Expand All @@ -802,6 +838,7 @@ fn attempt_security_scan(
command_ctx,
original_cwd,
false,
env,
)
}

Expand All @@ -813,6 +850,7 @@ fn attempt_security_scan_with_retry(
command_ctx: CommandContext,
original_cwd: &[u8],
is_retry: bool,
env: &ScannerEnv,
) -> Result<ScanAttemptResult, Error> {
if manager.options.log_level == crate::package_manager::Options::LogLevel::Verbose {
bun_core::pretty_errorln!(
Expand Down Expand Up @@ -910,7 +948,7 @@ fn attempt_security_scan_with_retry(
});
// Cleanup of code/json_data/process handled by `Drop for SecurityScanSubprocess` when Box drops.

scanner.spawn()?;
scanner.spawn(env)?;

// `sleep_until` takes `*mut PackageManager` + `fn(&mut C) -> bool`; pass the
// boxed scanner as the closure context and a fn pointer that probes `is_done`.
Expand Down Expand Up @@ -1004,7 +1042,7 @@ bun_io::impl_buffered_reader_parent! {
}

impl<'a> SecurityScanSubprocess<'a> {
pub(crate) fn spawn(&mut self) -> Result<(), Error> {
fn spawn(&mut self, env: &ScannerEnv) -> Result<(), Error> {
self.ipc_data = Vec::new();
let parent: *mut Self = self;
self.ipc_reader.set_parent(parent.cast());
Expand All @@ -1025,36 +1063,32 @@ impl<'a> SecurityScanSubprocess<'a> {

let exec_path = bun_core::self_exe_path()?;

// Build
// owned NUL-terminated buffers so the pointers stay valid across the
// `spawn_process` FFI boundary.
let mut argv0_buf: Vec<u8> = exec_path.as_bytes().to_vec();
argv0_buf.push(0);
let mut argv3_buf: Vec<u8> = self.code.to_vec();
argv3_buf.push(0);
// Element type MUST be bare `*const c_char` (null sentinel), never
// `Option<*const c_char>`: raw pointers are already nullable, and
// `Option<*const T>` is a 2-word (tag, ptr) pair — casting that to
// `Argv` interleaves discriminant words and EFAULTs in the kernel.
let mut argv: [*const core::ffi::c_char; 5] = [
argv0_buf.as_ptr().cast(),
c"--no-install".as_ptr(),
c"-e".as_ptr(),
argv3_buf.as_ptr().cast(),
core::ptr::null(),
];
const _: () = assert!(
core::mem::size_of::<[*const core::ffi::c_char; 5]>()
== 5 * core::mem::size_of::<usize>()
);
let code = CString::new(&*self.code).map_err(|_| crate::Error::InvalidCharacter)?;
let mut argv: Vec<&CStr> = Vec::with_capacity(5);
argv.push(exec_path.as_cstr());
argv.push(c"--no-install");
if !matches!(env, ScannerEnv::Default) {
argv.push(c"--no-env-file");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟣 pre-existing, not blocking: CI users who steer bun with BUN_OPTIONS=--env-file=ci.env from a workspace member now get a scanner that is told --no-env-file and still re-reads ci.env relative to the project root, hanging when that root file is a FIFO (a missing file is silently skipped). At src/install/PackageManager/security_scanner.rs:1071 the child gets --no-env-file, but the envp built at :1080 still contains BUN_OPTIONS, which the child parses again in the root cwd. The PR claims the explicit case now follows the install's env decision, yet this route makes the child read a second, different file. Fix: strip or neutralise BUN_OPTIONS (and --env-file within it) from the scanner child's environment in Explicit and Disabled modes so the child's .env set is exactly the parent's.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: BUN_OPTIONS=--env-file=ci.env bun install run from packages/a where packages/a/ci.env exists but <root>/ci.env does not, or is a FIFO, with a scanner configured. PackageManager::init loads the member file via load_explicit_env_files (src/install/PackageManager.rs:1491) and records EnvFiles::Explicit. ScannerEnv::new copies the loader, including the inherited BUN_OPTIONS, at security_scanner.rs:282; spawn adds --no-env-file at :1071 and passes the copy at :1080. The child runs in the project root, parses BUN_OPTIONS again, and opens <root>/ci.env: a missing file errors the scan, a FIFO blocks it. The dismissal calls this pre-existing because the base child also inherited BUN_OPTIONS, but the PR's stated contract is that with --env-file the child gets only the install's values; the test file records the gap only as test.failing (test/cli/install/bun-install-env-file.test.ts:494). Remedy: remove BUN_OPTIONS from the resolved map, or pass an internal flag that makes the child skip it.

Verification: Pre-existing; acknowledged in diff: the test.failing case at test/cli/install/bun-install-env-file.test.ts:479-494 records exactly this route, and the base fails the same way. --no-env-file only sets disable_default_env_files (src/cli/Arguments.rs:1021-1022), so the child opens <root>/ci.env, and a FIFO at the root path blocks the scanner child and hangs the install.

}
argv.push(c"-e");
argv.push(&code);

let resolved: Vec<&CStr>;
let env = match env {
ScannerEnv::Default | ScannerEnv::NoEnvFile => SpawnEnv::Inherit,
ScannerEnv::Resolved(envp) => {
resolved = envp.iter().collect();
SpawnEnv::Strings(&resolved)
}
};

#[cfg(windows)]
{
self.spawn_windows(&mut argv, ipc_output_fds)?;
self.spawn_windows(&argv, env, ipc_output_fds)?;
}
#[cfg(not(windows))]
{
self.spawn_posix(&mut argv, ipc_output_fds)?;
self.spawn_posix(&argv, env, ipc_output_fds)?;
}

Ok(())
Expand All @@ -1067,7 +1101,8 @@ impl<'a> SecurityScanSubprocess<'a> {
#[cfg(unix)]
fn spawn_posix(
&mut self,
argv: &mut [*const core::ffi::c_char; 5],
argv: &[&CStr],
env: SpawnEnv<'_>,
ipc_output_fds: [Fd; 2],
) -> Result<(), Error> {
let extra_fds: Box<[Stdio]> = Box::new([
Expand All @@ -1084,16 +1119,8 @@ impl<'a> SecurityScanSubprocess<'a> {
..Default::default()
};

// SAFETY: `argv` is a local null-terminated C-string array with a
// non-null argv[0]; `environ_ptr()` is the process environ block.
let mut spawned = unsafe {
spawn::spawn_process(
&spawn_options,
argv.as_mut_ptr().cast(),
bun_sys::environ_ptr(),
)
}?
.map_err(|e| e.to_zig_err())?;
let mut spawned =
spawn::spawn_process_cstr(&spawn_options, argv, env)?.map_err(|e| e.to_zig_err())?;
// `defer spawned.extra_pipes.deinit()` — drops at scope exit.

ipc_output_fds[1].close();
Expand All @@ -1117,7 +1144,8 @@ impl<'a> SecurityScanSubprocess<'a> {
#[cfg(windows)]
fn spawn_windows(
&mut self,
argv: &mut [*const core::ffi::c_char; 5],
argv: &[&CStr],
env: SpawnEnv<'_>,
ipc_output_fds: [Fd; 2],
) -> Result<(), Error> {
use bun_sys::ReturnCodeExt as _;
Expand Down Expand Up @@ -1196,16 +1224,8 @@ impl<'a> SecurityScanSubprocess<'a> {
..Default::default()
};

// SAFETY: `argv` is a local null-terminated C-string array with a
// non-null argv[0]; `environ_ptr()` is the process environ block.
let mut spawned = unsafe {
spawn::spawn_process(
&spawn_options,
argv.as_mut_ptr().cast(),
bun_sys::environ_ptr(),
)
}?
.map_err(|e| e.to_zig_err())?;
let mut spawned =
spawn::spawn_process_cstr(&spawn_options, argv, env)?.map_err(|e| e.to_zig_err())?;
// `defer spawned.extra_pipes.deinit()` — drops at scope exit.

ipc_output_fds[1].close();
Expand Down
Loading
Loading