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.
@@ -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
+ };