Skip to content
Open
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
16 changes: 11 additions & 5 deletions docs/pm/cli/install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -255,19 +255,23 @@ The default is controlled by a `configVersion` field in your lockfile. For a det

## Minimum release age

To protect against supply chain attacks where malicious packages are quickly published, you can configure a minimum age requirement for npm packages. Package versions published more recently than the specified threshold (in seconds) will be filtered out during installation.
To protect against supply chain attacks where malicious packages are quickly published, you can configure a minimum age requirement for npm packages. Package versions published more recently than the specified threshold will be filtered out during installation. The filter is disabled by default; pass `0` to disable it explicitly.

The value accepts either an [`ms`](https://npmjs.com/package/ms)-style duration string (`3d`, `1 week`, `48h`) or a bare number of seconds:

```bash terminal icon="terminal"
# Only install package versions published at least 3 days ago
bun add @types/bun --minimum-release-age 259200 # seconds
bun add @types/bun --minimum-release-age 3d
# Or as a number of seconds:
bun add @types/bun --minimum-release-age 259200
```

You can also configure this in `bunfig.toml`:

```toml bunfig.toml icon="settings"
[install]
# Only install package versions published at least 3 days ago
minimumReleaseAge = 259200 # seconds
minimumReleaseAge = "3d" # or 259200 (seconds)

# Exclude trusted packages from the age gate
minimumReleaseAgeExcludes = ["@types/node", "typescript"]
Expand Down Expand Up @@ -334,8 +338,10 @@ concurrentScripts = 16 # (cpu count or GOMAXPROCS) x2
linker = "hoisted"


# minimum age config
minimumReleaseAge = 259200 # seconds
# skip package versions published within this period (security feature)
# accepts ms-style duration strings ("2d", "48h", "1 week") or a number of seconds
# (disabled by default; 0 also disables)
minimumReleaseAge = 259200 # or "3d"
minimumReleaseAgeExcludes = ["@types/node", "typescript"]
```

Expand Down
6 changes: 4 additions & 2 deletions docs/runtime/bunfig.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -735,12 +735,14 @@ Learn more about [using and writing security scanners](/pm/security-scanner-api)

### `install.minimumReleaseAge`

Configure a minimum age (in seconds) for npm package versions. Package versions published more recently than this threshold will be filtered out during installation. Default is `null` (disabled).
Configure a minimum age for npm package versions. Package versions published more recently than this threshold will be filtered out during installation. Accepts either a number of seconds or an [`ms`](https://npmjs.com/package/ms)-style duration string like `"2d"`, `"1 week"`, or `"48h"`. Set to `0` to disable. Disabled by default.
Comment thread
robobun marked this conversation as resolved.

```toml title="bunfig.toml" icon="settings"
[install]
# Only install package versions published at least 3 days ago
minimumReleaseAge = 259200
minimumReleaseAge = "3d"
# Or as a number of seconds:
# minimumReleaseAge = 259200
```

For more details see [Minimum release age](/pm/cli/install#minimum-release-age) in the install documentation.
Expand Down
86 changes: 86 additions & 0 deletions src/bun_core/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,92 @@ pub fn parse_num<T: core::str::FromStr>(s: &[u8]) -> Option<T> {
parse_ascii(s)
}

/// Parse a duration string and return the value in milliseconds.
///
/// Matches the behaviour of the npm `ms` package: accepts an optional
/// leading `+`/`-`, a number (integer or decimal), optional whitespace,
/// and an optional unit. When no unit is provided the number is
/// interpreted as milliseconds. Returns `None` for invalid input.
///
/// Supported units (case-insensitive):
/// - years / year / yrs / yr / y
/// - weeks / week / w
/// - days / day / d
/// - hours / hour / hrs / hr / h
/// - minutes / minute / mins / min / m
/// - seconds / second / secs / sec / s
/// - milliseconds / millisecond / msecs / msec / ms
pub fn parse_ms(input: &[u8]) -> Option<f64> {
const MS_PER_S: f64 = crate::time::MS_PER_S as f64;
const MS_PER_MIN: f64 = 60.0 * MS_PER_S;
const MS_PER_HOUR: f64 = 60.0 * MS_PER_MIN;
const MS_PER_DAY: f64 = crate::time::MS_PER_DAY as f64;
const MS_PER_WEEK: f64 = 7.0 * MS_PER_DAY;
const MS_PER_YEAR: f64 = 365.25 * MS_PER_DAY;

let remaining = input.trim_ascii();
if remaining.is_empty() {
return None;
}
// The npm `ms` package caps input at 100 characters.
if remaining.len() > 100 {
return None;
}

// Split the leading number from the unit.
let mut i: usize = 0;
if matches!(remaining[0], b'+' | b'-') {
i += 1;
}
let mut saw_digit = false;
while i < remaining.len() && remaining[i].is_ascii_digit() {
saw_digit = true;
i += 1;
}
if i < remaining.len() && remaining[i] == b'.' {
i += 1;
while i < remaining.len() && remaining[i].is_ascii_digit() {
saw_digit = true;
i += 1;
}
}
if !saw_digit {
return None;
}

let value = parse_f64(&remaining[..i])?;

let rest = remaining[i..].trim_ascii();

// Lowercase the unit into a small stack buffer for comparison.
let mut unit_buf = [0u8; 16];
if rest.len() > unit_buf.len() {
return None;
}
for (j, &c) in rest.iter().enumerate() {
unit_buf[j] = match c {
b'A'..=b'Z' => c | 0x20,
b'a'..=b'z' => c,
_ => return None,
};
}
let unit = &unit_buf[..rest.len()];

let multiplier: f64 = match unit {
b"" => 1.0,
b"ms" | b"msec" | b"msecs" | b"millisecond" | b"milliseconds" => 1.0,
b"s" | b"sec" | b"secs" | b"second" | b"seconds" => MS_PER_S,
b"m" | b"min" | b"mins" | b"minute" | b"minutes" => MS_PER_MIN,
b"h" | b"hr" | b"hrs" | b"hour" | b"hours" => MS_PER_HOUR,
b"d" | b"day" | b"days" => MS_PER_DAY,
b"w" | b"week" | b"weeks" => MS_PER_WEEK,
b"y" | b"yr" | b"yrs" | b"year" | b"years" => MS_PER_YEAR,
_ => return None,
};

Some(value * multiplier)
}

// ───────────────────────────────────────────────────────────────────────────
// Latin-1 formatting
// ───────────────────────────────────────────────────────────────────────────
Expand Down
4 changes: 2 additions & 2 deletions src/bun_core/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1262,8 +1262,8 @@ pub use crate::fmt::{
hex_char_lower, hex_char_upper, hex_digit_value, hex_lower, hex_pair_value, hex_u8, hex_u16,
hex_upper, hex2_lower, hex2_upper, hex4_lower, hex4_upper, int_as_bytes, parse_ascii,
parse_f32, parse_f64, parse_hex_prefix, parse_hex_to_int, parse_hex4,
parse_int as parse_int_radix, parse_num, print_int, quote, raw, s, size, size_f64, size_i64,
truncated_hash32, truncated_hash32_bytes, utf16,
parse_int as parse_int_radix, parse_ms, parse_num, print_int, quote, raw, s, size, size_f64,
size_i64, truncated_hash32, truncated_hash32_bytes, utf16,
};

/// Surrogate/transcode primitives + scalar-fallback string helpers that
Expand Down
32 changes: 29 additions & 3 deletions src/bunfig/bunfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1482,22 +1482,48 @@ impl<'a> Parser<'a> {
}

if let Some(min_age) = install_obj.get(b"minimumReleaseAge") {
const MS_PER_S: f64 = bun_core::time::MS_PER_S as f64;
match &min_age.data {
ExprData::ENumber(seconds) => {
if seconds.value() < 0.0 {
self.add_error(
min_age.loc,
b"Expected positive number of seconds for minimumReleaseAge",
b"Expected a non-negative number of seconds for minimumReleaseAge (0 disables)",
)?;
return Ok(());
}
const MS_PER_S: f64 = bun_core::time::MS_PER_S as f64;
install.minimum_release_age_ms = Some(seconds.value() * MS_PER_S);
}
ExprData::EString(s) => {
// A bare number with no unit is interpreted as seconds for
// consistency with the unquoted form and the CLI flag.
// Trim first so `"259200 "` doesn't fall through to
// `parse_ms` (which would treat it as milliseconds).
let text = s.string(self.bump)?.trim_ascii();
let ms = if let Some(secs) = bun_core::parse_f64(text) {
secs * MS_PER_S
} else if let Some(ms) = bun_core::parse_ms(text) {
ms
} else {
self.add_error(
min_age.loc,
b"Expected a duration like \"2d\" or \"1 week\" for minimumReleaseAge",
)?;
return Ok(());
};
if !(ms.is_finite() && ms >= 0.0) {
self.add_error(
min_age.loc,
b"Expected a non-negative duration for minimumReleaseAge (0 disables)",
)?;
return Ok(());
}
install.minimum_release_age_ms = Some(ms);
}
_ => {
self.add_error(
min_age.loc,
b"Expected number of seconds for minimumReleaseAge",
b"Expected a number of seconds or a duration string for minimumReleaseAge",
)?;
}
}
Expand Down
38 changes: 22 additions & 16 deletions src/install/PackageManager/CommandLineArguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const SHARED_PARAMS: &[ParamType] = &[
"--linker <STR> Linker strategy (one of \"isolated\" or \"hoisted\")"
),
clap::param!(
"--minimum-release-age <NUM> Only install packages published at least N seconds ago (security feature)"
"--minimum-release-age <STR> Skip package versions published within this period (e.g. \"2d\", \"1 week\", or a number of seconds)"
),
clap::param!(
"--cpu <STR>... Override CPU architecture for optional dependencies (e.g., x64, arm64, * for all)"
Expand Down Expand Up @@ -1089,26 +1089,32 @@ Full documentation is available at <magenta>https://bun.com/docs/cli/pm#scan<r>.
cli.save_text_lockfile = Some(true);
}

if let Some(min_age_secs) = args.option(b"--minimum-release-age") {
let secs: f64 = match bun_core::parse_double(min_age_secs) {
Ok(s) => s,
Err(_) => {
Output::err_generic(
"Expected --minimum-release-age to be a positive number: {}",
(bstr::BStr::new(min_age_secs),),
);
Global::crash();
}
if let Some(min_age) = args.option(b"--minimum-release-age") {
const MS_PER_S: f64 = bun_core::time::MS_PER_S as f64;
// A bare number with no unit is interpreted as seconds for
// backwards compatibility with earlier releases. Trim first so
// `"259200 "` doesn't fall through to `parse_ms` (which would
// treat it as milliseconds).
let trimmed = min_age.trim_ascii();
let ms: f64 = if let Some(secs) = bun_core::parse_f64(trimmed) {
secs * MS_PER_S
} else if let Some(ms) = bun_core::parse_ms(trimmed) {
ms
} else {
Output::err_generic(
"Expected --minimum-release-age to be a non-negative number of seconds or a duration like \"2d\": {}",
(bstr::BStr::new(min_age),),
);
Global::crash();
};
if secs < 0.0 {
if !(ms.is_finite() && ms >= 0.0) {
Output::err_generic(
"Expected --minimum-release-age to be a positive number: {}",
(bstr::BStr::new(min_age_secs),),
"Expected --minimum-release-age to be a non-negative number of seconds or a duration like \"2d\": {}",
(bstr::BStr::new(min_age),),
);
Global::crash();
}
const MS_PER_S: f64 = bun_core::time::MS_PER_S as f64;
cli.minimum_release_age_ms = Some(secs * MS_PER_S);
cli.minimum_release_age_ms = Some(ms);
}

let omit_values = args.options(b"--omit");
Expand Down
30 changes: 25 additions & 5 deletions src/install/PackageManager/PackageManagerEnqueue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1026,8 +1026,15 @@ pub fn enqueue_dependency_with_main_and_success_fn(
task_id,
dependency.behavior.is_required(),
) {
let needs_extended_manifest =
this.options.minimum_release_age_ms.is_some();
// `Some(0.0)` means the age filter is explicitly
// disabled, so it needs the abbreviated manifest
// just like the unset (`None`) case — only a
// positive age requires the `time`-bearing
// extended manifest.
let needs_extended_manifest = this
.options
.minimum_release_age_ms
.is_some_and(|ms| ms > 0.0);
if this.options.enable.manifest_cache() {
let mut expired = false;
// SAFETY: `this_ptr` is the live exclusive
Expand Down Expand Up @@ -1069,8 +1076,16 @@ pub fn enqueue_dependency_with_main_and_success_fn(
.version,
)
{
if let Some(min_age_ms) =
this.options.minimum_release_age_ms
// `Some(0.0)` means the filter is
// explicitly disabled — skip the
// check so this exact-version path
// agrees with the range/dist-tag
// filters (which also treat `0` as
// "no gate").
if let Some(min_age_ms) = this
.options
.minimum_release_age_ms
.filter(|&ms| ms > 0.0)
{
if !loaded_manifest
.as_ref()
Expand Down Expand Up @@ -2374,7 +2389,12 @@ fn get_or_put_resolved_package(
// materializing `&mut *this_ptr` after `name_str`/`scope` are
// derived from it would pop their borrow-stack tags under SB.
let cache_ctx = this.manifest_disk_cache_ctx();
let needs_ext = this.options.minimum_release_age_ms.is_some();
// Only a positive age needs the extended (`time`-bearing) manifest;
// `Some(0.0)` (filter explicitly disabled) behaves like unset.
let needs_ext = this
.options
.minimum_release_age_ms
.is_some_and(|ms| ms > 0.0);
let this_ptr: *mut PackageManager = this;
// SAFETY: `string_bytes` is not resized between here and the
// `find_result` lookup; `manifest` lives in `this.manifests` and
Expand Down
16 changes: 13 additions & 3 deletions src/install/PackageManager/PopulateManifestCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,12 @@ pub fn populate_manifest_cache(
let pkg_name_slice = pkg_name.slice(string_buf);
// `options` is not mutated between here and the
// `start_manifest_task` call — read via the BACKREF `mgr_ref`.
let needs_extended_manifest = mgr_ref.options.minimum_release_age_ms.is_some();
// `Some(0.0)` (filter explicitly disabled) behaves like unset;
// only a positive age needs the extended manifest.
let needs_extended_manifest = mgr_ref
.options
.minimum_release_age_ms
.is_some_and(|ms| ms > 0.0);

// `scope_for_package_name` borrows only `options` (via the
// BACKREF `mgr_ref`); `manifests` is a disjoint field projected
Expand Down Expand Up @@ -233,8 +238,13 @@ pub fn populate_manifest_cache(
}

// `options` read via BACKREF `mgr_ref` — see provenance-root
// note above.
let needs_extended_manifest = mgr_ref.options.minimum_release_age_ms.is_some();
// note above. `Some(0.0)` (filter explicitly disabled)
// behaves like unset; only a positive age needs the
// extended manifest.
let needs_extended_manifest = mgr_ref
.options
.minimum_release_age_ms
.is_some_and(|ms| ms > 0.0);
let package_name = pkg_names[pkg_id as usize].slice(string_buf);
// See disjoint-field note on the `.All` arm above.
let scope =
Expand Down
14 changes: 12 additions & 2 deletions src/install/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1658,7 +1658,12 @@ impl PackageManifest {
return FindVersionResult::Err(FindVersionError::NotFound);
};
let min_age_gate_ms = match minimum_release_age_ms {
Some(min_age_ms) if !self.should_exclude_from_age_filter(exclusions) => {
// `Some(0.0)` means the filter is explicitly disabled — treat it
// like unset so no age gate is applied (and no `time` data, which
// only the extended manifest carries, is required).
Some(min_age_ms)
if min_age_ms > 0.0 && !self.should_exclude_from_age_filter(exclusions) =>
{
Some(min_age_ms)
}
_ => None,
Expand Down Expand Up @@ -1782,7 +1787,12 @@ impl PackageManifest {
exclusions: Option<&[&[u8]]>,
) -> FindVersionResult<'_> {
let min_age_gate_ms = match minimum_release_age_ms {
Some(min_age_ms) if !self.should_exclude_from_age_filter(exclusions) => {
// `Some(0.0)` means the filter is explicitly disabled — treat it
// like unset so no age gate is applied (and no `time` data, which
// only the extended manifest carries, is required).
Some(min_age_ms)
if min_age_ms > 0.0 && !self.should_exclude_from_age_filter(exclusions) =>
{
Some(min_age_ms)
}
_ => None,
Expand Down
Loading
Loading