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.
@@ -0,0 +1,245 @@
1
+ /**
2
+ * MR Safety Review — DependencyIQ as the safety layer for *incoming*
3
+ * dependency merge requests (from Dependabot, Renovate, or a human),
4
+ * not just a tool that opens its own.
5
+ *
6
+ * The unique lever: when an MR changes a dependency, query GitLab Orbit
7
+ * for that package's real blast radius and turn it into an
8
+ * approve / review / block verdict — "this bumps axios, imported by 5
9
+ * public-API files; major version → blocked, verify first" vs "patch
10
+ * bump, internal usage only → safe". This makes DependencyIQ
11
+ * complementary to update bots rather than competing: it makes their MRs
12
+ * trustworthy.
13
+ *
14
+ * This module is pure (manifest contents + an exposure lookup in, verdict
15
+ * out) so it's fully testable; the CLI/CI layer (agent.js `review-mr`)
16
+ * supplies the real base/head manifests and the Orbit exposure lookup.
17
+ */
18
+
19
+ const { semverDistance } = require('./freshnessChecker');
20
+
21
+ const VERDICT_RANK = { safe: 0, review: 1, blocked: 2 };
22
+
23
+ function parseJsonSafe(content) {
24
+ try {
25
+ return JSON.parse(content);
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function npmDependencyMap(pkg) {
32
+ const map = {};
33
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
34
+ for (const [name, range] of Object.entries(pkg[section] || {})) {
35
+ map[name] = { range: String(range), version: String(range).replace(/^[~^>=<\s]+/, ''), section };
36
+ }
37
+ }
38
+ return map;
39
+ }
40
+
41
+ /**
42
+ * Diff two package.json contents into a list of dependency changes.
43
+ * @returns {Array} [{ package, ecosystem, kind, fromVersion, toVersion }]
44
+ * kind: 'added' | 'removed' | 'changed' | 'override-added'
45
+ */
46
+ function diffNpmManifests(baseContent, headContent) {
47
+ const base = parseJsonSafe(baseContent);
48
+ const head = parseJsonSafe(headContent);
49
+ const baseMap = npmDependencyMap(base);
50
+ const headMap = npmDependencyMap(head);
51
+ const changes = [];
52
+
53
+ const names = new Set([...Object.keys(baseMap), ...Object.keys(headMap)]);
54
+ for (const name of names) {
55
+ const b = baseMap[name];
56
+ const h = headMap[name];
57
+ if (b && !h) {
58
+ changes.push({ package: name, ecosystem: 'npm', kind: 'removed', fromVersion: b.version, toVersion: null });
59
+ } else if (!b && h) {
60
+ changes.push({ package: name, ecosystem: 'npm', kind: 'added', fromVersion: null, toVersion: h.version });
61
+ } else if (b && h && b.range !== h.range) {
62
+ changes.push({ package: name, ecosystem: 'npm', kind: 'changed', fromVersion: b.version, toVersion: h.version });
63
+ }
64
+ }
65
+
66
+ // `overrides` changes are security-relevant (transitive pins).
67
+ const baseOv = base.overrides || {};
68
+ const headOv = head.overrides || {};
69
+ for (const [name, value] of Object.entries(headOv)) {
70
+ if (baseOv[name] !== value) {
71
+ changes.push({ package: name, ecosystem: 'npm', kind: 'override-added', fromVersion: baseOv[name] || null, toVersion: String(value) });
72
+ }
73
+ }
74
+
75
+ return changes;
76
+ }
77
+
78
+ function worse(a, b) {
79
+ return VERDICT_RANK[a] >= VERDICT_RANK[b] ? a : b;
80
+ }
81
+
82
+ /**
83
+ * Turn one dependency change + its Orbit exposure into a safety verdict.
84
+ * @param {Object} change - from diffNpmManifests
85
+ * @param {Object} exposure - { available, affectedFilesCount, isInPublicAPI,
86
+ * source } from blastRadius.analyzeExposure (or { available:false })
87
+ * @returns {Object} { package, kind, fromVersion, toVersion, semver,
88
+ * verdict, reasons, exposureSource }
89
+ */
90
+ function reviewChange(change, exposure = {}) {
91
+ const reasons = [];
92
+ let verdict = 'safe';
93
+ const orbitReal = exposure.source === 'orbit';
94
+ const fileCount = orbitReal ? (exposure.affectedFilesCount || 0) : null;
95
+ const publicApi = orbitReal && exposure.isInPublicAPI;
96
+
97
+ if (change.kind === 'removed') {
98
+ reasons.push('Dependency removed — reduces attack surface. No upgrade risk.');
99
+ return finalize(change, 'safe', reasons, exposure);
100
+ }
101
+
102
+ if (change.kind === 'override-added') {
103
+ reasons.push(`Adds an \`overrides\` pin for **${change.package}** (${change.toVersion}) — forces a patched floor on a transitive package. This is a security hardening change.`);
104
+ return finalize(change, 'safe', reasons, exposure);
105
+ }
106
+
107
+ if (change.kind === 'added') {
108
+ verdict = 'review';
109
+ reasons.push(`New dependency **${change.package}@${change.toVersion}** added — review supply-chain trust (maintainer, install scripts) and whether it's necessary.`);
110
+ return finalize(change, verdict, reasons, exposure);
111
+ }
112
+
113
+ // 'changed' — the main case.
114
+ const dist = semverDistance(change.fromVersion, change.toVersion);
115
+ if (dist.comparable && dist.majors < 0) {
116
+ verdict = worse(verdict, 'review');
117
+ reasons.push(`Version **downgrade** ${change.fromVersion} → ${change.toVersion} — confirm this is intentional, not an accidental regression.`);
118
+ } else if (dist.comparable && dist.majors > 0) {
119
+ if (publicApi) {
120
+ verdict = worse(verdict, 'blocked');
121
+ reasons.push(`**Major** bump ${change.fromVersion} → ${change.toVersion} in a package imported by ${fileCount} file(s), including public-API code — high risk of breaking production-facing behaviour. Verify call sites and run the full suite before merging.`);
122
+ } else {
123
+ verdict = worse(verdict, 'review');
124
+ reasons.push(`**Major** bump ${change.fromVersion} → ${change.toVersion} — semver allows breaking changes${orbitReal ? `; ${fileCount} file(s) import it` : ''}. Review the changelog and affected call sites.`);
125
+ }
126
+ } else if (dist.comparable) {
127
+ // minor / patch
128
+ if (publicApi) {
129
+ verdict = worse(verdict, 'review');
130
+ reasons.push(`Minor/patch bump ${change.fromVersion} → ${change.toVersion}, but the package is imported by public-API code (${fileCount} file(s)) — a quick smoke check is worthwhile.`);
131
+ } else {
132
+ reasons.push(`Minor/patch bump ${change.fromVersion} → ${change.toVersion}${orbitReal ? `, used by ${fileCount} internal/test file(s)` : ''} — low risk.`);
133
+ }
134
+ } else {
135
+ verdict = worse(verdict, 'review');
136
+ reasons.push(`Version change ${change.fromVersion} → ${change.toVersion} is not comparable as semver — review manually.`);
137
+ }
138
+
139
+ // Honesty: if Orbit couldn't tell us the blast radius, don't bless a
140
+ // change as fully "safe" on faith — nudge to at least a review and say so.
141
+ if (!orbitReal) {
142
+ reasons.push('GitLab Orbit exposure was unavailable for this run, so this verdict is based on the version change alone, not real import-graph evidence.');
143
+ if (verdict === 'safe' && change.kind === 'changed') verdict = 'review';
144
+ }
145
+
146
+ return finalize(change, verdict, reasons, exposure);
147
+ }
148
+
149
+ function finalize(change, verdict, reasons, exposure) {
150
+ return {
151
+ package: change.package,
152
+ kind: change.kind,
153
+ fromVersion: change.fromVersion,
154
+ toVersion: change.toVersion,
155
+ semver: semverDistance(change.fromVersion, change.toVersion),
156
+ verdict,
157
+ reasons,
158
+ exposureSource: exposure.source === 'orbit' ? 'orbit' : 'unavailable',
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Map a Supply-Chain Trust classification (see
164
+ * scanners/supplyChainTrustSignals.js) onto the same safe/review/blocked
165
+ * vocabulary as the semver verdict, so the two can be combined with the
166
+ * same `worse()` rule — without ever blending their underlying numbers.
167
+ * A package can be CRITICAL on trust with zero CVEs; this is what lets
168
+ * that show up in the overall verdict without touching the CVSS-driven
169
+ * risk score anywhere else in the codebase.
170
+ */
171
+ function classifyTrustVerdict(trust) {
172
+ if (!trust) return null;
173
+ if (trust.classification === 'CRITICAL') return 'blocked';
174
+ if (trust.classification === 'ELEVATED') return 'review';
175
+ return 'safe';
176
+ }
177
+
178
+ /**
179
+ * @param {Object} review - one reviewChange() result
180
+ * @param {Object} trustByPackage - { [packageName]: assessSupplyChainTrust() result }
181
+ */
182
+ function effectiveVerdict(review, trustByPackage = {}) {
183
+ const trustVerdict = classifyTrustVerdict(trustByPackage[review.package]);
184
+ return trustVerdict ? worse(review.verdict, trustVerdict) : review.verdict;
185
+ }
186
+
187
+ /**
188
+ * Roll individual change reviews into an overall MR verdict.
189
+ * `trustByPackage` is optional and additive — omitting it reproduces the
190
+ * exact semver-only behaviour this function always had.
191
+ */
192
+ function summarizeReview(reviews, trustByPackage = {}) {
193
+ const counts = { safe: 0, review: 0, blocked: 0 };
194
+ let overall = 'safe';
195
+ for (const r of reviews) {
196
+ const v = effectiveVerdict(r, trustByPackage);
197
+ counts[v] += 1;
198
+ overall = worse(overall, v);
199
+ }
200
+ return { overall, counts, total: reviews.length };
201
+ }
202
+
203
+ const VERDICT_ICON = { safe: '✅', review: '🟡', blocked: '🔴' };
204
+ const TRUST_ICON = { CRITICAL: '🔴', ELEVATED: '🟡', NORMAL: '✅' };
205
+
206
+ /**
207
+ * Build the markdown review note posted to the MR.
208
+ * @param {Array} reviews - reviewChange() results
209
+ * @param {Object} trustByPackage - optional { [packageName]: assessSupplyChainTrust() result },
210
+ * rendered as a separate "Supply-chain trust" line per change — never
211
+ * merged into the semver-based reasons above it.
212
+ */
213
+ function buildReviewNote(reviews, trustByPackage = {}) {
214
+ if (reviews.length === 0) {
215
+ return '### 🛡️ DependencyIQ MR Safety Review\n\nNo dependency changes detected in this merge request.';
216
+ }
217
+ const summary = summarizeReview(reviews, trustByPackage);
218
+ const header = `### 🛡️ DependencyIQ MR Safety Review\n\n**Overall: ${VERDICT_ICON[summary.overall]} ${summary.overall.toUpperCase()}** — ${summary.total} dependency change(s): ${summary.counts.blocked} blocked, ${summary.counts.review} to review, ${summary.counts.safe} safe.`;
219
+
220
+ const rows = reviews.map(r => {
221
+ const change = r.kind === 'removed' ? `removed (was ${r.fromVersion})`
222
+ : r.kind === 'added' ? `added @ ${r.toVersion}`
223
+ : r.kind === 'override-added' ? `override → ${r.toVersion}`
224
+ : `${r.fromVersion} → ${r.toVersion}`;
225
+ const verdict = effectiveVerdict(r, trustByPackage);
226
+ const trust = trustByPackage[r.package];
227
+ const trustLine = trust
228
+ ? `\n- **Supply-chain trust: ${TRUST_ICON[trust.classification]} ${trust.classification} (${trust.score}/100)** — ${trust.reasons.filter((_, i) => trust.classification !== 'NORMAL' || i === 0).join(' ')}`
229
+ : '';
230
+ return `\n#### ${VERDICT_ICON[verdict]} \`${r.package}\` — ${change}\n${r.reasons.map(x => `- ${x}`).join('\n')}${trustLine}`;
231
+ }).join('\n');
232
+
233
+ const footer = `\n\n---\nExposure evidence: ${reviews.some(r => r.exposureSource === 'orbit') ? 'GitLab Orbit blast-radius query' : 'unavailable — Orbit not reachable for this run'}. ${summary.overall === 'blocked' ? '**Recommend blocking merge until the flagged items are verified.**' : summary.overall === 'review' ? 'Recommend a quick human review of the flagged items before merge.' : 'No blast-radius concerns — safe to merge once CI is green.'}`;
234
+
235
+ return `${header}\n${rows}${footer}`;
236
+ }
237
+
238
+ module.exports = {
239
+ diffNpmManifests,
240
+ reviewChange,
241
+ summarizeReview,
242
+ buildReviewNote,
243
+ classifyTrustVerdict,
244
+ effectiveVerdict,
245
+ };
@@ -0,0 +1,214 @@
1
+ /**
2
+ * GitLab Orbit REST client.
3
+ *
4
+ * Public API docs confirm:
5
+ * - POST /api/v4/orbit/query takes { query, query_type, response_format }.
6
+ * - `search` uses a singular `node`.
7
+ * - joins use `traversal` with `nodes` and `relationships`.
8
+ * - result rows are flattened by alias, for example `f_path`, not
9
+ * necessarily nested as `row.f.path`.
10
+ *
11
+ * The exact source-code relationship names can evolve with the schema,
12
+ * so this client asks Orbit schema first and falls back through a small
13
+ * set of likely edge names. If Orbit rejects every candidate, callers
14
+ * fall back to "exposure unavailable" rather than inventing data.
15
+ */
16
+
17
+ const axios = require('axios');
18
+ const { getAuthHeaders: authHeaders } = require('./gitlabAuth');
19
+ const { withRetry } = require('./httpRetry');
20
+
21
+ const GITLAB_API = process.env.GITLAB_API_URL
22
+ || (process.env.GITLAB_BASE_URL ? `${process.env.GITLAB_BASE_URL}/api/v4` : 'https://gitlab.com/api/v4');
23
+ const ORBIT_BASE = `${GITLAB_API}/orbit`;
24
+
25
+ const IMPORT_TO_FILE_EDGE_CANDIDATES = [
26
+ 'IN_FILE',
27
+ 'IMPORTED_IN',
28
+ 'DEFINED_IN',
29
+ 'REFERENCED_IN',
30
+ 'BELONGS_TO',
31
+ ];
32
+
33
+ const FILE_TO_PROJECT_EDGE_CANDIDATES = [
34
+ 'IN_PROJECT',
35
+ 'BELONGS_TO',
36
+ ];
37
+
38
+ let expandedSourceSchema = null;
39
+
40
+ async function getStatus() {
41
+ const response = await withRetry(() => axios.get(`${ORBIT_BASE}/status`, {
42
+ headers: authHeaders(),
43
+ timeout: 5000,
44
+ }));
45
+ return response.data;
46
+ }
47
+
48
+ async function getSchema(expand) {
49
+ const response = await withRetry(() => axios.get(`${ORBIT_BASE}/schema`, {
50
+ headers: authHeaders(),
51
+ params: expand ? { expand } : undefined,
52
+ timeout: 10000,
53
+ }));
54
+ return response.data;
55
+ }
56
+
57
+ async function getSourceSchema() {
58
+ if (!expandedSourceSchema) {
59
+ expandedSourceSchema = await getSchema('ImportedSymbol,File,Project');
60
+ }
61
+ return expandedSourceSchema;
62
+ }
63
+
64
+ async function listTools() {
65
+ const response = await withRetry(() => axios.get(`${ORBIT_BASE}/tools`, {
66
+ headers: authHeaders(),
67
+ timeout: 10000,
68
+ }));
69
+ return response.data;
70
+ }
71
+
72
+ async function query(queryDsl, options = {}) {
73
+ const response = await withRetry(() => axios.post(
74
+ `${ORBIT_BASE}/query`,
75
+ {
76
+ query: queryDsl,
77
+ query_type: 'json',
78
+ response_format: options.responseFormat || 'raw',
79
+ },
80
+ { headers: authHeaders(), timeout: 15000 }
81
+ ));
82
+ return response.data;
83
+ }
84
+
85
+ function edgeName(edge) {
86
+ return edge?.type || edge?.name || edge?.relationship_type;
87
+ }
88
+
89
+ function edgeEndpoint(edge, keys) {
90
+ for (const key of keys) {
91
+ if (edge?.[key]) return String(edge[key]);
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function edgeMatches(edge, fromEntity, toEntity) {
97
+ const from = edgeEndpoint(edge, ['from', 'source', 'source_node', 'source_type', 'from_node', 'from_entity']);
98
+ const to = edgeEndpoint(edge, ['to', 'target', 'target_node', 'target_type', 'to_node', 'to_entity']);
99
+ if (!from || !to) return false;
100
+ return from.includes(fromEntity) && to.includes(toEntity);
101
+ }
102
+
103
+ async function relationshipCandidates(fromEntity, toEntity, fallback) {
104
+ try {
105
+ const schema = await getSourceSchema();
106
+ const schemaEdges = (schema.edges || [])
107
+ .filter(edge => edgeMatches(edge, fromEntity, toEntity))
108
+ .map(edgeName)
109
+ .filter(Boolean);
110
+ return [...new Set([...schemaEdges, ...fallback])];
111
+ } catch {
112
+ return fallback;
113
+ }
114
+ }
115
+
116
+ function rowValue(row, alias, field) {
117
+ return row?.[`${alias}_${field}`] ?? row?.[alias]?.[field] ?? row?.[field];
118
+ }
119
+
120
+ async function queryWithRelationshipFallback(buildQuery, relationshipSets) {
121
+ let lastError = null;
122
+ const [firstSet, secondSet = [null]] = relationshipSets;
123
+ for (const first of firstSet) {
124
+ for (const second of secondSet) {
125
+ try {
126
+ return await query(buildQuery(first, second));
127
+ } catch (error) {
128
+ lastError = error;
129
+ }
130
+ }
131
+ }
132
+ throw lastError;
133
+ }
134
+
135
+ async function findPackageImporters(projectId, packageName) {
136
+ const importToFileEdges = await relationshipCandidates('ImportedSymbol', 'File', IMPORT_TO_FILE_EDGE_CANDIDATES);
137
+ const fileToProjectEdges = await relationshipCandidates('File', 'Project', FILE_TO_PROJECT_EDGE_CANDIDATES);
138
+ const result = await queryWithRelationshipFallback(
139
+ (importToFileEdge, fileToProjectEdge) => ({
140
+ query_type: 'traversal',
141
+ nodes: [
142
+ { id: 'p', entity: 'Project', node_ids: [Number(projectId)] },
143
+ {
144
+ id: 'imp',
145
+ entity: 'ImportedSymbol',
146
+ filters: { import_path: { contains: packageName } },
147
+ },
148
+ { id: 'f', entity: 'File', columns: ['path', 'language'] },
149
+ ],
150
+ relationships: [
151
+ { type: importToFileEdge, from: 'imp', to: 'f' },
152
+ { type: fileToProjectEdge, from: 'f', to: 'p' },
153
+ ],
154
+ limit: 200,
155
+ }),
156
+ [importToFileEdges, fileToProjectEdges]
157
+ );
158
+
159
+ return (result.result || []).map(row => ({
160
+ path: rowValue(row, 'f', 'path'),
161
+ language: rowValue(row, 'f', 'language'),
162
+ importPath: rowValue(row, 'imp', 'import_path') || packageName,
163
+ }));
164
+ }
165
+
166
+ async function findProjectsImportingPackage(groupId, packageName) {
167
+ const importToFileEdges = await relationshipCandidates('ImportedSymbol', 'File', IMPORT_TO_FILE_EDGE_CANDIDATES);
168
+ const fileToProjectEdges = await relationshipCandidates('File', 'Project', FILE_TO_PROJECT_EDGE_CANDIDATES);
169
+ const result = await queryWithRelationshipFallback(
170
+ (importToFileEdge, fileToProjectEdge) => ({
171
+ query_type: 'traversal',
172
+ nodes: [
173
+ {
174
+ id: 'imp',
175
+ entity: 'ImportedSymbol',
176
+ filters: { import_path: { contains: packageName } },
177
+ },
178
+ { id: 'f', entity: 'File', columns: ['path', 'language'] },
179
+ { id: 'p', entity: 'Project', columns: ['id', 'full_path'], filters: { group_id: String(groupId) } },
180
+ ],
181
+ relationships: [
182
+ { type: importToFileEdge, from: 'imp', to: 'f' },
183
+ { type: fileToProjectEdge, from: 'f', to: 'p' },
184
+ ],
185
+ limit: 500,
186
+ }),
187
+ [importToFileEdges, fileToProjectEdges]
188
+ );
189
+
190
+ const byProject = new Map();
191
+ for (const row of result.result || []) {
192
+ const projectId = rowValue(row, 'p', 'id');
193
+ if (projectId) {
194
+ if (!byProject.has(projectId)) {
195
+ byProject.set(projectId, { projectId, projectPath: rowValue(row, 'p', 'full_path'), files: [] });
196
+ }
197
+ byProject.get(projectId).files.push({
198
+ path: rowValue(row, 'f', 'path'),
199
+ language: rowValue(row, 'f', 'language'),
200
+ importPath: rowValue(row, 'imp', 'import_path') || packageName,
201
+ });
202
+ }
203
+ }
204
+ return Array.from(byProject.values());
205
+ }
206
+
207
+ module.exports = {
208
+ getStatus,
209
+ getSchema,
210
+ listTools,
211
+ query,
212
+ findPackageImporters,
213
+ findProjectsImportingPackage,
214
+ };
@@ -0,0 +1,228 @@
1
+ /**
2
+ * PR Generator
3
+ * Commits an applied dependency fix and opens a real merge request for it.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const axios = require('axios');
9
+ const { getAuthHeaders, hasAnyToken } = require('./gitlabAuth');
10
+
11
+ // GITLAB_API_URL: manual override for standalone CLI/CI use.
12
+ // GITLAB_BASE_URL: auto-injected by GitLab when this runs inside a Duo
13
+ // Custom Flow (see .gitlab/duo/flows/vulnerability-analysis-flow.yaml).
14
+ const GITLAB_API = process.env.GITLAB_API_URL
15
+ || (process.env.GITLAB_BASE_URL ? `${process.env.GITLAB_BASE_URL}/api/v4` : 'https://gitlab.com/api/v4');
16
+
17
+ function apiConfig() {
18
+ return {
19
+ headers: {
20
+ ...getAuthHeaders(),
21
+ 'Content-Type': 'application/json',
22
+ },
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Commit one or more already-edited files and open a merge request.
28
+ * @param {string} projectId - GitLab project ID
29
+ * @param {string} repoPath - local checkout path the files were edited in
30
+ * @param {Object} vulnerability - vulnerability being fixed
31
+ * @param {Object} fixResult - result from ecosystemFixers.applyFix (must have filePath)
32
+ * @param {string} description - MR description (markdown)
33
+ * @returns {Object} created MR info
34
+ */
35
+ async function createAutomatedPR(projectId, repoPath, vulnerability, fixResult, description = '') {
36
+ if (!hasAnyToken()) {
37
+ console.warn(' ⚠️ No GITLAB_TOKEN or CI_JOB_TOKEN available. Skipping PR creation.');
38
+ return { success: false, reason: 'No GitLab API token available', web_url: '#' };
39
+ }
40
+
41
+ if (!fixResult?.applied || !fixResult.filePath) {
42
+ return { success: false, reason: 'No applied fix to commit (run with --fix first)' };
43
+ }
44
+
45
+ console.log(`📝 Creating merge request for ${vulnerability.package}...`);
46
+ const branchName = `security/upgrade-${slug(vulnerability.package)}-${Date.now()}`;
47
+
48
+ try {
49
+ await axios.post(
50
+ `${GITLAB_API}/projects/${projectId}/repository/branches`,
51
+ { branch: branchName, ref: 'main' },
52
+ apiConfig()
53
+ );
54
+ console.log(` ✓ Created branch: ${branchName}`);
55
+ } catch (error) {
56
+ if (error.response?.status !== 400) throw error;
57
+ }
58
+
59
+ const fileContent = fs.readFileSync(path.join(repoPath, fixResult.filePath), 'utf-8');
60
+
61
+ await axios.post(
62
+ `${GITLAB_API}/projects/${projectId}/repository/commits`,
63
+ {
64
+ branch: branchName,
65
+ commit_message: fixResult.action === 'remove'
66
+ ? `chore: remove unused dependency ${vulnerability.package} (${vulnerability.id}, Orbit found zero importers)`
67
+ : `security: upgrade ${vulnerability.package} to ${vulnerability.fixedVersion} (${vulnerability.id})`,
68
+ actions: [
69
+ {
70
+ action: 'update',
71
+ file_path: fixResult.filePath.replace(/\\/g, '/'),
72
+ content: fileContent,
73
+ },
74
+ ],
75
+ },
76
+ apiConfig()
77
+ );
78
+ console.log(` ✓ Committed ${fixResult.filePath} to ${branchName}`);
79
+
80
+ const mrResponse = await axios.post(
81
+ `${GITLAB_API}/projects/${projectId}/merge_requests`,
82
+ {
83
+ source_branch: branchName,
84
+ target_branch: 'main',
85
+ title: fixResult.action === 'remove'
86
+ ? `Cleanup: Remove unused dependency ${vulnerability.package}`
87
+ : `Security: Upgrade ${vulnerability.package} (${vulnerability.severity})`,
88
+ description,
89
+ labels: fixResult.action === 'remove'
90
+ ? ['cleanup', 'automated', 'dependencies', 'dependencyiq']
91
+ : ['security', 'automated', 'dependencies', 'dependencyiq'],
92
+ allow_collaboration: true,
93
+ squash_commits: true,
94
+ },
95
+ apiConfig()
96
+ );
97
+
98
+ const mr = mrResponse.data;
99
+ console.log(` ✓ MR created: ${mr.web_url}`);
100
+
101
+ return {
102
+ success: true,
103
+ id: mr.iid,
104
+ url: mr.web_url,
105
+ title: mr.title,
106
+ branch: branchName,
107
+ followUp: fixResult.followUp,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Open a merge request for a branch that's already been committed to
113
+ * (e.g. by remoteFixer.js for a cross-project emergency response, where
114
+ * the commit happens via the API with no local checkout at all).
115
+ * @param {string} projectId - GitLab project ID
116
+ * @param {Object} vulnerability - vulnerability being fixed
117
+ * @param {Object} fixResult - result from remoteFixer.applyRemoteFix (must have branch + action)
118
+ * @param {string} description - MR description (markdown)
119
+ */
120
+ async function openMergeRequestForBranch(projectId, vulnerability, fixResult, description = '') {
121
+ const mrResponse = await axios.post(
122
+ `${GITLAB_API}/projects/${projectId}/merge_requests`,
123
+ {
124
+ source_branch: fixResult.branch,
125
+ target_branch: 'main',
126
+ title: fixResult.action === 'remove'
127
+ ? `Cleanup: Remove unused dependency ${vulnerability.package}`
128
+ : `Security: Upgrade ${vulnerability.package} (${vulnerability.severity})`,
129
+ description,
130
+ labels: fixResult.action === 'remove'
131
+ ? ['cleanup', 'automated', 'dependencies', 'dependencyiq']
132
+ : ['security', 'automated', 'dependencies', 'dependencyiq'],
133
+ allow_collaboration: true,
134
+ squash_commits: true,
135
+ },
136
+ apiConfig()
137
+ );
138
+
139
+ const mr = mrResponse.data;
140
+ return { success: true, id: mr.iid, url: mr.web_url, title: mr.title, branch: fixResult.branch, followUp: fixResult.followUp };
141
+ }
142
+
143
+ /**
144
+ * Create a tracking issue in a project — used for every affected project
145
+ * in an emergency response, even ones where an automated fix wasn't
146
+ * applied (e.g. transitive dependency, or --fix-all wasn't passed).
147
+ */
148
+ async function createTrackingIssue(projectId, title, description, labels = ['security', 'automated', 'dependencyiq']) {
149
+ const response = await axios.post(
150
+ `${GITLAB_API}/projects/${projectId}/issues`,
151
+ { title, description, labels: labels.join(',') },
152
+ apiConfig()
153
+ );
154
+ return { success: true, iid: response.data.iid, url: response.data.web_url };
155
+ }
156
+
157
+ /**
158
+ * Post a note (comment) on a merge request — used by the MR Safety Review
159
+ * to attach its Orbit-grounded verdict to an incoming dependency MR.
160
+ */
161
+ async function postMergeRequestNote(projectId, mrIid, body) {
162
+ if (!hasAnyToken()) {
163
+ return { success: false, reason: 'No GITLAB_TOKEN or CI_JOB_TOKEN available' };
164
+ }
165
+ const response = await axios.post(
166
+ `${GITLAB_API}/projects/${encodeURIComponent(projectId)}/merge_requests/${mrIid}/notes`,
167
+ { body },
168
+ apiConfig()
169
+ );
170
+ return { success: true, id: response.data.id };
171
+ }
172
+
173
+ function slug(name) {
174
+ return name.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
175
+ }
176
+
177
+ /**
178
+ * Format PR/analysis report as markdown
179
+ * @param {Array} vulnerabilities - Vulnerabilities analyzed
180
+ * @returns {string} Formatted report
181
+ */
182
+ function formatRiskReport(vulnerabilities = []) {
183
+ let report = '# Dependency Vulnerability Report\n\n';
184
+
185
+ report += '## Summary\n\n';
186
+ report += `Found **${vulnerabilities.length}** vulnerabilities\n\n`;
187
+
188
+ const urgent = vulnerabilities.filter(v => v.riskScore?.priority === 'URGENT');
189
+ const high = vulnerabilities.filter(v => v.riskScore?.priority === 'HIGH');
190
+ const medium = vulnerabilities.filter(v => v.riskScore?.priority === 'MEDIUM');
191
+
192
+ if (urgent.length > 0) report += `- **🔴 URGENT**: ${urgent.length}\n`;
193
+ if (high.length > 0) report += `- **🟠 HIGH**: ${high.length}\n`;
194
+ if (medium.length > 0) report += `- **🟡 MEDIUM**: ${medium.length}\n`;
195
+
196
+ report += '\n## Vulnerabilities by Risk\n\n';
197
+
198
+ const sorted = [...vulnerabilities].sort((a, b) => (b.riskScore?.score || 0) - (a.riskScore?.score || 0));
199
+
200
+ for (let i = 0; i < Math.min(5, sorted.length); i += 1) {
201
+ const v = sorted[i];
202
+ const emoji = v.riskScore?.priority === 'URGENT' ? '🔴'
203
+ : v.riskScore?.priority === 'HIGH' ? '🟠'
204
+ : v.riskScore?.priority === 'MEDIUM' ? '🟡' : '🟢';
205
+
206
+ report += `\n### ${emoji} ${i + 1}. ${v.package} (${v.ecosystem || 'unknown ecosystem'})\n`;
207
+ report += `- **Version**: ${v.currentVersion} → ${v.fixedVersion}\n`;
208
+ report += `- **Severity**: ${v.severity} (CVSS ${v.cvss})\n`;
209
+ report += `- **Risk Score**: ${v.riskScore?.score ?? 'N/A'}/100\n`;
210
+ report += `- **Exposure data**: ${v.riskScore?.exposureDataSource === 'orbit' ? 'GitLab Orbit blast-radius query' : 'unavailable (Orbit not enabled/reachable)'}\n`;
211
+ report += `- **Issue**: ${v.vulnerability}\n`;
212
+ }
213
+
214
+ report += '\n## Recommended Actions\n\n';
215
+ report += '1. Review URGENT/HIGH vulnerabilities first\n';
216
+ report += '2. Run with `--fix --create-pr` for the top vulnerability to open a real MR\n';
217
+ report += '3. Schedule remaining MEDIUM/LOW items for a follow-up sprint\n';
218
+
219
+ return report;
220
+ }
221
+
222
+ module.exports = {
223
+ createAutomatedPR,
224
+ openMergeRequestForBranch,
225
+ createTrackingIssue,
226
+ postMergeRequestNote,
227
+ formatRiskReport,
228
+ };