dependabot-npm_and_yarn 0.382.0 → 0.383.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6b62776c4e4f4808215eb847c97e755f49062eda411ee412828ed1d29569378
4
- data.tar.gz: 96ce53758c58b319deba6b6611cdff40a9a5673c66b6a0b066eb11410fd5a83d
3
+ metadata.gz: 36bb333ca707b5155b7c82a95c5a3e934a5b7acf0ae96723bf9e3ef46723679f
4
+ data.tar.gz: '0889ddfed838d230dd4a7e299847816690a4151ac4cd81b6230160a933e44e67'
5
5
  SHA512:
6
- metadata.gz: faa9c39442466c97715c626036b1a711217b8e6c4b5dcd1b9438fc63e4009c20acab40a924c52bccf62d56656b3e7f5d3ed79c83e7ebb6a5053407865bd0d883
7
- data.tar.gz: 3be31e18189e2e9a541fd884cbc91cf4a83e732a887da56b033785539b4f9e0f332f62774548c62487a1b0ac487060f377c9a9cb3138d232fdb33dfff3f15667
6
+ metadata.gz: 162d5f20b24ad893b8ae76c6389ded05c31309f7fb43a4f1ce43186cfe8c0cafea09816e245b1a30e3ecc4ffc5f3792a01e5167f5aff09c9f86ff8baa1fa7d11
7
+ data.tar.gz: 8a97cb90adf36509589b5309bb50036a2d6f86def852456dadec850c8fec0b3b532042898716f1c4920587d42da48b9d62dd08ad57f3d6d7b20821a9c6d11a53
@@ -49,6 +49,13 @@ interface FixUpdate {
49
49
  target_version?: string;
50
50
  }
51
51
 
52
+ interface BlockingDependency {
53
+ name: string;
54
+ version: string;
55
+ requirement: string;
56
+ top_level_ancestor: string;
57
+ }
58
+
52
59
  interface AuditResponse {
53
60
  dependency_name: string;
54
61
  current_version?: string;
@@ -56,6 +63,7 @@ interface AuditResponse {
56
63
  fix_available: boolean;
57
64
  fix_updates: FixUpdate[];
58
65
  top_level_ancestors: string[];
66
+ blocking_dependencies?: BlockingDependency[];
59
67
  }
60
68
 
61
69
  interface BulkAdvisoryEntry {
@@ -154,6 +162,9 @@ export async function findVulnerableDependencies(
154
162
  response.fix_available = !!fixAvailable;
155
163
 
156
164
  if (!fixAvailable) {
165
+ // Surface the parent package(s) that pull in the vulnerable version so the
166
+ // Ruby side can explain exactly what is blocking the update.
167
+ response.blocking_dependencies = extractBlockingDependencies(vuln, name);
157
168
  return response;
158
169
  }
159
170
 
@@ -161,8 +172,10 @@ export async function findVulnerableDependencies(
161
172
 
162
173
  // In order for the vuln dependency in question to be considered fixable,
163
174
  // all dependency chains originating from it must be fixable.
164
- if (chains.some((chain) => !chain.fixAvailable)) {
175
+ const unfixableChains = chains.filter((chain) => !chain.fixAvailable);
176
+ if (unfixableChains.length > 0) {
165
177
  response.fix_available = false;
178
+ response.blocking_dependencies = extractBlockingDependencies(vuln, name);
166
179
  return response;
167
180
  }
168
181
 
@@ -345,6 +358,93 @@ function buildDependencyChains(
345
358
  return helper(auditReport.tree, { nodes: [] }, new Set());
346
359
  }
347
360
 
361
+ /* Identify the parent package(s) that require a vulnerable copy of `name`,
362
+ * together with the requirement (spec) they place on it and the top-level
363
+ * dependency each is reachable from. This explains why the vulnerable version
364
+ * can't simply be replaced: a package in the tree depends on it directly.
365
+ *
366
+ * We inspect the actual vulnerable nodes' incoming edges rather than the audit's
367
+ * dependency chains because, when a vulnerability can't be fixed, npm propagates
368
+ * it up the entire chain. As a result `buildDependencyChains` collapses to the
369
+ * top-level package and loses the immediate parent that pins the vulnerable
370
+ * version. Results are de-duplicated by parent + requirement + ancestor.
371
+ */
372
+ function extractBlockingDependencies(
373
+ vuln: Arborist.Vuln,
374
+ name: string
375
+ ): BlockingDependency[] {
376
+ const blockers = new Map<string, BlockingDependency>();
377
+ for (const node of vuln.nodes) {
378
+ // `vuln.nodes` may include packages that are only vulnerable *via* this
379
+ // dependency; we only care about the actual vulnerable copies of `name`.
380
+ if (node.name !== name) {
381
+ continue;
382
+ }
383
+ for (const edge of node.edgesIn) {
384
+ const parent = edge.from;
385
+ if (!parent || parent.isProjectRoot) {
386
+ // The vulnerable dependency is itself a top-level dependency. Arborist
387
+ // uses the project root node (which has no incoming edges) as `edge.from`
388
+ // for direct dependencies, so skip it to avoid reporting the project
389
+ // root itself as a blocking dependency.
390
+ continue;
391
+ }
392
+ const requirement = edge.spec || "*";
393
+ const ancestors = findTopLevelAncestorNames(edge);
394
+ const topLevelAncestors = ancestors.length > 0 ? ancestors : [parent.name];
395
+ for (const ancestor of topLevelAncestors) {
396
+ const key = `${parent.name}@${parent.version}|${requirement}|${ancestor}`;
397
+ if (!blockers.has(key)) {
398
+ blockers.set(key, {
399
+ name: parent.name,
400
+ version: parent.version,
401
+ requirement,
402
+ top_level_ancestor: ancestor,
403
+ });
404
+ }
405
+ }
406
+ }
407
+ }
408
+ return [...blockers.values()].sort(
409
+ (a, b) =>
410
+ a.name.localeCompare(b.name) ||
411
+ a.version.localeCompare(b.version) ||
412
+ a.requirement.localeCompare(b.requirement) ||
413
+ a.top_level_ancestor.localeCompare(b.top_level_ancestor)
414
+ );
415
+ }
416
+
417
+ /* Walk up the dependency graph from the given edge to find the name(s) of the
418
+ * top-level dependencies (direct dependencies of the project root) the edge is
419
+ * reachable from. */
420
+ function findTopLevelAncestorNames(edge: Arborist.Edge): string[] {
421
+ const result = new Set<string>();
422
+ const seen = new Set<Arborist.Edge>();
423
+ const visit = (current: Arborist.Edge): void => {
424
+ if (seen.has(current)) {
425
+ return;
426
+ }
427
+ seen.add(current);
428
+ const from = current.from;
429
+ if (!from) {
430
+ return;
431
+ }
432
+ for (const parentEdge of from.edgesIn) {
433
+ if (parentEdge.from?.isProjectRoot) {
434
+ // `parentEdge.from` is the project root, so `parentEdge.to` is a
435
+ // direct (top-level) dependency.
436
+ if (parentEdge.to) {
437
+ result.add(parentEdge.to.name);
438
+ }
439
+ } else {
440
+ visit(parentEdge);
441
+ }
442
+ }
443
+ };
444
+ visit(edge);
445
+ return [...result];
446
+ }
447
+
348
448
  function groupBy<T>(
349
449
  elems: Iterable<T>,
350
450
  fn: (elem: T, index: number, elems: Iterable<T>) => string
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "transitive-dependency-locked-by-parent",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 2,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "transitive-dependency-locked-by-parent",
9
+ "version": "1.0.0",
10
+ "license": "ISC",
11
+ "dependencies": {
12
+ "@dependabot-fixtures/npm-parent-dependency-5": "1.0.0"
13
+ }
14
+ },
15
+ "node_modules/@dependabot-fixtures/npm-intermediate-dependency": {
16
+ "version": "0.0.1",
17
+ "resolved": "https://registry.npmjs.org/@dependabot-fixtures/npm-intermediate-dependency/-/npm-intermediate-dependency-0.0.1.tgz",
18
+ "integrity": "sha512-/N77Dzpfg8BIfFgpJrMk86ueUYTVhmpc4RobuHpIpKSc3GZr4Ltu4au92brnUGk66UkzgrMmtgqRXO8OrOspKQ==",
19
+ "dependencies": {
20
+ "@dependabot-fixtures/npm-transitive-dependency": "1.0.0"
21
+ }
22
+ },
23
+ "node_modules/@dependabot-fixtures/npm-parent-dependency-5": {
24
+ "version": "1.0.0",
25
+ "resolved": "https://registry.npmjs.org/@dependabot-fixtures/npm-parent-dependency-5/-/npm-parent-dependency-5-1.0.0.tgz",
26
+ "integrity": "sha512-xWlNw4sxT1wbrnSXZU/5PVd0ta4X+15XT9vNgZfB82q0ybr91SNVVUs+IPNZPbJ/nY70LZuRAg01kOuVPeHFlg==",
27
+ "dependencies": {
28
+ "@dependabot-fixtures/npm-intermediate-dependency": "0.0.1"
29
+ }
30
+ },
31
+ "node_modules/@dependabot-fixtures/npm-transitive-dependency": {
32
+ "version": "1.0.0",
33
+ "resolved": "https://registry.npmjs.org/@dependabot-fixtures/npm-transitive-dependency/-/npm-transitive-dependency-1.0.0.tgz",
34
+ "integrity": "sha512-nFbzQH0TRgdzSA2/FH6MPnxZDpD+5Bgz00aD5Edgbc1wY/k8VC9s7lnk22dBTgJLwoY7MgbrnAf9rAvN08hHVg=="
35
+ }
36
+ },
37
+ "dependencies": {
38
+ "@dependabot-fixtures/npm-intermediate-dependency": {
39
+ "version": "0.0.1",
40
+ "resolved": "https://registry.npmjs.org/@dependabot-fixtures/npm-intermediate-dependency/-/npm-intermediate-dependency-0.0.1.tgz",
41
+ "integrity": "sha512-/N77Dzpfg8BIfFgpJrMk86ueUYTVhmpc4RobuHpIpKSc3GZr4Ltu4au92brnUGk66UkzgrMmtgqRXO8OrOspKQ==",
42
+ "requires": {
43
+ "@dependabot-fixtures/npm-transitive-dependency": "1.0.0"
44
+ }
45
+ },
46
+ "@dependabot-fixtures/npm-parent-dependency-5": {
47
+ "version": "1.0.0",
48
+ "resolved": "https://registry.npmjs.org/@dependabot-fixtures/npm-parent-dependency-5/-/npm-parent-dependency-5-1.0.0.tgz",
49
+ "integrity": "sha512-xWlNw4sxT1wbrnSXZU/5PVd0ta4X+15XT9vNgZfB82q0ybr91SNVVUs+IPNZPbJ/nY70LZuRAg01kOuVPeHFlg==",
50
+ "requires": {
51
+ "@dependabot-fixtures/npm-intermediate-dependency": "0.0.1"
52
+ }
53
+ },
54
+ "@dependabot-fixtures/npm-transitive-dependency": {
55
+ "version": "1.0.0",
56
+ "resolved": "https://registry.npmjs.org/@dependabot-fixtures/npm-transitive-dependency/-/npm-transitive-dependency-1.0.0.tgz",
57
+ "integrity": "sha512-nFbzQH0TRgdzSA2/FH6MPnxZDpD+5Bgz00aD5Edgbc1wY/k8VC9s7lnk22dBTgJLwoY7MgbrnAf9rAvN08hHVg=="
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "transitive-dependency-locked-by-parent",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "keywords": [],
10
+ "author": "",
11
+ "license": "ISC",
12
+ "dependencies": {
13
+ "@dependabot-fixtures/npm-parent-dependency-5": "1.0.0"
14
+ }
15
+ }
@@ -144,6 +144,52 @@ describe("findVulnerableDependencies", () => {
144
144
  expect(actual).toEqual(expected);
145
145
  });
146
146
 
147
+ it("reports blocking dependencies when no fix is available", async () => {
148
+ helpers.copyDependencies("vulnerability-auditor/locked-by-parent", tempDir);
149
+
150
+ const advisories = [
151
+ {
152
+ dependency_name: "@dependabot-fixtures/npm-transitive-dependency",
153
+ affected_versions: ["< 1.0.1"],
154
+ },
155
+ ];
156
+ const actual = await findVulnerableDependencies(tempDir, advisories);
157
+
158
+ expect(actual.dependency_name).toBe(
159
+ "@dependabot-fixtures/npm-transitive-dependency"
160
+ );
161
+ expect(actual.current_version).toBe("1.0.0");
162
+ expect(actual.fix_available).toBe(false);
163
+ expect(actual.fix_updates).toEqual([]);
164
+ expect(actual.blocking_dependencies).toEqual([
165
+ {
166
+ name: "@dependabot-fixtures/npm-intermediate-dependency",
167
+ version: "0.0.1",
168
+ requirement: "1.0.0",
169
+ top_level_ancestor: "@dependabot-fixtures/npm-parent-dependency-5",
170
+ },
171
+ ]);
172
+ });
173
+
174
+ it("does not report the project root as a blocking dependency", async () => {
175
+ helpers.copyDependencies("vulnerability-auditor/simple", tempDir);
176
+
177
+ const advisories = [
178
+ {
179
+ dependency_name: "@dependabot-fixtures/npm-parent-dependency",
180
+ affected_versions: [">= 0"],
181
+ },
182
+ ];
183
+ const actual = await findVulnerableDependencies(tempDir, advisories);
184
+
185
+ expect(actual.dependency_name).toBe(
186
+ "@dependabot-fixtures/npm-parent-dependency"
187
+ );
188
+ expect(actual.fix_available).toBe(false);
189
+ expect(actual.fix_updates).toEqual([]);
190
+ expect(actual.blocking_dependencies).toEqual([]);
191
+ });
192
+
147
193
  it("has undefined target_version when a vulnerable package is removed", async () => {
148
194
  helpers.copyDependencies(
149
195
  "vulnerability-auditor/fix-removes-package",
@@ -12,6 +12,7 @@ require "dependabot/npm_and_yarn/helpers"
12
12
  require "dependabot/npm_and_yarn/package_manager"
13
13
  require "dependabot/npm_and_yarn/file_parser"
14
14
  require "dependabot/npm_and_yarn/file_parser/lockfile_parser"
15
+ require "dependabot/npm_and_yarn/file_updater/npmrc_builder"
15
16
 
16
17
  module Dependabot
17
18
  module NpmAndYarn
@@ -82,7 +83,7 @@ module Dependabot
82
83
  def fetch_files
83
84
  fetched_files = T.let([], T::Array[DependencyFile])
84
85
  fetched_files << package_json
85
- fetched_files << T.must(npmrc) if npmrc
86
+ fetched_files << T.must(npmrc) if npmrc && !scope_overrides_npmrc?
86
87
  fetched_files += npm_files if npm_version
87
88
  fetched_files += yarn_files if yarn_version
88
89
  fetched_files += pnpm_files if pnpm_version
@@ -137,15 +138,35 @@ module Dependabot
137
138
  fetched_lerna_files
138
139
  end
139
140
 
140
- # If every entry in the lockfile uses the same registry, we can infer
141
- # that there is a global .npmrc file, so add it here as if it were in the repo.
141
+ # Generates or infers an .npmrc file for the project.
142
+ # Priority order:
143
+ # 1. If credentials have `scope` → generate from credentials (authoritative, overrides everything)
144
+ # 2. If no `scope` AND `.npmrc` in repo → return nil (committed file handled upstream)
145
+ # 3. If no `scope` AND no `.npmrc` → try lockfile inference (transitional)
146
+ # 4. If nothing works → return nil
142
147
 
143
148
  # rubocop:disable Metrics/AbcSize
144
149
  # rubocop:disable Metrics/PerceivedComplexity
150
+ # rubocop:disable Metrics/CyclomaticComplexity
151
+ # rubocop:disable Metrics/MethodLength
145
152
  sig { returns(T.nilable(DependencyFile)) }
146
153
  def inferred_npmrc # rubocop:disable Metrics/PerceivedComplexity
147
154
  return @inferred_npmrc if defined?(@inferred_npmrc)
148
- return @inferred_npmrc ||= T.let(nil, T.nilable(DependencyFile)) unless npmrc.nil? && package_lock
155
+
156
+ if Dependabot::Experiments.enabled?(:enable_npmrc_credential_generation) && credentials_have_scope?
157
+ npmrc_from_credentials = generate_npmrc_from_credentials
158
+ if npmrc_from_credentials
159
+ Dependabot.logger.info("Generated .npmrc from credential scope configuration (overrides committed .npmrc)")
160
+ return @inferred_npmrc ||= T.let(npmrc_from_credentials, T.nilable(DependencyFile))
161
+ end
162
+ end
163
+
164
+ unless npmrc.nil? && package_lock
165
+ # If .npmrc exists in the repo, it handles things — no rejection needed.
166
+ # If no .npmrc AND no lockfile, we can't infer, so check for rejection.
167
+ reject_if_private_registry_without_config! if npmrc.nil?
168
+ return @inferred_npmrc ||= T.let(nil, T.nilable(DependencyFile))
169
+ end
149
170
 
150
171
  known_registries = []
151
172
  FileParser::JsonLock.new(T.must(package_lock)).parsed.fetch(
@@ -157,9 +178,6 @@ module Dependabot
157
178
  begin
158
179
  uri = URI.parse(resolved)
159
180
  rescue URI::InvalidURIError
160
- # Ignoring non-URIs since they're not registries.
161
- # This can happen if resolved is `false`, for instance
162
- # npm6 bug https://github.com/npm/cli/issues/1138
163
181
  next
164
182
  end
165
183
 
@@ -187,11 +205,87 @@ module Dependabot
187
205
  )
188
206
  end
189
207
 
208
+ # Lockfile inference failed — fall back to replaces-base credential generation
209
+ if Dependabot::Experiments.enabled?(:enable_npmrc_credential_generation)
210
+ npmrc_from_credentials = generate_npmrc_from_credentials
211
+ if npmrc_from_credentials
212
+ Dependabot.logger.info("Generated .npmrc from credential replaces-base configuration")
213
+ return @inferred_npmrc ||= npmrc_from_credentials
214
+ end
215
+ end
216
+
217
+ # Phase 3: Reject updates when private registries exist but no config is resolvable
218
+ reject_if_private_registry_without_config!
219
+
190
220
  @inferred_npmrc ||= nil
191
221
  end
222
+ # rubocop:enable Metrics/MethodLength
223
+ # rubocop:enable Metrics/CyclomaticComplexity
192
224
  # rubocop:enable Metrics/AbcSize
193
225
  # rubocop:enable Metrics/PerceivedComplexity
194
226
 
227
+ sig { returns(T.nilable(DependencyFile)) }
228
+ def generate_npmrc_from_credentials
229
+ content = NpmAndYarn::FileUpdater::NpmrcBuilder.npmrc_content_from_credentials(wrapped_credentials)
230
+ return unless content
231
+
232
+ Dependabot::DependencyFile.new(
233
+ name: ".npmrc",
234
+ content: content
235
+ )
236
+ end
237
+
238
+ sig { void }
239
+ def reject_if_private_registry_without_config!
240
+ return unless Dependabot::Experiments.enabled?(:enable_npmrc_credential_generation)
241
+
242
+ private_registry_creds = wrapped_credentials.select do |cred|
243
+ next false unless cred["type"] == "npm_registry"
244
+
245
+ registry = cred["registry"]
246
+ next false if registry.nil?
247
+
248
+ # Normalize: strip scheme to compare against CENTRAL_REGISTRIES (bare hostnames)
249
+ normalized = registry.sub(%r{^https?://}, "")
250
+ !NpmAndYarn::FileUpdater::NpmrcBuilder::CENTRAL_REGISTRIES.include?(normalized)
251
+ end
252
+ return if private_registry_creds.empty?
253
+
254
+ registry = private_registry_creds.first&.fetch("registry", nil) || "unknown"
255
+ raise Dependabot::PrivateRegistryConfigNotFound, registry
256
+ end
257
+
258
+ sig { returns(T::Boolean) }
259
+ def credentials_have_scope?
260
+ wrapped_credentials.any? { |cred| cred["type"] == "npm_registry" && cred.scope }
261
+ end
262
+
263
+ # file_fetcher_command.rb may pass raw Hashes as credentials at runtime.
264
+ # Wrap them in Credential objects so we can use .scope and .replaces_base? safely.
265
+ # Credential#initialize destructively removes "scope"/"replaces-base" keys, so we .dup first.
266
+ sig { returns(T::Array[Dependabot::Credential]) }
267
+ def wrapped_credentials
268
+ @wrapped_credentials ||= T.let(
269
+ credentials.map { |cred| ensure_credential(cred) },
270
+ T.nilable(T::Array[Dependabot::Credential])
271
+ )
272
+ end
273
+
274
+ sig do
275
+ params(cred: T.any(Dependabot::Credential, T::Hash[String, T.untyped]))
276
+ .returns(Dependabot::Credential)
277
+ end
278
+ def ensure_credential(cred)
279
+ return cred if cred.is_a?(Dependabot::Credential)
280
+
281
+ Dependabot::Credential.new(cred.dup)
282
+ end
283
+
284
+ sig { returns(T::Boolean) }
285
+ def scope_overrides_npmrc?
286
+ Dependabot::Experiments.enabled?(:enable_npmrc_credential_generation) && credentials_have_scope?
287
+ end
288
+
195
289
  sig { returns(T.nilable(T.any(Integer, String))) }
196
290
  def npm_version
197
291
  @npm_version ||= T.let(package_manager_helper.setup(NpmPackageManager::NAME), T.nilable(T.any(Integer, String)))
@@ -12,7 +12,7 @@ module Dependabot
12
12
  # Build a .npmrc file from the lockfile content, credentials, and any
13
13
  # committed .npmrc
14
14
  # We should refactor this to use Package::RegistryFinder
15
- class NpmrcBuilder
15
+ class NpmrcBuilder # rubocop:disable Metrics/ClassLength
16
16
  extend T::Sig
17
17
 
18
18
  CENTRAL_REGISTRIES = T.let(
@@ -38,9 +38,50 @@ module Dependabot
38
38
  @dependencies = dependencies
39
39
  end
40
40
 
41
+ # Generates .npmrc content solely from credential scope/replaces-base properties,
42
+ # without lockfile inference or auth token lines. Used by FileFetcher as a fallback
43
+ # when lockfile inference fails.
44
+ # rubocop:disable Metrics/PerceivedComplexity
45
+ sig { params(credentials: T::Array[Dependabot::Credential]).returns(T.nilable(String)) }
46
+ def self.npmrc_content_from_credentials(credentials)
47
+ registry_creds = credentials.select { |cred| cred.fetch("type") == "npm_registry" }
48
+ replaces_base_cred = registry_creds.find(&:replaces_base?)
49
+ scoped_credentials = registry_creds.select { |cred| cred.scope && cred["registry"] }
50
+ return if replaces_base_cred.nil? && scoped_credentials.empty?
51
+
52
+ lines = T.let([], T::Array[String])
53
+
54
+ if replaces_base_cred
55
+ registry = replaces_base_cred.fetch("registry")
56
+ registry_url = registry.start_with?("http") ? registry : "https://#{registry}"
57
+ lines << "registry=#{registry_url}"
58
+ end
59
+
60
+ scoped_credentials.each do |cred|
61
+ registry = cred.fetch("registry")
62
+ registry_url = registry.start_with?("http") ? registry : "https://#{registry}"
63
+ T.must(cred.scope).each do |s|
64
+ lines << "#{Helpers.normalize_npm_scope(s)}:registry=#{registry_url}"
65
+ end
66
+ end
67
+
68
+ lines.join("\n")
69
+ end
70
+ # rubocop:enable Metrics/PerceivedComplexity
71
+
41
72
  # PROXY WORK
73
+ # rubocop:disable Metrics/PerceivedComplexity
42
74
  sig { returns(String) }
43
75
  def npmrc_content
76
+ # When credentials have explicit scope, always generate from credentials
77
+ # (overrides committed .npmrc and lockfile inference)
78
+ if credentials_have_scope?
79
+ Dependabot.logger.info(
80
+ "Generating .npmrc from credential scope configuration (committed .npmrc ignored)"
81
+ )
82
+ return build_npmrc_from_scope_credentials
83
+ end
84
+
44
85
  initial_content =
45
86
  if npmrc_file then complete_npmrc_from_credentials
46
87
  elsif yarnrc_file then build_npmrc_from_yarnrc
@@ -60,6 +101,7 @@ module Dependabot
60
101
 
61
102
  final_content
62
103
  end
104
+ # rubocop:enable Metrics/PerceivedComplexity
63
105
 
64
106
  # PROXY WORK
65
107
  # Yarn allows registries to be defined either in an .npmrc or .yarnrc
@@ -87,6 +129,30 @@ module Dependabot
87
129
  sig { returns(T::Array[Dependabot::Dependency]) }
88
130
  attr_reader :dependencies
89
131
 
132
+ sig { returns(T::Boolean) }
133
+ def credentials_have_scope?
134
+ registry_credentials.any?(&:scope)
135
+ end
136
+
137
+ sig { returns(String) }
138
+ def build_npmrc_from_scope_credentials
139
+ content = T.must(NpmrcBuilder.npmrc_content_from_credentials(credentials))
140
+
141
+ # Append auth lines for all configured registries
142
+ lines = [content]
143
+ registry_credentials.each do |cred|
144
+ token = cred.fetch("token", nil)
145
+ next unless token
146
+
147
+ lines << auth_line(token, cred.fetch("registry"))
148
+ end
149
+
150
+ replaces_base_cred = registry_credentials.find(&:replaces_base?)
151
+ lines << "always-auth = true" if replaces_base_cred
152
+
153
+ lines.reject(&:empty?).join("\n")
154
+ end
155
+
90
156
  sig { returns(T.nilable(String)) }
91
157
  def build_npmrc_content_from_lockfile
92
158
  return unless yarn_lock || package_lock || shrinkwrap
@@ -101,19 +167,13 @@ module Dependabot
101
167
 
102
168
  sig { returns(T.nilable(String)) }
103
169
  def build_npmrc_content_from_credential_scopes
104
- scoped_credentials = registry_credentials.select { |cred| cred.scope && cred["registry"] }
105
- return if scoped_credentials.empty?
170
+ content = NpmrcBuilder.npmrc_content_from_credentials(credentials)
171
+ return unless content
106
172
 
107
- lines = T.let([], T::Array[String])
108
- scoped_credentials.each do |cred|
109
- registry = cred.fetch("registry")
110
- registry_url = registry.start_with?("http") ? registry : "https://#{registry}"
111
- T.must(cred.scope).each do |s|
112
- lines << "#{Helpers.normalize_npm_scope(s)}:registry=#{registry_url}"
113
- end
114
- end
173
+ replaces_base_cred = registry_credentials.find(&:replaces_base?)
174
+ return content unless replaces_base_cred
115
175
 
116
- lines.join("\n")
176
+ "#{content}\nalways-auth = true"
117
177
  end
118
178
 
119
179
  sig { returns(T.nilable(String)) }
@@ -61,7 +61,7 @@ module Dependabot
61
61
  def registry
62
62
  return @registry if @registry
63
63
 
64
- @registry = configured_registry || locked_registry || scoped_credential_registry_for_dependency ||
64
+ @registry = scoped_credential_registry_for_dependency || configured_registry || locked_registry ||
65
65
  first_registry_with_dependency_details
66
66
  T.must(@registry)
67
67
  end
@@ -226,7 +226,7 @@ module Dependabot
226
226
  .select { |cred| cred["type"] == "npm_registry" && cred["registry"] }
227
227
  .tap do |arr|
228
228
  arr.each do |c|
229
- c["registry"] = prepare_registry_url(c["registry"])
229
+ c["registry"] = prepare_registry_url(c["registry"])&.delete_suffix("/")
230
230
  c["token"] ||= nil
231
231
  end
232
232
  end
@@ -27,6 +27,16 @@ module Dependabot
27
27
  # Default npm registry - no need to set env vars for this
28
28
  DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org"
29
29
 
30
+ # Canonicalise a registry URL: ensure an https:// scheme and strip any
31
+ # trailing slashes. Used wherever a registry URL is written into an env var
32
+ # so there is a single source of truth for URL normalisation.
33
+ sig { params(url: String).returns(String) }
34
+ def self.normalize_registry_url(url)
35
+ normalized = url.start_with?("http://", "https://") ? url.dup : "https://#{url}"
36
+ normalized.delete_suffix!("/") while normalized.end_with?("/")
37
+ normalized
38
+ end
39
+
30
40
  sig do
31
41
  params(
32
42
  registry_config_files: T::Hash[Symbol, T.nilable(Dependabot::DependencyFile)],
@@ -44,11 +54,9 @@ module Dependabot
44
54
 
45
55
  env_variables = {}
46
56
 
47
- if registry_info[:registry] # Prevent the https from being stripped in the process
48
- registry = registry_info[:registry]
49
- registry = "https://#{T.must(registry)}" unless T.must(registry).start_with?("http://", "https://")
57
+ if (raw_registry = registry_info[:registry])
58
+ registry = RegistryHelper.normalize_registry_url(raw_registry)
50
59
 
51
- # Set both in the env_variables hash
52
60
  unless registry == DEFAULT_NPM_REGISTRY
53
61
  env_variables[COREPACK_NPM_REGISTRY_ENV] = registry # For Corepack
54
62
  env_variables[NPM_CONFIG_REGISTRY_ENV] = registry # For npm
@@ -11,6 +11,7 @@ require "sorbet-runtime"
11
11
  require "dependabot/npm_and_yarn/requirement"
12
12
  require "dependabot/npm_and_yarn/update_checker"
13
13
  require "dependabot/npm_and_yarn/version"
14
+ require "dependabot/dependency_requirement"
14
15
  require "dependabot/requirements_update_strategy"
15
16
 
16
17
  module Dependabot
@@ -33,7 +34,7 @@ module Dependabot
33
34
 
34
35
  sig do
35
36
  params(
36
- requirements: T::Array[T::Hash[Symbol, T.untyped]],
37
+ requirements: T::Array[Dependabot::DependencyRequirement],
37
38
  updated_source: T.nilable(T::Hash[Symbol, T.untyped]),
38
39
  update_strategy: Dependabot::RequirementsUpdateStrategy,
39
40
  latest_resolvable_version: T.nilable(T.any(String, Gem::Version))
@@ -41,7 +42,10 @@ module Dependabot
41
42
  .void
42
43
  end
43
44
  def initialize(requirements:, updated_source:, update_strategy:, latest_resolvable_version:)
44
- @requirements = requirements
45
+ @requirements = T.let(
46
+ requirements.map { |req| Dependabot::DependencyRequirement.create(req) },
47
+ T::Array[Dependabot::DependencyRequirement]
48
+ )
45
49
  @updated_source = updated_source
46
50
  @update_strategy = update_strategy
47
51
 
@@ -55,12 +59,12 @@ module Dependabot
55
59
  )
56
60
  end
57
61
 
58
- sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
62
+ sig { returns(T::Array[Dependabot::DependencyRequirement]) }
59
63
  def updated_requirements
60
64
  return requirements if update_strategy.lockfile_only?
61
65
 
62
66
  requirements.map do |req|
63
- req = req.merge(source: updated_source)
67
+ req = Dependabot::DependencyRequirement.create(req.merge(source: updated_source))
64
68
  next req unless latest_resolvable_version
65
69
  next initial_req_after_source_change(req) unless req[:requirement]
66
70
  next req if req[:requirement].sub(NpmAndYarn::Requirement::JSR_PREFIX, "")
@@ -78,7 +82,7 @@ module Dependabot
78
82
 
79
83
  private
80
84
 
81
- sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
85
+ sig { returns(T::Array[Dependabot::DependencyRequirement]) }
82
86
  attr_reader :requirements
83
87
 
84
88
  sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
@@ -105,15 +109,15 @@ module Dependabot
105
109
  original_source&.fetch(:type) == "git"
106
110
  end
107
111
 
108
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
112
+ sig { params(req: Dependabot::DependencyRequirement).returns(Dependabot::DependencyRequirement) }
109
113
  def initial_req_after_source_change(req)
110
114
  return req unless updating_from_git_to_npm?
111
115
  return req unless req[:requirement].nil?
112
116
 
113
- req.merge(requirement: "^#{latest_resolvable_version}")
117
+ Dependabot::DependencyRequirement.create(req.merge(requirement: "^#{latest_resolvable_version}"))
114
118
  end
115
119
 
116
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
120
+ sig { params(req: Dependabot::DependencyRequirement).returns(Dependabot::DependencyRequirement) }
117
121
  def update_version_requirement(req)
118
122
  current_requirement = req[:requirement]
119
123
 
@@ -122,14 +126,14 @@ module Dependabot
122
126
  return req if ruby_req&.satisfied_by?(latest_resolvable_version)
123
127
 
124
128
  updated_req = update_range_requirement(current_requirement)
125
- return req.merge(requirement: updated_req)
129
+ return Dependabot::DependencyRequirement.create(req.merge(requirement: updated_req))
126
130
  end
127
131
 
128
132
  reqs = current_requirement.strip.split(SEPARATOR).map(&:strip)
129
- req.merge(requirement: update_version_string(reqs.first))
133
+ Dependabot::DependencyRequirement.create(req.merge(requirement: update_version_string(reqs.first)))
130
134
  end
131
135
 
132
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
136
+ sig { params(req: Dependabot::DependencyRequirement).returns(Dependabot::DependencyRequirement) }
133
137
  def update_version_requirement_if_needed(req)
134
138
  current_requirement = req[:requirement]
135
139
  version = latest_resolvable_version
@@ -141,7 +145,7 @@ module Dependabot
141
145
  update_version_requirement(req)
142
146
  end
143
147
 
144
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
148
+ sig { params(req: Dependabot::DependencyRequirement).returns(Dependabot::DependencyRequirement) }
145
149
  def widen_requirement(req)
146
150
  current_requirement = req[:requirement]
147
151
  version = latest_resolvable_version
@@ -161,7 +165,7 @@ module Dependabot
161
165
  current_requirement
162
166
  end
163
167
 
164
- req.merge(requirement: updated_requirement)
168
+ Dependabot::DependencyRequirement.create(req.merge(requirement: updated_requirement))
165
169
  end
166
170
 
167
171
  sig { params(requirement_string: String).returns(T::Array[NpmAndYarn::Requirement]) }
@@ -935,7 +935,7 @@ module Dependabot
935
935
  registry_url = replaces_base_cred&.[]("registry")
936
936
  return nil unless registry_url
937
937
 
938
- registry_url = "https://#{registry_url}" unless registry_url.start_with?("http")
938
+ registry_url = Dependabot::NpmAndYarn::RegistryHelper.normalize_registry_url(registry_url)
939
939
 
940
940
  { "npm_config_registry" => registry_url }
941
941
  end
@@ -32,7 +32,6 @@ module Dependabot
32
32
  @credentials = credentials
33
33
  end
34
34
 
35
- # rubocop:disable Metrics/MethodLength
36
35
  # Finds any dependencies in the `package-lock.json` or `npm-shrinkwrap.json` that have
37
36
  # a subdependency on the given dependency that is locked to a vuln version range.
38
37
  #
@@ -63,69 +62,122 @@ module Dependabot
63
62
  end
64
63
  def audit(dependency:, security_advisories:)
65
64
  Dependabot.logger.info("VulnerabilityAuditor: starting audit")
65
+ fix_unavailable = fix_unavailable_response(dependency)
66
66
 
67
- fix_unavailable = {
67
+ run_audit(
68
+ dependency: dependency,
69
+ security_advisories: security_advisories,
70
+ fix_unavailable: fix_unavailable
71
+ )
72
+ rescue SharedHelpers::HelperSubprocessFailed => e
73
+ log_helper_subprocess_failure(dependency, e)
74
+ T.must(fix_unavailable)
75
+ end
76
+
77
+ private
78
+
79
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
80
+ attr_reader :dependency_files
81
+
82
+ sig { returns(T::Array[Dependabot::Credential]) }
83
+ attr_reader :credentials
84
+
85
+ sig { params(dependency: Dependabot::Dependency).returns(T::Hash[String, T.untyped]) }
86
+ def fix_unavailable_response(dependency)
87
+ {
68
88
  "dependency_name" => dependency.name,
69
89
  "fix_available" => false,
70
90
  "fix_updates" => [],
71
91
  "top_level_ancestors" => []
72
92
  }
93
+ end
73
94
 
95
+ sig do
96
+ params(
97
+ dependency: Dependabot::Dependency,
98
+ security_advisories: T::Array[Dependabot::SecurityAdvisory],
99
+ fix_unavailable: T::Hash[String, T.untyped]
100
+ ).returns(T::Hash[String, T.untyped])
101
+ end
102
+ def run_audit(dependency:, security_advisories:, fix_unavailable:)
74
103
  SharedHelpers.in_a_temporary_directory do
75
- dependency_files_builder = DependencyFilesBuilder.new(
104
+ dependency_files_builder = prepare_dependency_files(dependency)
105
+ return fix_unavailable unless lockfile_present?(dependency_files_builder)
106
+
107
+ audit_result = run_vulnerability_auditor(security_advisories)
108
+ handle_audit_result(
76
109
  dependency: dependency,
77
- dependency_files: dependency_files,
78
- credentials: credentials
110
+ security_advisories: security_advisories,
111
+ audit_result: audit_result,
112
+ fix_unavailable: fix_unavailable
79
113
  )
80
- dependency_files_builder.write_temporary_dependency_files
81
-
82
- # `npm-shrinkwrap.js`, if present, takes precedence over `package-lock.js`.
83
- # Both files use the same format. See https://bit.ly/3lDIAJV for more.
84
- lockfile = (dependency_files_builder.shrinkwraps + dependency_files_builder.package_locks).first
85
- unless lockfile
86
- Dependabot.logger.info("VulnerabilityAuditor: missing lockfile")
87
- return fix_unavailable
88
- end
114
+ end
115
+ end
89
116
 
90
- vuln_versions = security_advisories.map do |a|
91
- {
92
- dependency_name: a.dependency_name,
93
- affected_versions: a.vulnerable_version_strings
94
- }
95
- end
117
+ sig { params(dependency: Dependabot::Dependency).returns(DependencyFilesBuilder) }
118
+ def prepare_dependency_files(dependency)
119
+ dependency_files_builder = DependencyFilesBuilder.new(
120
+ dependency: dependency,
121
+ dependency_files: dependency_files,
122
+ credentials: credentials
123
+ )
124
+ dependency_files_builder.write_temporary_dependency_files
125
+ dependency_files_builder
126
+ end
96
127
 
97
- audit_result = T.cast(
98
- SharedHelpers.run_helper_subprocess(
99
- command: NativeHelpers.helper_path,
100
- function: "npm:vulnerabilityAuditor",
101
- args: [Dir.pwd, vuln_versions]
102
- ),
103
- T::Hash[String, T.untyped]
104
- )
128
+ sig { params(dependency_files_builder: DependencyFilesBuilder).returns(T::Boolean) }
129
+ def lockfile_present?(dependency_files_builder)
130
+ # `npm-shrinkwrap.js`, if present, takes precedence over `package-lock.js`.
131
+ # Both files use the same format. See https://bit.ly/3lDIAJV for more.
132
+ lockfile = (dependency_files_builder.shrinkwraps + dependency_files_builder.package_locks).first
133
+ return true if lockfile
105
134
 
106
- validation_result = validate_audit_result(audit_result, security_advisories)
107
- if validation_result != :viable
108
- Dependabot.logger.info("VulnerabilityAuditor: audit result not viable: #{validation_result}")
109
- fix_unavailable["explanation"] = explain_fix_unavailable(validation_result, dependency, audit_result)
110
- return fix_unavailable
111
- end
135
+ Dependabot.logger.info("VulnerabilityAuditor: missing lockfile")
136
+ false
137
+ end
112
138
 
113
- Dependabot.logger.info("VulnerabilityAuditor: audit result viable")
114
- audit_result
115
- end
116
- rescue SharedHelpers::HelperSubprocessFailed => e
117
- log_helper_subprocess_failure(dependency, e)
118
- T.must(fix_unavailable)
139
+ sig do
140
+ params(security_advisories: T::Array[Dependabot::SecurityAdvisory]).returns(T::Hash[String, T.untyped])
119
141
  end
120
- # rubocop:enable Metrics/MethodLength
142
+ def run_vulnerability_auditor(security_advisories)
143
+ vuln_versions = security_advisories.map do |advisory|
144
+ {
145
+ dependency_name: advisory.dependency_name,
146
+ affected_versions: advisory.vulnerable_version_strings
147
+ }
148
+ end
121
149
 
122
- private
150
+ T.cast(
151
+ SharedHelpers.run_helper_subprocess(
152
+ command: NativeHelpers.helper_path,
153
+ function: "npm:vulnerabilityAuditor",
154
+ args: [Dir.pwd, vuln_versions]
155
+ ),
156
+ T::Hash[String, T.untyped]
157
+ )
158
+ end
123
159
 
124
- sig { returns(T::Array[Dependabot::DependencyFile]) }
125
- attr_reader :dependency_files
160
+ sig do
161
+ params(
162
+ dependency: Dependabot::Dependency,
163
+ security_advisories: T::Array[Dependabot::SecurityAdvisory],
164
+ audit_result: T::Hash[String, T.untyped],
165
+ fix_unavailable: T::Hash[String, T.untyped]
166
+ ).returns(T::Hash[String, T.untyped])
167
+ end
168
+ def handle_audit_result(dependency:, security_advisories:, audit_result:, fix_unavailable:)
169
+ validation_result = validate_audit_result(audit_result, security_advisories)
170
+ if validation_result == :viable
171
+ Dependabot.logger.info("VulnerabilityAuditor: audit result viable")
172
+ return audit_result
173
+ end
126
174
 
127
- sig { returns(T::Array[Dependabot::Credential]) }
128
- attr_reader :credentials
175
+ Dependabot.logger.info("VulnerabilityAuditor: audit result not viable: #{validation_result}")
176
+ fix_unavailable["explanation"] = explain_fix_unavailable(validation_result, dependency, audit_result)
177
+ blocking_dependencies = audit_result["blocking_dependencies"]
178
+ fix_unavailable["blocking_dependencies"] = blocking_dependencies if blocking_dependencies
179
+ fix_unavailable
180
+ end
129
181
 
130
182
  sig do
131
183
  params(
@@ -137,7 +189,7 @@ module Dependabot
137
189
  def explain_fix_unavailable(validation_result, dependency, audit_result)
138
190
  case validation_result
139
191
  when :fix_unavailable
140
- fix_unavailable_message(dependency)
192
+ fix_unavailable_message(dependency, audit_result)
141
193
  when :dependency_still_vulnerable
142
194
  dependency_still_vulnerable_message(dependency, audit_result)
143
195
  when :downgrades_dependencies
@@ -209,10 +261,43 @@ module Dependabot
209
261
  current > target
210
262
  end
211
263
 
264
+ sig do
265
+ params(
266
+ dependency: Dependabot::Dependency,
267
+ audit_result: T::Hash[String, T.untyped]
268
+ ).returns(String)
269
+ end
270
+ def fix_unavailable_message(dependency, audit_result)
271
+ blockers = audit_result["blocking_dependencies"]
272
+ return generic_fix_unavailable_message(dependency) if blockers.nil? || blockers.empty?
273
+
274
+ conflicts = blockers.map do |blocker|
275
+ blocker_name = blocker["name"]
276
+ blocker_version = blocker["version"]
277
+ blocker_requirement = blocker["requirement"]
278
+ detail = "#{blocker_name}@#{blocker_version} requires " \
279
+ "#{dependency.name}@#{blocker_requirement}"
280
+ ancestor = blocker["top_level_ancestor"]
281
+
282
+ detail += " (pulled in via #{ancestor})" if ancestor && ancestor != blocker_name
283
+ detail
284
+ end.join("; ")
285
+
286
+ "#{dependency.name} can't be updated to a non-vulnerable version because the npm helper identified parent " \
287
+ "package constraints in the dependency tree that still require a vulnerable version: #{conflicts}. To " \
288
+ "resolve this, update the parent " \
289
+ "package(s) listed above to a release that allows a non-vulnerable #{dependency.name}, or add an " \
290
+ "override/resolution pinning #{dependency.name} to a non-vulnerable version."
291
+ end
292
+
212
293
  sig { params(dependency: Dependabot::Dependency).returns(String) }
213
- def fix_unavailable_message(dependency)
214
- "Dependabot could not find a lockfile update that resolves " \
215
- "#{dependency.name} to a non-vulnerable version."
294
+ def generic_fix_unavailable_message(dependency)
295
+ "#{dependency.name} is a transitive dependency and no update path was found that " \
296
+ "resolves it to a non-vulnerable version. A fixed version of #{dependency.name} is published, but the " \
297
+ "parent package(s) that depend on it in the lockfile require a vulnerable version, and Dependabot could " \
298
+ "not update those parent package(s) to a release that allows the fixed version without introducing a " \
299
+ "conflict. To resolve this, update the parent dependency that requires #{dependency.name}, or add an " \
300
+ "override/resolution pinning #{dependency.name} to a non-vulnerable version."
216
301
  end
217
302
 
218
303
  sig do
@@ -177,14 +177,12 @@ module Dependabot
177
177
  end
178
178
 
179
179
  @updated_requirements ||=
180
- wrap_requirements(
181
- RequirementsUpdater.new(
182
- requirements: dependency.requirements,
183
- updated_source: updated_source,
184
- latest_resolvable_version: resolvable_version,
185
- update_strategy: T.must(requirements_update_strategy)
186
- ).updated_requirements
187
- )
180
+ RequirementsUpdater.new(
181
+ requirements: dependency.requirements,
182
+ updated_source: updated_source,
183
+ latest_resolvable_version: resolvable_version,
184
+ update_strategy: T.must(requirements_update_strategy)
185
+ ).updated_requirements
188
186
  end
189
187
 
190
188
  sig { returns(T::Boolean) }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-npm_and_yarn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.382.0
4
+ version: 0.383.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.382.0
18
+ version: 0.383.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.382.0
25
+ version: 0.383.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -275,6 +275,8 @@ files:
275
275
  - helpers/test/npm/fixtures/vulnerability-auditor/fix-hoists-package/package.json
276
276
  - helpers/test/npm/fixtures/vulnerability-auditor/fix-removes-package/package-lock.json
277
277
  - helpers/test/npm/fixtures/vulnerability-auditor/fix-removes-package/package.json
278
+ - helpers/test/npm/fixtures/vulnerability-auditor/locked-by-parent/package-lock.json
279
+ - helpers/test/npm/fixtures/vulnerability-auditor/locked-by-parent/package.json
278
280
  - helpers/test/npm/fixtures/vulnerability-auditor/outdated-package-lock/package-lock.json
279
281
  - helpers/test/npm/fixtures/vulnerability-auditor/outdated-package-lock/package.json
280
282
  - helpers/test/npm/fixtures/vulnerability-auditor/simple/package-lock.json
@@ -375,7 +377,7 @@ licenses:
375
377
  - MIT
376
378
  metadata:
377
379
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
378
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.382.0
380
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.383.0
379
381
  rdoc_options: []
380
382
  require_paths:
381
383
  - lib