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/README.md +374 -0
- package/package.json +1 -1
- package/src/agent.js +2 -2
- package/src/blastRadius.js +44 -2
- package/src/fleetAggregator.js +155 -155
- package/src/fleetDashboardGenerator.js +199 -199
- package/src/fleetSnapshot.js +103 -103
- package/src/httpRetry.js +48 -48
- package/src/orbitClient.js +40 -0
- package/src/scanners/dependencyTreeBuilder.js +39 -1
- package/src/scanners/ecosystemFixers.js +36 -4
- package/src/scanners/supplyChainTrustSignals.js +472 -472
- package/src/strategyGenerator.js +13 -8
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 };
|
package/src/orbitClient.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|