screwdriver-coverage-sonar 5.1.1 → 6.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/README.md +26 -0
- package/index.js +133 -25
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -15,6 +15,32 @@ npm install screwdriver-coverage-sonar
|
|
|
15
15
|
npm test
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
## BREAKING CHANGE: `getAccessToken` no longer trusts caller-supplied project identity
|
|
19
|
+
|
|
20
|
+
For security reasons ([PSECBUGS-115276](https://github.com/screwdriver-cd/screwdriver/pull/3518)),
|
|
21
|
+
`getAccessToken` now validates any caller-supplied `projectKey` against an allowlist derived from the
|
|
22
|
+
build's own JWT (`getAuthorizedProjectKeys`), and rejects it with a `403` unless it also matches the
|
|
23
|
+
build's own configured coverage scope — its own pipeline and its own job are both always "authorized," so
|
|
24
|
+
without the scope check a build could still cross between the two. `projectName` and `username` are never
|
|
25
|
+
trusted at all — both are always derived. `username` is checked against the derived value and triggers a
|
|
26
|
+
mismatch warning on its own; `projectName` is accepted only for backward compatibility and is included in
|
|
27
|
+
that warning's message text when one fires, but a stale or bogus `projectName` alone does not trigger it.
|
|
28
|
+
|
|
29
|
+
A caller that still sends only the old resolved tuple (`projectKey`/`username`/`projectName`, no
|
|
30
|
+
`pipelineName`) will still successfully mint a token, but the Git App binding (`configureGitApp`) may be
|
|
31
|
+
skipped for newly created enterprise projects, since it now depends on a resolved `pipelineName` rather than
|
|
32
|
+
a caller-supplied `projectName`. See the `getAccessToken` JSDoc in `index.js` for the full parameter contract.
|
|
33
|
+
|
|
34
|
+
### Deployment order
|
|
35
|
+
|
|
36
|
+
This release requires [screwdriver-cd/screwdriver#3518](https://github.com/screwdriver-cd/screwdriver/pull/3518)
|
|
37
|
+
to already be deployed. Before #3518, the API's `/coverage/token` route only sends `pipelineName` when the
|
|
38
|
+
caller-supplied `projectName` is absent or malformed — so against an un-upgraded API, every `projectKey`
|
|
39
|
+
this plugin receives resolves to an authorized, scope-matching key with no `pipelineName`, and the Git App
|
|
40
|
+
binding is silently skipped for newly created enterprise projects (existing bindings are unaffected). No
|
|
41
|
+
build is rejected and no token request fails; the only symptom is that PR decoration doesn't get configured.
|
|
42
|
+
Deploy #3518 first, then upgrade this dependency.
|
|
43
|
+
|
|
18
44
|
## Related Links
|
|
19
45
|
- See [coverage-base](https://github.com/screwdriver-cd/coverage-base)
|
|
20
46
|
|
package/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const joi = require('joi');
|
|
7
|
+
const boom = require('@hapi/boom');
|
|
7
8
|
const hoek = require('@hapi/hoek');
|
|
8
9
|
const path = require('path');
|
|
9
10
|
const request = require('screwdriver-request');
|
|
@@ -147,6 +148,28 @@ class CoverageSonar extends CoverageBase {
|
|
|
147
148
|
return gitAppSetting.key;
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Normalize the repository name expected by Sonar's Git binding API.
|
|
153
|
+
* Upstream callers may pass a broader project name like "org/repo:checkout-root" specific to SD pipelines, but Sonar expects just "org/repo".
|
|
154
|
+
* @param {String} projectName Sonar project name
|
|
155
|
+
* @returns {String} Repository slug in org/repo format when available
|
|
156
|
+
*/
|
|
157
|
+
_getRepositoryName(projectName) {
|
|
158
|
+
if (!projectName) {
|
|
159
|
+
return projectName;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const [organization, repositoryWithSuffix] = projectName.split('/');
|
|
163
|
+
|
|
164
|
+
if (!organization || !repositoryWithSuffix) {
|
|
165
|
+
return projectName;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const repository = repositoryWithSuffix.split(':')[0];
|
|
169
|
+
|
|
170
|
+
return `${organization}/${repository}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
150
173
|
/**
|
|
151
174
|
* Configure Git App in SonarQube
|
|
152
175
|
* @param {String} projectKey Sonar project key
|
|
@@ -158,6 +181,7 @@ class CoverageSonar extends CoverageBase {
|
|
|
158
181
|
const gitApp = await this._getGitApp(scmContext);
|
|
159
182
|
const gitAppEncoded = encodeURIComponent(gitApp);
|
|
160
183
|
const componentId = encodeURIComponent(projectKey);
|
|
184
|
+
const repositoryName = this._getRepositoryName(projectName);
|
|
161
185
|
|
|
162
186
|
// Check if binding exists
|
|
163
187
|
return request({
|
|
@@ -167,8 +191,8 @@ class CoverageSonar extends CoverageBase {
|
|
|
167
191
|
})
|
|
168
192
|
.then(result => {
|
|
169
193
|
// if project name has been changed, update it
|
|
170
|
-
if (
|
|
171
|
-
throw new Error(`Repository name has been changed from ${result.repository} to ${
|
|
194
|
+
if (repositoryName && (!result.repository || result.repository !== repositoryName)) {
|
|
195
|
+
throw new Error(`Repository name has been changed from ${result.repository} to ${repositoryName}!`);
|
|
172
196
|
}
|
|
173
197
|
|
|
174
198
|
return result;
|
|
@@ -177,11 +201,11 @@ class CoverageSonar extends CoverageBase {
|
|
|
177
201
|
// if binding does not exist, add it
|
|
178
202
|
logger.info(`Binding does not exist for Sonar project ${projectKey}, adding`);
|
|
179
203
|
|
|
180
|
-
if (!this.sonarEnterprise || !
|
|
204
|
+
if (!this.sonarEnterprise || !repositoryName) {
|
|
181
205
|
return Promise.resolve();
|
|
182
206
|
}
|
|
183
207
|
|
|
184
|
-
const parameters = `almSetting=${gitAppEncoded}&project=${componentId}&repository=${
|
|
208
|
+
const parameters = `almSetting=${gitAppEncoded}&project=${componentId}&repository=${repositoryName}&summaryCommentEnabled=true&monorepo=false`;
|
|
185
209
|
|
|
186
210
|
logger.info(`Configuring git app with following parameters, ${parameters}`);
|
|
187
211
|
|
|
@@ -308,6 +332,41 @@ class CoverageSonar extends CoverageBase {
|
|
|
308
332
|
});
|
|
309
333
|
}
|
|
310
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Determine which Sonar project keys a build is allowed to act on.
|
|
337
|
+
* Derived exclusively from the build JWT, never from caller-supplied input.
|
|
338
|
+
* @method getAuthorizedProjectKeys
|
|
339
|
+
* @param {Object} buildCredentials Information stored in a build JWT
|
|
340
|
+
* @param {String} [buildCredentials.jobId] Screwdriver job ID
|
|
341
|
+
* @param {String} [buildCredentials.pipelineId] Screwdriver pipeline ID
|
|
342
|
+
* @param {String} [buildCredentials.prParentJobId] Screwdriver PR parent job ID
|
|
343
|
+
* @return {Array} Sonar project keys the build may use
|
|
344
|
+
*/
|
|
345
|
+
getAuthorizedProjectKeys({ jobId, pipelineId, prParentJobId }) {
|
|
346
|
+
const ids = [
|
|
347
|
+
['pipeline', pipelineId],
|
|
348
|
+
['job', jobId],
|
|
349
|
+
['job', prParentJobId]
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
return ids.filter(([, id]) => id !== undefined && id !== null).map(([prefix, id]) => `${prefix}:${id}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Resolve the coverage scope: the caller-configured scope when there is one, otherwise the default
|
|
357
|
+
* for this SonarQube edition (pipeline for enterprise, job for everything else).
|
|
358
|
+
* @method _resolveCoverageScope
|
|
359
|
+
* @param {Object} config
|
|
360
|
+
* @param {String} [config.scope] Coverage scope (pipeline or job)
|
|
361
|
+
* @param {Boolean} config.enterpriseEnabled If enterprise is enabled
|
|
362
|
+
* @return {String} Resolved coverage scope
|
|
363
|
+
*/
|
|
364
|
+
_resolveCoverageScope({ scope, enterpriseEnabled }) {
|
|
365
|
+
const userScope = scope && scope !== 'undefined' ? scope : undefined;
|
|
366
|
+
|
|
367
|
+
return userScope || (enterpriseEnabled ? 'pipeline' : 'job');
|
|
368
|
+
}
|
|
369
|
+
|
|
311
370
|
/**
|
|
312
371
|
* Determine Sonar project key, project name, and username based on:
|
|
313
372
|
* - SonarQube edition
|
|
@@ -359,9 +418,7 @@ class CoverageSonar extends CoverageBase {
|
|
|
359
418
|
return projectData;
|
|
360
419
|
}
|
|
361
420
|
|
|
362
|
-
|
|
363
|
-
const userScope = scope && scope !== 'undefined' ? scope : undefined;
|
|
364
|
-
const coverageScope = userScope || (enterpriseEnabled ? 'pipeline' : 'job');
|
|
421
|
+
const coverageScope = this._resolveCoverageScope({ scope, enterpriseEnabled });
|
|
365
422
|
|
|
366
423
|
if (coverageScope === 'pipeline') {
|
|
367
424
|
const componentId = encodeURIComponent(`pipeline:${pipelineId}`);
|
|
@@ -376,8 +433,10 @@ class CoverageSonar extends CoverageBase {
|
|
|
376
433
|
};
|
|
377
434
|
}
|
|
378
435
|
|
|
379
|
-
// Use prParentJobId as ID for PRs
|
|
380
|
-
|
|
436
|
+
// Use prParentJobId as ID for PRs. jobName is what identifies a PR job, so without it a PR build
|
|
437
|
+
// falls back to its own job's project rather than its parent's; callers that cannot resolve jobName
|
|
438
|
+
// must not crash here.
|
|
439
|
+
if (coverageScope === 'job' && enterpriseEnabled && jobName) {
|
|
381
440
|
const prRegex = /^PR-(\d+)(?::([\w-]+))?$/;
|
|
382
441
|
const prNameMatch = jobName.match(prRegex);
|
|
383
442
|
|
|
@@ -403,27 +462,76 @@ class CoverageSonar extends CoverageBase {
|
|
|
403
462
|
* @param {Object} config.buildCredentials Information stored in a build JWT
|
|
404
463
|
* @param {String} [config.jobName] Screwdriver job name
|
|
405
464
|
* @param {String} config.pipelineName Screwdriver pipeline name
|
|
406
|
-
* @param {String} [config.projectKey] Sonar project key
|
|
407
|
-
*
|
|
408
|
-
*
|
|
465
|
+
* @param {String} [config.projectKey] Sonar project key. Rejected with a 403 if it does not name a
|
|
466
|
+
* project the calling build is authorized for (its own pipeline,
|
|
467
|
+
* its own job, or its PR parent job), or if it does not match
|
|
468
|
+
* the build's own configured coverage scope.
|
|
469
|
+
* @param {String} [config.projectName] Ignored; retained for backward compatibility. Always derived,
|
|
470
|
+
* never taken from the caller. Included in the mismatch warning
|
|
471
|
+
* message when username mismatches, but does not itself trigger
|
|
472
|
+
* that warning (pipelineName, its derivation source, is
|
|
473
|
+
* unavailable to legacy callers on every request).
|
|
474
|
+
* @param {String} [config.username] Ignored; retained for backward compatibility and mismatch
|
|
475
|
+
* logging only. Always derived from the project key.
|
|
409
476
|
* @return {Promise} An access token that build can use
|
|
410
477
|
* to talk to coverage server
|
|
411
478
|
*/
|
|
412
479
|
_getAccessToken({ scope, username, projectKey, projectName, jobName, pipelineName, buildCredentials }) {
|
|
413
480
|
const { jobId, pipelineId, prParentJobId, scmContext } = buildCredentials;
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
481
|
+
const authorizedProjectKeys = this.getAuthorizedProjectKeys({ jobId, pipelineId, prParentJobId });
|
|
482
|
+
|
|
483
|
+
// A build may only ever request a token for its own pipeline or job.
|
|
484
|
+
if (projectKey && !authorizedProjectKeys.includes(String(projectKey))) {
|
|
485
|
+
logger.error(
|
|
486
|
+
`Rejected coverage token request for project ${projectKey} from build in pipeline ${pipelineId} / ` +
|
|
487
|
+
`job ${jobId}; authorized: ${authorizedProjectKeys.join(', ')}`
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
return Promise.reject(
|
|
491
|
+
boom.forbidden(`Not authorized to request a coverage token for project ${projectKey}`)
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// An authorized key is only trusted when it also matches the build's own configured coverage
|
|
496
|
+
// scope - otherwise a build could use an authorized key for the *other* scope (its own pipeline and
|
|
497
|
+
// its own job are both always "authorized") to cross from job to pipeline scope or back. Reject
|
|
498
|
+
// outright rather than silently re-deriving: a silent mismatch would mint a token for one project
|
|
499
|
+
// while the scanner uploads to another, and commands.txt runs the scanner under `|| true`, so that
|
|
500
|
+
// failure would never surface.
|
|
501
|
+
const coverageScope = this._resolveCoverageScope({ scope, enterpriseEnabled: this.sonarEnterprise });
|
|
502
|
+
|
|
503
|
+
if (projectKey && projectKey.split(':')[0] !== coverageScope) {
|
|
504
|
+
logger.error(
|
|
505
|
+
`Rejected coverage token request for project ${projectKey} from build in pipeline ${pipelineId} / ` +
|
|
506
|
+
`job ${jobId}; does not match this build's coverage scope: ${coverageScope}`
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
return Promise.reject(
|
|
510
|
+
boom.forbidden(`Project ${projectKey} does not match coverage scope ${coverageScope} for this build`)
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// projectName and username are never trusted here, even when projectKey is: every Sonar call below
|
|
515
|
+
// runs with the instance-wide admin token, so a caller-supplied projectName would let a build point
|
|
516
|
+
// another project's Git App binding at an arbitrary repository, and a caller-supplied username would
|
|
517
|
+
// let a build mint a token for an arbitrary Sonar account. Both are always derived instead.
|
|
518
|
+
const projectData = this.getProjectData({
|
|
519
|
+
enterpriseEnabled: this.sonarEnterprise,
|
|
520
|
+
jobId,
|
|
521
|
+
jobName,
|
|
522
|
+
prParentJobId,
|
|
523
|
+
pipelineId,
|
|
524
|
+
pipelineName,
|
|
525
|
+
scope,
|
|
526
|
+
projectKey
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
if (username && username !== projectData.username) {
|
|
530
|
+
logger.warn(
|
|
531
|
+
`Coverage token request for build in pipeline ${pipelineId} / job ${jobId} supplied ` +
|
|
532
|
+
`username=${JSON.stringify(username)}, projectName=${JSON.stringify(projectName)}, but ` +
|
|
533
|
+
`derived username=${JSON.stringify(projectData.username)} instead; using the derived value`
|
|
534
|
+
);
|
|
427
535
|
}
|
|
428
536
|
|
|
429
537
|
const password = uuidv4();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screwdriver-coverage-sonar",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "SonarQube implementation of the coverage-base class",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"sinon": "^15.0.0"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
+
"@hapi/boom": "^10.0.1",
|
|
44
45
|
"@hapi/hoek": "^11.0.7",
|
|
45
46
|
"joi": "^17.13.3",
|
|
46
47
|
"screwdriver-coverage-base": "^4.0.0",
|