Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
21 changes: 21 additions & 0 deletions npm_and_yarn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

Yarn and npm support for [`dependabot-core`][core-repo].

### Native npm transitive updates

`npm update` accepts package names, not per-package version constraints. Dependabot
checks changed installations in v1, v2, and v3 lockfiles, including nested packages
and aliases. It also checks the effective replacement of a removed installation,
so deduplication cannot bypass the policy. Required installations must retain
their package identity and a valid version.

The resolver rejects versions above its allowable version or inside an ignore
range; the writer raises `UpdateNotPossible` if npm exceeds the requested version.
Existing unchanged effective versions are preserved.

An out-of-policy native result is rejected, not relabelled as a lower version.
Dependabot does not add manifest overrides to force an older candidate, since
overrides replace the parent package's requirements. Selecting an older compatible
candidate requires native npm support for per-package constraints.

### Running locally

1. Start a development shell
Expand All @@ -15,4 +32,8 @@ Yarn and npm support for [`dependabot-core`][core-repo].
[dependabot-core-dev] ~ $ cd npm_and_yarn && rspec
```

The npm spec helper clears the active npm version selector and per-directory
registrations after each example. Configure required selectors within each
example so randomized runs remain independent.

[core-repo]: https://github.com/dependabot/dependabot-core
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,37 @@ def legacy_entries
entries(object.fetch("dependencies", {}), "#{@context}.dependencies")
end

sig { returns(T::Hash[String, Record]) }
def package_entries
entries(object.fetch("packages", {}), "#{@context}.packages")
end

# Keep modern packages authoritative, including an empty table in a v2 lockfile.
# Legacy dependency trees describe the same installation paths recursively.
sig { returns(T::Hash[String, Record]) }
def installed_entries
return package_entries if object.key?("packages")

legacy_entries.each_with_object({ "" => self }) do |(name, record), installed|
record.installed_entries.each do |path, child|
installed[File.join("node_modules", name, path).delete_suffix("/")] = child
end
end
end

sig { returns(T::Array[String]) }
def dependency_names
%w(requires dependencies devDependencies optionalDependencies peerDependencies).flat_map do |field|
value = object[field]
next [] if value.nil? || (field == "requires" && value == true)

# v1 dependencies are installations; its requirements live in "requires".
object_value(value, "#{@context}.#{field}").filter_map do |name, requirement|
name if requirement.is_a?(String)
end
end
end

sig { params(name: String).returns(T.nilable(Record)) }
def legacy_entry(name)
dependencies = object["dependencies"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,21 @@ def top_level_dependency_update_not_required?(dependency)

sig { returns(T::Hash[String, String]) }
def run_current_npm_update
run_npm_updater(top_level_dependencies: top_level_dependencies, sub_dependencies: sub_dependencies)
updated_files = run_npm_updater(
top_level_dependencies: top_level_dependencies,
sub_dependencies: sub_dependencies
)
# The previous-state diagnostic must not enforce the new update's policy.
invalid_dependencies = sub_dependencies.reject do |dependency|
NativeHelpers.npm_subdependency_update_allowed?(
lockfile: lockfile,
updated_content: updated_files.fetch(lockfile_basename),
dependency: dependency
)
end
raise Dependabot::UpdateNotPossible, invalid_dependencies.map(&:name) if invalid_dependencies.any?

updated_files
end

sig { returns(T::Hash[String, String]) }
Expand Down
228 changes: 228 additions & 0 deletions npm_and_yarn/lib/dependabot/npm_and_yarn/native_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

require "sorbet-runtime"
require "dependabot/npm_and_yarn/helpers"
require "dependabot/npm_and_yarn/file_parser/json_lock"

module Dependabot
module NpmAndYarn
Expand Down Expand Up @@ -74,6 +75,233 @@ def self.run_npm_audit_fix_command(min_release_age_arg: nil)
Helpers.run_npm_command(command, fingerprint: fingerprint)
end

# npm update accepts names, not version constraints. Check the actual
# changed occurrences before accepting its result, including audit fallback.
sig do
params(
lockfile: DependencyFile,
updated_content: String,
dependency: Dependency,
ignored_versions: T::Array[String]
).returns(T::Boolean)
end
def self.npm_subdependency_update_allowed?(lockfile:, updated_content:, dependency:, ignored_versions: [])
allowable_version = dependency.version
allowable = Version.new(allowable_version) if allowable_version
ignored = ignored_versions.flat_map { |req| dependency.requirement_class.requirements_array(req) }

changed_npm_dependency_records(lockfile, updated_content, dependency.name).all? do |record|
version = npm_record_version(record)
next false unless Version.semver_for(version)

candidate = Version.new(version)
next false if allowable && candidate > allowable

ignored.none? { |requirement| requirement.satisfied_by?(candidate) }
end
end

sig do
params(lockfile: DependencyFile, updated_content: String, dependency_name: String)
.returns(T::Array[T.nilable(FileParser::JsonLock::Record)])
end
def self.changed_npm_dependency_records(lockfile, updated_content, dependency_name)
updated_lockfile = lockfile.dup
updated_lockfile.content = updated_content
before = FileParser::JsonLock.new(lockfile).parsed.installed_entries
after = FileParser::JsonLock.new(updated_lockfile).parsed.installed_entries

changed = T.let([], T::Array[T.nilable(FileParser::JsonLock::Record)])
after.each do |path, record|
next unless npm_dependency_record?(path, record, dependency_name)

previous = before[path]
if previous && npm_record_name(path, previous) != npm_record_name(path, record)
changed << nil
next
end
next if previous && npm_record_version(previous) == npm_record_version(record)

changed << record
end
changed + removed_npm_dependency_replacements(before, after, dependency_name)
end
private_class_method :changed_npm_dependency_records

sig do
params(
before: T::Hash[String, FileParser::JsonLock::Record],
after: T::Hash[String, FileParser::JsonLock::Record],
dependency_name: String
).returns(T::Array[T.nilable(FileParser::JsonLock::Record)])
end
def self.removed_npm_dependency_replacements(before, after, dependency_name)
replacements = T.let([], T::Array[T.nilable(FileParser::JsonLock::Record)])
before.each do |path, record|
next unless npm_dependency_record?(path, record, dependency_name)
next if after[path] && npm_dependency_record?(path, T.must(after[path]), dependency_name)

installed_name = T.must(path.split("node_modules/").last)
npm_replacement_consumers(before, after, path).each do |consumer_path|
replacement = npm_replacement_record(after, consumer_path, path, record)
version = npm_record_version(replacement)
previous_version = npm_consumed_version(before, consumer_path, installed_name)
next if Version.semver_for(version) && version == previous_version

replacements << replacement
end
end
replacements
end
private_class_method :removed_npm_dependency_replacements

sig do
params(
entries: T::Hash[String, FileParser::JsonLock::Record],
consumer_path: String,
original_path: String,
original: FileParser::JsonLock::Record
).returns(T.nilable(FileParser::JsonLock::Record))
end
def self.npm_replacement_record(entries, consumer_path, original_path, original)
name = T.must(original_path.split("node_modules/").last)
replacement_path = npm_dependency_path(entries, consumer_path, name)
return unless replacement_path

replacement = entries.fetch(replacement_path)
# Missing or differently named installations do not prove a safe replacement.
replacement if npm_record_name(replacement_path, replacement) == npm_record_name(original_path, original)
end
private_class_method :npm_replacement_record

sig do
params(
entries: T::Hash[String, FileParser::JsonLock::Record],
consumer_path: String,
name: String
).returns(T.nilable(String))
end
def self.npm_consumed_version(entries, consumer_path, name)
return unless npm_consumer_requires?(entries, consumer_path, name)

path = npm_dependency_path(entries, consumer_path, name)
npm_record_version(entries.fetch(path)) if path
end
private_class_method :npm_consumed_version

sig do
params(
entries: T::Hash[String, FileParser::JsonLock::Record],
consumer_path: String,
name: String
).returns(T::Boolean)
end
def self.npm_consumer_requires?(entries, consumer_path, name)
entries[consumer_path]&.dependency_names&.include?(name) || false
end
private_class_method :npm_consumer_requires?

sig do
params(
before: T::Hash[String, FileParser::JsonLock::Record],
after: T::Hash[String, FileParser::JsonLock::Record],
path: String
).returns(T::Array[String])
end
def self.npm_replacement_consumers(before, after, path)
prefix, _, installed_name = path.rpartition("node_modules/")
consumers = before.filter_map do |consumer_path, consumer|
next unless consumer.dependency_names.include?(installed_name)

consumer_path if npm_dependency_path(before, consumer_path, installed_name) == path
end
if consumers.empty?
# Without requirement metadata, only a removed parent or a proven
# in-policy replacement makes the removal safe.
parent_path = prefix.delete_suffix("/")
consumers = !parent_path.empty? && !after.key?(parent_path) ? [] : [parent_path]
else
consumers.select! { |consumer_path| npm_consumer_requires?(after, consumer_path, installed_name) }
end

consumers + npm_new_consumers(before, after, path)
end
private_class_method :npm_replacement_consumers

sig do
params(
before: T::Hash[String, FileParser::JsonLock::Record],
after: T::Hash[String, FileParser::JsonLock::Record],
path: String
).returns(T::Array[String])
end
def self.npm_new_consumers(before, after, path)
# A parent may itself have moved during deduplication. New consumers
# must not acquire an out-of-policy installation either.
installed_name = T.must(path.split("node_modules/").last)
after.filter_map do |consumer_path, consumer|
next if npm_consumer_requires?(before, consumer_path, installed_name)
next unless consumer.dependency_names.include?(installed_name)

replacement_path = npm_dependency_path(after, consumer_path, installed_name)
next unless replacement_path
next unless npm_record_name(replacement_path, after.fetch(replacement_path)) ==
npm_record_name(path, before.fetch(path))

consumer_path
end
end
private_class_method :npm_new_consumers

sig do
params(
entries: T::Hash[String, FileParser::JsonLock::Record],
parent_path: String,
name: String
).returns(T.nilable(String))
end
def self.npm_dependency_path(entries, parent_path, name)
loop do
candidate = parent_path.empty? ? "node_modules/#{name}" : "#{parent_path}/node_modules/#{name}"
return candidate if File.basename(parent_path) != "node_modules" && entries.key?(candidate)
return if parent_path.empty?

# Node searches ancestor directories, skipping node_modules/node_modules.
# Keep scoped names and workspace directory prefixes in these paths.
parent = File.dirname(parent_path)
return if parent == parent_path

parent_path = parent == "." ? "" : parent
end
end
private_class_method :npm_dependency_path

sig { params(path: String, record: FileParser::JsonLock::Record, name: String).returns(T::Boolean) }
def self.npm_dependency_record?(path, record, name)
path.include?("node_modules/") &&
(path.split("node_modules/").last == name || npm_record_name(path, record) == name)
end
private_class_method :npm_dependency_record?

sig { params(path: String, record: FileParser::JsonLock::Record).returns(String) }
def self.npm_record_name(path, record)
name = record.name
return name if name

version = record.version
return version.delete_prefix("npm:").rpartition("@").first if version&.start_with?("npm:")

T.must(path.split("node_modules/").last)
end
private_class_method :npm_record_name

sig { params(record: T.nilable(FileParser::JsonLock::Record)).returns(T.nilable(String)) }
def self.npm_record_version(record)
version = record&.version
version&.start_with?("npm:") ? version.rpartition("@").last : version
end
private_class_method :npm_record_version

# Masks the varying cooldown day count out of the telemetry fingerprint while
# keeping the security `=0` bypass distinguishable (mirrors the npm lockfile
# updater's `fingerprint_min_release_age_arg`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ def update_subdependency_in_lockfile(lockfile)

sig { params(updated_lockfiles: T::Array[Dependabot::DependencyFile]).returns(T.nilable(Gem::Version)) }
def version_from_updated_lockfiles(updated_lockfiles)
return unless npm_updates_allowed?(updated_lockfiles)

updated_files = dependency_files -
dependency_files_builder.lockfiles +
updated_lockfiles
Expand All @@ -143,6 +145,23 @@ def version_from_updated_lockfiles(updated_lockfiles)
)
end

sig { params(updated_lockfiles: T::Array[Dependabot::DependencyFile]).returns(T::Boolean) }
def npm_updates_allowed?(updated_lockfiles)
allowed = updated_lockfiles.all? do |updated|
next true unless updated.name.end_with?("package-lock.json", "npm-shrinkwrap.json")

original = dependency_files_builder.lockfiles.find { |lockfile| lockfile.path == updated.path }
NativeHelpers.npm_subdependency_update_allowed?(
lockfile: T.must(original),
updated_content: T.must(updated.content),
dependency: updated_dependency,
ignored_versions: ignored_versions
)
end
Dependabot.logger.info("npm resolved #{dependency.name} outside the allowed versions") unless allowed
allowed
end

sig { returns(PnpmTransitiveUpdate) }
def pnpm_update
@pnpm_update ||= PnpmTransitiveUpdate.new(dependency: dependency, latest_allowable_version: allowable_version)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -915,8 +915,6 @@ def grapher_for(directory, npm_engine_constraint)
Dependabot::NpmAndYarn::Helpers.npm_version_selector = "99"
end

after { Dependabot::NpmAndYarn::Helpers.npm_version_selector = nil }

it "activates each directory's own npm engine selector before generating its ephemeral lockfile" do
selectors_seen = []
allow(Dependabot::NpmAndYarn::DependencyGrapher::LockfileGenerator).to receive(:new) do |**kwargs|
Expand Down
Loading
Loading