-
-
Notifications
You must be signed in to change notification settings - Fork 10.7k
feat(security): Implement dependency cooldown for new formulae (#21129) #21131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(security): Implement dependency cooldown for new formulae (#21129) #21131
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements a dependency cooldown security feature that introduces a 5-minute mandatory delay before installing newly published formulae to mitigate supply chain attacks like typosquatting and key compromise exploitation. The implementation adds a --no-cooldown flag to bypass the security check when needed.
Key Changes
- Added
git_commit_timemethod to Formula class to retrieve Git commit timestamps for cooldown calculation - Implemented cooldown logic in FormulaInstaller that pauses installation for formulae published within the last 5 minutes
- Added
--no-cooldownCLI flag to allow users to bypass the security delay
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 15 comments.
| File | Description |
|---|---|
| Library/Homebrew/formula_installer.rb | Adds cooldown constant, no_cooldown parameter throughout installer, and implements the 5-minute delay logic in the install method |
| Library/Homebrew/formula.rb | Implements git_commit_time method to retrieve last commit time for formula files and adds necessary require statements |
| Library/Homebrew/cmd/install.rb | Adds --no-cooldown switch to CLI interface and passes the flag to formula installers |
Critical Issues Found:
Utils::Git.last_commit_timemethod does not exist and must be implementedInstall.formula_installersmethod doesn't accept theno_cooldownparameter, breaking the feature- Missing
no_cooldownparameter infetch_dependencymethod causing inconsistent behavior - Potential integer truncation bug in wait time calculation
- No test coverage for this security-critical feature
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| skip_post_install: args.skip_post_install?, | ||
| skip_link: args.skip_link?, | ||
| # ADDED for Dependency Cooldown: pass the flag down to the installer | ||
| no_cooldown: args.no_cooldown?, |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The no_cooldown parameter is being passed to Install.formula_installers but this method doesn't accept this parameter. The Install.formula_installers method in Library/Homebrew/install.rb needs to be updated to:
- Accept
no_cooldownas a parameter in its signature - Pass it through to the
FormulaInstaller.newconstructor
Without this change, the cooldown feature will not work because the parameter will be ignored, and formulae will always install with the default no_cooldown: false behavior.
| # Add the Cooldown Constant here | ||
| COOLDOWN_SECONDS = 5 * 60 # 5 minutes |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The constant COOLDOWN_SECONDS is defined at the module level (line 31) rather than as a class constant. In Ruby, constants defined at the top level of a file (outside any class/module) become top-level constants. This can lead to:
- Namespace pollution - the constant is globally accessible as
::COOLDOWN_SECONDS - Potential conflicts with other gems or code that might define the same constant
- Inconsistency with typical Homebrew patterns
Consider moving this inside the FormulaInstaller class:
class FormulaInstaller
include FormulaCellarChecks
COOLDOWN_SECONDS = 5 * 60 # 5 minutesThis would make it accessible as FormulaInstaller::COOLDOWN_SECONDS and follow Ruby/Homebrew conventions better.
| opoo "Pausing installation for #{wait_time}s to mitigate supply chain risk. Use --no-cooldown to skip." | ||
|
|
||
| # This is the actual pause | ||
| sleep(wait_time) |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The cooldown check uses a blocking sleep() call which will freeze the entire installation process. This has several implications:
- User Experience: Users see no progress indication during the wait period - the terminal appears frozen
- Interruptibility: Users may think the process is hung and try to kill it
- Testing: Makes testing difficult as tests need to wait real time
- Batch Operations: When installing multiple formulae, each cooldown is sequential, compounding delays
Consider:
- Adding a progress indicator (countdown timer) during the sleep
- Display remaining time with updates (e.g., "Waiting 287 seconds remaining...")
- For testing, allow the sleep duration to be configurable via an environment variable
- Consider if the check should happen earlier (during download/fetch) to parallelize the wait
| # --- START DEPENDENCY COOLDOWN LOGIC (INJECTED) --- | ||
| if no_cooldown | ||
| opoo "Skipping dependency cooldown check due to user request." | ||
| else | ||
| begin | ||
| # This requires Formula#git_commit_time to be implemented in formula.rb | ||
| published_time = formula.git_commit_time | ||
|
|
||
| if published_time.is_a?(Time) | ||
| time_since_publish = Time.now - published_time | ||
|
|
||
| if time_since_publish < COOLDOWN_SECONDS | ||
| wait_time = (COOLDOWN_SECONDS - time_since_publish).to_i | ||
|
|
||
| opoo "Formula '#{formula.name}' was recently published (#{time_since_publish.to_i}s ago)." | ||
| opoo "Pausing installation for #{wait_time}s to mitigate supply chain risk. Use --no-cooldown to skip." | ||
|
|
||
| # This is the actual pause | ||
| sleep(wait_time) | ||
| end | ||
| end | ||
| rescue StandardError => e | ||
| # Silently ignore errors during Git time retrieval | ||
| odebug "Cooldown check failed: #{e}", Utils::Backtrace.clean(e) if debug? | ||
| end | ||
| end | ||
| # --- END DEPENDENCY COOLDOWN LOGIC (INJECTED) --- |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Inconsistent spacing in comments. The comment on line 558 has a period after "INJECTED" while the end comment on line 579 doesn't:
- Line 553:
# --- START DEPENDENCY COOLDOWN LOGIC (INJECTED) --- - Line 579:
# --- END DEPENDENCY COOLDOWN LOGIC (INJECTED) ---
While this is minor, typical comment block markers either both have periods or neither do. Consider removing the period from line 553 or adding it to line 579 for consistency.
| opoo "Skipping dependency cooldown check due to user request." | ||
| else | ||
| begin | ||
| # This requires Formula#git_commit_time to be implemented in formula.rb |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment references Formula#git_commit_time needing to be "implemented in formula.rb", but the method is already being added in this PR (lines 697-711 of formula.rb). This comment suggests the method doesn't exist yet, which could be confusing to future readers.
Consider updating this comment to:
# Retrieve the formula's Git commit time to determine if cooldown applies| # This requires Formula#git_commit_time to be implemented in formula.rb | |
| # Retrieve the formula's Git commit time to determine if cooldown applies |
| time_since_publish = Time.now - published_time | ||
|
|
||
| if time_since_publish < COOLDOWN_SECONDS | ||
| wait_time = (COOLDOWN_SECONDS - time_since_publish).to_i |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential integer overflow when calculating wait_time. On line 565, if time_since_publish is very close to (but less than) COOLDOWN_SECONDS, the subtraction (COOLDOWN_SECONDS - time_since_publish) could result in a float value that when converted with .to_i becomes 0, causing no actual wait.
For example:
- If
time_since_publish = 299.9andCOOLDOWN_SECONDS = 300 - Then
wait_time = (300 - 299.9).to_i = 0.1.to_i = 0 - No sleep occurs, bypassing the intended protection
Consider using ceil instead:
wait_time = (COOLDOWN_SECONDS - time_since_publish).ceilThis ensures at least 1 second of wait when the cooldown period hasn't fully elapsed.
| wait_time = (COOLDOWN_SECONDS - time_since_publish).to_i | |
| wait_time = (COOLDOWN_SECONDS - time_since_publish).ceil |
| # ... (around line 20-30 in formula.rb) | ||
| require "utils/shell" | ||
| require "utils/git" # <-- ADD THIS LINE | ||
| require "utils/git_repository" | ||
| require "build_environment" | ||
| # ... |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Incorrect comment placement. The comment # ... (around line 20-30 in formula.rb) on line 46 is misleading since this is actually at line 46, not 20-30. Additionally, lines 47-51 show require statements that don't align with the actual file structure.
The comment suggests adding require "utils/git" but require "utils/shell", require "utils/git_repository", and require "build_environment" are already present in the diff. This is confusing. Either:
- Remove the misleading comment and keep only the necessary require statement
- Update the comment to accurately reflect what's being added
| # ... (around line 20-30 in formula.rb) | |
| require "utils/shell" | |
| require "utils/git" # <-- ADD THIS LINE | |
| require "utils/git_repository" | |
| require "build_environment" | |
| # ... | |
| require "utils/git" |
| # --- START DEPENDENCY COOLDOWN LOGIC (INJECTED) --- | ||
| if no_cooldown | ||
| opoo "Skipping dependency cooldown check due to user request." | ||
| else | ||
| begin | ||
| # This requires Formula#git_commit_time to be implemented in formula.rb | ||
| published_time = formula.git_commit_time | ||
|
|
||
| if published_time.is_a?(Time) | ||
| time_since_publish = Time.now - published_time | ||
|
|
||
| if time_since_publish < COOLDOWN_SECONDS | ||
| wait_time = (COOLDOWN_SECONDS - time_since_publish).to_i | ||
|
|
||
| opoo "Formula '#{formula.name}' was recently published (#{time_since_publish.to_i}s ago)." | ||
| opoo "Pausing installation for #{wait_time}s to mitigate supply chain risk. Use --no-cooldown to skip." | ||
|
|
||
| # This is the actual pause | ||
| sleep(wait_time) | ||
| end | ||
| end | ||
| rescue StandardError => e | ||
| # Silently ignore errors during Git time retrieval | ||
| odebug "Cooldown check failed: #{e}", Utils::Backtrace.clean(e) if debug? | ||
| end | ||
| end | ||
| # --- END DEPENDENCY COOLDOWN LOGIC (INJECTED) --- |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Security consideration: The cooldown mechanism uses Git commit time which can be manipulated by attackers who have already compromised a repository. An attacker with write access to a tap could:
- Backdate Git commits using
--dateflag to bypass the cooldown - Rebase/rewrite history to make malicious formulae appear older
While this doesn't defeat the purpose entirely (attackers with repo access are already in a privileged position), consider documenting this limitation and/or:
- Also checking the GitHub API for package publication time as a secondary validation
- Warning users when Git timestamps are suspiciously old but the formula is new to the tap
- Logging/alerting when commit timestamps significantly differ from current time during cooldown checks
| force: force?, | ||
| debug: debug?, | ||
| quiet: quiet?, | ||
| verbose: verbose?, |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing no_cooldown parameter in fetch_dependency. The fetch_dependency method creates a FormulaInstaller instance (lines 886-903) but doesn't pass the no_cooldown parameter, while install_dependency does (line 963).
This means:
- Dependencies will always use the default
no_cooldown: falseduring the fetch phase - Inconsistency between fetch and install behavior for dependencies
Add no_cooldown: @no_cooldown, to the FormulaInstaller.new call in fetch_dependency to ensure consistent behavior.
| verbose: verbose?, | |
| verbose: verbose?, | |
| no_cooldown: @no_cooldown, |
| sig { params(dependent: Formula, inherited_options: Options).returns(BuildOptions) } | ||
| def effective_build_options_for(dependent, inherited_options = Options.new) | ||
| args = dependent.build.used_options | ||
| args = dependent.build.used_options |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
I don't think this is needed as packages are added to the API after some short delay |
This Pull Request introduces a Dependency Cooldown mechanism to mitigate specific classes of supply chain attacks, aligning with the security objectives outlined in #21129.
Feature Overview
Installation of newly published formulae will be subject to a 5-minute (300 seconds) mandatory delay. This delay acts as a critical time barrier against automated dependency injection attacks (e.g., typosquatting, key compromise exploitation).
Verification (Command Syntax):
Normal Install (Triggers Cooldown): brew install
Bypass Cooldown (Installs Immediately): brew install --no-cooldown