dependencyiq 2.0.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/LICENSE +21 -0
- package/package.json +50 -0
- package/src/activityFetcher.js +66 -0
- package/src/agent.js +506 -0
- package/src/blastRadius.js +134 -0
- package/src/configLoader.js +61 -0
- package/src/crossProjectFanOut.js +180 -0
- package/src/dashboardGenerator.js +642 -0
- package/src/executiveSummary.js +76 -0
- package/src/fleetAggregator.js +155 -0
- package/src/fleetDashboardGenerator.js +199 -0
- package/src/fleetSnapshot.js +103 -0
- package/src/freshnessChecker.js +306 -0
- package/src/freshnessPolicy.js +73 -0
- package/src/gitlabAuth.js +38 -0
- package/src/httpRetry.js +48 -0
- package/src/impactReport.js +92 -0
- package/src/mrReviewer.js +245 -0
- package/src/orbitClient.js +214 -0
- package/src/prGenerator.js +228 -0
- package/src/remoteFixer.js +129 -0
- package/src/riskCalculator.js +143 -0
- package/src/scanners/cvss.js +78 -0
- package/src/scanners/dependencyTreeBuilder.js +227 -0
- package/src/scanners/ecosystemFixers.js +371 -0
- package/src/scanners/manifestParser.js +99 -0
- package/src/scanners/osvScanner.js +228 -0
- package/src/scanners/supplyChainTrustSignals.js +472 -0
- package/src/strategyGenerator.js +384 -0
- package/src/upgradeImpactSimulator.js +241 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency freshness checker.
|
|
3
|
+
*
|
|
4
|
+
* Queries each ecosystem's own registry for the latest stable version and
|
|
5
|
+
* its publish date — real registry data, not a guessed "latest is always
|
|
6
|
+
* best" heuristic. Used for two things:
|
|
7
|
+
* 1. Freshness score: how far behind latest stable is the current pin.
|
|
8
|
+
* 2. Stability-aware recommendation: prefer a release that's been out
|
|
9
|
+
* for a while over one published days ago, since a brand-new release
|
|
10
|
+
* hasn't had time to surface regressions.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const axios = require('axios');
|
|
14
|
+
const { withRetry } = require('./httpRetry');
|
|
15
|
+
|
|
16
|
+
const STABILITY_WINDOW_DAYS = 14; // a release this old with no immediate yank is "settled"
|
|
17
|
+
|
|
18
|
+
async function npmLatest(packageName) {
|
|
19
|
+
const { data } = await withRetry(() => axios.get(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, { timeout: 8000 }));
|
|
20
|
+
const latestTag = data['dist-tags']?.latest;
|
|
21
|
+
if (!latestTag) return null;
|
|
22
|
+
return {
|
|
23
|
+
latestVersion: latestTag,
|
|
24
|
+
publishedAt: data.time?.[latestTag] || null,
|
|
25
|
+
allVersions: Object.keys(data.versions || {}),
|
|
26
|
+
versionTimes: data.time || {},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function pypiLatest(packageName) {
|
|
31
|
+
const { data } = await withRetry(() => axios.get(`https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`, { timeout: 8000 }));
|
|
32
|
+
const latestVersion = data.info?.version;
|
|
33
|
+
if (!latestVersion) return null;
|
|
34
|
+
const releases = data.releases || {};
|
|
35
|
+
return {
|
|
36
|
+
latestVersion,
|
|
37
|
+
publishedAt: releases[latestVersion]?.[0]?.upload_time_iso_8601 || null,
|
|
38
|
+
allVersions: Object.keys(releases),
|
|
39
|
+
versionTimes: Object.fromEntries(
|
|
40
|
+
Object.entries(releases).map(([v, files]) => [v, files?.[0]?.upload_time_iso_8601])
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function goProxyLatest(packageName) {
|
|
46
|
+
const { data } = await withRetry(() => axios.get(`https://proxy.golang.org/${encodeURIComponent(packageName.toLowerCase())}/@latest`, { timeout: 8000 }));
|
|
47
|
+
if (!data?.Version) return null;
|
|
48
|
+
return {
|
|
49
|
+
latestVersion: data.Version,
|
|
50
|
+
publishedAt: data.Time || null,
|
|
51
|
+
allVersions: null, // Go proxy doesn't give a full version list in one call
|
|
52
|
+
versionTimes: {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function mavenLatest(packageName) {
|
|
57
|
+
// packageName is "groupId:artifactId"
|
|
58
|
+
const [groupId, artifactId] = packageName.includes(':') ? packageName.split(':') : [null, packageName];
|
|
59
|
+
if (!groupId) return null;
|
|
60
|
+
const { data } = await withRetry(() => axios.get('https://search.maven.org/solrsearch/select', {
|
|
61
|
+
params: { q: `g:${groupId}+AND+a:${artifactId}`, core: 'gav', rows: 1, sort: 'timestamp desc', wt: 'json' },
|
|
62
|
+
timeout: 8000,
|
|
63
|
+
}));
|
|
64
|
+
const doc = data?.response?.docs?.[0];
|
|
65
|
+
if (!doc) return null;
|
|
66
|
+
return {
|
|
67
|
+
latestVersion: doc.v,
|
|
68
|
+
publishedAt: doc.timestamp ? new Date(doc.timestamp).toISOString() : null,
|
|
69
|
+
allVersions: null,
|
|
70
|
+
versionTimes: {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const REGISTRY_LOOKUPS = {
|
|
75
|
+
npm: npmLatest,
|
|
76
|
+
PyPI: pypiLatest,
|
|
77
|
+
Go: goProxyLatest,
|
|
78
|
+
Maven: mavenLatest,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function parseSemverLoose(version) {
|
|
82
|
+
const match = String(version).replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
83
|
+
if (!match) return null;
|
|
84
|
+
return { major: +match[1], minor: +match[2], patch: +match[3] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function semverDistance(current, latest) {
|
|
88
|
+
const c = parseSemverLoose(current);
|
|
89
|
+
const l = parseSemverLoose(latest);
|
|
90
|
+
if (!c || !l) return { majors: null, minors: null, comparable: false };
|
|
91
|
+
return {
|
|
92
|
+
majors: l.major - c.major,
|
|
93
|
+
minors: l.major === c.major ? l.minor - c.minor : null,
|
|
94
|
+
comparable: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compare two semver strings: -1 if a<b, 0 if equal, 1 if a>b, null if
|
|
100
|
+
* either isn't parseable.
|
|
101
|
+
*/
|
|
102
|
+
function compareSemver(a, b) {
|
|
103
|
+
const pa = parseSemverLoose(a);
|
|
104
|
+
const pb = parseSemverLoose(b);
|
|
105
|
+
if (!pa || !pb) return null;
|
|
106
|
+
if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
|
|
107
|
+
if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
|
|
108
|
+
if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Choose which version to actually upgrade to for a security fix — the
|
|
114
|
+
* real "which version will work" decision that OSV alone can't make.
|
|
115
|
+
*
|
|
116
|
+
* OSV reports the *minimum* version that patches the CVE (the security
|
|
117
|
+
* floor). Blindly taking it is a mistake: it might be a brand-new
|
|
118
|
+
* release (regression risk), or it might force an unnecessary major
|
|
119
|
+
* bump. This picks the LEAST-DISRUPTIVE version that is still secure:
|
|
120
|
+
*
|
|
121
|
+
* 1. must be >= the OSV fixed version (non-negotiable — below it is
|
|
122
|
+
* still vulnerable)
|
|
123
|
+
* 2. prefer staying within the current MAJOR (avoid breaking changes)
|
|
124
|
+
* 3. within that, prefer the LOWEST such version (smallest change)
|
|
125
|
+
* that has been out >= the settling window (regression-tested by
|
|
126
|
+
* the ecosystem, not published hours ago)
|
|
127
|
+
* 4. only cross a major if the fix simply isn't available in the
|
|
128
|
+
* current major — and flag it loudly when that happens
|
|
129
|
+
*
|
|
130
|
+
* Registry-history-dependent, so npm/PyPI only. Go/Maven (single-latest
|
|
131
|
+
* lookups) return { available: false } and the caller falls back to the
|
|
132
|
+
* OSV floor — flagged honestly, never guessed.
|
|
133
|
+
*
|
|
134
|
+
* @returns {Promise<Object>} {
|
|
135
|
+
* available, recommendedVersion, osvFloor, crossesMajor, settled,
|
|
136
|
+
* ageDays, rationale, reason? }
|
|
137
|
+
*/
|
|
138
|
+
async function chooseSecureTargetVersion(ecosystem, packageName, currentVersion, osvFixedVersion, stabilityWindowDays = STABILITY_WINDOW_DAYS) {
|
|
139
|
+
const fallback = (reason) => ({ available: false, reason, recommendedVersion: osvFixedVersion, osvFloor: osvFixedVersion });
|
|
140
|
+
|
|
141
|
+
const lookup = REGISTRY_LOOKUPS[ecosystem];
|
|
142
|
+
if (!lookup) return fallback(`No registry lookup for "${ecosystem}" — using OSV fixed version`);
|
|
143
|
+
if (!parseSemverLoose(osvFixedVersion)) return fallback(`OSV fixed version "${osvFixedVersion}" is not semver — using it as-is`);
|
|
144
|
+
|
|
145
|
+
let info;
|
|
146
|
+
try {
|
|
147
|
+
info = await lookup(packageName);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return fallback(`Registry lookup failed: ${error.message} — using OSV fixed version`);
|
|
150
|
+
}
|
|
151
|
+
if (!info?.allVersions) return fallback(`No full version history for "${ecosystem}" — using OSV fixed version`);
|
|
152
|
+
|
|
153
|
+
const cur = parseSemverLoose(currentVersion);
|
|
154
|
+
const ascending = (a, b) => compareSemver(a.version, b.version);
|
|
155
|
+
|
|
156
|
+
// Real releases at or above the security floor; skip pre-releases/builds.
|
|
157
|
+
const candidates = info.allVersions
|
|
158
|
+
.filter(v => !/[-+]/.test(v))
|
|
159
|
+
.map(v => ({ version: v, parsed: parseSemverLoose(v), publishedAt: info.versionTimes?.[v] }))
|
|
160
|
+
.filter(c => c.parsed && compareSemver(c.version, osvFixedVersion) >= 0)
|
|
161
|
+
.map(c => ({ ...c, ageDays: c.publishedAt ? Math.floor((Date.now() - new Date(c.publishedAt).getTime()) / 86400000) : null }));
|
|
162
|
+
|
|
163
|
+
if (candidates.length === 0) return fallback('No registry version at or above the OSV fixed version — using OSV fixed version');
|
|
164
|
+
|
|
165
|
+
const isSettled = c => c.ageDays !== null && c.ageDays >= stabilityWindowDays;
|
|
166
|
+
const sameMajor = cur ? candidates.filter(c => c.parsed.major === cur.major) : [];
|
|
167
|
+
|
|
168
|
+
const settledSameMajor = sameMajor.filter(isSettled).sort(ascending);
|
|
169
|
+
const anySameMajor = [...sameMajor].sort(ascending);
|
|
170
|
+
const settledAny = candidates.filter(isSettled).sort(ascending);
|
|
171
|
+
const anyAll = [...candidates].sort(ascending);
|
|
172
|
+
|
|
173
|
+
let chosen;
|
|
174
|
+
let settled;
|
|
175
|
+
if (settledSameMajor.length) {
|
|
176
|
+
[chosen] = settledSameMajor; settled = true;
|
|
177
|
+
} else if (anySameMajor.length) {
|
|
178
|
+
[chosen] = anySameMajor; settled = false;
|
|
179
|
+
} else if (settledAny.length) {
|
|
180
|
+
[chosen] = settledAny; settled = true;
|
|
181
|
+
} else {
|
|
182
|
+
[chosen] = anyAll; settled = false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const crossesMajor = cur ? chosen.parsed.major > cur.major : false;
|
|
186
|
+
|
|
187
|
+
let rationale;
|
|
188
|
+
if (compareSemver(chosen.version, osvFixedVersion) === 0) {
|
|
189
|
+
rationale = `${chosen.version} is the OSV-reported fixed version${settled ? ` and has been out ${chosen.ageDays}d (settled)` : ' (note: recently published)'}.`;
|
|
190
|
+
} else if (crossesMajor) {
|
|
191
|
+
rationale = `The fix is not available within the current major (${cur ? cur.major : '?'}.x); ${chosen.version} is the lowest secure version and crosses a MAJOR boundary — review for breaking changes.`;
|
|
192
|
+
} else {
|
|
193
|
+
rationale = `${chosen.version} is the lowest version ≥ the OSV floor (${osvFixedVersion}) that stays within the current major${settled ? ` and has been out ${chosen.ageDays}d (settled)` : ' (note: recently published — no settled option within this major)'}.`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
available: true,
|
|
198
|
+
recommendedVersion: chosen.version,
|
|
199
|
+
osvFloor: osvFixedVersion,
|
|
200
|
+
crossesMajor,
|
|
201
|
+
settled,
|
|
202
|
+
ageDays: chosen.ageDays,
|
|
203
|
+
rationale,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Recommend the most recent version that's been out at least
|
|
209
|
+
* `stabilityWindowDays`, instead of the literal newest — a release
|
|
210
|
+
* published a few hours ago hasn't had time to surface regressions.
|
|
211
|
+
* Only possible for ecosystems whose registry returns a full version
|
|
212
|
+
* history in one call (npm, PyPI) — Go/Maven lookups here only return a
|
|
213
|
+
* single latest version, so this returns `null` for those rather than
|
|
214
|
+
* guessing from incomplete data.
|
|
215
|
+
* @param {Object} info - result of a REGISTRY_LOOKUPS[...] call
|
|
216
|
+
* @param {number} stabilityWindowDays
|
|
217
|
+
* @returns {Object|null} { recommendedVersion, isLatest, ageDays } or null
|
|
218
|
+
*/
|
|
219
|
+
function recommendStableVersion(info, stabilityWindowDays = STABILITY_WINDOW_DAYS) {
|
|
220
|
+
if (!info?.allVersions || !info.versionTimes) return null;
|
|
221
|
+
|
|
222
|
+
const candidates = info.allVersions
|
|
223
|
+
.map(v => ({ version: v, parsed: parseSemverLoose(v), publishedAt: info.versionTimes[v] }))
|
|
224
|
+
.filter(c => c.parsed && c.publishedAt)
|
|
225
|
+
.map(c => ({ ...c, ageDays: Math.floor((Date.now() - new Date(c.publishedAt).getTime()) / (1000 * 60 * 60 * 24)) }))
|
|
226
|
+
.filter(c => c.ageDays >= stabilityWindowDays)
|
|
227
|
+
.sort((a, b) => (
|
|
228
|
+
b.parsed.major - a.parsed.major || b.parsed.minor - a.parsed.minor || b.parsed.patch - a.parsed.patch
|
|
229
|
+
));
|
|
230
|
+
|
|
231
|
+
if (candidates.length === 0) return null;
|
|
232
|
+
const best = candidates[0];
|
|
233
|
+
return {
|
|
234
|
+
recommendedVersion: best.version,
|
|
235
|
+
isLatest: best.version === info.latestVersion,
|
|
236
|
+
ageDays: best.ageDays,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check freshness of one dependency against its registry.
|
|
242
|
+
* @param {string} ecosystem - npm | PyPI | Go | Maven
|
|
243
|
+
* @param {string} packageName
|
|
244
|
+
* @param {string} currentVersion
|
|
245
|
+
* @returns {Promise<Object>} freshness result, or { available: false } if
|
|
246
|
+
* the ecosystem isn't supported or the registry call failed — never a
|
|
247
|
+
* guessed result.
|
|
248
|
+
*/
|
|
249
|
+
async function checkFreshness(ecosystem, packageName, currentVersion) {
|
|
250
|
+
const lookup = REGISTRY_LOOKUPS[ecosystem];
|
|
251
|
+
if (!lookup) {
|
|
252
|
+
return { available: false, reason: `No registry lookup implemented for ecosystem "${ecosystem}"` };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let info;
|
|
256
|
+
try {
|
|
257
|
+
info = await lookup(packageName);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return { available: false, reason: `Registry lookup failed: ${error.message}` };
|
|
260
|
+
}
|
|
261
|
+
if (!info) return { available: false, reason: 'Package not found in registry' };
|
|
262
|
+
|
|
263
|
+
const distance = semverDistance(currentVersion, info.latestVersion);
|
|
264
|
+
const ageDays = info.publishedAt
|
|
265
|
+
? Math.floor((Date.now() - new Date(info.publishedAt).getTime()) / (1000 * 60 * 60 * 24))
|
|
266
|
+
: null;
|
|
267
|
+
|
|
268
|
+
// Freshness score: 100 if on latest, degrading with major/minor distance.
|
|
269
|
+
// Capped at 0. Unknown distance (non-semver) leaves it null rather than 0.
|
|
270
|
+
let freshnessScore = null;
|
|
271
|
+
if (distance.comparable) {
|
|
272
|
+
const majorPenalty = Math.max(distance.majors, 0) * 40;
|
|
273
|
+
const minorPenalty = distance.minors !== null ? Math.max(distance.minors, 0) * 5 : 0;
|
|
274
|
+
freshnessScore = Math.max(100 - majorPenalty - minorPenalty, 0);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
available: true,
|
|
279
|
+
ecosystem,
|
|
280
|
+
package: packageName,
|
|
281
|
+
currentVersion,
|
|
282
|
+
latestVersion: info.latestVersion,
|
|
283
|
+
latestPublishedAt: info.publishedAt,
|
|
284
|
+
latestAgeDays: ageDays,
|
|
285
|
+
versionsBehind: distance,
|
|
286
|
+
freshnessScore,
|
|
287
|
+
// A release published in the last STABILITY_WINDOW_DAYS hasn't had time
|
|
288
|
+
// to surface regressions yet — recommend it cautiously, not blindly.
|
|
289
|
+
stabilityNote: ageDays === null
|
|
290
|
+
? 'unknown publish date'
|
|
291
|
+
: ageDays < STABILITY_WINDOW_DAYS
|
|
292
|
+
? `latest was published ${ageDays}d ago — still within the ${STABILITY_WINDOW_DAYS}d settling window, consider waiting unless this is a security fix`
|
|
293
|
+
: `latest has been out ${ageDays}d with no newer release — settled`,
|
|
294
|
+
stableRecommendation: recommendStableVersion(info),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = {
|
|
299
|
+
checkFreshness,
|
|
300
|
+
recommendStableVersion,
|
|
301
|
+
chooseSecureTargetVersion,
|
|
302
|
+
semverDistance,
|
|
303
|
+
compareSemver,
|
|
304
|
+
parseSemverLoose,
|
|
305
|
+
STABILITY_WINDOW_DAYS,
|
|
306
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freshness policy enforcement: "never stay more than 2 minor versions
|
|
3
|
+
* behind" / "never more than 6 months behind" — the technical-debt
|
|
4
|
+
* prevention angle, separate from CVE-driven urgency. Thresholds come
|
|
5
|
+
* from AGENTS.md via configLoader.js; null/unset means "not enforced",
|
|
6
|
+
* never a hardcoded default that wasn't actually requested.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} dependency - { package, ecosystem, currentVersion }
|
|
11
|
+
* @param {Object} freshness - result from freshnessChecker.checkFreshness
|
|
12
|
+
* @param {Object} policy - freshness_policy section from configLoader
|
|
13
|
+
* @returns {Object} { compliant, violations: string[] }
|
|
14
|
+
*/
|
|
15
|
+
function checkPolicy(dependency, freshness, policy = {}) {
|
|
16
|
+
if (!freshness?.available) {
|
|
17
|
+
return { compliant: true, violations: [], note: 'freshness data unavailable — policy not evaluated' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const violations = [];
|
|
21
|
+
|
|
22
|
+
if (policy.max_minor_versions_behind != null && freshness.versionsBehind?.comparable) {
|
|
23
|
+
const totalMinorsBehind = freshness.versionsBehind.majors > 0
|
|
24
|
+
? Infinity // any major version behind exceeds a minor-version budget
|
|
25
|
+
: (freshness.versionsBehind.minors ?? 0);
|
|
26
|
+
if (totalMinorsBehind > policy.max_minor_versions_behind) {
|
|
27
|
+
violations.push(
|
|
28
|
+
`${dependency.package} is ${freshness.versionsBehind.majors > 0 ? 'a major version' : `${totalMinorsBehind} minor versions`} behind ${freshness.latestVersion} — policy allows at most ${policy.max_minor_versions_behind} minor version(s) behind`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (policy.max_days_behind != null && freshness.latestAgeDays != null) {
|
|
34
|
+
// "days behind" here means: how long has a newer version been
|
|
35
|
+
// available that we haven't adopted — approximated by the age of the
|
|
36
|
+
// latest release, since that's the real registry data we have (we
|
|
37
|
+
// don't track "when did we last upgrade" without git history analysis).
|
|
38
|
+
if (freshness.currentVersion !== freshness.latestVersion && freshness.latestAgeDays > policy.max_days_behind) {
|
|
39
|
+
violations.push(
|
|
40
|
+
`${dependency.package} has been behind the latest release (${freshness.latestVersion}, published ${freshness.latestAgeDays}d ago) for longer than the ${policy.max_days_behind}d policy window`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { compliant: violations.length === 0, violations };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run policy checks across a whole dependency list, skipping excluded
|
|
50
|
+
* packages from AGENTS.md.
|
|
51
|
+
* @param {Array} dependenciesWithFreshness - [{ dependency, freshness }]
|
|
52
|
+
* @param {Object} config - result of configLoader.loadAgentsConfig
|
|
53
|
+
* @returns {Object} { compliantCount, violationCount, violations: [{package, violations}] }
|
|
54
|
+
*/
|
|
55
|
+
function runPolicyCheck(dependenciesWithFreshness, config) {
|
|
56
|
+
const excluded = new Set(config.excluded_packages || []);
|
|
57
|
+
const violations = [];
|
|
58
|
+
let compliantCount = 0;
|
|
59
|
+
|
|
60
|
+
const included = dependenciesWithFreshness.filter(({ dependency }) => !excluded.has(dependency.package));
|
|
61
|
+
for (const { dependency, freshness } of included) {
|
|
62
|
+
const result = checkPolicy(dependency, freshness, config.freshness_policy);
|
|
63
|
+
if (result.compliant) {
|
|
64
|
+
compliantCount += 1;
|
|
65
|
+
} else {
|
|
66
|
+
violations.push({ package: dependency.package, ecosystem: dependency.ecosystem, violations: result.violations });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { compliantCount, violationCount: violations.length, violations };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { checkPolicy, runPolicyCheck };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared GitLab API auth headers.
|
|
3
|
+
*
|
|
4
|
+
* Two distinct tokens, used for different things:
|
|
5
|
+
* - GITLAB_TOKEN: a manually-configured Personal/Project Access Token
|
|
6
|
+
* (CI/CD variable, or auto-injected by a Custom Flow's composite
|
|
7
|
+
* identity — see .gitlab/duo/flows/vulnerability-analysis-flow.yaml).
|
|
8
|
+
* Needed for anything that has to reach across projects (the
|
|
9
|
+
* cross-project emergency response) or act with broader scope than a
|
|
10
|
+
* single job.
|
|
11
|
+
* - CI_JOB_TOKEN: automatically present in every GitLab CI job, zero
|
|
12
|
+
* setup, scoped to that job's own project with the triggering user's
|
|
13
|
+
* permissions. This is the right default for same-project, read-ish
|
|
14
|
+
* calls (the activity feed, Orbit status) so a human never has to
|
|
15
|
+
* manually paste a token just to make the dashboard's "what has the
|
|
16
|
+
* agent done" section work.
|
|
17
|
+
*
|
|
18
|
+
* GITLAB_TOKEN takes priority when set (it's the more capable token);
|
|
19
|
+
* CI_JOB_TOKEN is the automatic fallback. If neither is present, callers
|
|
20
|
+
* get an empty auth header and should treat the resulting 401 as
|
|
21
|
+
* "unavailable", not invent data.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function getAuthHeaders() {
|
|
25
|
+
if (process.env.GITLAB_TOKEN) {
|
|
26
|
+
return { 'PRIVATE-TOKEN': process.env.GITLAB_TOKEN };
|
|
27
|
+
}
|
|
28
|
+
if (process.env.CI_JOB_TOKEN) {
|
|
29
|
+
return { 'JOB-TOKEN': process.env.CI_JOB_TOKEN };
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasAnyToken() {
|
|
35
|
+
return Boolean(process.env.GITLAB_TOKEN || process.env.CI_JOB_TOKEN);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { getAuthHeaders, hasAnyToken };
|
package/src/httpRetry.js
ADDED
|
@@ -0,0 +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 };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade Impact Report.
|
|
3
|
+
*
|
|
4
|
+
* Answers "how painful will this upgrade actually be?" by combining:
|
|
5
|
+
* - blast radius (files/services actually affected — from Orbit)
|
|
6
|
+
* - semver distance (major/minor jump — from freshnessChecker)
|
|
7
|
+
* - a breaking-change *flag*, not a guess: a major version bump is a
|
|
8
|
+
* documented semver signal that breaking changes are allowed, so we
|
|
9
|
+
* surface that as "likely breaking" with the reasoning shown, rather
|
|
10
|
+
* than claiming to know which specific APIs changed. Actually
|
|
11
|
+
* diffing APIs across 12 languages is out of scope — this is honest
|
|
12
|
+
* about that limit instead of inventing a fake API diff.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { semverDistance } = require('./freshnessChecker');
|
|
16
|
+
const { buildUpgradeImpactSimulation } = require('./upgradeImpactSimulator');
|
|
17
|
+
|
|
18
|
+
const ECOSYSTEM_COMPLEXITY_WEIGHT = {
|
|
19
|
+
npm: 1.0,
|
|
20
|
+
PyPI: 1.1,
|
|
21
|
+
Go: 0.9,
|
|
22
|
+
Maven: 1.3, // XML/build-tool friction tends to add overhead
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {Object} vulnerability - normalized vulnerability with riskScore/exposure
|
|
27
|
+
* @param {Object} freshness - result from freshnessChecker.checkFreshness (optional)
|
|
28
|
+
* @returns {Object} impact report
|
|
29
|
+
*/
|
|
30
|
+
function buildImpactReport(vulnerability, freshness = null) {
|
|
31
|
+
const affectedFiles = vulnerability.affectedFiles || [];
|
|
32
|
+
const distance = freshness?.versionsBehind
|
|
33
|
+
|| semverDistance(vulnerability.currentVersion, vulnerability.fixedVersion);
|
|
34
|
+
|
|
35
|
+
const likelyBreaking = distance.comparable && distance.majors > 0;
|
|
36
|
+
|
|
37
|
+
// Effort estimate: base 30 minutes plus per-file review time, scaled by
|
|
38
|
+
// ecosystem friction and bumped if this crosses a major version. This is
|
|
39
|
+
// a heuristic estimate, not a measurement — labelled as such everywhere
|
|
40
|
+
// it's surfaced.
|
|
41
|
+
const weight = ECOSYSTEM_COMPLEXITY_WEIGHT[vulnerability.ecosystem] || 1.0;
|
|
42
|
+
const perFileMinutes = 8 * weight;
|
|
43
|
+
const baseMinutes = 30 * weight;
|
|
44
|
+
const majorBumpPenaltyMinutes = likelyBreaking ? 120 * weight : 0;
|
|
45
|
+
const estimatedMinutes = Math.round(baseMinutes + (affectedFiles.length * perFileMinutes) + majorBumpPenaltyMinutes);
|
|
46
|
+
|
|
47
|
+
const report = {
|
|
48
|
+
package: vulnerability.package,
|
|
49
|
+
ecosystem: vulnerability.ecosystem,
|
|
50
|
+
from: vulnerability.currentVersion,
|
|
51
|
+
to: vulnerability.fixedVersion,
|
|
52
|
+
affectedFilesCount: affectedFiles.length,
|
|
53
|
+
affectedFiles: affectedFiles.slice(0, 10).map(f => f.path || f),
|
|
54
|
+
exposureDataSource: vulnerability.riskScore?.exposureDataSource || 'unavailable',
|
|
55
|
+
versionsBehind: distance,
|
|
56
|
+
likelyBreaking,
|
|
57
|
+
breakingChangeReasoning: likelyBreaking
|
|
58
|
+
? `Major version bump (${distance.majors} major version${distance.majors > 1 ? 's' : ''}) — semver allows breaking changes here. This flags the risk; it does not enumerate the specific API changes, since that requires per-language AST diffing this tool does not do.`
|
|
59
|
+
: 'No major version bump detected — breaking changes are possible but not signaled by semver.',
|
|
60
|
+
estimatedEffort: {
|
|
61
|
+
minutes: estimatedMinutes,
|
|
62
|
+
hours: Math.round((estimatedMinutes / 60) * 10) / 10,
|
|
63
|
+
basis: `${affectedFiles.length} affected file(s) × ~${Math.round(perFileMinutes)}min review${likelyBreaking ? ' + major-version-bump overhead' : ''} (${vulnerability.ecosystem} complexity weight ${weight})`,
|
|
64
|
+
},
|
|
65
|
+
freshness: freshness?.available ? freshness : null,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
...report,
|
|
70
|
+
simulation: buildUpgradeImpactSimulation(vulnerability, report),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render the impact report as markdown for an MR description or issue.
|
|
76
|
+
*/
|
|
77
|
+
function formatImpactReport(report) {
|
|
78
|
+
return `## Upgrade Impact Report: ${report.package}
|
|
79
|
+
|
|
80
|
+
- **Version**: ${report.from} → ${report.to}${report.versionsBehind?.comparable ? ` (${report.versionsBehind.majors} major version(s) behind)` : ''}
|
|
81
|
+
- **Affected files**: ${report.affectedFilesCount} (source: ${report.exposureDataSource === 'orbit' ? 'GitLab Orbit blast-radius query' : 'unavailable — Orbit not enabled/reachable'})
|
|
82
|
+
${report.affectedFiles.map(f => ` - ${f}`).join('\n')}
|
|
83
|
+
- **Likely breaking?**: ${report.likelyBreaking ? '⚠️ Yes' : 'Not signaled by semver'} — ${report.breakingChangeReasoning}
|
|
84
|
+
- **Estimated effort**: ~${report.estimatedEffort.hours}h (${report.estimatedEffort.basis})
|
|
85
|
+
${report.freshness ? `- **Registry freshness**: latest is ${report.freshness.latestVersion}, published ${report.freshness.latestAgeDays ?? '?'}d ago — ${report.freshness.stabilityNote}` : ''}
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
buildImpactReport,
|
|
91
|
+
formatImpactReport,
|
|
92
|
+
};
|