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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 [Your Name/Organization]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "dependencyiq",
3
+ "version": "2.0.0",
4
+ "description": "Multi-language dependency vulnerability agent: OSV-Scanner + GitLab Orbit blast-radius analysis + automated MRs",
5
+ "main": "src/agent.js",
6
+ "bin": {
7
+ "dependencyiq": "src/agent.js"
8
+ },
9
+ "files": [
10
+ "src/"
11
+ ],
12
+ "scripts": {
13
+ "start": "node src/agent.js",
14
+ "test": "jest --coverage --coverageReporters=cobertura --coverageReporters=text",
15
+ "test:watch": "jest --watch",
16
+ "lint": "eslint src/",
17
+ "validate-config": "node scripts/validate-config.js",
18
+ "dev": "node -r dotenv/config src/agent.js"
19
+ },
20
+ "keywords": [
21
+ "security",
22
+ "vulnerabilities",
23
+ "dependencies",
24
+ "gitlab",
25
+ "orbit",
26
+ "ai",
27
+ "automation"
28
+ ],
29
+ "author": "DependencyIQ Team",
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "axios": "^1.6.0",
33
+ "commander": "^11.0.0",
34
+ "dotenv": "^16.3.0",
35
+ "js-yaml": "^4.2.0",
36
+ "toml": "^4.1.1"
37
+ },
38
+ "devDependencies": {
39
+ "eslint": "^8.50.0",
40
+ "eslint-config-airbnb-base": "^15.0.0",
41
+ "eslint-plugin-import": "^2.28.1",
42
+ "jest": "^29.7.0"
43
+ },
44
+ "jest": {
45
+ "testPathIgnorePatterns": [
46
+ "/node_modules/",
47
+ "/\\.tmp"
48
+ ]
49
+ }
50
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Agent activity feed — what DependencyIQ has actually suggested and
3
+ * done, sourced live from GitLab's own API rather than a separate log
4
+ * file this project would have to keep in sync. Every issue/MR the
5
+ * agent suite creates (src/prGenerator.js) is tagged with the
6
+ * `dependencyiq` label specifically so this can query for them: a real
7
+ * GitLab object with a real timestamp and URL, not a parallel record
8
+ * that could drift from what actually happened.
9
+ */
10
+
11
+ const axios = require('axios');
12
+ const { getAuthHeaders, hasAnyToken } = require('./gitlabAuth');
13
+
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
+ /**
18
+ * @param {string} projectId
19
+ * @param {number} limit - max items per type (issues, MRs) to fetch
20
+ * @returns {Promise<Object>} { available, items: [...], reason? }
21
+ */
22
+ async function getRecentActivity(projectId, limit = 20) {
23
+ if (!hasAnyToken()) {
24
+ return { available: false, reason: 'No GITLAB_TOKEN or CI_JOB_TOKEN available — cannot query issues/MRs', items: [] };
25
+ }
26
+
27
+ try {
28
+ const [issuesRes, mrsRes] = await Promise.all([
29
+ axios.get(`${GITLAB_API}/projects/${projectId}/issues`, {
30
+ headers: getAuthHeaders(),
31
+ params: { labels: 'dependencyiq', order_by: 'created_at', sort: 'desc', per_page: limit },
32
+ timeout: 10000,
33
+ }),
34
+ axios.get(`${GITLAB_API}/projects/${projectId}/merge_requests`, {
35
+ headers: getAuthHeaders(),
36
+ params: { labels: 'dependencyiq', order_by: 'created_at', sort: 'desc', per_page: limit },
37
+ timeout: 10000,
38
+ }),
39
+ ]);
40
+
41
+ const issueItems = issuesRes.data.map(issue => ({
42
+ type: 'issue',
43
+ title: issue.title,
44
+ url: issue.web_url,
45
+ state: issue.state,
46
+ createdAt: issue.created_at,
47
+ labels: issue.labels,
48
+ }));
49
+
50
+ const mrItems = mrsRes.data.map(mr => ({
51
+ type: 'merge_request',
52
+ title: mr.title,
53
+ url: mr.web_url,
54
+ state: mr.state,
55
+ createdAt: mr.created_at,
56
+ labels: mr.labels,
57
+ }));
58
+
59
+ const items = [...issueItems, ...mrItems].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
60
+ return { available: true, items };
61
+ } catch (error) {
62
+ return { available: false, reason: `Failed to fetch activity: ${error.message}`, items: [] };
63
+ }
64
+ }
65
+
66
+ module.exports = { getRecentActivity };
package/src/agent.js ADDED
@@ -0,0 +1,506 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * DependencyIQ - Multi-language dependency vulnerability agent
5
+ *
6
+ * Orchestrates: scan (OSV-Scanner, any ecosystem) → blast-radius analysis
7
+ * (GitLab Orbit) → risk scoring → optional fix + merge request.
8
+ *
9
+ * Deeper strategy/migration-plan reasoning is generated by templates here
10
+ * (no AI call) and is meant to be handed to the GitLab Duo "DependencyIQ"
11
+ * agent in Chat for interactive, model-backed follow-up — see
12
+ * .gitlab/duo/agents/dependencyiq-agent.yaml.
13
+ */
14
+
15
+ require('dotenv').config();
16
+ const { Command } = require('commander');
17
+ const path = require('path');
18
+ const fs = require('fs');
19
+ const { execFileSync } = require('child_process');
20
+
21
+ const { detectVulnerabilities, detectEcosystems } = require('./scanners/osvScanner');
22
+ const { applyFix } = require('./scanners/ecosystemFixers');
23
+ const { calculateRiskScore, sortByRisk, findUnusedDependencies } = require('./riskCalculator');
24
+ const {
25
+ generateRefactoringAnalysis, generateMigrationTimeline, generateAnalysisSummary,
26
+ formatVulnerabilityCard, generatePRDescription, generateStructuredMigrationPlan, formatMigrationPlan
27
+ } = require('./strategyGenerator');
28
+ const { analyzeExposure } = require('./blastRadius');
29
+ const { createAutomatedPR, postMergeRequestNote } = require('./prGenerator');
30
+ const { diffNpmManifests, reviewChange, buildReviewNote, summarizeReview } = require('./mrReviewer');
31
+ const { assessSupplyChainTrust } = require('./scanners/supplyChainTrustSignals');
32
+ const { getLockfileEntry } = require('./scanners/dependencyTreeBuilder');
33
+ const { buildFleetSnapshot, writeFleetSnapshot } = require('./fleetSnapshot');
34
+ const { buildFleetReport } = require('./fleetAggregator');
35
+ const { generateFleetDashboardHtml } = require('./fleetDashboardGenerator');
36
+ const { buildExecutiveSummary, formatExecutiveSummary } = require('./executiveSummary');
37
+ const { checkFreshness, chooseSecureTargetVersion } = require('./freshnessChecker');
38
+ const { buildImpactReport, formatImpactReport } = require('./impactReport');
39
+ const { generateDashboardHtml, generateScanFailedHtml } = require('./dashboardGenerator');
40
+ const { getRecentActivity } = require('./activityFetcher');
41
+ const { resolveDependencyChain } = require('./scanners/dependencyTreeBuilder');
42
+ const { runEmergencyResponse, formatRollup } = require('./crossProjectFanOut');
43
+ const { listDirectDependencies } = require('./scanners/manifestParser');
44
+ const { loadAgentsConfig } = require('./configLoader');
45
+ const { runPolicyCheck } = require('./freshnessPolicy');
46
+
47
+ /**
48
+ * Run complete vulnerability analysis
49
+ */
50
+ async function analyzeRepository(repoPath, projectId, options = {}) {
51
+ console.log('\n═══════════════════════════════════════════════════════════');
52
+ console.log('šŸ›”ļø DependencyIQ — Multi-language Dependency Vulnerability Agent');
53
+ console.log('šŸ”— GitLab Orbit + GitLab Duo Agent Platform');
54
+ console.log('═══════════════════════════════════════════════════════════\n');
55
+
56
+ try {
57
+ const ecosystems = detectEcosystems(repoPath);
58
+ console.log(`šŸ“¦ Detected ecosystems: ${ecosystems.length ? ecosystems.join(', ') : 'none found'}`);
59
+
60
+ const vulnerabilities = await detectVulnerabilities(repoPath);
61
+
62
+ if (vulnerabilities.length === 0) {
63
+ console.log('āœ… No vulnerabilities found!');
64
+ return { success: true, vulnerabilities: [] };
65
+ }
66
+
67
+ // AGENTS.md path hints (public_api_paths/test_paths) let a project's
68
+ // own layout drive exposure classification instead of generic
69
+ // heuristics — see configLoader/blastRadius.
70
+ const projectConfig = loadAgentsConfig(repoPath);
71
+ const pathHints = { public_api_paths: projectConfig.public_api_paths, test_paths: projectConfig.test_paths };
72
+
73
+ console.log('\nšŸ“Š Analyzing blast radius with GitLab Orbit...');
74
+ for (const vuln of vulnerabilities) {
75
+ const exposure = await analyzeExposure(projectId, vuln.package, pathHints);
76
+
77
+ vuln.riskScore = calculateRiskScore(vuln, {
78
+ affectedFilesCount: exposure.affectedFilesCount,
79
+ usageCount: exposure.affectedFilesCount,
80
+ isInPublicAPI: exposure.isInPublicAPI,
81
+ isInBusinessLogic: !exposure.isInTests,
82
+ testCoverage: exposure.isInTests ? 0.8 : 0.3,
83
+ exposureDataSource: exposure.source,
84
+ });
85
+
86
+ vuln.affectedFiles = exposure.affectedFiles;
87
+ vuln.exposure = exposure;
88
+ vuln.recommendation = vuln.riskScore.recommendation;
89
+ vuln.dependencyChain = resolveDependencyChain(repoPath, vuln.ecosystem, vuln.package);
90
+ }
91
+
92
+ console.log('\nšŸŽÆ Ranking by actual risk...');
93
+ const ranked = sortByRisk(vulnerabilities);
94
+
95
+ for (let i = 0; i < Math.min(5, ranked.length); i += 1) {
96
+ const v = ranked[i];
97
+ console.log(` ${i + 1}. [${v.ecosystem}] ${v.package}: Risk ${v.riskScore.score}/100 (${v.riskScore.priority})`);
98
+ }
99
+
100
+ const unused = findUnusedDependencies(ranked);
101
+ if (unused.length > 0) {
102
+ console.log('\n🧹 Cleanup opportunities (Orbit found zero importers — recommend removing, not patching):');
103
+ for (const v of unused) {
104
+ console.log(` - [${v.ecosystem}] ${v.package} (${v.id})`);
105
+ }
106
+ }
107
+
108
+ console.log('\nšŸ“‹ Generating analysis summary...');
109
+ const summary = generateAnalysisSummary(ranked);
110
+ console.log('\n' + summary);
111
+
112
+ const execSummary = buildExecutiveSummary(ranked);
113
+ console.log('\n' + formatExecutiveSummary(execSummary));
114
+
115
+ if (options.detailed) {
116
+ console.log('\nšŸ“Œ DETAILED VULNERABILITY CARDS:\n');
117
+ for (let i = 0; i < Math.min(3, ranked.length); i += 1) {
118
+ console.log(formatVulnerabilityCard(ranked[i], i + 1));
119
+ }
120
+ }
121
+
122
+ if (ranked.length > 0 && options.strategies) {
123
+ console.log('\n═══════════════════════════════════════════════════════════');
124
+ console.log('šŸ’” Ask GitLab Duo Chat (DependencyIQ agent) to expand this:');
125
+ console.log('═══════════════════════════════════════════════════════════\n');
126
+
127
+ const topVuln = ranked[0];
128
+ console.log(generateRefactoringAnalysis(topVuln, { affectedFiles: topVuln.affectedFiles }));
129
+ }
130
+
131
+ if (options.plan) {
132
+ console.log('\n═══════════════════════════════════════════════════════════');
133
+ console.log('šŸ“… MIGRATION TIMELINE');
134
+ console.log('═══════════════════════════════════════════════════════════\n');
135
+ console.log(generateMigrationTimeline(ranked));
136
+ }
137
+
138
+ let impactReport = null;
139
+ let migrationPlan = null;
140
+ if (ranked.length > 0 && options.impact) {
141
+ console.log('\n═══════════════════════════════════════════════════════════');
142
+ console.log('šŸ”¬ UPGRADE IMPACT REPORT');
143
+ console.log('═══════════════════════════════════════════════════════════\n');
144
+
145
+ const topVuln = ranked[0];
146
+ const freshness = await checkFreshness(topVuln.ecosystem, topVuln.package, topVuln.currentVersion)
147
+ .catch(() => ({ available: false, reason: 'lookup failed' }));
148
+
149
+ impactReport = buildImpactReport(topVuln, freshness);
150
+ migrationPlan = generateStructuredMigrationPlan(impactReport);
151
+
152
+ console.log(formatImpactReport(impactReport));
153
+ console.log(formatMigrationPlan(migrationPlan));
154
+ }
155
+
156
+ // Fix + PR (real, not simulated): edits the manifest locally, then
157
+ // commits it and opens an MR via the GitLab API.
158
+ let fixResult = null;
159
+ let prResult = null;
160
+ if (ranked.length > 0 && (options.fix || options.createPR)) {
161
+ const topVuln = ranked[0];
162
+
163
+ // "Which version?" decision (the part OSV alone can't answer): for an
164
+ // upgrade, don't blindly take OSV's minimum fixed version — pick the
165
+ // least-disruptive secure version (>= floor, settled, staying within
166
+ // the current major where possible). Preserve the OSV floor so the
167
+ // transitive-override path still pins against the security minimum.
168
+ if (topVuln.recommendation !== 'remove' && topVuln.fixedVersion && topVuln.fixedVersion !== 'unknown') {
169
+ const target = await chooseSecureTargetVersion(topVuln.ecosystem, topVuln.package, topVuln.currentVersion, topVuln.fixedVersion)
170
+ .catch(() => ({ available: false }));
171
+ topVuln.osvFloorVersion = topVuln.fixedVersion;
172
+ topVuln.versionSelection = target;
173
+ if (target.available && target.recommendedVersion !== topVuln.fixedVersion) {
174
+ console.log(` šŸŽÆ Target version: ${topVuln.fixedVersion} (OSV floor) → ${target.recommendedVersion} — ${target.rationale}`);
175
+ topVuln.fixedVersion = target.recommendedVersion;
176
+ }
177
+ if (target.available && target.crossesMajor) {
178
+ console.log(' āš ļø This fix crosses a major version — review for breaking changes.');
179
+ }
180
+ }
181
+
182
+ console.log(`\nšŸ”§ Applying fix for ${topVuln.package}...`);
183
+ fixResult = applyFix(repoPath, topVuln);
184
+ if (fixResult.action === 'override') {
185
+ console.log(` šŸ”— ${topVuln.package} is transitive — forced a secure version via package.json "overrides".`);
186
+ }
187
+
188
+ if (fixResult.applied) {
189
+ console.log(` āœ“ Updated ${fixResult.filePath}`);
190
+ if (fixResult.followUp) console.log(` ā„¹ļø Follow-up needed: ${fixResult.followUp}`);
191
+ } else {
192
+ console.log(` āš ļø Could not auto-apply fix: ${fixResult.warning}`);
193
+ }
194
+
195
+ if (options.createPR && projectId && fixResult.applied) {
196
+ const description = generatePRDescription(topVuln, fixResult);
197
+ prResult = await createAutomatedPR(projectId, repoPath, topVuln, fixResult, description);
198
+ } else if (options.createPR && !fixResult.applied) {
199
+ console.log(' āš ļø Skipping PR creation: no fix was applied to commit');
200
+ }
201
+ }
202
+
203
+ console.log('\nāœ… Analysis complete!\n');
204
+
205
+ return {
206
+ success: true,
207
+ vulnerabilities: ranked,
208
+ summary,
209
+ fixResult,
210
+ prResult,
211
+ impactReport,
212
+ migrationPlan,
213
+ };
214
+ } catch (error) {
215
+ console.error('\nāŒ Error during analysis:', error.message);
216
+ return { success: false, error: error.message };
217
+ }
218
+ }
219
+
220
+ /**
221
+ * CLI Interface
222
+ */
223
+ async function main() {
224
+ const program = new Command();
225
+
226
+ program
227
+ .name('dependencyiq')
228
+ .description('Multi-language dependency vulnerability agent (GitLab Orbit + Duo)')
229
+ .version('2.0.0');
230
+
231
+ program
232
+ .command('analyze <repoPath>')
233
+ .option('-p, --project-id <id>', 'GitLab project ID')
234
+ .option('--strategies', 'Print refactoring strategy analysis for the top vulnerability')
235
+ .option('--plan', 'Generate migration timeline')
236
+ .option('--impact', 'Generate an Upgrade Impact Report + structured migration plan for the top vulnerability')
237
+ .option('--fix', 'Apply the fix to the top vulnerability locally')
238
+ .option('--create-pr', 'Apply the fix, commit it, and open a merge request')
239
+ .option('--detailed', 'Show detailed vulnerability cards')
240
+ .option('--json-out <file>', 'Write a compact JSON snapshot (for Fleet Dashboard aggregation) to this path')
241
+ .description('Analyze repository for vulnerabilities')
242
+ .action(async (repoPath, options) => {
243
+ const projectId = options.projectId || process.env.GITLAB_PROJECT_ID;
244
+ const fullPath = path.resolve(repoPath);
245
+
246
+ const result = await analyzeRepository(fullPath, projectId, {
247
+ strategies: options.strategies ?? true,
248
+ plan: options.plan || false,
249
+ impact: options.impact || false,
250
+ fix: options.fix || false,
251
+ createPR: options.createPr || false,
252
+ detailed: options.detailed || false,
253
+ });
254
+
255
+ // Only write a snapshot on a real, successful scan — a failed scan
256
+ // must never produce an artifact that the Fleet Dashboard could
257
+ // misread as "this project ran cleanly with zero findings."
258
+ if (options.jsonOut && result.success) {
259
+ const snapshot = buildFleetSnapshot(result, {
260
+ projectId,
261
+ projectPath: process.env.CI_PROJECT_PATH || null,
262
+ ecosystems: detectEcosystems(fullPath),
263
+ });
264
+ writeFleetSnapshot(snapshot, options.jsonOut);
265
+ console.log(`\nšŸ“„ Fleet snapshot written to ${options.jsonOut}`);
266
+ }
267
+
268
+ if (!result.success) process.exit(1);
269
+ });
270
+
271
+ program
272
+ .command('emergency <groupId> <packageName>')
273
+ .option('-i, --vulnerability-id <id>', 'CVE/advisory ID for this incident', 'UNSPECIFIED')
274
+ .option('-e, --ecosystem <ecosystem>', 'npm | PyPI | Go | Maven', 'npm')
275
+ .option('-f, --fixed-version <version>', 'Version to upgrade direct dependents to')
276
+ .option('--remove', 'Treat this as a removal (e.g. the package itself is compromised, not just outdated)')
277
+ .option('--fix-all', 'Open real merge requests for every directly-affected project, not just issues')
278
+ .option('--dry-run', 'Query and classify only — create nothing', false)
279
+ .description('Org-wide emergency response: find every project in a group that imports a compromised package, and open issues/MRs across all of them')
280
+ .action(async (groupId, packageName, options) => {
281
+ const vulnerability = {
282
+ package: packageName,
283
+ ecosystem: options.ecosystem,
284
+ id: options.vulnerabilityId,
285
+ fixedVersion: options.fixedVersion || 'unknown',
286
+ recommendation: options.remove ? 'remove' : 'upgrade',
287
+ vulnerability: `Supply chain incident: ${packageName}`,
288
+ };
289
+
290
+ const rollup = await runEmergencyResponse(groupId, vulnerability, {
291
+ dryRun: options.dryRun,
292
+ fixDirect: options.fixAll || false,
293
+ });
294
+
295
+ console.log(formatRollup(rollup));
296
+ });
297
+
298
+ program
299
+ .command('freshness <repoPath>')
300
+ .description('Check every direct dependency (vulnerable or not) against AGENTS.md freshness policy — the tech-debt-prevention check, separate from CVE scanning')
301
+ .action(async (repoPath) => {
302
+ const fullPath = path.resolve(repoPath);
303
+ const config = loadAgentsConfig(fullPath);
304
+ console.log(`\nšŸ“ Freshness policy loaded from: ${config.source}`);
305
+ if (config.freshness_policy.max_minor_versions_behind == null && config.freshness_policy.max_days_behind == null) {
306
+ console.log(' āš ļø No freshness_policy thresholds set in AGENTS.md — nothing will be flagged as a violation, only reported.');
307
+ }
308
+
309
+ const dependencies = listDirectDependencies(fullPath);
310
+ console.log(`šŸ“¦ Found ${dependencies.length} direct dependencies across all ecosystems\n`);
311
+
312
+ const withFreshness = [];
313
+ for (const dependency of dependencies) {
314
+ const freshness = await checkFreshness(dependency.ecosystem, dependency.package, dependency.currentVersion)
315
+ .catch(() => ({ available: false }));
316
+ withFreshness.push({ dependency, freshness });
317
+ if (freshness.available && freshness.currentVersion !== freshness.latestVersion) {
318
+ console.log(` [${dependency.ecosystem}] ${dependency.package}: ${dependency.currentVersion} → ${freshness.latestVersion} (freshness ${freshness.freshnessScore ?? '?'}/100)`);
319
+ }
320
+ }
321
+
322
+ const policyResult = runPolicyCheck(withFreshness, config);
323
+ console.log(`\nāœ… Compliant: ${policyResult.compliantCount} | āš ļø Violations: ${policyResult.violationCount}\n`);
324
+ for (const v of policyResult.violations) {
325
+ console.log(` šŸ”“ ${v.package} (${v.ecosystem})`);
326
+ v.violations.forEach(msg => console.log(` - ${msg}`));
327
+ }
328
+ });
329
+
330
+ program
331
+ .command('dashboard <repoPath>')
332
+ .option('-p, --project-id <id>', 'GitLab project ID')
333
+ .option('-o, --out <dir>', 'Output directory for the static site', 'public')
334
+ .description('Generate a static HTML dashboard of the analysis (publishable via GitLab Pages — no backend)')
335
+ .action(async (repoPath, options) => {
336
+ const projectId = options.projectId || process.env.GITLAB_PROJECT_ID;
337
+ const fullPath = path.resolve(repoPath);
338
+ const meta = {
339
+ projectName: process.env.CI_PROJECT_NAME || path.basename(fullPath),
340
+ generatedAt: new Date().toISOString(),
341
+ projectPath: process.env.CI_PROJECT_PATH,
342
+ serverUrl: process.env.CI_SERVER_URL,
343
+ };
344
+ const outDir = path.resolve(options.out);
345
+ fs.mkdirSync(outDir, { recursive: true });
346
+
347
+ const result = await analyzeRepository(fullPath, projectId, {
348
+ strategies: false, plan: false, impact: false, fix: false, createPR: false, detailed: false,
349
+ });
350
+
351
+ if (!result.success) {
352
+ // Still publish a page — an honest "scan failed" page, never a
353
+ // silent "no vulnerabilities found" one — and still fail the CI
354
+ // job (exit 1) so the pipeline visibly flags it red.
355
+ fs.writeFileSync(path.join(outDir, 'index.html'), generateScanFailedHtml(result.error, meta));
356
+ console.log(`\nšŸ“„ Scan-failed dashboard written to ${path.join(outDir, 'index.html')}`);
357
+ process.exit(1);
358
+ }
359
+
360
+ const activity = await getRecentActivity(projectId).catch(error => ({ available: false, reason: error.message, items: [] }));
361
+ const html = generateDashboardHtml(result, meta, activity);
362
+ fs.writeFileSync(path.join(outDir, 'index.html'), html);
363
+ console.log(`\nšŸ“„ Dashboard written to ${path.join(outDir, 'index.html')}`);
364
+ });
365
+
366
+ program
367
+ .command('fleet-dashboard <groupId>')
368
+ .option('-o, --out <dir>', 'Output directory for the static site', 'public/fleet')
369
+ .option('-n, --name <groupName>', 'Display name for the group (cosmetic only)')
370
+ .description('Generate a cross-project static dashboard for every project in a GitLab group, aggregated from each project\'s own analyze_vulnerabilities CI artifact (requires GITLAB_TOKEN)')
371
+ .action(async (groupId, options) => {
372
+ const outDir = path.resolve(options.out);
373
+ fs.mkdirSync(outDir, { recursive: true });
374
+
375
+ console.log(`\nšŸš€ Building Fleet Dashboard for group ${groupId}...`);
376
+ const report = await buildFleetReport(groupId);
377
+
378
+ if (!report.available) {
379
+ console.warn(` āš ļø ${report.reason}`);
380
+ } else {
381
+ console.log(` āœ“ ${report.rollup.onboardedCount}/${report.rollup.totalProjects} project(s) onboarded, ${report.rollup.totalFindings} total finding(s)`);
382
+ }
383
+
384
+ const html = generateFleetDashboardHtml(report, { groupId, groupName: options.name });
385
+ fs.writeFileSync(path.join(outDir, 'index.html'), html);
386
+ console.log(`\nšŸ“„ Fleet dashboard written to ${path.join(outDir, 'index.html')}`);
387
+
388
+ if (!report.available) process.exit(1);
389
+ });
390
+
391
+ program
392
+ .command('review-mr <repoPath>')
393
+ .option('-p, --project-id <id>', 'GitLab project ID')
394
+ .option('-b, --base <ref>', 'Git ref of the MR target/base to diff against (default: CI_MERGE_REQUEST_DIFF_BASE_SHA or origin/HEAD)')
395
+ .option('--post', 'Post the review as a note on the merge request (needs CI_MERGE_REQUEST_IID)')
396
+ .description('Safety-review an incoming dependency MR: diff the manifest, query Orbit for blast radius, and emit an approve/review/block verdict')
397
+ .action(async (repoPath, options) => {
398
+ const projectId = options.projectId || process.env.CI_PROJECT_ID || process.env.GITLAB_PROJECT_ID;
399
+ const fullPath = path.resolve(repoPath);
400
+ const manifestPath = path.join(fullPath, 'package.json');
401
+
402
+ if (!fs.existsSync(manifestPath)) {
403
+ console.log('No package.json found — MR Safety Review currently covers npm manifests.');
404
+ return;
405
+ }
406
+ const headContent = fs.readFileSync(manifestPath, 'utf-8');
407
+
408
+ // Base manifest = the MR target. Prefer GitLab's merge-base SHA in CI,
409
+ // else an explicit --base, else origin/HEAD. If we can't read it, treat
410
+ // base as empty (everything reads as "added") rather than crashing.
411
+ const baseRef = options.base || process.env.CI_MERGE_REQUEST_DIFF_BASE_SHA
412
+ || (process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME ? `origin/${process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME}` : 'origin/HEAD');
413
+ let baseContent = '{}';
414
+ try {
415
+ baseContent = execFileSync('git', ['show', `${baseRef}:package.json`], { cwd: fullPath, encoding: 'utf-8' });
416
+ } catch (error) {
417
+ console.warn(` āš ļø Could not read base package.json at ${baseRef} (${error.message}) — reviewing all current dependencies as new.`);
418
+ }
419
+
420
+ const changes = diffNpmManifests(baseContent, headContent);
421
+ if (changes.length === 0) {
422
+ console.log('āœ… No dependency changes in this MR — nothing to review.');
423
+ return;
424
+ }
425
+
426
+ console.log(`\nšŸ›”ļø MR Safety Review: ${changes.length} dependency change(s)\n`);
427
+ const reviews = [];
428
+ const trustByPackage = {};
429
+ for (const change of changes) {
430
+ // Removal/override don't need exposure to verdict; others query Orbit.
431
+ const needsExposure = change.kind === 'changed' || change.kind === 'added';
432
+ const exposure = needsExposure
433
+ ? await analyzeExposure(projectId, change.package).catch(() => ({ source: 'unavailable' }))
434
+ : { source: 'unavailable' };
435
+ reviews.push(reviewChange(change, exposure));
436
+
437
+ // Supply-Chain Trust signals: a separate, never-blended dimension
438
+ // (see scanners/supplyChainTrustSignals.js) — only meaningful for
439
+ // a package whose code is actually changing.
440
+ if (needsExposure) {
441
+ const lockEntry = getLockfileEntry(fullPath, change.package);
442
+ trustByPackage[change.package] = await assessSupplyChainTrust({
443
+ packageName: change.package,
444
+ ecosystem: 'npm',
445
+ targetVersion: change.toVersion,
446
+ previousVersion: change.kind === 'changed' ? change.fromVersion : undefined,
447
+ resolvedUrl: lockEntry?.resolved,
448
+ }).catch(() => null);
449
+ }
450
+ }
451
+
452
+ const note = buildReviewNote(reviews, trustByPackage);
453
+ const summary = summarizeReview(reviews, trustByPackage);
454
+ console.log(note);
455
+
456
+ const mrIid = process.env.CI_MERGE_REQUEST_IID;
457
+ if (options.post && projectId && mrIid) {
458
+ const posted = await postMergeRequestNote(projectId, mrIid, note).catch(e => ({ success: false, reason: e.message }));
459
+ console.log(posted.success ? `\nšŸ“ Posted review to MR !${mrIid}` : `\nāš ļø Could not post review note: ${posted.reason}`);
460
+ }
461
+
462
+ // Exit non-zero on a blocking verdict so the CI job visibly fails —
463
+ // a real gate, not just a comment.
464
+ if (summary.overall === 'blocked') process.exit(1);
465
+ });
466
+
467
+ program
468
+ .command('scan <repoPath>')
469
+ .description('Scan repository for vulnerabilities only (no Orbit/risk scoring)')
470
+ .action(async (repoPath) => {
471
+ const vulns = await detectVulnerabilities(path.resolve(repoPath));
472
+ console.log(JSON.stringify(vulns, null, 2));
473
+ });
474
+
475
+ program
476
+ .command('quick <repoPath>')
477
+ .option('-p, --project-id <id>', 'GitLab project ID')
478
+ .description('Quick analysis (just risk scores, no detailed output)')
479
+ .action(async (repoPath, options) => {
480
+ const projectId = options.projectId || process.env.GITLAB_PROJECT_ID;
481
+ const result = await analyzeRepository(path.resolve(repoPath), projectId, {
482
+ strategies: false,
483
+ plan: false,
484
+ fix: false,
485
+ createPR: false,
486
+ detailed: false,
487
+ });
488
+
489
+ if (!result.success) process.exit(1);
490
+ });
491
+
492
+ program.parse(process.argv);
493
+
494
+ if (process.argv.length < 3) {
495
+ program.help();
496
+ }
497
+ }
498
+
499
+ module.exports = { analyzeRepository };
500
+
501
+ if (require.main === module) {
502
+ main().catch(error => {
503
+ console.error('Fatal error:', error);
504
+ process.exit(1);
505
+ });
506
+ }