dependencyiq 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dependencyiq",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Multi-language dependency vulnerability agent: OSV-Scanner + GitLab Orbit blast-radius analysis + automated MRs",
5
5
  "main": "src/agent.js",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -72,7 +72,7 @@ async function analyzeRepository(repoPath, projectId, options = {}) {
72
72
 
73
73
  console.log('\nšŸ“Š Analyzing blast radius with GitLab Orbit...');
74
74
  for (const vuln of vulnerabilities) {
75
- const exposure = await analyzeExposure(projectId, vuln.package, pathHints, vuln.ecosystem);
75
+ const exposure = await analyzeExposure(projectId, vuln.package, vuln.ecosystem, pathHints);
76
76
 
77
77
  vuln.riskScore = calculateRiskScore(vuln, {
78
78
  affectedFilesCount: exposure.affectedFilesCount,
@@ -86,7 +86,7 @@ async function analyzeRepository(repoPath, projectId, options = {}) {
86
86
  vuln.affectedFiles = exposure.affectedFiles;
87
87
  vuln.exposure = exposure;
88
88
  vuln.recommendation = vuln.riskScore.recommendation;
89
- vuln.dependencyChain = resolveDependencyChain(repoPath, vuln.ecosystem, vuln.package);
89
+ vuln.dependencyChain = resolveDependencyChain(repoPath, vuln.ecosystem, vuln.package, vuln.currentVersion);
90
90
  }
91
91
 
92
92
  console.log('\nšŸŽÆ Ranking by actual risk...');
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  const orbitClient = require('./orbitClient');
10
+ const { hasAnyToken } = require('./gitlabAuth');
10
11
 
11
12
  // Built-in heuristic defaults, used when AGENTS.md doesn't specify
12
13
  // public_api_paths / test_paths. A project with a non-standard layout
@@ -39,7 +40,12 @@ let orbitReachable = null; // cached per-process: null = unknown, true/false onc
39
40
 
40
41
  async function isOrbitReachable() {
41
42
  if (orbitReachable !== null) return orbitReachable;
42
- if (!process.env.GITLAB_TOKEN) {
43
+ // CI_JOB_TOKEN (auto-present in every CI job) is sufficient for this
44
+ // same-project read call — see gitlabAuth.js's getAuthHeaders, which
45
+ // already falls back to it. Requiring GITLAB_TOKEN specifically here
46
+ // reported Orbit as unavailable in every normal CI/Flow run that never
47
+ // set that CI/CD variable, even when Orbit was actually healthy.
48
+ if (!hasAnyToken()) {
43
49
  orbitReachable = false;
44
50
  return false;
45
51
  }
@@ -99,7 +105,7 @@ const ECOSYSTEM_LANGUAGE = {
99
105
  Packagist: 'php',
100
106
  };
101
107
 
102
- async function analyzeExposure(projectId, packageName, hints = {}, ecosystem) {
108
+ async function analyzeExposure(projectId, packageName, ecosystem, hints = {}) {
103
109
  if (projectId && await isOrbitReachable()) {
104
110
  try {
105
111
  const files = await orbitClient.findPackageImporters(projectId, packageName);
@@ -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 };
@@ -16,6 +16,11 @@ function generateRefactoringAnalysis(vulnerability, codeContext = {}) {
16
16
  usageExamples: [],
17
17
  ...codeContext
18
18
  };
19
+ const orbitChecked = vulnerability.riskScore?.exposureDataSource === 'orbit'
20
+ || vulnerability.exposure?.source === 'orbit';
21
+ const noFilesNote = orbitChecked
22
+ ? ' - GitLab Orbit confirmed no importers of this package in this project'
23
+ : ' - Orbit exposure data unavailable for this project — file list unknown';
19
24
 
20
25
  return `
21
26
  # Refactoring Analysis: ${vulnerability.package}
@@ -28,14 +33,14 @@ function generateRefactoringAnalysis(vulnerability, codeContext = {}) {
28
33
  - **Issue**: ${vulnerability.vulnerability}
29
34
 
30
35
  ## Code Impact
31
- - **Files affected**: ${context.affectedFiles?.length || 1}
32
- ${context.affectedFiles?.map(f => ` - ${f.path || f}`).join('\n') || ' - no Orbit exposure data available'}
33
-
36
+ - **Files affected**: ${context.affectedFiles?.length || 0}
37
+ ${context.affectedFiles?.length ? context.affectedFiles.map(f => ` - ${f.path || f}`).join('\n') : noFilesNote}
38
+ ${context.usageExamples?.[0] ? `
34
39
  ## Usage Example
35
40
  \`\`\`javascript
36
- ${context.usageExamples?.[0] || 'const merged = _.merge({}, defaults, userInput);'}
41
+ ${context.usageExamples[0]}
37
42
  \`\`\`
38
-
43
+ ` : ''}
39
44
  ## Task for GitLab Duo Chat
40
45
 
41
46
  Generate **3 upgrade strategies** ranked by safety vs speed:
@@ -155,8 +160,8 @@ ${vulnerabilities.slice(0, 3).map((v, i) => `
155
160
  - **Risk Score**: ${v.riskScore?.score}/100 (${v.riskScore?.priority})
156
161
  - **Issue**: ${v.vulnerability}
157
162
  - **Severity**: ${v.severity} (CVSS ${v.cvss})
158
- - **Files Affected**: ${v.affectedFiles?.length || 1}
159
- ${v.affectedFiles?.slice(0, 2).map(f => ` - ${f.path || f}`).join('\n') || ' - internal usage'}
163
+ - **Files Affected**: ${v.affectedFiles?.length || 0}
164
+ ${v.affectedFiles?.length ? v.affectedFiles.slice(0, 2).map(f => ` - ${f.path || f}`).join('\n') : (v.riskScore?.exposureDataSource === 'orbit' ? ' - GitLab Orbit confirmed no importers' : ' - Orbit exposure data unavailable')}
160
165
  `).join('\n')}
161
166
 
162
167
  ## Recommendation
@@ -199,7 +204,7 @@ ${icon} **${index}. ${vuln.package} → ${vuln.fixedVersion}**
199
204
  | **Issue** | ${vuln.vulnerability} |
200
205
  | **Severity** | ${vuln.severity} (CVSS ${vuln.cvss}) |
201
206
  | **Your Risk Score** | ${vuln.riskScore?.score || '?'}/100 (${vuln.riskScore?.priority}) |
202
- | **Files Affected** | ${vuln.affectedFiles?.length || 1} |
207
+ | **Files Affected** | ${vuln.affectedFiles?.length || 0} |
203
208
  | **Exposed to API?** | ${vuln.riskScore?.isInPublicAPI ? 'āœ… Yes' : 'āŒ No'} |
204
209
  | **Effort to Fix** | ${vuln.riskScore?.effortMinutes ? Math.ceil(vuln.riskScore.effortMinutes / 60) + 'h' : '?'} |
205
210
  `;