dependencyiq 2.0.0 → 2.2.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.
package/src/httpRetry.js CHANGED
@@ -1,48 +1,48 @@
1
- /**
2
- * Shared retry/backoff helper for the HTTP calls this project makes to
3
- * GitLab Orbit and package registries (npm, PyPI, Go proxy, Maven
4
- * Central). A flaky network hiccup during a live demo or a CI run
5
- * shouldn't read as "Orbit unavailable" / "registry lookup failed" when
6
- * a second attempt would have succeeded — but a real rejection (a 404 for
7
- * "this package doesn't exist," a 400 for a malformed query) must never
8
- * be retried, since retrying a deterministic rejection just wastes time
9
- * and risks masking a real bug as a transient one.
10
- *
11
- * Transient = no HTTP response at all (DNS failure, connection reset,
12
- * timeout) or a 5xx from the server. Anything else (4xx, or a 2xx that
13
- * the caller itself decides is wrong) is final on the first attempt.
14
- */
15
-
16
- function isTransientError(error) {
17
- const transientCodes = new Set(['ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN']);
18
- if (error.code && transientCodes.has(error.code)) return true;
19
- const status = error.response?.status;
20
- // No response at all (network-level failure axios didn't tag with a
21
- // known code) is treated the same as a 5xx: worth one retry.
22
- return status === undefined || status >= 500;
23
- }
24
-
25
- /**
26
- * @param {() => Promise<any>} fn - the call to attempt
27
- * @param {Object} [options]
28
- * @param {number} [options.retries=2] - additional attempts after the first
29
- * @param {number} [options.baseDelayMs=300] - delay before the first retry; doubles each subsequent attempt
30
- * @returns {Promise<any>}
31
- */
32
- async function withRetry(fn, { retries = 2, baseDelayMs = 300 } = {}) {
33
- let lastError;
34
- for (let attempt = 0; attempt <= retries; attempt += 1) {
35
- try {
36
- return await fn();
37
- } catch (error) {
38
- lastError = error;
39
- const isFinalAttempt = attempt === retries;
40
- if (isFinalAttempt || !isTransientError(error)) throw error;
41
- const delayMs = baseDelayMs * 2 ** attempt;
42
- await new Promise(resolve => { setTimeout(resolve, delayMs); });
43
- }
44
- }
45
- throw lastError;
46
- }
47
-
48
- module.exports = { withRetry, isTransientError };
1
+ /**
2
+ * Shared retry/backoff helper for the HTTP calls this project makes to
3
+ * GitLab Orbit and package registries (npm, PyPI, Go proxy, Maven
4
+ * Central). A flaky network hiccup during a live demo or a CI run
5
+ * shouldn't read as "Orbit unavailable" / "registry lookup failed" when
6
+ * a second attempt would have succeeded — but a real rejection (a 404 for
7
+ * "this package doesn't exist," a 400 for a malformed query) must never
8
+ * be retried, since retrying a deterministic rejection just wastes time
9
+ * and risks masking a real bug as a transient one.
10
+ *
11
+ * Transient = no HTTP response at all (DNS failure, connection reset,
12
+ * timeout) or a 5xx from the server. Anything else (4xx, or a 2xx that
13
+ * the caller itself decides is wrong) is final on the first attempt.
14
+ */
15
+
16
+ function isTransientError(error) {
17
+ const transientCodes = new Set(['ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN']);
18
+ if (error.code && transientCodes.has(error.code)) return true;
19
+ const status = error.response?.status;
20
+ // No response at all (network-level failure axios didn't tag with a
21
+ // known code) is treated the same as a 5xx: worth one retry.
22
+ return status === undefined || status >= 500;
23
+ }
24
+
25
+ /**
26
+ * @param {() => Promise<any>} fn - the call to attempt
27
+ * @param {Object} [options]
28
+ * @param {number} [options.retries=2] - additional attempts after the first
29
+ * @param {number} [options.baseDelayMs=300] - delay before the first retry; doubles each subsequent attempt
30
+ * @returns {Promise<any>}
31
+ */
32
+ async function withRetry(fn, { retries = 2, baseDelayMs = 300 } = {}) {
33
+ let lastError;
34
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
35
+ try {
36
+ return await fn();
37
+ } catch (error) {
38
+ lastError = error;
39
+ const isFinalAttempt = attempt === retries;
40
+ if (isFinalAttempt || !isTransientError(error)) throw error;
41
+ const delayMs = baseDelayMs * 2 ** attempt;
42
+ await new Promise(resolve => { setTimeout(resolve, delayMs); });
43
+ }
44
+ }
45
+ throw lastError;
46
+ }
47
+
48
+ module.exports = { withRetry, isTransientError };
@@ -204,6 +204,45 @@ async function findProjectsImportingPackage(groupId, packageName) {
204
204
  return Array.from(byProject.values());
205
205
  }
206
206
 
207
+ /**
208
+ * Does Orbit have indexed source for this project in the given language?
209
+ * Orbit indexes the DEFAULT branch only. A project whose default branch is,
210
+ * say, JavaScript will have File nodes — but scanning that project's Python
211
+ * feature branch must NOT be treated as "indexed", or a zero-importer Python
212
+ * package looks falsely "unused". So we ask specifically: does Orbit have any
213
+ * File of `language` for this project? If `language` is omitted we fall back
214
+ * to "any File at all". Returns false on any query error — the safe
215
+ * direction, since this only ever suppresses a removal recommendation, never
216
+ * invents one.
217
+ */
218
+ async function isProjectIndexed(projectId, language) {
219
+ try {
220
+ const fileToProjectEdges = await relationshipCandidates('File', 'Project', FILE_TO_PROJECT_EDGE_CANDIDATES);
221
+ const result = await queryWithRelationshipFallback(
222
+ (fileToProjectEdge) => ({
223
+ query_type: 'traversal',
224
+ nodes: [
225
+ { id: 'p', entity: 'Project', node_ids: [Number(projectId)] },
226
+ {
227
+ id: 'f',
228
+ entity: 'File',
229
+ columns: ['path', 'language'],
230
+ ...(language ? { filters: { language: { contains: language } } } : {}),
231
+ },
232
+ ],
233
+ relationships: [
234
+ { type: fileToProjectEdge, from: 'f', to: 'p' },
235
+ ],
236
+ limit: 1,
237
+ }),
238
+ [fileToProjectEdges]
239
+ );
240
+ return (result.result || []).length > 0;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
207
246
  module.exports = {
208
247
  getStatus,
209
248
  getSchema,
@@ -211,4 +250,5 @@ module.exports = {
211
250
  query,
212
251
  findPackageImporters,
213
252
  findProjectsImportingPackage,
253
+ isProjectIndexed,
214
254
  };
@@ -160,15 +160,45 @@ function buildPoetryGraph(repoPath) {
160
160
  return { directDeps, lookup };
161
161
  }
162
162
 
163
+ /**
164
+ * The hoisted top-level lockfile entry only ever records ONE resolved
165
+ * version of `packageName`. When OSV flags a version that doesn't match
166
+ * that top-level entry, the vulnerable copy is a separate nested
167
+ * duplicate the hoisted lookup never sees (see module docstring) — find
168
+ * it by scanning every `node_modules/.../node_modules/<packageName>`
169
+ * path for one whose `version` matches, and return its immediate parent
170
+ * package name so the caller can scope an `overrides` entry to that
171
+ * subtree instead of the (already-safe) top-level package.
172
+ * @returns {string|null} immediate parent package name, or null if no
173
+ * nested duplicate at that exact version was found
174
+ */
175
+ function findNestedDuplicateParent(lockfile, packageName, vulnerableVersion) {
176
+ if (!lockfile?.packages || !vulnerableVersion) return null;
177
+ const suffix = `/node_modules/${packageName}`;
178
+ for (const [pkgPath, entry] of Object.entries(lockfile.packages)) {
179
+ const isNestedDuplicate = pkgPath !== `node_modules/${packageName}` && pkgPath.endsWith(suffix);
180
+ if (isNestedDuplicate && entry.version === vulnerableVersion) {
181
+ const parentPath = pkgPath.slice(0, -suffix.length);
182
+ const lastSegment = parentPath.split('node_modules/').pop();
183
+ if (lastSegment) return lastSegment;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
163
189
  /**
164
190
  * @param {string} repoPath
165
191
  * @param {string} ecosystem
166
192
  * @param {string} packageName
193
+ * @param {string} [vulnerableVersion] - the specific version OSV flagged;
194
+ * when it disagrees with the top-level hoisted resolution, this looks
195
+ * for a nested duplicate at that version instead of trusting the
196
+ * "direct dependency" name match (see findNestedDuplicateParent)
167
197
  * @returns {Object} { available, isDirect, chain } — chain is null when
168
198
  * not determinable (unsupported ecosystem, no lockfile, or not found
169
199
  * within the BFS depth limit)
170
200
  */
171
- function resolveDependencyChain(repoPath, ecosystem, packageName) {
201
+ function resolveDependencyChain(repoPath, ecosystem, packageName, vulnerableVersion) {
172
202
  if (ecosystem === 'npm') {
173
203
  const lockfile = loadNpmLockfile(repoPath);
174
204
  if (!lockfile) {
@@ -179,6 +209,13 @@ function resolveDependencyChain(repoPath, ecosystem, packageName) {
179
209
  return { available: false, isDirect: null, chain: null, reason: 'Unsupported or malformed lockfile format' };
180
210
  }
181
211
  if (graph.directDeps.includes(packageName)) {
212
+ const topLevelEntry = lockfile.packages[`node_modules/${packageName}`];
213
+ if (vulnerableVersion && topLevelEntry && topLevelEntry.version !== vulnerableVersion) {
214
+ const parent = findNestedDuplicateParent(lockfile, packageName, vulnerableVersion);
215
+ if (parent) {
216
+ return { available: true, isDirect: false, chain: [parent, packageName], nestedDuplicateOfDirectDep: true };
217
+ }
218
+ }
182
219
  return { available: true, isDirect: true, chain: [packageName] };
183
220
  }
184
221
  return { available: true, isDirect: false, chain: findShortestChain(graph, packageName) };
@@ -220,6 +257,7 @@ module.exports = {
220
257
  resolveDependencyChain,
221
258
  buildNpmGraph,
222
259
  findShortestChain,
260
+ findNestedDuplicateParent,
223
261
  getLockfileEntry,
224
262
  normalizePyPiName,
225
263
  loadPoetryDirectDeps,
@@ -55,10 +55,41 @@ function transformNpmContent(content, packageName, fixedVersion) {
55
55
  * still pick a parent-compatible version, just never one below the
56
56
  * patched floor.
57
57
  */
58
- function addNpmOverrideContent(content, packageName, floorVersion) {
58
+ function addNpmOverrideContent(content, packageName, floorVersion, parentPackage) {
59
59
  const pkg = JSON.parse(content);
60
60
  pkg.overrides = pkg.overrides || {};
61
61
  const constraint = `>=${floorVersion}`;
62
+ const isAlsoDirectDep = ['dependencies', 'devDependencies', 'optionalDependencies']
63
+ .some((section) => pkg[section]?.[packageName]);
64
+
65
+ // A flat top-level override (`overrides: { js-yaml: ">=x" }`) collides
66
+ // with npm's own resolution of a same-named DIRECT dependency — npm
67
+ // rejects that combination at install time with EOVERRIDE ("conflicts
68
+ // with direct dependency"), even when the constraint is compatible.
69
+ // When the flagged vulnerable copy is nested under a known parent
70
+ // (the common case: a transitive dev-tool dependency shadows a
71
+ // package you also depend on directly at a safe version), scope the
72
+ // override to that parent instead — it only affects that subtree and
73
+ // never touches the direct dependency's own resolution.
74
+ if (isAlsoDirectDep && !parentPackage) {
75
+ return { touched: false, warning: `${packageName} is already a direct dependency, and the vulnerable copy's parent package is unknown — a flat "overrides" entry would conflict with npm's EOVERRIDE check; resolve the dependency chain first or add a scoped override (overrides: { "<parent>": { "${packageName}": "${constraint}" } }) manually` };
76
+ }
77
+
78
+ if (parentPackage) {
79
+ pkg.overrides[parentPackage] = pkg.overrides[parentPackage] || {};
80
+ if (pkg.overrides[parentPackage][packageName] === constraint) {
81
+ return { touched: false, warning: `package.json already overrides ${packageName} to ${constraint} under ${parentPackage}` };
82
+ }
83
+ pkg.overrides[parentPackage][packageName] = constraint;
84
+ return {
85
+ touched: true,
86
+ action: 'override',
87
+ content: JSON.stringify(pkg, null, 2) + '\n',
88
+ followUp: 'npm install (regenerates package-lock.json with the override applied)',
89
+ warning: `Forced ${packageName} to ${constraint} within ${parentPackage}'s dependency subtree via package.json "overrides" — verify the resolved tree with "npm ls ${packageName}" after install`,
90
+ };
91
+ }
92
+
62
93
  if (pkg.overrides[packageName] === constraint) {
63
94
  return { touched: false, warning: `package.json already overrides ${packageName} to ${constraint}` };
64
95
  }
@@ -251,10 +282,10 @@ function bumpNpm(repoPath, manifestRelPath, packageName, fixedVersion) {
251
282
  return applyContentResultToFile(repoPath, manifestRelPath, 'package.json', transformNpmContent(content, packageName, fixedVersion));
252
283
  }
253
284
 
254
- function overrideNpm(repoPath, manifestRelPath, packageName, floorVersion) {
285
+ function overrideNpm(repoPath, manifestRelPath, packageName, floorVersion, parentPackage) {
255
286
  const content = readManifest(repoPath, manifestRelPath, 'package.json');
256
287
  if (content === null) return { applied: false, warning: 'package.json not found' };
257
- return applyContentResultToFile(repoPath, manifestRelPath, 'package.json', addNpmOverrideContent(content, packageName, floorVersion));
288
+ return applyContentResultToFile(repoPath, manifestRelPath, 'package.json', addNpmOverrideContent(content, packageName, floorVersion, parentPackage));
258
289
  }
259
290
 
260
291
  function bumpPython(repoPath, manifestRelPath, packageName, fixedVersion) {
@@ -330,7 +361,8 @@ function applyFix(repoPath, vulnerability) {
330
361
  const chain = vulnerability.dependencyChain;
331
362
  if (vulnerability.ecosystem === 'npm' && chain?.available && chain.isDirect === false) {
332
363
  const floor = vulnerability.osvFloorVersion || vulnerability.fixedVersion;
333
- return overrideNpm(repoPath, vulnerability.manifestFile, vulnerability.package, floor);
364
+ const parent = chain.chain?.length >= 2 ? chain.chain[chain.chain.length - 2] : undefined;
365
+ return overrideNpm(repoPath, vulnerability.manifestFile, vulnerability.package, floor, parent);
334
366
  }
335
367
 
336
368
  const fixers = { npm: bumpNpm, PyPI: bumpPython, Go: bumpGo, Maven: bumpMaven };