From f207c2c176ec6d96768f4fefec546596cce57463 Mon Sep 17 00:00:00 2001 From: Kurt Date: Mon, 30 Sep 2024 12:34:04 -0400 Subject: [PATCH] ESLint Rule to discourage hashes being created with unsafe algorithms (#190973) Closes https://github.com/elastic/kibana/issues/185601 ## Summary Using non-compliant algorithms with Node Cryptos createHash function will cause failures when running Kibana in FIPS mode. We want to discourage usages of such algorithms. --------- Co-authored-by: Sid Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine --- .../src/bundle_routes/utils.ts | 2 +- .../src/bootstrap/bootstrap_renderer.ts | 2 +- .../src/get_migration_hash.ts | 2 +- packages/kbn-es/src/install/install_source.ts | 4 +- packages/kbn-eslint-config/.eslintrc.js | 1 + packages/kbn-eslint-plugin-eslint/index.js | 1 + .../rules/no_unsafe_hash.js | 166 ++++++++++++++++++ .../rules/no_unsafe_hash.test.js | 142 +++++++++++++++ .../report_failures_to_file.ts | 2 +- .../kbn-optimizer/src/common/dll_manifest.ts | 2 +- .../server/rest_api_routes/internal/fields.ts | 2 +- .../server/routes/fullstory.ts | 2 +- .../common/plugins/cases/server/routes.ts | 2 +- 13 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 packages/kbn-eslint-plugin-eslint/rules/no_unsafe_hash.js create mode 100644 packages/kbn-eslint-plugin-eslint/rules/no_unsafe_hash.test.js diff --git a/packages/core/apps/core-apps-server-internal/src/bundle_routes/utils.ts b/packages/core/apps/core-apps-server-internal/src/bundle_routes/utils.ts index 05a31f85a51cc..ee115cda6e5b8 100644 --- a/packages/core/apps/core-apps-server-internal/src/bundle_routes/utils.ts +++ b/packages/core/apps/core-apps-server-internal/src/bundle_routes/utils.ts @@ -13,7 +13,7 @@ import * as Rx from 'rxjs'; import { map, takeUntil } from 'rxjs'; export const generateFileHash = (fd: number): Promise => { - const hash = createHash('sha1'); + const hash = createHash('sha1'); // eslint-disable-line @kbn/eslint/no_unsafe_hash const read = createReadStream(null as any, { fd, start: 0, diff --git a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.ts b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.ts index 757862d1d3c6c..8aa0d2a6c0387 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.ts @@ -114,7 +114,7 @@ export const bootstrapRendererFactory: BootstrapRendererFactory = ({ publicPathMap, }); - const hash = createHash('sha1'); + const hash = createHash('sha1'); // eslint-disable-line @kbn/eslint/no_unsafe_hash hash.update(body); const etag = hash.digest('hex'); diff --git a/packages/core/test-helpers/core-test-helpers-so-type-serializer/src/get_migration_hash.ts b/packages/core/test-helpers/core-test-helpers-so-type-serializer/src/get_migration_hash.ts index 461188703b3aa..c65f6330e176b 100644 --- a/packages/core/test-helpers/core-test-helpers-so-type-serializer/src/get_migration_hash.ts +++ b/packages/core/test-helpers/core-test-helpers-so-type-serializer/src/get_migration_hash.ts @@ -16,7 +16,7 @@ type SavedObjectTypeMigrationHash = string; export const getMigrationHash = (soType: SavedObjectsType): SavedObjectTypeMigrationHash => { const migInfo = extractMigrationInfo(soType); - const hash = createHash('sha1'); + const hash = createHash('sha1'); // eslint-disable-line @kbn/eslint/no_unsafe_hash const hashParts = [ migInfo.name, diff --git a/packages/kbn-es/src/install/install_source.ts b/packages/kbn-es/src/install/install_source.ts index 7dfbe8d7bd5b3..244b349002829 100644 --- a/packages/kbn-es/src/install/install_source.ts +++ b/packages/kbn-es/src/install/install_source.ts @@ -84,7 +84,7 @@ async function sourceInfo(cwd: string, license: string, log: ToolingLog = defaul log.info('on %s at %s', chalk.bold(branch), chalk.bold(sha)); log.info('%s locally modified file(s)', chalk.bold(status.modified.length)); - const etag = crypto.createHash('md5').update(branch); + const etag = crypto.createHash('md5').update(branch); // eslint-disable-line @kbn/eslint/no_unsafe_hash etag.update(sha); // for changed files, use last modified times in hash calculation @@ -92,7 +92,7 @@ async function sourceInfo(cwd: string, license: string, log: ToolingLog = defaul etag.update(fs.statSync(path.join(cwd, file.path)).mtime.toString()); }); - const cwdHash = crypto.createHash('md5').update(cwd).digest('hex').substr(0, 8); + const cwdHash = crypto.createHash('md5').update(cwd).digest('hex').substr(0, 8); // eslint-disable-line @kbn/eslint/no_unsafe_hash const basename = `${branch}-${task}-${cwdHash}`; const filename = `${basename}.${ext}`; diff --git a/packages/kbn-eslint-config/.eslintrc.js b/packages/kbn-eslint-config/.eslintrc.js index a68dc6ecd949e..205e5b182e215 100644 --- a/packages/kbn-eslint-config/.eslintrc.js +++ b/packages/kbn-eslint-config/.eslintrc.js @@ -314,6 +314,7 @@ module.exports = { '@kbn/eslint/no_constructor_args_in_property_initializers': 'error', '@kbn/eslint/no_this_in_property_initializers': 'error', '@kbn/eslint/no_unsafe_console': 'error', + '@kbn/eslint/no_unsafe_hash': 'error', '@kbn/imports/no_unresolvable_imports': 'error', '@kbn/imports/uniform_imports': 'error', '@kbn/imports/no_unused_imports': 'error', diff --git a/packages/kbn-eslint-plugin-eslint/index.js b/packages/kbn-eslint-plugin-eslint/index.js index 1b9c04a2b7918..5ff3d70ae8a32 100644 --- a/packages/kbn-eslint-plugin-eslint/index.js +++ b/packages/kbn-eslint-plugin-eslint/index.js @@ -19,5 +19,6 @@ module.exports = { no_constructor_args_in_property_initializers: require('./rules/no_constructor_args_in_property_initializers'), no_this_in_property_initializers: require('./rules/no_this_in_property_initializers'), no_unsafe_console: require('./rules/no_unsafe_console'), + no_unsafe_hash: require('./rules/no_unsafe_hash'), }, }; diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_hash.js b/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_hash.js new file mode 100644 index 0000000000000..2088c196ddd60 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_hash.js @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +const allowedAlgorithms = ['sha256', 'sha3-256', 'sha512']; + +module.exports = { + allowedAlgorithms, + meta: { + type: 'problem', + docs: { + description: 'Allow usage of createHash only with allowed algorithms.', + category: 'FIPS', + recommended: false, + }, + messages: { + noDisallowedHash: + 'Usage of {{functionName}} with "{{algorithm}}" is not allowed. Only the following algorithms are allowed: [{{allowedAlgorithms}}]. If you need to use a different algorithm, please contact the Kibana security team.', + }, + schema: [], + }, + create(context) { + let isCreateHashImported = false; + let createHashName = 'createHash'; + let cryptoLocalName = 'crypto'; + let usedFunctionName = ''; + const sourceCode = context.getSourceCode(); + + const disallowedAlgorithmNodes = new Set(); + + function isAllowedAlgorithm(algorithm) { + return allowedAlgorithms.includes(algorithm); + } + + function isHashOrCreateHash(value) { + if (value === 'hash' || value === 'createHash') { + usedFunctionName = value; + return true; + } + return false; + } + + function getIdentifierValue(node) { + const scope = sourceCode.getScope(node); + if (!scope) { + return; + } + const variable = scope.variables.find((variable) => variable.name === node.name); + if (variable && variable.defs.length > 0) { + const def = variable.defs[0]; + if ( + def.node.init && + def.node.init.type === 'Literal' && + !isAllowedAlgorithm(def.node.init.value) + ) { + disallowedAlgorithmNodes.add(node.name); + return def.node.init.value; + } + } + } + + return { + ImportDeclaration(node) { + if (node.source.value === 'crypto' || node.source.value === 'node:crypto') { + node.specifiers.forEach((specifier) => { + if ( + specifier.type === 'ImportSpecifier' && + isHashOrCreateHash(specifier.imported.name) + ) { + isCreateHashImported = true; + createHashName = specifier.local.name; // Capture local name (renamed or not) + } else if (specifier.type === 'ImportDefaultSpecifier') { + cryptoLocalName = specifier.local.name; + } + }); + } + }, + VariableDeclarator(node) { + if (node.init && node.init.type === 'Literal' && !isAllowedAlgorithm(node.init.value)) { + disallowedAlgorithmNodes.add(node.id.name); + } + }, + AssignmentExpression(node) { + if ( + node.right.type === 'Literal' && + node.right.value === 'md5' && + node.left.type === 'Identifier' + ) { + disallowedAlgorithmNodes.add(node.left.name); + } + }, + CallExpression(node) { + const callee = node.callee; + + if ( + callee.type === 'MemberExpression' && + callee.object.name === cryptoLocalName && + isHashOrCreateHash(callee.property.name) + ) { + const arg = node.arguments[0]; + if (arg) { + if (arg.type === 'Literal' && !isAllowedAlgorithm(arg.value)) { + context.report({ + node, + messageId: 'noDisallowedHash', + data: { + algorithm: arg.value, + allowedAlgorithms: allowedAlgorithms.join(', '), + functionName: usedFunctionName, + }, + }); + } else if (arg.type === 'Identifier') { + const identifierValue = getIdentifierValue(arg); + if (disallowedAlgorithmNodes.has(arg.name) && identifierValue) { + context.report({ + node, + messageId: 'noDisallowedHash', + data: { + algorithm: identifierValue, + allowedAlgorithms: allowedAlgorithms.join(', '), + functionName: usedFunctionName, + }, + }); + } + } + } + } + + if (isCreateHashImported && callee.name === createHashName) { + const arg = node.arguments[0]; + if (arg) { + if (arg.type === 'Literal' && !isAllowedAlgorithm(arg.value)) { + context.report({ + node, + messageId: 'noDisallowedHash', + data: { + algorithm: arg.value, + allowedAlgorithms: allowedAlgorithms.join(', '), + functionName: usedFunctionName, + }, + }); + } else if (arg.type === 'Identifier') { + const identifierValue = getIdentifierValue(arg); + if (disallowedAlgorithmNodes.has(arg.name) && identifierValue) { + context.report({ + node, + messageId: 'noDisallowedHash', + data: { + algorithm: identifierValue, + allowedAlgorithms: allowedAlgorithms.join(', '), + functionName: usedFunctionName, + }, + }); + } + } + } + } + }, + }; + }, +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_hash.test.js b/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_hash.test.js new file mode 100644 index 0000000000000..d384ea40819eb --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_hash.test.js @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +const { RuleTester } = require('eslint'); +const { allowedAlgorithms, ...rule } = require('./no_unsafe_hash'); + +const dedent = require('dedent'); + +const joinedAllowedAlgorithms = `[${allowedAlgorithms.join(', ')}]`; + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + ecmaFeatures: { + jsx: true, + }, + }, +}); + +ruleTester.run('@kbn/eslint/no_unsafe_hash', rule, { + valid: [ + // valid import of crypto and call of createHash + { + code: dedent` + import crypto from 'crypto'; + crypto.createHash('sha256'); + `, + }, + // valid import and call of createHash + { + code: dedent` + import { createHash } from 'crypto'; + createHash('sha256'); + `, + }, + // valid import and call of createHash with a variable containing a compliant aglorithm + { + code: dedent` + import { createHash } from 'crypto'; + const myHash = 'sha256'; + createHash(myHash); + `, + }, + // valid import and call of hash with a variable containing a compliant aglorithm + { + code: dedent` + import { hash } from 'crypto'; + const myHash = 'sha256'; + hash(myHash); + `, + }, + ], + + invalid: [ + // invalid call of createHash when calling from crypto + { + code: dedent` + import crypto from 'crypto'; + crypto.createHash('md5'); + `, + errors: [ + { + line: 2, + message: `Usage of createHash with "md5" is not allowed. Only the following algorithms are allowed: ${joinedAllowedAlgorithms}. If you need to use a different algorithm, please contact the Kibana security team.`, + }, + ], + }, + // invalid call of createHash when importing directly + { + code: dedent` + import { createHash } from 'crypto'; + createHash('md5'); + `, + errors: [ + { + line: 2, + message: `Usage of createHash with "md5" is not allowed. Only the following algorithms are allowed: ${joinedAllowedAlgorithms}. If you need to use a different algorithm, please contact the Kibana security team.`, + }, + ], + }, + // invalid call of createHash when calling with a variable containing md5 + { + code: dedent` + import { createHash } from 'crypto'; + const myHash = 'md5'; + createHash(myHash); + `, + errors: [ + { + line: 3, + message: `Usage of createHash with "md5" is not allowed. Only the following algorithms are allowed: ${joinedAllowedAlgorithms}. If you need to use a different algorithm, please contact the Kibana security team.`, + }, + ], + }, + // invalid import and call of hash when importing directly + { + code: dedent` + import { hash } from 'crypto'; + hash('md5'); + `, + errors: [ + { + line: 2, + message: `Usage of hash with "md5" is not allowed. Only the following algorithms are allowed: ${joinedAllowedAlgorithms}. If you need to use a different algorithm, please contact the Kibana security team.`, + }, + ], + }, + { + code: dedent` + import _crypto from 'crypto'; + _crypto.hash('md5'); + `, + errors: [ + { + line: 2, + message: `Usage of hash with "md5" is not allowed. Only the following algorithms are allowed: ${joinedAllowedAlgorithms}. If you need to use a different algorithm, please contact the Kibana security team.`, + }, + ], + }, + + { + code: dedent` + import { hash as _hash } from 'crypto'; + _hash('md5'); + `, + errors: [ + { + line: 2, + message: `Usage of hash with "md5" is not allowed. Only the following algorithms are allowed: ${joinedAllowedAlgorithms}. If you need to use a different algorithm, please contact the Kibana security team.`, + }, + ], + }, + ], +}); diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts index 7876efb8502a5..b1e3997ebf030 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts @@ -127,7 +127,7 @@ export async function reportFailuresToFile( // Jest could, in theory, fail 1000s of tests and write 1000s of failures // So let's just write files for the first 20 for (const failure of failures.slice(0, 20)) { - const hash = createHash('md5').update(failure.name).digest('hex'); + const hash = createHash('md5').update(failure.name).digest('hex'); // eslint-disable-line @kbn/eslint/no_unsafe_hash const filenameBase = `${ process.env.BUILDKITE_JOB_ID ? process.env.BUILDKITE_JOB_ID + '_' : '' }${hash}`; diff --git a/packages/kbn-optimizer/src/common/dll_manifest.ts b/packages/kbn-optimizer/src/common/dll_manifest.ts index 0a5bebefdeca5..fc8c597110156 100644 --- a/packages/kbn-optimizer/src/common/dll_manifest.ts +++ b/packages/kbn-optimizer/src/common/dll_manifest.ts @@ -20,7 +20,7 @@ export interface ParsedDllManifest { } const hash = (s: string) => { - return Crypto.createHash('sha1').update(s).digest('base64').replace(/=+$/, ''); + return Crypto.createHash('sha1').update(s).digest('base64').replace(/=+$/, ''); // eslint-disable-line @kbn/eslint/no_unsafe_hash }; export function parseDllManifest(manifest: DllManifest): ParsedDllManifest { diff --git a/src/plugins/data_views/server/rest_api_routes/internal/fields.ts b/src/plugins/data_views/server/rest_api_routes/internal/fields.ts index 7b13704f3c50a..0d8f8b4dd67b5 100644 --- a/src/plugins/data_views/server/rest_api_routes/internal/fields.ts +++ b/src/plugins/data_views/server/rest_api_routes/internal/fields.ts @@ -21,7 +21,7 @@ import { parseFields, IBody, IQuery, querySchema, validate } from './fields_for' import { DEFAULT_FIELD_CACHE_FRESHNESS } from '../../constants'; export function calculateHash(srcBuffer: Buffer) { - const hash = createHash('sha1'); + const hash = createHash('sha1'); // eslint-disable-line @kbn/eslint/no_unsafe_hash hash.update(srcBuffer); return hash.digest('hex'); } diff --git a/x-pack/plugins/cloud_integrations/cloud_full_story/server/routes/fullstory.ts b/x-pack/plugins/cloud_integrations/cloud_full_story/server/routes/fullstory.ts index 03e38baee4e91..d983191c726df 100644 --- a/x-pack/plugins/cloud_integrations/cloud_full_story/server/routes/fullstory.ts +++ b/x-pack/plugins/cloud_integrations/cloud_full_story/server/routes/fullstory.ts @@ -26,7 +26,7 @@ export const renderFullStoryLibraryFactory = (dist = true) => headers: HttpResponseOptions['headers']; }> => { const srcBuffer = await fs.readFile(FULLSTORY_LIBRARY_PATH); - const hash = createHash('sha1'); + const hash = createHash('sha1'); // eslint-disable-line @kbn/eslint/no_unsafe_hash hash.update(srcBuffer); const hashDigest = hash.digest('hex'); diff --git a/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts b/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts index 10139f636c809..3269f9f059446 100644 --- a/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts +++ b/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts @@ -19,7 +19,7 @@ import { CASES_TELEMETRY_TASK_NAME } from '@kbn/cases-plugin/common/constants'; import type { FixtureStartDeps } from './plugin'; const hashParts = (parts: string[]): string => { - const hash = createHash('sha1'); + const hash = createHash('sha1'); // eslint-disable-line @kbn/eslint/no_unsafe_hash const hashFeed = parts.join('-'); return hash.update(hashFeed).digest('hex'); };