dependencyiq 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +374 -0
- package/package.json +1 -1
- package/src/agent.js +2 -2
- package/src/blastRadius.js +44 -2
- package/src/fleetAggregator.js +155 -155
- package/src/fleetDashboardGenerator.js +199 -199
- package/src/fleetSnapshot.js +103 -103
- package/src/httpRetry.js +48 -48
- package/src/orbitClient.js +40 -0
- package/src/scanners/dependencyTreeBuilder.js +39 -1
- package/src/scanners/ecosystemFixers.js +36 -4
- package/src/scanners/supplyChainTrustSignals.js +472 -472
- package/src/strategyGenerator.js +13 -8
package/src/fleetAggregator.js
CHANGED
|
@@ -1,155 +1,155 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fleet Aggregator: cross-project rollup for the Fleet Dashboard.
|
|
3
|
-
*
|
|
4
|
-
* A static page can't query live across projects it doesn't own, so
|
|
5
|
-
* this builds a build-time snapshot of the whole group instead: list
|
|
6
|
-
* every project in the group, then fetch each one's latest
|
|
7
|
-
* `dependencyiq-report.json` CI artifact (written by `analyze_vulnerabilities`
|
|
8
|
-
* — see fleetSnapshot.js) via GitLab's Job Artifacts API. A project that
|
|
9
|
-
* has never produced that artifact is reported as NOT ONBOARDED, never
|
|
10
|
-
* silently merged into "zero findings" — those are different facts and
|
|
11
|
-
* this rollup keeps them visibly different.
|
|
12
|
-
*
|
|
13
|
-
* Requires GITLAB_TOKEN specifically (not the job-scoped CI_JOB_TOKEN):
|
|
14
|
-
* listing every project in a group and reading another project's
|
|
15
|
-
* artifacts both need broader-than-one-project scope.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
const axios = require('axios');
|
|
19
|
-
const { withRetry } = require('./httpRetry');
|
|
20
|
-
const { parseFleetSnapshot } = require('./fleetSnapshot');
|
|
21
|
-
|
|
22
|
-
const GITLAB_API = process.env.GITLAB_API_URL
|
|
23
|
-
|| (process.env.GITLAB_BASE_URL ? `${process.env.GITLAB_BASE_URL}/api/v4` : 'https://gitlab.com/api/v4');
|
|
24
|
-
|
|
25
|
-
const ANALYZE_JOB_NAME = 'analyze_vulnerabilities';
|
|
26
|
-
const SNAPSHOT_ARTIFACT_PATH = 'dependencyiq-report.json';
|
|
27
|
-
|
|
28
|
-
function authHeaders() {
|
|
29
|
-
return { 'PRIVATE-TOKEN': process.env.GITLAB_TOKEN };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* @param {string} groupId
|
|
34
|
-
* @returns {Promise<Array>} [{ id, pathWithNamespace, defaultBranch }]
|
|
35
|
-
*/
|
|
36
|
-
async function listGroupProjects(groupId) {
|
|
37
|
-
const projects = [];
|
|
38
|
-
let page = 1;
|
|
39
|
-
// GitLab paginates at up to 100/page; loop until a response's
|
|
40
|
-
// x-next-page header is empty, capped generously so a misbehaving API
|
|
41
|
-
// response can't spin this forever.
|
|
42
|
-
for (let guard = 0; guard < 50; guard += 1) {
|
|
43
|
-
const currentPage = page;
|
|
44
|
-
const response = await withRetry(() => axios.get(`${GITLAB_API}/groups/${groupId}/projects`, {
|
|
45
|
-
headers: authHeaders(),
|
|
46
|
-
params: { include_subgroups: true, per_page: 100, page: currentPage, archived: false },
|
|
47
|
-
timeout: 15000,
|
|
48
|
-
}));
|
|
49
|
-
for (const p of response.data) {
|
|
50
|
-
projects.push({ id: p.id, pathWithNamespace: p.path_with_namespace, defaultBranch: p.default_branch });
|
|
51
|
-
}
|
|
52
|
-
const nextPage = response.headers['x-next-page'];
|
|
53
|
-
if (!nextPage) break;
|
|
54
|
-
page = Number(nextPage);
|
|
55
|
-
}
|
|
56
|
-
return projects;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* @param {number} projectId
|
|
61
|
-
* @param {string} ref - default branch
|
|
62
|
-
* @returns {Promise<Object>} the parsed snapshot, or { available: false, reason }
|
|
63
|
-
*/
|
|
64
|
-
async function fetchLatestSnapshot(projectId, ref) {
|
|
65
|
-
if (!ref) return { available: false, reason: 'project has no default branch (empty repository?)' };
|
|
66
|
-
const url = `${GITLAB_API}/projects/${projectId}/jobs/artifacts/${encodeURIComponent(ref)}/raw/${SNAPSHOT_ARTIFACT_PATH}`;
|
|
67
|
-
try {
|
|
68
|
-
const response = await withRetry(() => axios.get(url, {
|
|
69
|
-
headers: authHeaders(),
|
|
70
|
-
params: { job: ANALYZE_JOB_NAME },
|
|
71
|
-
timeout: 15000,
|
|
72
|
-
responseType: 'text',
|
|
73
|
-
transformResponse: [(data) => data], // keep raw text; we parse it ourselves
|
|
74
|
-
}));
|
|
75
|
-
const snapshot = parseFleetSnapshot(response.data);
|
|
76
|
-
if (!snapshot) return { available: false, reason: 'artifact found but not a recognized snapshot schema' };
|
|
77
|
-
return { available: true, snapshot };
|
|
78
|
-
} catch (error) {
|
|
79
|
-
if (error.response?.status === 404) {
|
|
80
|
-
return { available: false, reason: `not onboarded — no ${ANALYZE_JOB_NAME} artifact found on ${ref}` };
|
|
81
|
-
}
|
|
82
|
-
return { available: false, reason: `artifact fetch failed: ${error.message}` };
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Pure rollup: turns a list of { project, snapshotResult } pairs into the
|
|
88
|
-
* fleet-level totals and sort order the dashboard renders. No network
|
|
89
|
-
* calls here — fully unit-testable.
|
|
90
|
-
* @param {Array} entries - [{ project: {id, pathWithNamespace}, snapshotResult }]
|
|
91
|
-
*/
|
|
92
|
-
function buildFleetRollup(entries) {
|
|
93
|
-
const onboarded = [];
|
|
94
|
-
const notOnboarded = [];
|
|
95
|
-
|
|
96
|
-
for (const { project, snapshotResult } of entries) {
|
|
97
|
-
if (snapshotResult.available) {
|
|
98
|
-
onboarded.push({ project, snapshot: snapshotResult.snapshot });
|
|
99
|
-
} else {
|
|
100
|
-
notOnboarded.push({ project, reason: snapshotResult.reason });
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
onboarded.sort((a, b) => (b.snapshot.topRiskScore || 0) - (a.snapshot.topRiskScore || 0));
|
|
105
|
-
|
|
106
|
-
const totals = onboarded.reduce((acc, { snapshot }) => ({
|
|
107
|
-
totalFindings: acc.totalFindings + (snapshot.totalFindings || 0),
|
|
108
|
-
urgentCount: acc.urgentCount + (snapshot.urgentCount || 0),
|
|
109
|
-
highCount: acc.highCount + (snapshot.highCount || 0),
|
|
110
|
-
unusedCount: acc.unusedCount + (snapshot.unusedCount || 0),
|
|
111
|
-
}), { totalFindings: 0, urgentCount: 0, highCount: 0, unusedCount: 0 });
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
generatedAt: new Date().toISOString(),
|
|
115
|
-
totalProjects: entries.length,
|
|
116
|
-
onboardedCount: onboarded.length,
|
|
117
|
-
notOnboardedCount: notOnboarded.length,
|
|
118
|
-
...totals,
|
|
119
|
-
onboarded,
|
|
120
|
-
notOnboarded,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* @param {string} groupId
|
|
126
|
-
* @returns {Promise<Object>} { available: true, rollup } or { available: false, reason }
|
|
127
|
-
*/
|
|
128
|
-
async function buildFleetReport(groupId) {
|
|
129
|
-
if (!process.env.GITLAB_TOKEN) {
|
|
130
|
-
return { available: false, reason: 'GITLAB_TOKEN is required (CI_JOB_TOKEN is scoped to one project and cannot list/read other projects in the group)' };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
let projects;
|
|
134
|
-
try {
|
|
135
|
-
projects = await listGroupProjects(groupId);
|
|
136
|
-
} catch (error) {
|
|
137
|
-
return { available: false, reason: `Could not list group projects: ${error.message}` };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const entries = await Promise.all(projects.map(async (project) => ({
|
|
141
|
-
project,
|
|
142
|
-
snapshotResult: await fetchLatestSnapshot(project.id, project.defaultBranch),
|
|
143
|
-
})));
|
|
144
|
-
|
|
145
|
-
return { available: true, rollup: buildFleetRollup(entries) };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
module.exports = {
|
|
149
|
-
listGroupProjects,
|
|
150
|
-
fetchLatestSnapshot,
|
|
151
|
-
buildFleetRollup,
|
|
152
|
-
buildFleetReport,
|
|
153
|
-
ANALYZE_JOB_NAME,
|
|
154
|
-
SNAPSHOT_ARTIFACT_PATH,
|
|
155
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Fleet Aggregator: cross-project rollup for the Fleet Dashboard.
|
|
3
|
+
*
|
|
4
|
+
* A static page can't query live across projects it doesn't own, so
|
|
5
|
+
* this builds a build-time snapshot of the whole group instead: list
|
|
6
|
+
* every project in the group, then fetch each one's latest
|
|
7
|
+
* `dependencyiq-report.json` CI artifact (written by `analyze_vulnerabilities`
|
|
8
|
+
* — see fleetSnapshot.js) via GitLab's Job Artifacts API. A project that
|
|
9
|
+
* has never produced that artifact is reported as NOT ONBOARDED, never
|
|
10
|
+
* silently merged into "zero findings" — those are different facts and
|
|
11
|
+
* this rollup keeps them visibly different.
|
|
12
|
+
*
|
|
13
|
+
* Requires GITLAB_TOKEN specifically (not the job-scoped CI_JOB_TOKEN):
|
|
14
|
+
* listing every project in a group and reading another project's
|
|
15
|
+
* artifacts both need broader-than-one-project scope.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const axios = require('axios');
|
|
19
|
+
const { withRetry } = require('./httpRetry');
|
|
20
|
+
const { parseFleetSnapshot } = require('./fleetSnapshot');
|
|
21
|
+
|
|
22
|
+
const GITLAB_API = process.env.GITLAB_API_URL
|
|
23
|
+
|| (process.env.GITLAB_BASE_URL ? `${process.env.GITLAB_BASE_URL}/api/v4` : 'https://gitlab.com/api/v4');
|
|
24
|
+
|
|
25
|
+
const ANALYZE_JOB_NAME = 'analyze_vulnerabilities';
|
|
26
|
+
const SNAPSHOT_ARTIFACT_PATH = 'dependencyiq-report.json';
|
|
27
|
+
|
|
28
|
+
function authHeaders() {
|
|
29
|
+
return { 'PRIVATE-TOKEN': process.env.GITLAB_TOKEN };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} groupId
|
|
34
|
+
* @returns {Promise<Array>} [{ id, pathWithNamespace, defaultBranch }]
|
|
35
|
+
*/
|
|
36
|
+
async function listGroupProjects(groupId) {
|
|
37
|
+
const projects = [];
|
|
38
|
+
let page = 1;
|
|
39
|
+
// GitLab paginates at up to 100/page; loop until a response's
|
|
40
|
+
// x-next-page header is empty, capped generously so a misbehaving API
|
|
41
|
+
// response can't spin this forever.
|
|
42
|
+
for (let guard = 0; guard < 50; guard += 1) {
|
|
43
|
+
const currentPage = page;
|
|
44
|
+
const response = await withRetry(() => axios.get(`${GITLAB_API}/groups/${groupId}/projects`, {
|
|
45
|
+
headers: authHeaders(),
|
|
46
|
+
params: { include_subgroups: true, per_page: 100, page: currentPage, archived: false },
|
|
47
|
+
timeout: 15000,
|
|
48
|
+
}));
|
|
49
|
+
for (const p of response.data) {
|
|
50
|
+
projects.push({ id: p.id, pathWithNamespace: p.path_with_namespace, defaultBranch: p.default_branch });
|
|
51
|
+
}
|
|
52
|
+
const nextPage = response.headers['x-next-page'];
|
|
53
|
+
if (!nextPage) break;
|
|
54
|
+
page = Number(nextPage);
|
|
55
|
+
}
|
|
56
|
+
return projects;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {number} projectId
|
|
61
|
+
* @param {string} ref - default branch
|
|
62
|
+
* @returns {Promise<Object>} the parsed snapshot, or { available: false, reason }
|
|
63
|
+
*/
|
|
64
|
+
async function fetchLatestSnapshot(projectId, ref) {
|
|
65
|
+
if (!ref) return { available: false, reason: 'project has no default branch (empty repository?)' };
|
|
66
|
+
const url = `${GITLAB_API}/projects/${projectId}/jobs/artifacts/${encodeURIComponent(ref)}/raw/${SNAPSHOT_ARTIFACT_PATH}`;
|
|
67
|
+
try {
|
|
68
|
+
const response = await withRetry(() => axios.get(url, {
|
|
69
|
+
headers: authHeaders(),
|
|
70
|
+
params: { job: ANALYZE_JOB_NAME },
|
|
71
|
+
timeout: 15000,
|
|
72
|
+
responseType: 'text',
|
|
73
|
+
transformResponse: [(data) => data], // keep raw text; we parse it ourselves
|
|
74
|
+
}));
|
|
75
|
+
const snapshot = parseFleetSnapshot(response.data);
|
|
76
|
+
if (!snapshot) return { available: false, reason: 'artifact found but not a recognized snapshot schema' };
|
|
77
|
+
return { available: true, snapshot };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.response?.status === 404) {
|
|
80
|
+
return { available: false, reason: `not onboarded — no ${ANALYZE_JOB_NAME} artifact found on ${ref}` };
|
|
81
|
+
}
|
|
82
|
+
return { available: false, reason: `artifact fetch failed: ${error.message}` };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pure rollup: turns a list of { project, snapshotResult } pairs into the
|
|
88
|
+
* fleet-level totals and sort order the dashboard renders. No network
|
|
89
|
+
* calls here — fully unit-testable.
|
|
90
|
+
* @param {Array} entries - [{ project: {id, pathWithNamespace}, snapshotResult }]
|
|
91
|
+
*/
|
|
92
|
+
function buildFleetRollup(entries) {
|
|
93
|
+
const onboarded = [];
|
|
94
|
+
const notOnboarded = [];
|
|
95
|
+
|
|
96
|
+
for (const { project, snapshotResult } of entries) {
|
|
97
|
+
if (snapshotResult.available) {
|
|
98
|
+
onboarded.push({ project, snapshot: snapshotResult.snapshot });
|
|
99
|
+
} else {
|
|
100
|
+
notOnboarded.push({ project, reason: snapshotResult.reason });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
onboarded.sort((a, b) => (b.snapshot.topRiskScore || 0) - (a.snapshot.topRiskScore || 0));
|
|
105
|
+
|
|
106
|
+
const totals = onboarded.reduce((acc, { snapshot }) => ({
|
|
107
|
+
totalFindings: acc.totalFindings + (snapshot.totalFindings || 0),
|
|
108
|
+
urgentCount: acc.urgentCount + (snapshot.urgentCount || 0),
|
|
109
|
+
highCount: acc.highCount + (snapshot.highCount || 0),
|
|
110
|
+
unusedCount: acc.unusedCount + (snapshot.unusedCount || 0),
|
|
111
|
+
}), { totalFindings: 0, urgentCount: 0, highCount: 0, unusedCount: 0 });
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
generatedAt: new Date().toISOString(),
|
|
115
|
+
totalProjects: entries.length,
|
|
116
|
+
onboardedCount: onboarded.length,
|
|
117
|
+
notOnboardedCount: notOnboarded.length,
|
|
118
|
+
...totals,
|
|
119
|
+
onboarded,
|
|
120
|
+
notOnboarded,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} groupId
|
|
126
|
+
* @returns {Promise<Object>} { available: true, rollup } or { available: false, reason }
|
|
127
|
+
*/
|
|
128
|
+
async function buildFleetReport(groupId) {
|
|
129
|
+
if (!process.env.GITLAB_TOKEN) {
|
|
130
|
+
return { available: false, reason: 'GITLAB_TOKEN is required (CI_JOB_TOKEN is scoped to one project and cannot list/read other projects in the group)' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let projects;
|
|
134
|
+
try {
|
|
135
|
+
projects = await listGroupProjects(groupId);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return { available: false, reason: `Could not list group projects: ${error.message}` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const entries = await Promise.all(projects.map(async (project) => ({
|
|
141
|
+
project,
|
|
142
|
+
snapshotResult: await fetchLatestSnapshot(project.id, project.defaultBranch),
|
|
143
|
+
})));
|
|
144
|
+
|
|
145
|
+
return { available: true, rollup: buildFleetRollup(entries) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
listGroupProjects,
|
|
150
|
+
fetchLatestSnapshot,
|
|
151
|
+
buildFleetRollup,
|
|
152
|
+
buildFleetReport,
|
|
153
|
+
ANALYZE_JOB_NAME,
|
|
154
|
+
SNAPSHOT_ARTIFACT_PATH,
|
|
155
|
+
};
|