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
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
|
+
}
|