From db62e715ef594649de30ffe2245576ae2b9957e8 Mon Sep 17 00:00:00 2001 From: Michael Harry Scepaniak Date: Sun, 21 Mar 2021 18:04:10 -0400 Subject: [PATCH] Address Staticman issue #416 - "Improve support for multiple environments (e.g., dev, staging, prod)" - Add configuration properties that allow for code promotion (without modification of staticman.yml) through different environments. - Add logic checks that help prevent cross-talk between tiers/layers of different environments. For example, Staticman dev and Mailgun prod. - Flag config properties as sensitive, where appropriate. - Prepend various artifacts such as commit messages, mailing list addresses, email subjects, etc. with a value to help identify the source environment. - Allow for the "entry" endpoint to be "locked down" to specific origins. --- config.js | 67 ++++++++++++-- controllers/confirmSubscription.js | 4 + controllers/webhook.js | 143 ++++++++++++++++++----------- lib/Confirmation.js | 11 +++ lib/Notification.js | 8 ++ lib/Staticman.js | 42 +++++++-- lib/SubscriptionsManager.js | 20 +++- server.js | 37 +++++++- siteConfig.js | 5 +- 9 files changed, 264 insertions(+), 73 deletions(-) diff --git a/config.js b/config.js index 590e10dd..0206ea05 100644 --- a/config.js +++ b/config.js @@ -5,6 +5,12 @@ const path = require('path') const schema = { akismet: { + enabled: { + doc: 'Whether to use Akismet to check entries for spam. This requires an Akismet account to be configured. If either this value or the same-named value in the repository-based configuration is set to true, Akismet will be enabled.', + format: Boolean, + default: false, + env: 'AKISMET_ENABLED' + }, site: { doc: 'URL of an Akismet account used for spam checking.', docExample: 'http://yourdomain.com', @@ -16,13 +22,15 @@ const schema = { doc: 'API key to be used with Akismet.', format: String, default: null, - env: 'AKISMET_API_KEY' + env: 'AKISMET_API_KEY', + sensitive: true }, bypassValue: { - doc: 'Value to pass in as comment author, author email, author URL, or content in order to bypass Akismet spam-checking. Intended to be used for testing in lieu of disabling Akismet via the site config.', + doc: 'Value to pass in as comment author, author email, author URL, or content in order to bypass Akismet spam-checking. Intended to be used for testing in lieu of disabling Akismet environment-wide via the configuration(s).', format: String, default: null, - env: 'AKISMET_BYPASS_VALUE' + env: 'AKISMET_BYPASS_VALUE', + sensitive: true } }, analytics: { @@ -34,15 +42,23 @@ const schema = { env: 'UA_TRACKING_ID' } }, + branch: { + doc: 'Name of the branch in the target git service repository to be referenced. Will be overridden by a `branch` parameter in the site/repo config, if one is set.', + docExample: 'main', + format: String, + default: null, + env: 'BRANCH' + }, email: { apiKey: { - doc: 'Mailgun API key to be used for email notifications. Will be overridden by a `notifications.apiKey` parameter in the site config, if one is set.', + doc: 'Mailgun API key to be used for email notifications. Will be overridden by a `notifications.apiKey` parameter in the site/repo config, if one is set.', format: String, default: null, - env: 'EMAIL_API_KEY' + env: 'EMAIL_API_KEY', + sensitive: true }, domain: { - doc: 'Domain to be used with Mailgun for email notifications. Will be overridden by a `notifications.domain` parameter in the site config, if one is set.', + doc: 'Domain to be used with Mailgun for email notifications. Will be overridden by a `notifications.domain` parameter in the site/repo config, if one is set.', format: String, default: 'staticman.net', env: 'EMAIL_DOMAIN' @@ -66,6 +82,18 @@ const schema = { default: 'development', env: 'NODE_ENV' }, + exeEnv: { + doc: 'Identifies the application execution environment, which is allowed to deviate from NODE_ENV. Typically a shorter value that is prepended to commit messages, mailing list addresses, email subjects, etc. to help identify the source environment.', + format: String, + default: null, + env: 'EXE_ENV' + }, + exeEnvProduction: { + doc: 'The value for exeEnv that identifies the production environment. In instances where the value for exeEnv is made visible, in the production environment, it won\'t be.', + format: String, + default: 'prod', + env: 'EXE_ENV_PRODUCTION' + }, githubAccessTokenUri: { doc: 'URI for the GitHub authentication provider.', format: String, @@ -96,6 +124,13 @@ const schema = { default: null, env: 'GITHUB_TOKEN' }, + githubWebhookSecret: { + doc: 'Token to verify that webhook requests are from GitHub. Will be overridden by a `githubWebhookSecret` parameter in the site/repo config, if one is set.', + format: 'String', + default: null, + env: 'GITHUB_WEBHOOK_SECRET', + sensitive: true + }, gitlabAccessTokenUri: { doc: 'URI for the GitLab authentication provider.', format: String, @@ -114,6 +149,20 @@ const schema = { default: null, env: 'GITLAB_TOKEN' }, + gitlabWebhookSecret: { + doc: 'Token to verify that webhook requests are from GitLab. Will be overridden by a `gitlabWebhookSecret` parameter in the site/repo config, if one is set.', + format: 'String', + default: null, + env: 'GITLAB_WEBHOOK_SECRET', + sensitive: true + }, + origins: { + doc: 'CORS-compliant origins which are allowed to make requests against the \'entry\' endpoint. Regular expressions supported. In the case of a mismatch, a 403 response is returned. This is not intended to act as a security measure. Rather, it is meant to help avoid misconfigurations of the sort where a dev site is accidentally pointed at a prod staticman.', + docExample: 'http://localhost:.*', + format: Array, + default: null, + env: 'ORIGINS' + }, port: { doc: 'The port to bind the application to.', format: 'port', @@ -125,14 +174,16 @@ const schema = { docExample: 'rsaPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\\nkey\\n-----END RSA PRIVATE KEY-----"', format: String, default: null, - env: 'RSA_PRIVATE_KEY' + env: 'RSA_PRIVATE_KEY', + sensitive: true }, cryptoPepper: { doc: 'Shared (app-wide) secret that can be used to verify the authenticity of hashed/encrypted strings that we create. Should be long to defend against brute force attacks.', docExample: 'bcacf76bef428bf6115abfaa664e73481657e5068b9534227dca6ec96c6931b113105be81cb177b4e22d42fbc32d04ea5a8133e97296de7852328', format: String, default: null, - env: 'CRYPTO_PEPPER' + env: 'CRYPTO_PEPPER', + sensitive: true }, logging: { slackWebhook: { diff --git a/controllers/confirmSubscription.js b/controllers/confirmSubscription.js index fe60b9e4..0a9e42d9 100644 --- a/controllers/confirmSubscription.js +++ b/controllers/confirmSubscription.js @@ -36,6 +36,10 @@ module.exports = async (req, res, next) => { throw new Error('Authenticity check failed.') } + if (confirmData === null || confirmData.exeEnv !== config.get('exeEnv')) { + throw new Error('Environment check failed.') + } + const staticman = await new Staticman(req.params) staticman.setConfigPath() diff --git a/controllers/webhook.js b/controllers/webhook.js index ad6c671e..cf5e0445 100644 --- a/controllers/webhook.js +++ b/controllers/webhook.js @@ -18,6 +18,7 @@ module.exports = async (req, res, next) => { let service = req.params.service const version = req.params.version let staticman = null + let configBranch = null // v1 of the webhook endpoint assumed GitHub. if (!service && version === '1') { service = 'github' @@ -29,21 +30,34 @@ module.exports = async (req, res, next) => { service = req.params.service staticman = await new Staticman(req.params) staticman.setConfigPath() + + await staticman.getSiteConfig().then((siteConfig) => { + configBranch = siteConfig.get('branch') || config.get('branch') + }).catch((error) => { + errorsRaised = errorsRaised.concat(error) + }) + + if (configBranch && (req.params.branch !== configBranch)) { + console.log(`Branch check failed - configBranch = ${configBranch}, paramsBranch = ${req.params.branch}`) + errorsRaised.push('Branch mismatch. Ignoring request.') + } } - switch (service) { - case 'github': - await _handleWebhookGitHub(req, service, staticman).catch((errors) => { - errorsRaised = errorsRaised.concat(errors) - }) - break - case 'gitlab': - await _handleWebhookGitLab(req, service, staticman).catch((errors) => { - errorsRaised = errorsRaised.concat(errors) - }) - break - default: - errorsRaised.push('Unexpected service specified.') + if (errorsRaised.length === 0) { + switch (service) { + case 'github': + await _handleWebhookGitHub(req, service, staticman, configBranch).catch((errors) => { + errorsRaised = errorsRaised.concat(errors) + }) + break + case 'gitlab': + await _handleWebhookGitLab(req, service, staticman, configBranch).catch((errors) => { + errorsRaised = errorsRaised.concat(errors) + }) + break + default: + errorsRaised.push('Unexpected service specified.') + } } if (errorsRaised.length > 0) { @@ -57,7 +71,7 @@ module.exports = async (req, res, next) => { } } -const _handleWebhookGitHub = async function (req, service, staticman) { +const _handleWebhookGitHub = async function (req, service, staticman, configBranch) { let errorsRaised = [] const event = req.headers['x-github-event'] @@ -69,7 +83,7 @@ const _handleWebhookGitHub = async function (req, service, staticman) { if (staticman) { // Webhook request authentication is NOT supported in v1 of the endpoint. await staticman.getSiteConfig().then((siteConfig) => { - webhookSecretExpected = siteConfig.get('githubWebhookSecret') + webhookSecretExpected = siteConfig.get('githubWebhookSecret') || config.get('githubWebhookSecret') }) } @@ -89,7 +103,7 @@ const _handleWebhookGitHub = async function (req, service, staticman) { } if (reqAuthenticated) { - await _handleMergeRequest(req.params, service, req.body, staticman).catch((errors) => { + await _handleMergeRequest(req.params, service, req.body, staticman, configBranch).catch((errors) => { errorsRaised = errors }) } @@ -101,7 +115,7 @@ const _handleWebhookGitHub = async function (req, service, staticman) { } } -const _handleWebhookGitLab = async function (req, service, staticman) { +const _handleWebhookGitLab = async function (req, service, staticman, configBranch) { let errorsRaised = [] const event = req.headers['x-gitlab-event'] @@ -113,7 +127,7 @@ const _handleWebhookGitLab = async function (req, service, staticman) { if (staticman) { // Webhook request authentication is NOT supported in v1 of the endpoint. await staticman.getSiteConfig().then((siteConfig) => { - webhookSecretExpected = siteConfig.get('gitlabWebhookSecret') + webhookSecretExpected = siteConfig.get('gitlabWebhookSecret') || config.get('gitlabWebhookSecret') }) } @@ -137,7 +151,7 @@ const _handleWebhookGitLab = async function (req, service, staticman) { } if (reqAuthenticated) { - await _handleMergeRequest(req.params, service, req.body, staticman).catch((errors) => { + await _handleMergeRequest(req.params, service, req.body, staticman, configBranch).catch((errors) => { errorsRaised = errors }) } @@ -154,7 +168,7 @@ const _verifyGitHubSignature = function (secret, data, signature) { return bufferEq(Buffer.from(signature), Buffer.from(signedData)) } -const _handleMergeRequest = async function (params, service, data, staticman) { +const _handleMergeRequest = async function (params, service, data, staticman, configBranch) { // Allow for multiple errors to be raised and reported back. const errors = [] @@ -162,43 +176,13 @@ const _handleMergeRequest = async function (params, service, data, staticman) { ? require('universal-analytics')(config.get('analytics.uaTrackingId')) : null - const version = params.version - let username = params.username - let repository = params.repository - let branch = params.branch - - let gitService = null let mergeReqNbr = null + let webhookBranch = null if (service === 'github') { - /* - * In v1 of the endpoint, the service, username, repository, and branch parameters were - * ommitted. As such, if not provided in the webhook request URL, pull them from the webhook - * payload. - */ - if (username === null || typeof username === 'undefined') { - username = data.repository.owner.login - } - if (repository === null || typeof repository === 'undefined') { - repository = data.repository.name - } - if (branch === null || typeof branch === 'undefined') { - branch = data.pull_request.base.ref - } - - gitService = await gitFactory.create('github', { - version: version, - username: username, - repository: repository, - branch: branch - }) + webhookBranch = data.pull_request.base.ref mergeReqNbr = data.number } else if (service === 'gitlab') { - gitService = await gitFactory.create('gitlab', { - version: version, - username: username, - repository: repository, - branch: branch - }) + webhookBranch = data.object_attributes.target_branch mergeReqNbr = data.object_attributes.iid } else { errors.push('Unable to determine service.') @@ -210,12 +194,18 @@ const _handleMergeRequest = async function (params, service, data, staticman) { return Promise.reject(errors) } + const gitService = await _buildGitService(params, service, configBranch, webhookBranch).catch((error) => { + errors.push(error) + return Promise.reject(errors) + }) + let review = await gitService.getReview(mergeReqNbr).catch((error) => { const msg = `Failed to retrieve merge request ${mergeReqNbr} - ${error}` console.error(msg) errors.push(msg) return Promise.reject(errors) }) + //console.log('review = %o', review) /* * We might receive "real" (non-bot) pull requests for files other than Staticman-processed @@ -252,6 +242,53 @@ const _handleMergeRequest = async function (params, service, data, staticman) { } } +const _buildGitService = async function (params, service, configBranch, webhookBranch) { + const version = params.version + let username = params.username + let repository = params.repository + let branch = params.branch + + if (service === 'github') { + /* + * In v1 of the endpoint, the service, username, repository, and branch parameters were + * omitted. As such, if not provided in the webhook request URL, pull them from the webhook + * payload. + */ + if (username === null || typeof username === 'undefined') { + username = data.repository.owner.login + } + if (repository === null || typeof repository === 'undefined') { + repository = data.repository.name + } + if (branch === null || typeof branch === 'undefined') { + branch = data.pull_request.base.ref + } + } + + let gitService = null + /* + * A merge request processed (i.e., opened, merged, closed) against one branch in a repository + * will trigger ALL webhooks triggered by merge request events in that repository. Meaning, + * the webhook controller running in a (for example) prod Staticman instance will receive + * webhook calls triggered by merge request events against a (for example) dev branch. As such, + * we should expect plenty of extraneous webhook requests. The critical criterion is the branch + * in the webhook payload matching the branch specified in the configuration. + */ + if ((configBranch && (configBranch !== webhookBranch)) || branch !== webhookBranch) { + console.log(`Merge branch mismatch - configBranch = ${configBranch}, webhookBranch = ${webhookBranch}, paramsBranch = ${branch}`) + return Promise.reject('Merge branch mismatch. Ignoring request.') + } else { + gitService = await gitFactory.create(service, { + version: version, + username: username, + repository: repository, + branch: branch + }) + } + + return gitService +} + const _createNotifyMailingList = async function (review, staticman, ua) { /* * The "staticman_notification" comment section of the pull/merge request comment only diff --git a/lib/Confirmation.js b/lib/Confirmation.js index a915ff98..5282c93a 100644 --- a/lib/Confirmation.js +++ b/lib/Confirmation.js @@ -44,6 +44,14 @@ Confirmation.prototype.send = function (toEmailAddress, fields, extendedFields, payload.html = await _buildMessage( toEmailAddress, fields, extendedFields, options, data, payload.subject) + const exeEnv = config.get('exeEnv') + const exeEnvProd = config.get('exeEnvProduction') + if (exeEnv && (exeEnv !== exeEnvProd)) { + // Identify the source environment to flag/prevent cross-talk between environments. + payload.from = exeEnv + ' - ' + payload.from + payload.subject = exeEnv + ' - ' + payload.subject + } + payload['h:Reply-To'] = payload.from this.mailAgent.messages().send(payload, (err, res) => { @@ -202,6 +210,9 @@ const _encryptConfirmationLink = function (toEmailAddress, fields, options, emai * the encrypted payload when it is submitted back. */ toEncrypt.pepper = config.get('cryptoPepper') + + // Identify the source environment to flag/prevent cross-talk between environments. + toEncrypt.exeEnv = config.get('exeEnv') const encryptedText = RSA.encrypt(JSON.stringify(toEncrypt)) return encryptedText diff --git a/lib/Notification.js b/lib/Notification.js index e4551645..ec1734f2 100644 --- a/lib/Notification.js +++ b/lib/Notification.js @@ -39,6 +39,14 @@ Notification.prototype.send = function (to, fields, extendedFields, options, dat payload.subject = await _buildSubject(fields, extendedFields, options, data) payload.html = await _buildMessage(fields, extendedFields, options, data) + const exeEnv = config.get('exeEnv') + const exeEnvProd = config.get('exeEnvProduction') + if (exeEnv && (exeEnv !== exeEnvProd)) { + // Identify the source environment to flag/prevent cross-talk between environments. + payload.from = exeEnv + ' - ' + payload.from + payload.subject = exeEnv + ' - ' + payload.subject + } + /* * If we set the "reply_preference" property on the Mailgun mailing list to "sender" (which * seems to be the safest and most appropriate option for a list meant to receive diff --git a/lib/Staticman.js b/lib/Staticman.js index 81b0737c..c799c9e9 100644 --- a/lib/Staticman.js +++ b/lib/Staticman.js @@ -147,7 +147,11 @@ class Staticman { } _checkForSpam (fields) { - if (!this.siteConfig.get('akismet.enabled')) return Promise.resolve(fields) + // By default, Akismet is disabled in both configs. + if (!config.get('akismet.enabled') && !this.siteConfig.get('akismet.enabled')) { + console.log('Akismet processing disabled.'); + return Promise.resolve(fields) + } return new Promise((resolve, reject) => { const akismet = akismetApi.client({ @@ -170,6 +174,7 @@ class Staticman { akismetData.comment_author_email === akismetBypassValue || akismetData.comment_author_url === akismetBypassValue || akismetData.comment_content === akismetBypassValue) { + console.log('Akismet processing bypassed.'); return resolve(fields) } } @@ -317,6 +322,12 @@ class Staticman { }) let message = this.siteConfig.get('pullRequestBody') + markdownTable(table) + const exeEnv = config.get('exeEnv') + const exeEnvProd = config.get('exeEnvProduction') + if (exeEnv && (exeEnv !== exeEnvProd)) { + // Identify the source environment to flag/prevent cross-talk between environments. + message = exeEnv + ' - ' + message + } if (this.siteConfig.get('notifications.enabled')) { const notificationsPayload = { @@ -433,9 +444,14 @@ class Staticman { return errorHandler('MISSING_CONFIG_BLOCK') } + /* + * Do not require the "branch" property in the site/repo config, as there is no one value that + * will be able to be promoted without change through multiple environments (e.g., dev, + * staging, prod). In such a set-up, setting the "branch" property, instead, in the server + * config (via environmental variables) makes more sense. + */ const requiredFields = [ 'allowedFields', - 'branch', 'format', 'path' ] @@ -512,14 +528,22 @@ class Staticman { if (!this.configPath) return Promise.reject(errorHandler('NO_CONFIG_PATH')) return this.git.readFile(this.configPath.file).then(data => { - const config = objectPath.get(data, this.configPath.path) - const validationErrors = this._validateConfig(config) + const repoConfig = objectPath.get(data, this.configPath.path) + const validationErrors = this._validateConfig(repoConfig) if (validationErrors) { return Promise.reject(validationErrors) } - if (config.branch !== this.parameters.branch) { + /* + * If the allowed branch is specified in config.js, make sure that the branch specified in + * the request matches. If no branch is specified in config.js, do the same check against the + * value in the site/repo configuration. + */ + const serverConfigBranch = config.get('branch') + if (serverConfigBranch && (serverConfigBranch !== this.parameters.branch)) { + return Promise.reject(errorHandler('BRANCH_MISMATCH')) + } else if (repoConfig.branch && (repoConfig.branch !== this.parameters.branch)) { return Promise.reject(errorHandler('BRANCH_MISMATCH')) } @@ -565,10 +589,16 @@ class Staticman { }).then(async data => { const filePath = this._getNewFilePath(fields) const subscriptions = this._initialiseSubscriptions() - const commitMessage = this._resolvePlaceholders(this.siteConfig.get('commitMessage'), { + let commitMessage = this._resolvePlaceholders(this.siteConfig.get('commitMessage'), { fields, options }) + const exeEnv = config.get('exeEnv') + const exeEnvProd = config.get('exeEnvProduction') + if (exeEnv && (exeEnv !== exeEnvProd)) { + // Identify the source environment to flag/prevent cross-talk between environments. + commitMessage = exeEnv + ' - ' + commitMessage + } /* * Handle a request from the commenter to subscribe to comments. This is performed diff --git a/lib/SubscriptionsManager.js b/lib/SubscriptionsManager.js index 4323b7c7..31dff98a 100644 --- a/lib/SubscriptionsManager.js +++ b/lib/SubscriptionsManager.js @@ -1,5 +1,6 @@ 'use strict' +const config = require('../config') const md5 = require('md5') const Confirmation = require('./Confirmation') const Notification = require('./Notification') @@ -124,18 +125,30 @@ SubscriptionsManager.prototype.set = function (data, email, siteConfig) { const entryName = data.parentName if (typeof entryName !== 'undefined') { + const exeEnv = config.get('exeEnv') + const exeEnvProd = config.get('exeEnvProduction') + /* * Set a name and description on the created list to aid in identification and * troubleshooting, as the automatically-generated list address is an obfuscated * hash value. */ payload.name = entryName + if (exeEnv && (exeEnv !== exeEnvProd)) { + // Identify the source environment to flag/prevent cross-talk between environments. + payload.name = exeEnv + ' - ' + payload.name + } + /* * For the description, include the elements that are used to generate the list * address hash value. */ payload.description = 'Subscribers to ' + entryId + ' (' + this.parameters.username + '/' + this.parameters.repository + ')' + if (exeEnv && (exeEnv !== exeEnvProd)) { + // Identify the source environment to flag/prevent cross-talk between environments. + payload.description = exeEnv + ' - ' + payload.description + } } this.mailAgent.lists().create(payload, (err, result) => { @@ -187,7 +200,12 @@ SubscriptionsManager.prototype.set = function (data, email, siteConfig) { module.exports = SubscriptionsManager const _getListAddress = function (entryId, listAddressParams) { - const compoundId = md5(`${listAddressParams.username}-${listAddressParams.repository}-${entryId}`) + const exeEnv = config.get('exeEnv') + let compoundId = md5(`${exeEnv}-${listAddressParams.username}-${listAddressParams.repository}-${entryId}`) + if (exeEnv) { + // Identify the source environment to flag/prevent cross-talk between environments. + compoundId = exeEnv + '-' + compoundId + } return `${compoundId}@${listAddressParams.mailAgent.domain}` } diff --git a/server.js b/server.js index ba6e4072..d755ec89 100644 --- a/server.js +++ b/server.js @@ -36,10 +36,41 @@ class StaticmanAPI { initialiseCORS () { this.server.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') + // By default, return a value that allows all origins. + let reqOrigin = '*' + let originAllowed = true - next() + /* + * For example, /v3/confirm/gitlab/username/repo-name/dev/comments + * We only want to "lock down" the "entry" endpoint, as that is the only endpoint called from + * browser-rendered web pages. + */ + const isEntryEndpoint = req.path.match(/^\/v\d\/entry\//) + const allowedOrigins = config.get('origins') + if (isEntryEndpoint && allowedOrigins !== null) { + reqOrigin = req.headers.origin + + originAllowed = allowedOrigins.some(oneOrigin => { + // Allow for regular expressions in the config. For example, http://localhost:.* + return new RegExp(oneOrigin).test(reqOrigin); + }); + } + + if (originAllowed) { + res.setHeader('Access-Control-Allow-Origin', reqOrigin) + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') + return next() + } else { + /* + * Abort processing of the request and return a 403 Forbidden. With this, we are taking + * liberties with the CORS-centric origin header, as a CORS violation is really only + * supposed to be acted upon by the user-agent, not the server. More info: + * https://github.com/expressjs/cors/issues/109#issuecomment-289324022 + */ + return res.status(403).send({ + success: false + }) + } }) } diff --git a/siteConfig.js b/siteConfig.js index 5235c5aa..f98d4d56 100644 --- a/siteConfig.js +++ b/siteConfig.js @@ -55,9 +55,10 @@ const schema = { } }, branch: { - doc: 'Name of the branch being used within the GitHub repository.', + doc: 'Name of the branch being used within the GitHub repository. Highly recommended to set when using a shared Staticman instance, but to be left un-set when the site/repo config needs to be promoted without change through multiple environments (e.g., dev, staging, prod).', + docExample: 'main', format: String, - default: 'master' + default: '' }, commitMessage: { doc: 'Text to be used as the commit message when pushing entries to the GitHub repository.',