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.
Files changed (3) hide show
  1. package/README.md +26 -0
  2. package/index.js +133 -25
  3. 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 (projectName && (!result.repository || result.repository !== projectName)) {
171
- throw new Error(`Repository name has been changed from ${result.repository} to ${projectName}!`);
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 || !projectName) {
204
+ if (!this.sonarEnterprise || !repositoryName) {
181
205
  return Promise.resolve();
182
206
  }
183
207
 
184
- const parameters = `almSetting=${gitAppEncoded}&project=${componentId}&repository=${projectName}&summaryCommentEnabled=true&monorepo=false`;
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
- // Use user-configured scope; otherwise figure out default scope: pipeline scope for enterprise edition, job scope for everything else
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
- if (coverageScope === 'job' && enterpriseEnabled) {
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
- * @param {String} [config.projectName] Sonar project name
408
- * @param {String} [config.username] Sonar username
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
- let projectData = { username, projectKey, projectName };
415
-
416
- if (!username || !projectKey || !projectName || projectName.includes('undefined')) {
417
- projectData = this.getProjectData({
418
- enterpriseEnabled: this.sonarEnterprise,
419
- jobId,
420
- jobName,
421
- prParentJobId,
422
- pipelineId,
423
- pipelineName,
424
- scope,
425
- projectKey
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": "5.1.1",
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",