diff --git a/.gitignore b/.gitignore index a8d60c77..ef8515a3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ yarn-error.log lerna-debug.log # compile source -lib +lib/* +!src/lib/* # test artifacts *xunit.xml @@ -32,4 +33,4 @@ node_modules # os specific files .DS_Store -.idea \ No newline at end of file +.idea diff --git a/COMMANDS.md b/COMMANDS.md index 823de81e..489723a3 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -1,41 +1,30 @@ ## Commands -A list of the available commands +validate a digital signature for a npm package -- [`sfdx hello:org [-n ] [-f] [-v ] [-u ] [--apiversion ] [--json] [--loglevel trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL]`](#sfdx-helloorg--n-string--f--v-string--u-string---apiversion-string---json---loglevel-tracedebuginfowarnerrorfataltracedebuginfowarnerrorfatal) +- [`sfdx plugins:trust:verify -n [-r ] [--json] [--loglevel trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL]`] -## `sfdx hello:org [-n ] [-f] [-v ] [-u ] [--apiversion ] [--json] [--loglevel trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL]` +## `sfdx plugins:trust:verify -n [-r ] [--json] [--loglevel trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL]` -print a greeting and your org IDs - -``` -USAGE - $ sfdx hello:org [-n ] [-f] [-v ] [-u ] [--apiversion ] [--json] [--loglevel +```USAGE + $ sfdx plugins:trust:verify -n [-r ] [--json] [--loglevel trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL] OPTIONS - -f, --force example boolean flag - -n, --name=name name to print - - -u, --targetusername=targetusername username or alias for the target - org; overrides default target org + -n, --npm=npm (required) Specify the npm + name. This can include a + tag/version - -v, --targetdevhubusername=targetdevhubusername username or alias for the dev hub - org; overrides default dev hub org - - --apiversion=apiversion override the api version used for - api requests made by this command + -r, --registry=registry The registry name. the + behavior is the same as npm --json format output as json - --loglevel=(trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL) [default: warn] logging level for - this command invocation + --loglevel=(trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL) [default: warn] logging + level for this command + invocation EXAMPLES - $ sfdx hello:org --targetusername myOrg@example.com --targetdevhubusername devhub@org.com - Hello world! This is org: MyOrg and I will be around until Tue Mar 20 2018! - My hub org id is: 00Dxx000000001234 - - $ sfdx hello:org --name myname --targetusername myOrg@example.com - Hello myname! This is org: MyOrg and I will be around until Tue Mar 20 2018! + sfdx plugins:trust:verify --npm @scope/npmName --registry http://my.repo.org:4874 + sfdx plugins:trust:verify --npm @scope/npmName ``` diff --git a/README.md b/README.md index a21e565f..6c0512d9 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,6 @@ -# plugin-<REPLACE ME> +# plugin-trust -Change above to before finalizing - -<REPLACE ME DESCRIPTION START> - -This repository provides a template for creating a plugin for the Salesforce CLI. To convert this template to a working plugin: - -1. Clone this repo -2. Delete the .git folder -3. Replace filler values - a) Every instance of `` can be directly substitued for the name of the new plugin. However beware, things like github paths are for the salesforcecli Github organization - b) Search for case-matching `REPLACE` to find other filler values, such as for the plugin description -4. Use `git init` to set up the desired git information -5. Follow the getting started steps below until the `sfdx hello:org` commmand is functioning -6. In order to prevent build failures on the intial build, you will need to do the following: - 1. `npm publish` - 2. `git tag v1.0.0 ; git push origin v1.0.0` - -<REPLACE ME DESCRIPTION END> - -### Everything past here is only a suggestion as to what should be in your specific plugin's descsription. +commands to verify the authenticity of a plugin ## Getting Started @@ -28,8 +9,8 @@ To use, install the [Salesforce CLI](https://developer.salesforce.com/tools/sfdx ``` Verify the CLI is installed $ sfdx (-v | --version) -Install the plugin - $ sfdx plugins:install +Install the plugin-trust plugin + $ sfdx plugins:install plugin-trust To run a command $ sfdx [command] ``` @@ -38,7 +19,7 @@ To build the plugin locally, make sure to have yarn installed and run the follow ``` Clone the repository - $ git clone git@github.com:salesforcecli/plugin- + $ git clone git@github.com:salesforcecli/plugin-trust Install the dependencies and compile $ yarn install $ yarn prepack @@ -52,7 +33,7 @@ To verify We recommend using the Visual Studio Code (VS Code) IDE for your plugin development. Included in the `.vscode` directory of this plugin is a `launch.json` config file, which allows you to attach a debugger to the node process when running your commands. -To debug the `hello:org` command: +To debug the `plugins:trust:verify` command: If you linked your plugin to the sfdx cli, call your command with the `dev-suspend` switch: @@ -63,7 +44,7 @@ $ sfdx hello:org -u myOrg@example.com --dev-suspend Alternatively, to call your command using the `bin/run` script, set the `NODE_OPTIONS` environment variable to `--inspect-brk` when starting the debugger: ```sh-session -$ NODE_OPTIONS=--inspect-brk bin/run hello:org -u myOrg@example.com +$ NODE_OPTIONS=--inspect-brk bin/run plugins:trust:verify ``` 2. Set some breakpoints in your command code @@ -71,45 +52,36 @@ $ NODE_OPTIONS=--inspect-brk bin/run hello:org -u myOrg@example.com 4. In the upper left hand corner of VS Code, verify that the "Attach to Remote" launch configuration has been chosen. 5. Hit the green play button to the left of the "Attach to Remote" launch configuration window. The debugger should now be suspended on the first line of the program. 6. Hit the green play button at the top middle of VS Code (this play button will be to the right of the play button that you clicked in step #5). -

+ ![how to debug](./.images/vscodeScreenshot.png) Congrats, you are debugging! ## Commands -- [`sfdx hello:org [-n ] [-f] [-v ] [-u ] [--apiversion ] [--json] [--loglevel trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL]`](#sfdx-helloorg--n-string--f--v-string--u-string---apiversion-string---json---loglevel-tracedebuginfowarnerrorfataltracedebuginfowarnerrorfatal) +validate a digital signature for a npm package -## `sfdx hello:org [-n ] [-f] [-v ] [-u ] [--apiversion ] [--json] [--loglevel trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL]` +- [`sfdx plugins:trust:verify -n [-r ] [--json] [--loglevel trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL]`] -print a greeting and your org IDs +## `sfdx plugins:trust:verify -n [-r ] [--json] [--loglevel trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL]` -``` -USAGE - $ sfdx hello:org [-n ] [-f] [-v ] [-u ] [--apiversion ] [--json] [--loglevel +```USAGE + $ sfdx plugins:trust:verify -n [-r ] [--json] [--loglevel trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL] OPTIONS - -f, --force example boolean flag - -n, --name=name name to print - - -u, --targetusername=targetusername username or alias for the target - org; overrides default target org + -n, --npm=npm (required) Specify the npm + name. This can include a + tag/version - -v, --targetdevhubusername=targetdevhubusername username or alias for the dev hub - org; overrides default dev hub org - - --apiversion=apiversion override the api version used for - api requests made by this command + -r, --registry=registry The registry name. the + behavior is the same as npm --json format output as json - --loglevel=(trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL) [default: warn] logging level for - this command invocation + --loglevel=(trace|debug|info|warn|error|fatal|TRACE|DEBUG|INFO|WARN|ERROR|FATAL) [default: warn] logging + level for this command + invocation EXAMPLES - $ sfdx hello:org --targetusername myOrg@example.com --targetdevhubusername devhub@org.com - Hello world! This is org: MyOrg and I will be around until Tue Mar 20 2018! - My hub org id is: 00Dxx000000001234 - - $ sfdx hello:org --name myname --targetusername myOrg@example.com - Hello myname! This is org: MyOrg and I will be around until Tue Mar 20 2018! + sfdx plugins:trust:verify --npm @scope/npmName --registry http://my.repo.org:4874 + sfdx plugins:trust:verify --npm @scope/npmName ``` diff --git a/messages/messages.json b/messages/messages.json deleted file mode 100644 index 9a166f4f..00000000 --- a/messages/messages.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "HelpDefaults": "If not supplied, the apiversion, template, and outputdir use default values.\n", - "HelpOutputDirRelative": "The outputdir can be an absolute path or relative to the current working directory.\n", - "HelpOutputDirRelativeLightning": "If you don’t specify an outputdir, we create a subfolder in your current working directory with the name of your bundle. For example, if the current working directory is force-app and your Lightning bundle is called myBundle, we create force-app/myBundle/ to store the files in the bundle.\n", - "HelpExamplesTitle": "\nExamples:\n", - "OutputDirFlagDescription": "folder for saving the created files", - "OutputDirFlagLongDescription": "The directory to store the newly created files. The location can be an absolute path or relative to the current working directory. The default is the current directory.", - "TemplateFlagDescription": "template to use for file creation", - "TemplateFlagLongDescription": "The template to use to create the file. Supplied parameter values or default values are filled into a copy of the template.", - "TargetDirOutput": "target dir = %s", - "App": "app", - "Event": "event", - "Interface": "interface", - "Test": "test", - "Component": "component", - "Page": "page", - - "AlphaNumericNameError": "Name must contain only alphanumeric characters.", - "NameMustStartWithLetterError": "Name must start with a letter.", - "EndWithUnderscoreError": "Name can't end with an underscore.", - "DoubleUnderscoreError": "Name can't contain 2 consecutive underscores." -} diff --git a/messages/org.json b/messages/org.json deleted file mode 100644 index b5c9eec0..00000000 --- a/messages/org.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "commandDescription": "print a greeting and your org IDs", - "nameFlagDescription": "name to print", - "forceFlagDescription": "example boolean flag", - "errorNoOrgResults": "No results found for the org '%s'." -} diff --git a/messages/verify.json b/messages/verify.json new file mode 100644 index 00000000..631da0bc --- /dev/null +++ b/messages/verify.json @@ -0,0 +1,11 @@ +{ + "description": "validate a digital signature for a npm package", + "examples": [ + "sfdx plugins:trust:verify --npm @scope/npmName --registry http://my.repo.org:4874", + "sfdx plugins:trust:verify --npm @scope/npmName" + ], + "flags": { + "npm": "Specify the npm name. This can include a tag/version", + "registry": "The registry name. the behavior is the same as npm" + } +} diff --git a/package.json b/package.json index a4dbea35..6575ef2b 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { - "name": "@salesforce/plugin-template", - "description": "A template repository for sfdx plugins", + "name": "@salesforce/plugin-trust", + "description": "validate a digital signature for a npm package", "version": "1.0.0", "author": "Salesforce", "bugs": "https://github.com/forcedotcom/cli/issues", "dependencies": { - "@oclif/config": "^1", - "@salesforce/command": "^3.0.3", - "@salesforce/core": "^2.6.0", + "@oclif/config": "^1.17.0", + "@salesforce/command": "^3.0.5", + "@salesforce/core": "^2.15.2", "tslib": "^1" }, "devDependencies": { @@ -53,7 +53,7 @@ "/messages", "/oclif.manifest.json" ], - "homepage": "https://github.com/salesforcecli/plugin-template", + "homepage": "https://github.com/salesforcecli/plugin-trust", "keywords": [ "force", "salesforce", @@ -64,13 +64,21 @@ "license": "BSD-3-Clause", "oclif": { "commands": "./lib/commands", - "bin": "sfdx", + "hooks": { + "plugins:preinstall:verify:signature": [ + "./lib/hooks/verifyInstallSignature.js" + ] + }, "devPlugins": [ "@oclif/plugin-help" ], "topics": { - "hello": { - "description": "Commands to say hello." + "plugins": { + "subtopics": { + "trust": { + "description": "validate a digital signature for a npm package" + } + } } } }, @@ -78,7 +86,7 @@ "scripts": { "build": "sf-build", "clean": "sf-clean", - "clean-all": "-clean all", + "clean-all": "sf-clean all", "clean:lib": "shx rm -rf lib && shx rm -rf coverage && shx rm -rf .nyc_output && shx rm -f oclif.manifest.json", "compile": "sf-compile", "docs": "sf-docs", diff --git a/src/commands/hello/org.ts b/src/commands/hello/org.ts deleted file mode 100644 index 82b3f28d..00000000 --- a/src/commands/hello/org.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (c) 2020, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import { flags, SfdxCommand } from '@salesforce/command'; -import { Messages, SfdxError } from '@salesforce/core'; -import { AnyJson } from '@salesforce/ts-types'; - -// Initialize Messages with the current plugin directory -Messages.importMessagesDirectory(__dirname); - -// Load the specific messages for this file. Messages from @salesforce/command, @salesforce/core, -// or any library that is using the messages framework can also be loaded this way. -// TODO: replace the package name with your new package's name -const messages = Messages.loadMessages('@salesforce/plugin-template', 'org'); - -export default class Org extends SfdxCommand { - public static description = messages.getMessage('commandDescription'); - - public static examples = [ - `$ sfdx hello:org --targetusername myOrg@example.com --targetdevhubusername devhub@org.com - Hello world! This is org: MyOrg and I will be around until Tue Mar 20 2018! - My hub org id is: 00Dxx000000001234 - `, - `$ sfdx hello:org --name myname --targetusername myOrg@example.com - Hello myname! This is org: MyOrg and I will be around until Tue Mar 20 2018! - `, - ]; - - public static args = [{ name: 'file' }]; - - protected static flagsConfig = { - // flag with a value (-n, --name=VALUE) - name: flags.string({ - char: 'n', - description: messages.getMessage('nameFlagDescription'), - }), - force: flags.boolean({ - char: 'f', - description: messages.getMessage('forceFlagDescription'), - }), - }; - - // Comment this out if your command does not require an org username - protected static requiresUsername = true; - - // Comment this out if your command does not support a hub org username - protected static supportsDevhubUsername = true; - - // Set this to true if your command requires a project workspace; 'requiresProject' is false by default - protected static requiresProject = false; - - public async run(): Promise { - const name: string = this.flags.name || 'world'; - - // this.org is guaranteed because requiresUsername=true, as opposed to supportsUsername - const conn = this.org.getConnection(); - const query = 'Select Name, TrialExpirationDate from Organization'; - - // The type we are querying for - interface Organization { - Name: string; - TrialExpirationDate: string; - } - - // Query the org - const result = await conn.query(query); - - // Organization will always return one result, but this is an example of throwing an error - // The output and --json will automatically be handled for you. - if (!result.records || result.records.length <= 0) { - throw new SfdxError(messages.getMessage('errorNoOrgResults', [this.org.getOrgId()])); - } - - // Organization always only returns one result - const orgName: string = result.records[0].Name; - const trialExpirationDate = result.records[0].TrialExpirationDate; - - let outputString = `Hello ${name}! This is org: ${orgName}`; - if (trialExpirationDate) { - const date = new Date(trialExpirationDate).toDateString(); - outputString = `${outputString} and I will be around until ${date}!`; - } - this.ux.log(outputString); - - // this.hubOrg is NOT guaranteed because supportsHubOrgUsername=true, as opposed to requiresHubOrgUsername. - if (this.hubOrg) { - const hubOrgId = this.hubOrg.getOrgId(); - this.ux.log(`My hub org id is: ${hubOrgId}`); - } - - if (this.flags.force && this.args.file) { - this.ux.log(`You input --force and a file: ${this.args.file as string}`); - } - - // Return an object to be displayed with --json - return { orgId: this.org?.getOrgId(), outputString }; - } -} diff --git a/src/commands/plugins/trust/verify.ts b/src/commands/plugins/trust/verify.ts new file mode 100644 index 00000000..eac54877 --- /dev/null +++ b/src/commands/plugins/trust/verify.ts @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2018, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import * as os from 'os'; +import { get } from '@salesforce/ts-types'; +import { flags, FlagsConfig, SfdxCommand } from '@salesforce/command'; +import { Messages, SfdxError } from '@salesforce/core'; +import { ConfigContext, InstallationVerification, VerificationConfig } from '../../../lib/installationVerification'; +import { NpmName } from '../../../lib/NpmName'; + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('@salesforce/plugin-trust', 'verify'); + +export interface VerifyResponse { + message: string; + verified: boolean; +} + +export class Verify extends SfdxCommand { + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessage('examples').split(os.EOL); + public static readonly hidden: true; + protected static readonly flagsConfig: FlagsConfig = { + npm: flags.string({ + char: 'n', + required: true, + description: messages.getMessage('flags.npm'), + }), + registry: flags.string({ + char: 'r', + required: false, + description: messages.getMessage('flags.registry'), + }), + }; + + public async run(): Promise { + this.ux.log('Checking for digital signature.'); + + const npmName: NpmName = NpmName.parse(this.flags.npm); + + this.logger.debug(`running verify command for npm: ${npmName.name}`); + + const vConfig = new VerificationConfig(); + + const configContext: ConfigContext = { + cacheDir: get(this.config, 'configDir') as string, + configDir: get(this.config, 'cacheDir') as string, + dataDir: get(this.config, 'dataDir') as string, + }; + + this.logger.debug(`cacheDir: ${configContext.cacheDir}`); + this.logger.debug(`configDir: ${configContext.configDir}`); + this.logger.debug(`dataDir: ${configContext.dataDir}`); + + vConfig.verifier = this.getVerifier(npmName, configContext); + + vConfig.log = this.ux.log.bind(this.ux); + + if (this.flags.registry) { + process.env.SFDX_NPM_REGISTRY = this.flags.registry; + } + + try { + const meta = await vConfig.verifier.verify(); + this.logger.debug(`meta.verified: ${meta.verified}`); + + if (!meta.verified) { + throw new SfdxError( + "A digital signature is specified for this plugin but it didn't verify against the certificate.", + 'FailedDigitalSignatureVerification' + ); + } + const message = `Successfully validated digital signature for ${npmName.name}.`; + + if (!this.flags.json) { + vConfig.log(message); + } else { + return { message, verified: true }; + } + } catch (err) { + this.logger.debug(`err reported: ${JSON.stringify(err, null, 4)}`); + const response: VerifyResponse = { + verified: false, + message: err.message, + }; + + if (err.name === 'NotSigned') { + let message: string = err.message; + if (await vConfig.verifier.isAllowListed()) { + message = `The plugin [${npmName.name}] is not digitally signed but it is allow-listed.`; + vConfig.log(message); + response.message = message; + } else { + message = 'The plugin is not digitally signed.'; + vConfig.log(message); + response.message = message; + } + return response; + } + throw SfdxError.wrap(err); + } + } + + private getVerifier(npmName: NpmName, config: ConfigContext): InstallationVerification { + return new InstallationVerification().setPluginNpmName(npmName).setConfig(config); + } +} diff --git a/src/hooks/verifyInstallSignature.ts b/src/hooks/verifyInstallSignature.ts new file mode 100644 index 00000000..753d5fdf --- /dev/null +++ b/src/hooks/verifyInstallSignature.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2018, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { Hook } from '@oclif/config'; +import { Logger } from '@salesforce/core'; +import { cli } from 'cli-ux'; +import { + ConfigContext, + doInstallationCodeSigningVerification, + doPrompt, + InstallationVerification, + VerificationConfig, +} from '../lib/installationVerification'; + +import { NpmName } from '../lib/NpmName'; + +/** + * Build a VerificationConfig. Useful for testing. + */ +export class VerificationConfigBuilder { + public static build(npmName: NpmName, configContext: ConfigContext): VerificationConfig { + const vConfig = new VerificationConfig(); + vConfig.verifier = new InstallationVerification().setPluginNpmName(npmName).setConfig(configContext); + + vConfig.log = cli.log.bind(cli); + vConfig.prompt = cli.prompt.bind(cli); + return vConfig; + } + public static buildForRepo(): VerificationConfig { + const vConfig = new VerificationConfig(); + vConfig.prompt = cli.prompt.bind(cli); + return vConfig; + } +} + +export const hook: Hook.PluginsPreinstall = async function (options) { + if (options.plugin && options.plugin.type === 'npm') { + const logger = await Logger.child('verifyInstallSignature'); + const plugin = options.plugin; + + logger.debug('parsing npm name'); + const npmName = NpmName.parse(plugin.name); + logger.debug(`npmName components: ${JSON.stringify(npmName, null, 4)}`); + + npmName.tag = plugin.tag || 'latest'; + + if (/^v[0-9].*/.test(npmName.tag)) { + npmName.tag = npmName.tag.slice(1); + } + + const configContext: ConfigContext = { + cacheDir: options.config.cacheDir, + configDir: options.config.configDir, + dataDir: options.config.dataDir, + }; + + const vConfig = VerificationConfigBuilder.build(npmName, configContext); + logger.debug('finished building the VerificationConfigBuilder'); + + try { + logger.debug('doing verification'); + await doInstallationCodeSigningVerification(configContext, { plugin: plugin.name, tag: plugin.tag }, vConfig); + cli.log('Finished digital signature check.'); + } catch (err) { + logger.debug(err.message); + this.error(err); + } + } else { + await doPrompt(VerificationConfigBuilder.buildForRepo()); + } +}; + +export default hook; diff --git a/src/lib/NpmName.ts b/src/lib/NpmName.ts new file mode 100644 index 00000000..69eef015 --- /dev/null +++ b/src/lib/NpmName.ts @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2018, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { SfdxError } from '@salesforce/core'; + +/** + * String representing the parsed components of an NpmName + * + * @example + * const f: NpmName = NpmName.parse('@salesforce/jj@foo'); + * console.log(f.tag === 'foo') + */ +export class NpmName { + public static readonly DEFAULT_TAG = 'latest'; + public scope: string; + public tag: string; + public name: string; + + /** + * Private ctor. Use static parse method. + */ + private constructor() { + this.tag = NpmName.DEFAULT_TAG; + } + /** + * Parse an NPM package name into {scope, name, tag}. The tag is 'latest' by default and can be any semver string. + * + * @param {string} npmName - The npm name to parse. + * @return {NpmName} - An object with the parsed components. + */ + public static parse(npmName: string): NpmName { + if (!npmName || npmName.length < 1) { + throw new SfdxError('The npm name is missing or invalid.', 'MissingOrInvalidNpmName'); + } + + const returnNpmName = new NpmName(); + + const components: string[] = npmName.split('@'); + + // salesforce/jj + if (components.length === 1) { + NpmName.setNameAndScope(components[0], returnNpmName); + } else { + // salesforce/jj@tag + if (components[0].includes('/')) { + NpmName.setNameAndScope(components[0], returnNpmName); + } else { + // @salesforce/jj@tag + if (components[1].includes('/')) { + NpmName.setNameAndScope(components[1], returnNpmName); + } else { + // Allow something like salesforcedx/pre-release + NpmName.setNameAndScope(components[0], returnNpmName); + returnNpmName.tag = components[1]; + } + } + } + + if (components.length > 2) { + returnNpmName.tag = components[2]; + } + return returnNpmName; + } + + /** + * Static helper to parse the name and scope. + * + * @param {string} name - The string to parse. + * @param returnNpmName - The object to update. + */ + private static setNameAndScope(name: string, returnNpmName): void { + // There are at least 2 components. So there is likely a scope. + const subComponents: string[] = name.split('/'); + if (subComponents.length === 2 && subComponents[0].trim().length > 0 && subComponents[1].trim().length > 0) { + returnNpmName.scope = NpmName.validateComponentString(subComponents[0]); + returnNpmName.name = NpmName.validateComponentString(subComponents[1]); + } else if (subComponents.length === 1) { + returnNpmName.name = NpmName.validateComponentString(subComponents[0]); + } else { + throw new SfdxError('The npm name is invalid.', 'InvalidNpmName'); + } + } + + /** + * Validate a component part that it's not empty and return it trimmed. + * + * @param {string} name The component to validate. + * @return {string} A whitespace trimmed version of the component. + */ + private static validateComponentString(name: string): string { + const trimmedName = name.trim(); + if (trimmedName && trimmedName.length > 0) { + return trimmedName; + } else { + throw new SfdxError('The npm name is missing or invalid.', 'MissingOrInvalidNpmName'); + } + } + + /** + * Produce a string that can be used by npm. @salesforce/jj@1.2.3 becomes "salesforce-jj-1.2.3.tgz + * + * @param {string} [ext = tgz] The file extension to use. + * @param {boolean} includeLatestTag - True if the "latest" tag should be used. Generally you wouldn't do this. + * @return {string} Formatted npm string thats compatible with the npm utility + */ + public toFilename(ext = 'tgz', includeLatestTag?: boolean): string { + const nameComponents: string[] = []; + + if (this.scope) { + nameComponents.push(this.scope); + } + + nameComponents.push(this.name); + + if (this.tag) { + if (this.tag !== NpmName.DEFAULT_TAG) { + nameComponents.push(this.tag); + } else if (includeLatestTag) { + nameComponents.push(this.tag); + } + } + + return nameComponents.join('-').concat(ext.startsWith('.') ? ext : `.${ext}`); + } + + /** + * Produces a formatted string version of the object. + * + * @return {string} A formatted string version of the object. + */ + public toString(includeTag = false): string { + const nameComponents: string[] = []; + if (this.scope && this.scope.length > 0) { + nameComponents.push(`@${this.scope}/`); + } + + nameComponents.push(this.name); + + if (includeTag && this.tag && this.tag.length > 0) { + nameComponents.push(`@${this.tag}`); + } + + return nameComponents.join(''); + } +} diff --git a/src/lib/installationVerification.ts b/src/lib/installationVerification.ts new file mode 100644 index 00000000..22ddb413 --- /dev/null +++ b/src/lib/installationVerification.ts @@ -0,0 +1,558 @@ +/* + * Copyright (c) 2018, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import * as path from 'path'; +import { Readable } from 'stream'; +import { parse as parseUrl, URL, UrlWithStringQuery } from 'url'; +import { promisify as utilPromisify } from 'util'; +import * as crypto from 'crypto'; +import { Logger, fs, SfdxError } from '@salesforce/core'; +import { get } from '@salesforce/ts-types'; +import * as request from 'request'; +import { NpmName } from './NpmName'; + +const CRYPTO_LEVEL = 'RSA-SHA256'; +export const ALLOW_LIST_FILENAME = 'unsignedPluginAllowList.json'; +export const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; +export type IRequest = (url: string, cb?: request.RequestCallback) => Readable; +type Version = { + sfdx: NpmMeta; + dist: { + tarball: string; + }; +}; +export interface ConfigContext { + configDir?: string; + cacheDir?: string; + dataDir?: string; +} +export interface Verifier { + verify(): Promise; + isAllowListed(): Promise; +} +export class CodeVerifierInfo { + private signature: Readable; + private publicKey: Readable; + private data: Readable; + + public get dataToVerify(): Readable { + return this.data; + } + + public set dataToVerify(value: Readable) { + this.data = value; + } + + public get signatureStream(): Readable { + return this.signature; + } + + public set signatureStream(value: Readable) { + this.signature = value; + } + + public get publicKeyStream(): Readable { + return this.publicKey; + } + + public set publicKeyStream(value: Readable) { + this.publicKey = value; + } +} + +export function validSalesforceHostname(url: string | null): boolean { + if (!url) { + return false; + } + const parsedUrl: UrlWithStringQuery = parseUrl(url); + + if (process.env.SFDX_ALLOW_ALL_SALESFORCE_CERTSIG_HOSTING === 'true') { + return parsedUrl.hostname && /(\.salesforce\.com)$/.test(parsedUrl.hostname); + } else { + return parsedUrl.protocol === 'https:' && parsedUrl.hostname && parsedUrl.hostname === 'developer.salesforce.com'; + } +} + +function retrieveKey(stream: Readable): Promise { + return new Promise((resolve, reject) => { + let key = ''; + if (stream) { + stream.on('data', (chunk) => { + key += chunk; + }); + stream.on('end', () => { + if (!key.includes('-----BEGIN')) { + return reject(new SfdxError('The specified key format is invalid.', 'InvalidKeyFormat')); + } + return resolve(key); + }); + stream.on('error', (err) => { + return reject(err); + }); + } + }); +} + +export async function verify(codeVerifierInfo: CodeVerifierInfo): Promise { + const publicKey = await retrieveKey(codeVerifierInfo.publicKeyStream); + const signApi = crypto.createVerify(CRYPTO_LEVEL); + + return new Promise((resolve, reject) => { + codeVerifierInfo.dataToVerify.pipe(signApi); + + codeVerifierInfo.dataToVerify.on('end', () => { + // The sign signature returns a base64 encode string. + let signature = Buffer.alloc(0); + codeVerifierInfo.signatureStream.on('data', (chunk: Buffer) => { + signature = Buffer.concat([signature, chunk]); + }); + + codeVerifierInfo.signatureStream.on('end', () => { + if (signature.byteLength === 0) { + return reject(new SfdxError('The provided signature is invalid or missing.', 'InvalidSignature')); + } else { + const verification = signApi.verify(publicKey, signature.toString('utf8'), 'base64'); + return resolve(verification); + } + }); + + codeVerifierInfo.signatureStream.on('error', (err) => { + return reject(err); + }); + }); + + codeVerifierInfo.dataToVerify.on('error', (err) => { + return reject(err); + }); + }); +} + +export const getNpmRegistry = (): URL => { + return new URL(process.env.SFDX_NPM_REGISTRY || DEFAULT_REGISTRY); +}; + +/** + * simple data structure representing the discovered meta information needed for signing, + */ +export class NpmMeta { + public tarballUrl: string; + public signatureUrl: string; + public publicKeyUrl: string; + public tarballLocalPath: string; + public verified: boolean; +} + +/** + * class for verifying a digital signature pack of an npm + */ +export class InstallationVerification implements Verifier { + // The name of the published plugin + private pluginNpmName: NpmName; + + // config derived from the cli environment + private config: ConfigContext; + + // Reference for the http client; + private readonly requestImpl: IRequest; + + // Reference for fs + private fsImpl; + + private readonly readFileAsync; + private readonly unlinkAsync; + + private logger: Logger; + + public constructor(requestImpl?: IRequest, fsImpl?: unknown) { + // why? dependency injection is better than sinon + this.requestImpl = requestImpl ? requestImpl : request; + this.fsImpl = fsImpl ? fsImpl : fs; + this.readFileAsync = utilPromisify(this.fsImpl.readFile); + this.unlinkAsync = utilPromisify(this.fsImpl.unlink); + } + + /** + * setter for the cli engine config + * + * @param _config cli engine config + */ + public setConfig(_config?: ConfigContext): InstallationVerification { + if (_config) { + this.config = _config; + return this; + } + throw new SfdxError('the cli engine config cannot be null', 'InvalidParam'); + } + + /** + * setter for the plugin name + * + * @param _pluginName the published plugin name + */ + public setPluginNpmName(_pluginName?: NpmName | undefined): InstallationVerification { + if (_pluginName) { + this.pluginNpmName = _pluginName; + return this; + } + throw new SfdxError('pluginName must be specified.', 'InvalidParam'); + } + + /** + * validates the digital signature. + */ + public async verify(): Promise { + const logger = await this.getLogger(); + + const npmMeta = await this.streamTagGz(); + logger.debug(`verify | Found npmMeta? ${!!npmMeta}`); + + logger.debug(`verify | creating a read stream for path - npmMeta.tarballLocalPath: ${npmMeta.tarballLocalPath}`); + const info = new CodeVerifierInfo(); + info.dataToVerify = this.fsImpl.createReadStream(npmMeta.tarballLocalPath, { encoding: 'binary' }); + + logger.debug(`verify | npmMeta.signatureUrl: ${npmMeta.signatureUrl}`); + logger.debug(`verify | npmMeta.publicKeyUrl: ${npmMeta.publicKeyUrl}`); + + return Promise.all([this.getSigningContent(npmMeta.signatureUrl), this.getSigningContent(npmMeta.publicKeyUrl)]) + .then((result) => { + info.signatureStream = result[0]; + info.publicKeyStream = result[1]; + return verify(info); + }) + .then((result) => { + npmMeta.verified = result; + return this.unlinkAsync(npmMeta.tarballLocalPath) + .catch((err) => { + logger.debug(`error occurred deleting cache tgz at path: ${npmMeta.tarballLocalPath}`); + logger.debug(err); + }) + .then(() => npmMeta); + }) + .catch((e) => { + if (e.code === 'DEPTH_ZERO_SELF_SIGNED_CERT') { + throw new SfdxError( + 'Encountered a self signed certificated. To enable "export NODE_TLS_REJECT_UNAUTHORIZED=0"', + 'SelfSignedCert' + ); + } + throw e; + }); + } + + public async isAllowListed(): Promise { + const logger = await this.getLogger(); + const allowListedFilePath = path.join(this.getConfigPath(), ALLOW_LIST_FILENAME); + logger.debug(`isAllowListed | allowlistFilePath: ${allowListedFilePath}`); + let fileContent: string; + try { + // deprecation time for whitelist -> allowlist + try { + // read new file + fileContent = await this.readFileAsync(allowListedFilePath); + } catch { + // read old file + fileContent = await this.readFileAsync(path.join(this.getConfigPath(), 'unsignedPluginWhiteList.json')); + // send message + process.emitWarning( + 'support for the file `unsignedPluginWhiteList.json` will be deprecated in the future. We are transitioning towards using `unsignedPluginAllowList.json` as a replacement' + ); + } + + const allowlistArray = JSON.parse(fileContent); + logger.debug('isAllowListed | Successfully parsed allowlist.'); + return allowlistArray && allowlistArray.includes(this.pluginNpmName.toString()); + } catch (err) { + if (err.code === 'ENOENT') { + return false; + } else { + throw err; + } + } + } + + /** + * Retrieve url content for a host + * + * @param url host url. + */ + public getSigningContent(url): Promise { + return new Promise((resolve, reject) => { + this.requestImpl(url, (err: Error, response: request.RequestResponse, responseData) => { + if (err) { + return reject(err); + } else { + if (response && response.statusCode === 200) { + // The verification api expects a readable + return resolve( + new Readable({ + read(): void { + this.push(responseData); + this.push(null); + }, + }) + ); + } else { + return reject( + new SfdxError( + `A request to url ${url as string} failed with error code: [${ + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + (response as { statusCode: string }) ? response.statusCode : 'undefined' + }]`, + 'ErrorGettingContent' + ) + ); + } + } + }); + }); + } + + /** + * Downloads the tgz file content and stores it in a cache folder + */ + public async streamTagGz(): Promise { + const logger = await this.getLogger(); + const npmMeta = await this.retrieveNpmMeta(); + const urlObject: URL = new URL(npmMeta.tarballUrl); + const urlPathsAsArray = urlObject.pathname.split('/'); + logger.debug(`streamTagGz | urlPathsAsArray: ${urlPathsAsArray.join(',')}`); + + const fileNameStr: string = urlPathsAsArray[urlPathsAsArray.length - 1]; + logger.debug(`streamTagGz | fileNameStr: ${fileNameStr}`); + + // Make sure the cache path exists. + try { + await fs.mkdirp(this.getCachePath()); + } catch (err) { + logger.debug(err); + } + + return new Promise((resolve, reject) => { + const cacheFilePath = path.join(this.getCachePath(), fileNameStr); + logger.debug(`streamTagGz | cacheFilePath: ${cacheFilePath}`); + + const writeStream = this.fsImpl.createWriteStream(cacheFilePath, { encoding: 'binary' }); + this.requestImpl(npmMeta.tarballUrl) + .on('end', () => { + logger.debug('streamTagGz | Finished writing tgz file'); + npmMeta.tarballLocalPath = cacheFilePath; + return resolve(npmMeta); + }) + .on('error', (err) => { + logger.debug(err); + return reject(err); + }) + .pipe(writeStream); + }); + } + + // this is generally $HOME/.config/sfdx + private getConfigPath(): string { + return this.config.configDir; + } + + // this is generally $HOME/Library/Caches/sfdx on mac + private getCachePath(): string { + return this.config.cacheDir; + } + + /** + * Invoke npm to discover a urls for the certificate and digital signature. + */ + private async retrieveNpmMeta(): Promise { + const logger = await this.getLogger(); + return new Promise((resolve, reject) => { + const npmRegistry = getNpmRegistry(); + + logger.debug(`retrieveNpmMeta | npmRegistry: ${npmRegistry.href}`); + logger.debug(`retrieveNpmMeta | this.pluginNpmName.name: ${this.pluginNpmName.name}`); + logger.debug(`retrieveNpmMeta | this.pluginNpmName.scope: ${this.pluginNpmName.scope}`); + logger.debug(`retrieveNpmMeta | this.pluginNpmName.tag: ${this.pluginNpmName.tag}`); + + if (this.pluginNpmName.scope) { + npmRegistry.pathname = path.join( + npmRegistry.pathname, + `@${this.pluginNpmName.scope}%2f${this.pluginNpmName.name}` + ); + } else { + npmRegistry.pathname = path.join(npmRegistry.pathname, this.pluginNpmName.name); + } + logger.debug(`retrieveNpmMeta | npmRegistry.pathname: ${npmRegistry.pathname}`); + + this.requestImpl(npmRegistry.href, (err, response, body) => { + if (err) { + return reject(err); + } + if (response && response.statusCode === 200) { + logger.debug('retrieveNpmMeta | Found npm meta information. Parsing.'); + const responseObj = JSON.parse(body); + + // Make sure the response has a version attribute + if (!responseObj.versions) { + return reject( + new SfdxError( + `The npm metadata for plugin ${this.pluginNpmName.name} is missing the versions attribute.`, + 'InvalidNpmMetadata' + ) + ); + } + + // Assume the tag is version tag. + let versionObject: Version = responseObj.versions[this.pluginNpmName.tag]; + + logger.debug(`retrieveNpmMeta | versionObject: ${JSON.stringify(versionObject)}`); + + // If the assumption was not correct the tag must be a non-versioned dist-tag or not specified. + if (!versionObject) { + // Assume dist-tag; + const distTags: string = get(responseObj, 'dist-tags') as string; + logger.debug(`retrieveNpmMeta | distTags: ${distTags}`); + if (distTags) { + const tagVersionStr: string = get(distTags, this.pluginNpmName.tag) as string; + logger.debug(`retrieveNpmMeta | tagVersionStr: ${tagVersionStr}`); + + // if we got a dist tag hit look up the version object + if (tagVersionStr && tagVersionStr.length > 0 && tagVersionStr.includes('.')) { + versionObject = responseObj.versions[tagVersionStr]; + logger.debug(`retrieveNpmMeta | versionObject: ${JSON.stringify(versionObject)}`); + } else { + return reject( + new SfdxError( + `The dist tag ${this.pluginNpmName.tag} was not found for plugin: ${this.pluginNpmName.name}`, + 'NpmTagNotFound' + ) + ); + } + } else { + return reject(new SfdxError('The deployed NPM is missing dist-tags.', 'UnexpectedNpmFormat')); + } + } + + if (!(versionObject && versionObject.sfdx)) { + return reject(new SfdxError('This plugin is not signed by Salesforce.com, Inc.', 'NotSigned')); + } else { + const meta: NpmMeta = new NpmMeta(); + if (!validSalesforceHostname(versionObject.sfdx.publicKeyUrl)) { + throw new SfdxError( + `The host is not allowed to provide signing information. [${versionObject.sfdx.publicKeyUrl}]`, + 'UnexpectedHost' + ); + } else { + logger.debug(`retrieveNpmMeta | versionObject.sfdx.publicKeyUrl: ${versionObject.sfdx.publicKeyUrl}`); + meta.publicKeyUrl = versionObject.sfdx.publicKeyUrl; + } + + if (!validSalesforceHostname(versionObject.sfdx.signatureUrl)) { + throw new SfdxError( + `The host is not allowed to provide signing information. [${versionObject.sfdx.signatureUrl}]`, + 'UnexpectedHost' + ); + } else { + logger.debug(`retrieveNpmMeta | versionObject.sfdx.signatureUrl: ${versionObject.sfdx.signatureUrl}`); + meta.signatureUrl = versionObject.sfdx.signatureUrl; + } + + meta.tarballUrl = versionObject.dist.tarball; + logger.debug(`retrieveNpmMeta | meta.tarballUrl: ${meta.tarballUrl}`); + + return resolve(meta); + } + } else { + switch (response.statusCode) { + case 403: + throw new SfdxError(`Access to the plugin was denied. url: ${npmRegistry.href}`, 'PluginAccessDenied'); + case 404: + throw new SfdxError(`The plugin requested was not found. url: ${npmRegistry.href}.`, 'PluginNotFound'); + default: + throw new SfdxError( + `The url request returned ${response.statusCode as string} - ${npmRegistry.href}`, + 'UrlRetrieve' + ); + } + } + }); + }); + } + + private async getLogger(): Promise { + if (!this.logger) { + this.logger = await Logger.child('InstallationVerification'); + } + return this.logger; + } +} + +export class VerificationConfig { + private verifierMember: Verifier; + private logMember: (message: string) => void; + private promptMember: (message: string) => Promise; + + public get verifier(): Verifier { + return this.verifierMember; + } + + public set verifier(value: Verifier) { + this.verifierMember = value; + } + + public get log(): (message: string) => void { + return this.logMember; + } + + public set log(value: (message: string) => void) { + this.logMember = value; + } + + public get prompt(): (message: string) => Promise { + return this.promptMember; + } + + public set prompt(value: (message: string) => Promise) { + this.promptMember = value; + } +} + +export async function doPrompt(vconfig: VerificationConfig): Promise { + const shouldContinue = await vconfig.prompt( + 'This plugin is not digitally signed and its authenticity cannot be verified. Continue installation y/n?' + ); + switch (shouldContinue.toLowerCase()) { + case 'y': + return; + default: + throw new SfdxError('The user canceled the plugin installation.', 'InstallationCanceledError'); + } +} + +export async function doInstallationCodeSigningVerification( + config: ConfigContext, + plugin: { plugin: string; tag: string }, + verificationConfig: VerificationConfig +): Promise { + try { + const meta = await verificationConfig.verifier.verify(); + if (!meta.verified) { + throw new SfdxError( + "A digital signature is specified for this plugin but it didn't verify against the certificate.", + 'FailedDigitalSignatureVerification' + ); + } + verificationConfig.log(`Successfully validated digital signature for ${plugin.plugin}.`); + } catch (err) { + if (err.name === 'NotSigned') { + if (await verificationConfig.verifier.isAllowListed()) { + verificationConfig.log(`The plugin [${plugin.plugin}] is not digitally signed but it is allow-listed.`); + return; + } else { + return await doPrompt(verificationConfig); + } + } else if (err.name === 'PluginNotFound' || err.name === 'PluginAccessDenied') { + throw new SfdxError(err.message || 'The user canceled the plugin installation.'); + } + throw SfdxError.wrap(err); + } +} diff --git a/test/.eslintrc.js b/test/.eslintrc.js index 79fd9f43..98494551 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -14,7 +14,6 @@ module.exports = { 'no-unused-expressions': 'off', // It is common for tests to stub out method. - // Return types are defined by the source code. Allows for quick overwrites. '@typescript-eslint/explicit-function-return-type': 'off', // Mocked out the methods that shouldn't do anything in the tests. diff --git a/test/commands/hello/org.test.ts b/test/commands/hello/org.test.ts deleted file mode 100644 index 7e3ad774..00000000 --- a/test/commands/hello/org.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2020, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import { expect, test } from '@salesforce/command/lib/test'; -import { ensureJsonMap, ensureString } from '@salesforce/ts-types'; - -describe('hello:org', () => { - test - .withOrg({ username: 'test@org.com' }, true) - .withConnectionRequest((request) => { - const requestMap = ensureJsonMap(request); - if (ensureString(requestMap.url).includes('Organization')) { - return Promise.resolve({ - records: [ - { - Name: 'Super Awesome Org', - TrialExpirationDate: '2018-03-20T23:24:11.000+0000', - }, - ], - }); - } - return Promise.resolve({ records: [] }); - }) - .stdout() - .command(['hello:org', '--targetusername', 'test@org.com']) - .it('runs hello:org --targetusername test@org.com', (ctx) => { - expect(ctx.stdout).to.contain( - 'Hello world! This is org: Super Awesome Org and I will be around until Tue Mar 20 2018!' - ); - }); -}); diff --git a/test/commands/verify.test.ts b/test/commands/verify.test.ts new file mode 100644 index 00000000..67ff46a7 --- /dev/null +++ b/test/commands/verify.test.ts @@ -0,0 +1,6 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ diff --git a/test/hooks/verifyInstallSignatureHook.test.ts b/test/hooks/verifyInstallSignatureHook.test.ts new file mode 100644 index 00000000..834ed8b6 --- /dev/null +++ b/test/hooks/verifyInstallSignatureHook.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { stubMethod } from '@salesforce/ts-sinon'; + +import { InstallationVerification, VerificationConfig } from '../../src/lib/installationVerification'; +import { hook, VerificationConfigBuilder } from '../../src/hooks/verifyInstallSignature'; + +describe('plugin install hook', () => { + let sandbox: sinon.SinonSandbox; + let vConfig: VerificationConfig; + let buildFromRepoStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + vConfig = new VerificationConfig(); + + vConfig.verifier = new InstallationVerification(); + stubMethod(sandbox, vConfig.verifier, 'verify').callsFake(async () => { + const err = new Error(); + err.name = 'NotSigned'; + throw err; + }); + stubMethod(sandbox, vConfig.verifier, 'isAllowListed').callsFake(async () => false); + + vConfig.prompt = async () => 'N'; + buildFromRepoStub = sandbox.stub().returns(vConfig); + VerificationConfigBuilder.build = () => vConfig; + + VerificationConfigBuilder.buildForRepo = buildFromRepoStub; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('exits by calling this.error', async () => { + let calledError = false; + await hook.call( + { + error: () => (calledError = true), + }, + { + plugin: { name: 'test', type: 'npm' }, + config: {}, + } + ); + expect(calledError).to.equal(true); + }); + + it('should prompt for repo urls', async () => { + try { + await hook.call( + {}, + { + plugin: { name: 'test', type: 'repo' }, + config: {}, + } + ); + } catch (error) { + expect(error).to.have.property('name', 'InstallationCanceledError'); + expect(buildFromRepoStub.called).to.be.true; + } + }); +}); diff --git a/test/lib/installationVerification.test.ts b/test/lib/installationVerification.test.ts new file mode 100644 index 00000000..ff841849 --- /dev/null +++ b/test/lib/installationVerification.test.ts @@ -0,0 +1,851 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { Readable, Writable } from 'stream'; +import { expect } from 'chai'; +import * as request from 'request'; + +import { SfdxError } from '@salesforce/core'; + +import { + ConfigContext, + DEFAULT_REGISTRY, + doInstallationCodeSigningVerification, + getNpmRegistry, + InstallationVerification, + IRequest, + NpmMeta, + VerificationConfig, + Verifier, +} from '../../src/lib/installationVerification'; +import { NpmName } from '../../src/lib/NpmName'; +import { CERTIFICATE, TEST_DATA, TEST_DATA_SIGNATURE } from '../testCert'; + +const BLANK_PLUGIN = { plugin: '', tag: '' }; + +describe('getNpmRegistry', () => { + const currentRegistry = process.env.SFDX_NPM_REGISTRY; + after(() => { + if (currentRegistry) { + process.env.SFDX_NPM_REGISTRY = currentRegistry; + } + }); + it('set registry', () => { + const TEST_REG = 'https://registry.example.com/'; + process.env.SFDX_NPM_REGISTRY = TEST_REG; + const reg = getNpmRegistry(); + expect(reg.href).to.be.equal(TEST_REG); + }); + it('default registry', () => { + delete process.env.SFDX_NPM_REGISTRY; + const reg = getNpmRegistry(); + expect(reg.href).to.be.equal(DEFAULT_REGISTRY); + }); +}); + +describe('InstallationVerification Tests', () => { + const config: ConfigContext = { + get dataDir() { + return 'dataPath'; + }, + get cacheDir() { + return 'cacheDir'; + }, + get configDir() { + return 'configDir'; + }, + }; + const currentRegistry = process.env.SFDX_NPM_REGISTRY; + let plugin: NpmName; + + beforeEach(() => { + plugin = NpmName.parse('foo'); + }); + + after(() => { + if (currentRegistry) { + process.env.SFDX_NPM_REGISTRY = currentRegistry; + } + }); + + it('falsy engine config', () => { + expect(() => new InstallationVerification().setConfig(null)) + .to.throw(Error) + .and.have.property('name', 'InvalidParam'); + }); + + it('Steel thread test', async () => { + const iRequest: IRequest = (url: string, cb?: request.RequestCallback): Readable => { + if (url.includes('foo.tgz')) { + const reader = new Readable({ + read() {}, + }); + process.nextTick(() => { + reader.emit('end'); + }); + return reader; + } else if (url.includes('sig')) { + cb(null, { statusCode: 200 } as request.Response, TEST_DATA_SIGNATURE); + } else if (url.includes('key')) { + cb(null, { statusCode: 200 } as request.Response, CERTIFICATE); + } else if (url.endsWith(plugin.name)) { + cb( + null, + { statusCode: 200 } as request.Response, + JSON.stringify({ + versions: { + '1.2.3': { + sfdx: { + publicKeyUrl: 'https://developer.salesforce.com/key', + signatureUrl: 'https://developer.salesforce.com/sig', + }, + dist: { + tarball: 'https://registry.example.com/foo.tgz', + }, + }, + }, + 'dist-tags': { + latest: '1.2.3', + }, + }) + ); + } else { + throw new Error(`Unexpected test url - ${url}`); + } + }; + + const fs = { + readFile() {}, + createWriteStream() { + return new Writable({ + write() {}, + }); + }, + createReadStream() { + return new Readable({ + read() { + this.push(TEST_DATA); + this.push(null); + }, + }); + }, + unlink() { + throw new Error('this should still resolve.'); + }, + }; + + const verification = new InstallationVerification(iRequest, fs).setPluginNpmName(plugin).setConfig(config); + + return verification.verify().then((meta: NpmMeta) => { + expect(meta).to.have.property('verified', true); + }); + }); + + it('Steel thread version - version number', async () => { + const iRequest: IRequest = (url: string, cb?: request.RequestCallback): Readable => { + if (url.includes('foo.tgz')) { + const reader = new Readable({ + read() {}, + }); + process.nextTick(() => { + reader.emit('end'); + }); + return reader; + } else if (url.includes('sig')) { + cb(null, { statusCode: 200 } as request.Response, TEST_DATA_SIGNATURE); + } else if (url.includes('key')) { + cb(null, { statusCode: 200 } as request.Response, CERTIFICATE); + } else if (url.endsWith(plugin.name)) { + cb( + null, + { statusCode: 200 } as request.Response, + JSON.stringify({ + versions: { + '1.2.3': { + sfdx: { + publicKeyUrl: 'https://developer.salesforce.com/key', + signatureUrl: 'https://developer.salesforce.com/sig', + }, + dist: { + tarball: 'https://registry.example.com/foo.tgz', + }, + }, + '1.2.4': { + sfdx: { + publicKeyUrl: 'https://developer.salesforce.com/key1', + signatureUrl: 'https://developer.salesforce.com/sig1', + }, + dist: { + tarball: 'https://registry.example.com/foo1.tgz', + }, + }, + }, + 'dist-tags': { + latest: '1.2.4', + }, + }) + ); + } else { + throw new Error(`Unexpected test url - ${url}`); + } + }; + + const fs = { + readFile() {}, + createWriteStream() { + return new Writable({ + write() {}, + }); + }, + createReadStream() { + return new Readable({ + read() { + this.push(TEST_DATA); + this.push(null); + }, + }); + }, + unlink() { + throw new Error('this should still resolve.'); + }, + }; + + plugin.tag = '1.2.3'; + const verification = new InstallationVerification(iRequest, fs).setPluginNpmName(plugin).setConfig(config); + + return verification.verify().then((meta: NpmMeta) => { + expect(meta).to.have.property('verified', true); + }); + }); + + it('Steel thread version - tag name', async () => { + const iRequest: IRequest = (url: string, cb?: request.RequestCallback): Readable => { + if (url.includes('foo.tgz')) { + const reader = new Readable({ + read() {}, + }); + process.nextTick(() => { + reader.emit('end'); + }); + return reader; + } else if (url.includes('sig.weaver')) { + cb(null, { statusCode: 200 } as request.Response, TEST_DATA_SIGNATURE); + } else if (url.includes('key.master')) { + cb(null, { statusCode: 200 } as request.Response, CERTIFICATE); + } else if (url.endsWith(plugin.name)) { + cb( + null, + { statusCode: 200 } as request.Response, + JSON.stringify({ + versions: { + '1.2.3': { + sfdx: { + publicKeyUrl: 'https://developer.salesforce.com/key.master', + signatureUrl: 'https://developer.salesforce.com/sig.weaver', + }, + dist: { + tarball: 'https://registry.example.com/foo.tgz', + }, + }, + '1.2.4': { + sfdx: { + publicKeyUrl: 'https://developer.salesforce.com/key1', + signatureUrl: 'https://developer.salesforce.com/sig1', + }, + dist: { + tarball: 'https://registry.example.com/foo1.tgz', + }, + }, + }, + 'dist-tags': { + latest: '1.2.4', + gozer: '1.2.3', + }, + }) + ); + } else { + throw new Error(`Unexpected test url - ${url}`); + } + }; + + const fs = { + readFile() {}, + createWriteStream() { + return new Writable({ + write() {}, + }); + }, + createReadStream() { + return new Readable({ + read() { + this.push(TEST_DATA); + this.push(null); + }, + }); + }, + unlink() { + throw new Error('this should still resolve.'); + }, + }; + + plugin.tag = 'gozer'; + // For the key and signature to line up gozer must map to 1.2.3 + const verification = new InstallationVerification(iRequest, fs).setPluginNpmName(plugin).setConfig(config); + + return verification.verify().then((meta: NpmMeta) => { + expect(meta).to.have.property('verified', true); + }); + }); + + it('Steel thread version - npm registry on path', async () => { + const TEST_REG = 'https://example.com/registry'; + process.env.SFDX_NPM_REGISTRY = TEST_REG; + + const iRequest: IRequest = (url: string, cb?: request.RequestCallback): Readable => { + if (url.includes('foo.tgz')) { + const reader = new Readable({ + read() {}, + }); + process.nextTick(() => { + reader.emit('end'); + }); + return reader; + } else if (url.includes('sig.weaver')) { + cb(null, { statusCode: 200 } as request.Response, TEST_DATA_SIGNATURE); + } else if (url.includes('key.master')) { + cb(null, { statusCode: 200 } as request.Response, CERTIFICATE); + } else if (url.endsWith(plugin.name)) { + expect(url).to.include(TEST_REG); + cb( + null, + { statusCode: 200 } as request.Response, + JSON.stringify({ + versions: { + '1.2.3': { + sfdx: { + publicKeyUrl: 'https://developer.salesforce.com/key.master', + signatureUrl: 'https://developer.salesforce.com/sig.weaver', + }, + dist: { + tarball: 'https://example.com/registry/foo.tgz', + }, + }, + '1.2.4': { + sfdx: { + publicKeyUrl: 'https://developer.salesforce.com/key1', + signatureUrl: 'https://developer.salesforce.com/sig1', + }, + dist: { + tarball: 'https://example.com/registry/foo1.tgz', + }, + }, + }, + 'dist-tags': { + latest: '1.2.4', + gozer: '1.2.3', + }, + }) + ); + } else { + throw new Error(`Unexpected test url - ${url}`); + } + }; + + const fs = { + readFile() {}, + createWriteStream() { + return new Writable({ + write() {}, + }); + }, + createReadStream() { + return new Readable({ + read() { + this.push(TEST_DATA); + this.push(null); + }, + }); + }, + unlink() { + throw new Error('this should still resolve.'); + }, + }; + + plugin.tag = 'gozer'; + // For the key and signature to line up gozer must map to 1.2.3 + const verification = new InstallationVerification(iRequest, fs).setPluginNpmName(plugin).setConfig(config); + + return verification.verify().then((meta: NpmMeta) => { + expect(meta).to.have.property('verified', true); + }); + }); + + it('InvalidNpmMetadata', async () => { + const iRequest: IRequest = (url: string, cb?: request.RequestCallback): Readable => { + if (url.includes('foo.tgz')) { + const reader = new Readable({ + read() {}, + }); + process.nextTick(() => { + reader.emit('end'); + }); + return reader; + } else if (url.includes('sig')) { + cb(null, { statusCode: 200 } as request.Response, TEST_DATA_SIGNATURE); + } else if (url.includes('key')) { + cb(null, { statusCode: 200 } as request.Response, CERTIFICATE); + } else if (url.endsWith(plugin.name)) { + cb(null, { statusCode: 200 } as request.Response, JSON.stringify({})); + } else { + throw new Error(`Unexpected test url - ${url}`); + } + }; + + const fs = { + readFile() {}, + unlink() {}, + }; + + const verification = new InstallationVerification(iRequest, fs).setPluginNpmName(plugin).setConfig(config); + + return verification + .verify() + .then(() => { + throw new Error("This shouldn't happen. Failure expected"); + }) + .catch((err: Error) => { + expect(err).to.have.property('name', 'InvalidNpmMetadata'); + }); + }); + + it('Not Signed', async () => { + const iRequest: IRequest = (url: string, cb?: request.RequestCallback): Readable => { + if (url.includes('foo.tgz')) { + const reader = new Readable({ + read() {}, + }); + process.nextTick(() => { + reader.emit('end'); + }); + return reader; + } else if (url.includes('sig')) { + cb(null, { statusCode: 200 } as request.Response, TEST_DATA_SIGNATURE); + } else if (url.includes('key')) { + cb(null, { statusCode: 200 } as request.Response, CERTIFICATE); + } else if (url.endsWith(plugin.name)) { + cb( + null, + { statusCode: 200 } as request.Response, + JSON.stringify({ + versions: { + '1.2.3': { + dist: { + tarball: 'https://registry.example.com/foo.tgz', + }, + }, + }, + 'dist-tags': { + latest: '1.2.3', + }, + }) + ); + } else { + throw new Error(`Unexpected test url - ${url}`); + } + }; + + const fs = { + readFile() {}, + unlink() {}, + }; + + const verification = new InstallationVerification(iRequest, fs).setPluginNpmName(plugin).setConfig(config); + + return verification + .verify() + .then(() => { + throw new Error("This shouldn't happen. Failure expected"); + }) + .catch((err: Error) => { + expect(err).to.have.property('name', 'NotSigned'); + }); + }); + + it('Npm Meta Request Error', async () => { + const iRequest: IRequest = (url: string, cb?: request.RequestCallback): Readable => { + if (url.includes('foo.tgz')) { + const reader = new Readable({ + read() {}, + }); + process.nextTick(() => { + reader.emit('end'); + }); + return reader; + } else if (url.includes('sig')) { + cb(null, { statusCode: 200 } as request.Response, TEST_DATA_SIGNATURE); + } else if (url.includes('key')) { + cb(null, { statusCode: 200 } as request.Response, CERTIFICATE); + } else if (url.endsWith(plugin.name)) { + const err = new Error(); + err.name = 'NPMMetaError'; + cb(err, { statusCode: 500 } as request.Response, {}); + } else { + throw new Error(`Unexpected test url - ${url}`); + } + }; + + const fs = { + readFile() {}, + unlink() {}, + }; + + const verification = new InstallationVerification(iRequest, fs).setPluginNpmName(plugin).setConfig(config); + + return verification + .verify() + .then(() => { + throw new Error("This shouldn't happen. Failure expected"); + }) + .catch((err: Error) => { + expect(err).to.have.property('name', 'NPMMetaError'); + }); + }); + + it('server error', async () => { + let returnCode = 404; + const iRequest: IRequest = (url: string, cb?: request.RequestCallback): Readable => { + if (url.includes('foo.tgz')) { + const reader = new Readable({ + read() {}, + }); + process.nextTick(() => { + reader.emit('end'); + }); + return reader; + } else if (url.includes('sig')) { + cb(null, { statusCode: 200 } as request.Response, TEST_DATA_SIGNATURE); + } else if (url.includes('key')) { + cb(null, { statusCode: 200 } as request.Response, CERTIFICATE); + } else if (url.endsWith(plugin.name)) { + cb(null, { statusCode: returnCode } as request.Response, {}); + } else { + throw new Error(`Unexpected test url - ${url}`); + } + }; + + const fs = { + readFile() {}, + unlink() {}, + }; + + const results = [ + { code: 404, expectedName: 'PluginNotFound' }, + { code: 403, expectedName: 'PluginAccessDenied' }, + ]; + + for (const testMeta of results) { + returnCode = testMeta.code; + const verification = new InstallationVerification(iRequest, fs).setPluginNpmName(plugin).setConfig(config); + try { + await verification.verify(); + } catch (error) { + expect(error).to.have.property('name', testMeta.expectedName); + } + } + }); + + it('Read tarball stream failed', () => { + const ERROR = 'Ok, who brought the dog? - Louis Tully'; + + const iRequest: IRequest = (url: string, cb?: request.RequestCallback): Readable => { + if (url.includes('foo.tgz')) { + const reader = new Readable({ + read() {}, + }); + process.nextTick(() => { + reader.emit('error', new Error(ERROR)); + }); + return reader; + } else if (url.endsWith(plugin.name)) { + cb( + null, + { statusCode: 200 } as request.Response, + JSON.stringify({ + versions: { + '1.2.3': { + sfdx: { + publicKeyUrl: 'https://developer.salesforce.com/key', + signatureUrl: 'https://developer.salesforce.com/sig', + }, + dist: { + tarball: 'https://registry.example.com/foo.tgz', + }, + }, + }, + 'dist-tags': { + latest: '1.2.3', + }, + }) + ); + } else { + throw new Error(`Unexpected test url - ${url}`); + } + }; + + const fs = { + readFile() {}, + unlink() {}, + createWriteStream() { + return new Writable({ + write() {}, + }); + }, + }; + + const verification = new InstallationVerification(iRequest, fs).setPluginNpmName(plugin).setConfig(config); + + return verification + .verify() + .then(() => { + throw new Error("This shouldn't happen. Failure expected"); + }) + .catch((err: Error) => { + expect(err).to.have.property('message', ERROR); + }); + }); + + it('404 for public key', () => { + const iRequest: IRequest = (url: string, cb?: request.RequestCallback): Readable => { + if (url.includes('foo.tgz')) { + const reader = new Readable({ + read() {}, + }); + process.nextTick(() => { + reader.emit('end'); + }); + return reader; + } else if (url.includes('sig')) { + cb(null, { statusCode: 200 } as request.Response, TEST_DATA_SIGNATURE); + } else if (url.includes('key')) { + cb(null, { statusCode: 404 } as request.Response, {}); + } else if (url.endsWith(plugin.name)) { + cb( + null, + { statusCode: 200 } as request.Response, + JSON.stringify({ + versions: { + '1.2.3': { + sfdx: { + publicKeyUrl: 'https://developer.salesforce.com/key', + signatureUrl: 'https://developer.salesforce.com/sig', + }, + dist: { + tarball: 'https://registry.example.com/foo.tgz', + }, + }, + }, + 'dist-tags': { + latest: '1.2.3', + }, + }) + ); + } else { + throw new Error(`Unexpected test url - ${url}`); + } + }; + + const fs = { + readFile() {}, + unlink() {}, + createWriteStream() { + return new Writable({ + write() {}, + }); + }, + createReadStream() { + return new Readable({ + read() { + this.push(TEST_DATA); + this.push(null); + }, + }); + }, + }; + + const verification = new InstallationVerification(iRequest, fs).setPluginNpmName(plugin).setConfig(config); + + return verification + .verify() + .then(() => { + throw new Error("This shouldn't happen. Failure expected"); + }) + .catch((err: Error) => { + expect(err).to.have.property('name', 'ErrorGettingContent'); + expect(err.message).to.include('404'); + }); + }); + + describe('isAllowListed', () => { + it('steel thread with scope', async () => { + const TEST_VALUE1 = '@salesforce/FOO'; + const fs = { + readFile(path, cb) { + cb(null, `["${TEST_VALUE1}"]`); + }, + unlink() {}, + }; + const verification1 = new InstallationVerification(null, fs) + .setPluginNpmName(NpmName.parse(TEST_VALUE1)) + .setConfig(config); + expect(await verification1.isAllowListed()).to.be.equal(true); + }); + + it('steel thread without scope', async () => { + const TEST_VALUE2 = 'FOO'; + const fs = { + readFile(path, cb) { + cb(null, `["${TEST_VALUE2}"]`); + }, + unlink() {}, + }; + const verification2 = new InstallationVerification(null, fs) + .setPluginNpmName(NpmName.parse(TEST_VALUE2)) + .setConfig(config); + expect(await verification2.isAllowListed()).to.be.equal(true); + }); + + it("file doesn't exist", async () => { + const fs = { + readFile(path, cb) { + const error = new SfdxError('ENOENT', 'ENOENT'); + error['code'] = 'ENOENT'; + cb(error); + }, + unlink() {}, + }; + + const verification = new InstallationVerification(null, fs) + .setPluginNpmName(NpmName.parse('BAR')) + .setConfig(config); + expect(await verification.isAllowListed()).to.be.equal(false); + }); + }); + + describe('doInstallationCodeSigningVerification', () => { + it('valid signature', async () => { + let message = ''; + const vConfig = new VerificationConfig(); + vConfig.verifier = { + async verify() { + return { + verified: true, + }; + }, + } as Verifier; + + vConfig.log = (_message) => { + message = _message; + }; + + await doInstallationCodeSigningVerification({}, BLANK_PLUGIN, vConfig); + expect(message).to.include('Successfully'); + expect(message).to.include('digital signature'); + }); + + it('FailedDigitalSignatureVerification', () => { + const vConfig = new VerificationConfig(); + vConfig.verifier = { + async verify() { + return { + verified: false, + }; + }, + } as Verifier; + + return doInstallationCodeSigningVerification({}, BLANK_PLUGIN, vConfig).catch((err) => { + expect(err).to.have.property('name', 'FailedDigitalSignatureVerification'); + }); + }); + + it('Canceled by user', () => { + const vConfig = new VerificationConfig(); + vConfig.verifier = { + async verify() { + const err = new Error(); + err.name = 'NotSigned'; + throw err; + }, + async isAllowListed() { + return false; + }, + } as Verifier; + + vConfig.prompt = async () => { + return 'N'; + }; + + return doInstallationCodeSigningVerification({}, BLANK_PLUGIN, vConfig) + .then(() => { + throw new Error('Failure: This should never happen'); + }) + .catch((err) => { + expect(err).to.have.property('name', 'InstallationCanceledError'); + }); + }); + + it('continue installation general error', () => { + const vConfig = new VerificationConfig(); + vConfig.verifier = { + async verify() { + const err = new Error(); + err.name = 'UnexpectedHost'; + throw err; + }, + async isAllowListed() { + return false; + }, + } as Verifier; + + vConfig.prompt = async () => { + return 'Y'; + }; + + return doInstallationCodeSigningVerification({}, BLANK_PLUGIN, vConfig) + .then(() => { + throw new Error('Failure: This should never happen'); + }) + .catch((err) => { + expect(err).to.have.property('name', 'UnexpectedHost'); + }); + }); + + it('continue installation name not signed', async () => { + const vConfig = new VerificationConfig(); + vConfig.verifier = { + async verify() { + const err = new Error(); + err.name = 'NotSigned'; + throw err; + }, + async isAllowListed() { + return false; + }, + } as Verifier; + + vConfig.prompt = async () => { + return 'Y'; + }; + + try { + await doInstallationCodeSigningVerification({}, BLANK_PLUGIN, vConfig); + } catch (e) { + const err = new Error("this test shouldn't fail."); + err.stack = e.stack; + throw err; + } + }); + }); +}); diff --git a/test/testCert.ts b/test/testCert.ts new file mode 100644 index 00000000..a427cf19 --- /dev/null +++ b/test/testCert.ts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +export const CERTIFICATE = `-----BEGIN CERTIFICATE----- +MIID2DCCAsACCQDpcdPLhYjD4zANBgkqhkiG9w0BAQUFADCBrTELMAkGA1UEBhMC +VVMxCzAJBgNVBAgTAkNPMRMwEQYDVQQHEwpCcm9vbWZpZWxkMRcwFQYDVQQKEw5T +YWxlc2ZvcmNlLmNvbTENMAsGA1UECxMEU0ZEWDEtMCsGA1UEAxMkdG5vb25hbi13 +c20yLmludGVybmFsLnNhbGVzZm9yY2UuY29tMSUwIwYJKoZIhvcNAQkBFhZ0bm9v +bmFuQHNhbGVzZm9yY2UuY29tMB4XDTE3MDkxODE5NTgzOVoXDTE3MTAxODE5NTgz +OVowga0xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDTzETMBEGA1UEBxMKQnJvb21m +aWVsZDEXMBUGA1UEChMOU2FsZXNmb3JjZS5jb20xDTALBgNVBAsTBFNGRFgxLTAr +BgNVBAMTJHRub29uYW4td3NtMi5pbnRlcm5hbC5zYWxlc2ZvcmNlLmNvbTElMCMG +CSqGSIb3DQEJARYWdG5vb25hbkBzYWxlc2ZvcmNlLmNvbTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBALkO67yUit8Bt34OnUrYCSqHn+oj88l02gzXDRQd +Y4JaLK9oF9mfnsHH87Af2DUNBxNZ0bxPZsaQl7BTk2LfPGjJV8JXODCeXWf3ErD7 +Bpv+UdKL7U8Rmg+nFtuNG/RUWLgq/E562dPccZnT4TculSCEaOG4HBN3cwjGQy72 +nEyVqc/+dftdgUArXwyrHJ1mov/lqZwsUo09iIznw106n+9VxukvKunR967+MSbR +kym9xr6ovLMnb32xoQgIi4z25BkhI/Tuci0x6/rQ5byt3Qodv/iGSgfBvAFldiQA +BVoRYgZ6tQI95ZvpFyjAWreh06fU8d7Qbd78DdG3mJM3FtMCAwEAATANBgkqhkiG +9w0BAQUFAAOCAQEAtzBMcSWjRYWgKBSKUYfCOZ5XUhZWc7+zszL0z/BPRxSuBLOT +GQpru65i8u5F/H+68srbhU/M/DIfsCppfvGW8zpdoVXYU6ZOPac+w0O1f4BO+PuK +vUem3ubhjAULmvlbfb/tnihYeYPyjvAq8DnFn0fCZWJFzpy1+ipFGZacYioRkJOU +qnv3mxtYzK2QDTFeEW+GNz1ZcBfTlRjNkyL9l1KJ0yKgof1eYfMgMgsvW714qtRY +ivqaMXwkVzIfuWnaFL8j/taxMI/+4DeZcHmP39v3lLyG5077B6K/64m2HuPjXemp +IQ1MMyGoCQ0c+k07kuOedAnQnzLak8wzC3/buw== +-----END CERTIFICATE-----`; +export const TEST_DATA = 'foo bar baz'; + +export const TEST_DATA_SIGNATURE = + 'Kgz0ad2MCtSU/BoRmo32sg5pw23pdMkH2Atxus3a9/eN8pg3CoXk+QaEqnvlEddFb/h06Pgbv3DjLtPilMAePvbjBSX/FiX/lfYMd2BlI1ZQRIo+4+t9IxvBCcu4Uo9zSWFkbRPcF79ckexPCCYBzChl+dxDYGs/TFihwj0Fttx4E0OPFUXElfDfMrUt8s9DtHxK5BDjv1HGMkVXPdFaZldNK+beusJH9yKGn8Bp/gNXlWuSo2P/huyvgLj9IiQ3H3C8bGZPOD8yJwYVTzGLr6mQw+Yt10GCqAeEZyezTFqnIG+IJ/axo4aogfEoh7lU20tiy8VwI7z/CHhpkRuuyQ=='; diff --git a/yarn.lock b/yarn.lock index 2d13885d..ae32d21c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -368,9 +368,9 @@ debug "^4.1.1" semver "^7.3.2" -"@oclif/config@^1", "@oclif/config@^1.12.12", "@oclif/config@^1.15.1": +"@oclif/config@^1.12.12", "@oclif/config@^1.15.1", "@oclif/config@^1.17.0": version "1.17.0" - resolved "https://registry.npmjs.org/@oclif/config/-/config-1.17.0.tgz#ba8639118633102a7e481760c50054623d09fcab" + resolved "https://registry.yarnpkg.com/@oclif/config/-/config-1.17.0.tgz#ba8639118633102a7e481760c50054623d09fcab" integrity sha512-Lmfuf6ubjQ4ifC/9bz1fSCHc6F6E653oyaRXxg+lgT4+bYf9bk+nqrUpAbrXyABkCqgIBiFr3J4zR/kiFdE1PA== dependencies: "@oclif/errors" "^1.3.3" @@ -477,33 +477,34 @@ mv "~2" safe-json-stringify "~1" -"@salesforce/command@^3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@salesforce/command/-/command-3.0.3.tgz#e750268bda094560992f1f494774d75f3644f6e3" - integrity sha512-ntHH64Badr46/S3J1wT8GTQ5LBnxHkrXreby5t/IV+06ZaUQMQgpdaijKBAvTGI7jtLh5sG0gnXLt6+mGsDC6A== +"@salesforce/command@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@salesforce/command/-/command-3.0.5.tgz#262665e0845f247005916b2a3ac62e1244b078e4" + integrity sha512-FhQjRALvcqXjVjv/tjPweqyN6bEhVrxV2cyM+gqns5aY5GQy9KMtNI6Sg/D5Mg4Htf89jHgV+iqgndNzGyDX2g== dependencies: "@oclif/command" "^1.5.17" "@oclif/errors" "^1.2.2" "@oclif/parser" "^3.8.3" "@oclif/plugin-help" "^2.2.0" "@oclif/test" "^1.2.4" - "@salesforce/core" "^2.6.0" + "@salesforce/core" "^2.15.2" "@salesforce/kit" "^1.2.2" "@salesforce/ts-types" "^1.2.0" chalk "^2.4.2" cli-ux "^4.9.3" -"@salesforce/core@^2.6.0": - version "2.10.0" - resolved "https://registry.npmjs.org/@salesforce/core/-/core-2.10.0.tgz#947a64a6c493337c55eff82545a8b0e8ae58e945" - integrity sha512-ZeXmSaldxD/gMRIyiLyLIgZbvrkMNRhiag1/bIWgYh/NZHzX/SWBx5jv9NMvJ1vpeCs/3bNYwWNZFwhh6N6K/A== +"@salesforce/core@^2.15.2": + version "2.15.2" + resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-2.15.2.tgz#7e2b0ac6c1d67f850a461e007298d1381b37c07a" + integrity sha512-PCP9HdGEl4us8X66tOfwlTOGFRju8m7ezqfBlH7nRzmKw57i2l0LhXBEZ+DPwEBtAwa9wlvd9FhMmlhUYhm/EQ== dependencies: "@salesforce/bunyan" "^2.0.0" - "@salesforce/kit" "^1.2.2" + "@salesforce/kit" "^1.3.3" "@salesforce/schemas" "^1.0.1" "@salesforce/ts-types" "^1.0.0" "@types/graceful-fs" "^4.1.3" - "@types/jsforce" "1.9.2" + "@types/jsforce" "1.9.23" + "@types/mkdirp" "1.0.0" debug "^3.1.0" graceful-fs "^4.2.4" jsen "0.6.6" @@ -565,6 +566,14 @@ "@salesforce/ts-types" "^1.4.2" tslib "^1.10.0" +"@salesforce/kit@^1.3.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@salesforce/kit/-/kit-1.3.3.tgz#eaea23b1be7aebb81f9f091c8f77c2733a8ae710" + integrity sha512-Ed5lh8xyCwaXeB1Sovr9xbQZ1tpQg5vSeNvKROlJQRk4Gj3IBm73pKPPuNn+AeXN51lWr9my0ftLREtyig3FoA== + dependencies: + "@salesforce/ts-types" "^1.4.3" + tslib "^1.10.0" + "@salesforce/prettier-config@^0.0.1": version "0.0.1" resolved "https://registry.npmjs.org/@salesforce/prettier-config/-/prettier-config-0.0.1.tgz#4ef70bca610ea981181fb030b25a27797b15cdff" @@ -591,6 +600,13 @@ dependencies: tslib "^1.10.0" +"@salesforce/ts-types@^1.4.3": + version "1.4.3" + resolved "https://registry.yarnpkg.com/@salesforce/ts-types/-/ts-types-1.4.3.tgz#941ceac6d72a2983ec03d5263b509f25bab574c3" + integrity sha512-Fdx9KEBalwxBFkP0ZW9uIcndjFys9fm8ma9vItd3EwPZLJcAHWfBa5/y9uHLAvYHkKK8F8FYE0sRrVZ1cn48hw== + dependencies: + tslib "^1.10.0" + "@sinonjs/commons@^1", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.7.2": version "1.8.1" resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.1.tgz#e7df00f98a203324f6dc7cc606cad9d4a8ab2217" @@ -681,10 +697,12 @@ dependencies: "@types/node" "*" -"@types/jsforce@1.9.2": - version "1.9.2" - resolved "https://registry.npmjs.org/@types/jsforce/-/jsforce-1.9.2.tgz#a321866dcb7c9c4c57a1d794d54ec52e3f07ff0b" - integrity sha512-ZRRPNf/e44QnFI8VEsPxzrM/+Y5vx/HGsMI8qE4JvBHDkSfoFWAdZ93uW6Oh3sHmcoShexcoTH9gufihTgYBLQ== +"@types/jsforce@1.9.23": + version "1.9.23" + resolved "https://registry.yarnpkg.com/@types/jsforce/-/jsforce-1.9.23.tgz#06c2b604e02bfc8ba1143c6bf53530e565f18bae" + integrity sha512-p1aqPWapTAG5xpTpebj4jSs5cwpNHe5PYFtEXCIjsSgfFgIW7GgQb5X/43/M8gkZNcGe8kchykrD9UgYKjz3eQ== + dependencies: + "@types/node" "*" "@types/json-schema@^7.0.3": version "7.0.6" @@ -711,6 +729,13 @@ resolved "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6" integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY= +"@types/mkdirp@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-1.0.0.tgz#16ce0eabe4a9a3afe64557ad0ee6886ec3d32927" + integrity sha512-ONFY9//bCEr3DWKON3iDv/Q8LXnhaYYaNDeFSN0AtO5o4sLf9F0pstJKKKjQhXE0kJEeHs8eR6SAsROhhc2Csw== + dependencies: + "@types/node" "*" + "@types/mocha@^7.0.2": version "7.0.2" resolved "https://registry.npmjs.org/@types/mocha/-/mocha-7.0.2.tgz#b17f16cf933597e10d6d78eae3251e692ce8b0ce"