diff --git a/package.json b/package.json index bab9be94..2dfaa1f0 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "node": ">= 8" }, "dependencies": { + "common-tags": "^1.8.0", + "conf": "^5.0.0", "hibp": "^8.0.0", "ora": "^3.4.0", "prettyjson": "^1.2.1", @@ -76,6 +78,7 @@ "@babel/preset-typescript": "7.3.3", "@commitlint/cli": "8.1.0", "@commitlint/config-conventional": "8.1.0", + "@types/common-tags": "1.8.0", "@types/jest": "24.0.15", "@types/node": "12.6.8", "@types/ora": "3.2.0", diff --git a/src/commands/apiKey.ts b/src/commands/apiKey.ts new file mode 100644 index 00000000..545785c3 --- /dev/null +++ b/src/commands/apiKey.ts @@ -0,0 +1,60 @@ +import { Argv, CommandBuilder } from 'yargs'; +import { oneLine } from 'common-tags'; +import config from '../utils/config'; +import logger from '../utils/logger'; + +export const command = 'apiKey '; +export const describe = 'set the API key to be used for authenticated requests'; + +interface ApiKeyArgvOptions { + key: string; +} + +interface ApiKeyHandlerOptions { + key: string; +} + +export const builder: CommandBuilder< + ApiKeyArgvOptions, + ApiKeyHandlerOptions +> /* istanbul ignore next */ = (yargs): Argv => + yargs + .positional('key', { type: 'string' }) + .demandOption('key') + .check(argv => { + if (!argv.key.length) { + throw new Error('The key argument must not be empty.'); + } + return true; + }) + .group(['h', 'v'], 'Global Options:').epilog(oneLine` + Please obtain an API key from https://haveibeenpwned.com/API/Key and then + run "pwned apiKey " to configure pwned. + `); + +/** + * Stores the user's specified API key to be used for future requests to + * authenticated endpoints. + * + * @param {object} argv the parsed argv object + * @param {string} argv.key the user's API key + * @returns {Promise} the resulting Promise where output is rendered + */ +export const handler = async ({ key }: ApiKeyHandlerOptions): Promise => { + try { + config.set('apiKey', key); + if (config.get('apiKey') === key) { + logger.log(oneLine` + ✔ API key saved successfully. It will be used in future requests made + to haveibeenpwned.com services that require authentication. + `); + } else { + throw new Error(oneLine` + ✖ API key mismatch: the key read back from config does not match the + key supplied! + `); + } + } catch (err) { + logger.error(err.message); + } +}; diff --git a/src/commands/ba.ts b/src/commands/ba.ts index 9527360f..12d73ae9 100644 --- a/src/commands/ba.ts +++ b/src/commands/ba.ts @@ -1,6 +1,9 @@ import { Argv, CommandBuilder } from 'yargs'; import { breachedAccount } from 'hibp'; import prettyjson from 'prettyjson'; +import { oneLine } from 'common-tags'; +import config from '../utils/config'; +import translateApiError from '../utils/translateApiError'; import logger from '../utils/logger'; import spinner from '../utils/spinner'; import userAgent from '../utils/ua'; @@ -65,7 +68,10 @@ export const builder: CommandBuilder< default: false, }) .group(['d', 'i', 't', 'r'], 'Command Options:') - .group(['h', 'v'], 'Global Options:'); + .group(['h', 'v'], 'Global Options:').epilog(oneLine` + 🔑 This command requires an API key. Make sure you've run the "apiKey" + command first. + `); /** * Fetches and outputs breach data for the specified account. @@ -94,6 +100,7 @@ export const handler = async ({ try { const breachData = await breachedAccount(account.trim(), { + apiKey: config.get('apiKey'), domain, includeUnverified, truncate, @@ -108,10 +115,11 @@ export const handler = async ({ spinner.succeed('Good news — no pwnage found!'); } } catch (err) { + const errMsg = translateApiError(err.message); if (!raw) { - spinner.fail(err.message); + spinner.fail(errMsg); } else { - logger.error(err.message); + logger.error(errMsg); } } }; diff --git a/src/commands/pa.ts b/src/commands/pa.ts index 560def2d..72d59aee 100644 --- a/src/commands/pa.ts +++ b/src/commands/pa.ts @@ -1,6 +1,9 @@ import { Argv, CommandBuilder } from 'yargs'; import { pasteAccount } from 'hibp'; import prettyjson from 'prettyjson'; +import { oneLine } from 'common-tags'; +import config from '../utils/config'; +import translateApiError from '../utils/translateApiError'; import logger from '../utils/logger'; import spinner from '../utils/spinner'; import userAgent from '../utils/ua'; @@ -40,7 +43,10 @@ export const builder: CommandBuilder< default: false, }) .group(['r'], 'Command Options:') - .group(['h', 'v'], 'Global Options:'); + .group(['h', 'v'], 'Global Options:').epilog(oneLine` + 🔑 This command requires an API key. Make sure you've run the "apiKey" + command first. + `); /** * Fetches and outputs all pastes for an account (email address). @@ -59,7 +65,10 @@ export const handler = async ({ } try { - const pasteData = await pasteAccount(email, { userAgent }); + const pasteData = await pasteAccount(email, { + apiKey: config.get('apiKey'), + userAgent, + }); if (pasteData && raw) { logger.log(JSON.stringify(pasteData)); } else if (pasteData) { @@ -69,10 +78,11 @@ export const handler = async ({ spinner.succeed('Good news — no pwnage found!'); } } catch (err) { + const errMsg = translateApiError(err.message); if (!raw) { - spinner.fail(err.message); + spinner.fail(errMsg); } else { - logger.error(err.message); + logger.error(errMsg); } } }; diff --git a/src/commands/search.ts b/src/commands/search.ts index 24a4db8d..8279a08e 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,6 +1,9 @@ import { Argv, CommandBuilder } from 'yargs'; import { search } from 'hibp'; import prettyjson from 'prettyjson'; +import { oneLine } from 'common-tags'; +import config from '../utils/config'; +import translateApiError from '../utils/translateApiError'; import logger from '../utils/logger'; import spinner from '../utils/spinner'; import userAgent from '../utils/ua'; @@ -57,7 +60,10 @@ export const builder: CommandBuilder< default: false, }) .group(['d', 't', 'r'], 'Command Options:') - .group(['h', 'v'], 'Global Options:'); + .group(['h', 'v'], 'Global Options:').epilog(oneLine` + 🔑 This command requires an API key. Make sure you've run the "apiKey" + command first. + `); /** * Fetches and outputs breach and paste data for the specified account. @@ -82,7 +88,12 @@ export const handler = async ({ } try { - const searchData = await search(account, { domain, truncate, userAgent }); + const searchData = await search(account, { + apiKey: config.get('apiKey'), + domain, + truncate, + userAgent, + }); const foundData = !!(searchData.breaches || searchData.pastes); if (foundData && raw) { logger.log(JSON.stringify(searchData)); @@ -93,10 +104,11 @@ export const handler = async ({ spinner.succeed('Good news — no pwnage found!'); } } catch (err) { + const errMsg = translateApiError(err.message); if (!raw) { - spinner.fail(err.message); + spinner.fail(errMsg); } else { - logger.error(err.message); + logger.error(errMsg); } } }; diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 00000000..a5ede151 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,9 @@ +import Conf from 'conf'; + +const config = new Conf({ + schema: { + apiKey: { type: 'string' }, + }, +}); + +export default config; diff --git a/src/utils/translateApiError.test.ts b/src/utils/translateApiError.test.ts new file mode 100644 index 00000000..e0148992 --- /dev/null +++ b/src/utils/translateApiError.test.ts @@ -0,0 +1,13 @@ +import translateApiError from './translateApiError'; + +describe('util: getErrorMessage', () => { + it('returns a custom error message if input matches "hibp-api-key"', () => { + const orig = 'something something hibp-api-key and stuff'; + expect(translateApiError(orig)).not.toBe(orig); + }); + + it("returns the original message if it doesn't need interpreted", () => { + const orig = 'stuff broke'; + expect(translateApiError(orig)).toBe(orig); + }); +}); diff --git a/src/utils/translateApiError.ts b/src/utils/translateApiError.ts new file mode 100644 index 00000000..ba5c7d5c --- /dev/null +++ b/src/utils/translateApiError.ts @@ -0,0 +1,15 @@ +import { oneLine } from 'common-tags'; + +const translateApiError = (originalMessage: string): string => { + const errMsg = /hibp-api-key/.test(originalMessage) + ? oneLine` + Access denied due to invalid or missing API key. Please obtain an API + key from https://haveibeenpwned.com/API/Key and then run "pwned apiKey + " to configure pwned. + ` + : originalMessage; + + return errMsg; +}; + +export default translateApiError; diff --git a/yarn.lock b/yarn.lock index b5644051..a3ac9539 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1331,6 +1331,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/common-tags@1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@types/common-tags/-/common-tags-1.8.0.tgz#79d55e748d730b997be5b7fce4b74488d8b26a6b" + integrity sha512-htRqZr5qn8EzMelhX/Xmx142z218lLyGaeZ3YR8jlze4TATRU9huKKvuBmAJEW4LCC4pnY1N6JAm6p85fMHjhg== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -1610,6 +1615,16 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^3.2.0" +ajv@^6.10.0: + version "6.10.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52" + integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw== + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + ajv@^6.5.5: version "6.6.2" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.6.2.tgz#caceccf474bf3fc3ce3b147443711a24063cc30d" @@ -2554,6 +2569,11 @@ commitizen@4.0.2: strip-bom "4.0.0" strip-json-comments "3.0.1" +common-tags@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" + integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw== + compare-func@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/compare-func/-/compare-func-1.3.2.tgz#99dd0ba457e1f9bc722b12c08ec33eeab31fa648" @@ -2582,6 +2602,19 @@ concat-stream@^1.5.0, concat-stream@^1.5.2: readable-stream "^2.2.2" typedarray "^0.0.6" +conf@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/conf/-/conf-5.0.0.tgz#6530308a36041bf010ab96b05a0f4aff5101c65d" + integrity sha512-lRNyt+iRD4plYaOSVTxu1zPWpaH0EOxgFIR1l3mpC/DGZ7XzhoGFMKmbl54LAgXcSu6knqWgOwdINkqm58N85A== + dependencies: + ajv "^6.10.0" + dot-prop "^5.0.0" + env-paths "^2.2.0" + json-schema-typed "^7.0.0" + make-dir "^3.0.0" + pkg-up "^3.0.1" + write-file-atomic "^3.0.0" + config-chain@^1.1.12: version "1.1.12" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.12.tgz#0fde8d091200eb5e808caf25fe618c02f48e4efa" @@ -3128,6 +3161,13 @@ dot-prop@^4.1.0: dependencies: is-obj "^1.0.0" +dot-prop@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.1.0.tgz#bdd8c986a77b83e3fca524e53786df916cabbd8a" + integrity sha512-n1oC6NBF+KM9oVXtjmen4Yo7HyAVWV2UUl50dCYJdw2924K6dX9bf9TTTWaKtYlRn0FEtxG27KS80ayVLixxJA== + dependencies: + is-obj "^2.0.0" + dotenv@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-5.0.1.tgz#a5317459bd3d79ab88cff6e44057a6a3fbb1fcef" @@ -3205,6 +3245,11 @@ env-ci@^4.0.0: execa "^1.0.0" java-properties "^1.0.0" +env-paths@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43" + integrity sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA== + err-code@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/err-code/-/err-code-1.1.2.tgz#06e0116d3028f6aef4806849eb0ea6a748ae6960" @@ -4715,6 +4760,11 @@ is-obj@^1.0.0, is-obj@^1.0.1: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= +is-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" + integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== + is-observable@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-1.1.0.tgz#b3e986c8f44de950867cab5403f5a3465005975e" @@ -4816,7 +4866,7 @@ is-text-path@^1.0.0: dependencies: text-extensions "^1.0.0" -is-typedarray@~1.0.0: +is-typedarray@^1.0.0, is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= @@ -5374,6 +5424,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-typed@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-7.0.1.tgz#5e56564b5a0950423e22b285a30ade219e38084d" + integrity sha512-IqUK+Cqc8/MqHsCvv1TMccbKdBzoATOLHXZAF5UDu70/CCxo648cHUig24hc+XTK53TyeNk1UeVTlc2Haovtsw== + json-schema@0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -5887,6 +5942,13 @@ make-dir@^2.0.0: pify "^4.0.1" semver "^5.6.0" +make-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.0.0.tgz#1b5f39f6b9270ed33f9f054c5c0f84304989f801" + integrity sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw== + dependencies: + semver "^6.0.0" + "make-fetch-happen@^2.5.0 || 3 || 4", make-fetch-happen@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-4.0.1.tgz#141497cb878f243ba93136c83d8aba12c216c083" @@ -7277,6 +7339,13 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +pkg-up@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" + integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== + dependencies: + find-up "^3.0.0" + please-upgrade-node@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.1.1.tgz#ed320051dfcc5024fae696712c8288993595e8ac" @@ -8893,6 +8962,13 @@ type-fest@^0.5.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.5.2.tgz#d6ef42a0356c6cd45f49485c3b6281fc148e48a2" integrity sha512-DWkS49EQKVX//Tbupb9TFa19c7+MK1XmzkrZUR8TAktmE/DizXoaoJV6TZ/tSIPXipqNiRI6CyAe7x69Jb6RSw== +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" @@ -9271,6 +9347,16 @@ write-file-atomic@^2.0.0, write-file-atomic@^2.3.0: imurmurhash "^0.1.4" signal-exit "^3.0.2" +write-file-atomic@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.0.tgz#1b64dbbf77cb58fd09056963d63e62667ab4fb21" + integrity sha512-EIgkf60l2oWsffja2Sf2AL384dx328c0B+cIYPTQq5q2rOYuDV00/iPFBOUiDKKwKMOhkymH8AidPaRvzfxY+Q== + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + write@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"