screwdriver-coverage-sonar 5.1.2 → 6.0.1

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 +109 -22
  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');
@@ -331,6 +332,41 @@ class CoverageSonar extends CoverageBase {
331
332
  });
332
333
  }
333
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
+
334
370
  /**
335
371
  * Determine Sonar project key, project name, and username based on:
336
372
  * - SonarQube edition
@@ -382,9 +418,7 @@ class CoverageSonar extends CoverageBase {
382
418
  return projectData;
383
419
  }
384
420
 
385
- // Use user-configured scope; otherwise figure out default scope: pipeline scope for enterprise edition, job scope for everything else
386
- const userScope = scope && scope !== 'undefined' ? scope : undefined;
387
- const coverageScope = userScope || (enterpriseEnabled ? 'pipeline' : 'job');
421
+ const coverageScope = this._resolveCoverageScope({ scope, enterpriseEnabled });
388
422
 
389
423
  if (coverageScope === 'pipeline') {
390
424
  const componentId = encodeURIComponent(`pipeline:${pipelineId}`);
@@ -399,8 +433,10 @@ class CoverageSonar extends CoverageBase {
399
433
  };
400
434
  }
401
435
 
402
- // Use prParentJobId as ID for PRs
403
- 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) {
404
440
  const prRegex = /^PR-(\d+)(?::([\w-]+))?$/;
405
441
  const prNameMatch = jobName.match(prRegex);
406
442
 
@@ -426,30 +462,81 @@ class CoverageSonar extends CoverageBase {
426
462
  * @param {Object} config.buildCredentials Information stored in a build JWT
427
463
  * @param {String} [config.jobName] Screwdriver job name
428
464
  * @param {String} config.pipelineName Screwdriver pipeline name
429
- * @param {String} [config.projectKey] Sonar project key
430
- * @param {String} [config.projectName] Sonar project name
431
- * @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.
432
476
  * @return {Promise} An access token that build can use
433
477
  * to talk to coverage server
434
478
  */
435
479
  _getAccessToken({ scope, username, projectKey, projectName, jobName, pipelineName, buildCredentials }) {
436
480
  const { jobId, pipelineId, prParentJobId, scmContext } = buildCredentials;
437
- let projectData = { username, projectKey, projectName };
438
-
439
- if (!username || !projectKey || !projectName || projectName.includes('undefined')) {
440
- projectData = this.getProjectData({
441
- enterpriseEnabled: this.sonarEnterprise,
442
- jobId,
443
- jobName,
444
- prParentJobId,
445
- pipelineId,
446
- pipelineName,
447
- scope,
448
- projectKey
449
- });
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
+ );
450
535
  }
451
536
 
452
- const password = uuidv4();
537
+ // Ensure the generated password contains uppercase, lowercase, numeric,
538
+ // and special characters to satisfy SonarQube's password requirements.
539
+ const password = `Aa1!${uuidv4()}`;
453
540
 
454
541
  return this.createProject(projectData.projectKey)
455
542
  .then(() => this.configureGitApp(projectData.projectKey, projectData.projectName, scmContext))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screwdriver-coverage-sonar",
3
- "version": "5.1.2",
3
+ "version": "6.0.1",
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",