From 96520019c941a4d37b8b3f81781e27c0b2d8926f Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:43:08 +0100 Subject: [PATCH 01/13] WIP --- package-lock.json | 18 + .../crypto-createcipheriv-migration/README.md | 34 ++ .../codemod.yaml | 22 + .../package.json | 24 + .../src/workflow.ts | 427 ++++++++++++++++++ .../tests/expected/commonjs-alias.js | 12 + .../commonjs-decipher-destructured.js | 10 + .../expected/commonjs-decipher-namespace.js | 10 + .../tests/expected/commonjs-destructured.js | 10 + .../tests/expected/commonjs-namespace.js | 12 + .../tests/expected/commonjs-options.js | 10 + .../tests/expected/esm-named-decipher.js | 10 + .../tests/expected/esm-namespace.js | 10 + .../tests/input/commonjs-alias.js | 5 + .../input/commonjs-decipher-destructured.js | 3 + .../input/commonjs-decipher-namespace.js | 3 + .../tests/input/commonjs-destructured.js | 3 + .../tests/input/commonjs-namespace.js | 5 + .../tests/input/commonjs-options.js | 3 + .../tests/input/esm-named-decipher.js | 3 + .../tests/input/esm-namespace.js | 3 + .../workflow.yaml | 25 + 22 files changed, 662 insertions(+) create mode 100644 recipes/crypto-createcipheriv-migration/README.md create mode 100644 recipes/crypto-createcipheriv-migration/codemod.yaml create mode 100644 recipes/crypto-createcipheriv-migration/package.json create mode 100644 recipes/crypto-createcipheriv-migration/src/workflow.ts create mode 100644 recipes/crypto-createcipheriv-migration/tests/expected/commonjs-alias.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/expected/commonjs-decipher-destructured.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/expected/commonjs-decipher-namespace.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/expected/commonjs-destructured.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/expected/commonjs-namespace.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/expected/commonjs-options.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/expected/esm-named-decipher.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/expected/esm-namespace.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/input/commonjs-alias.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/input/commonjs-decipher-destructured.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/input/commonjs-decipher-namespace.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/input/commonjs-destructured.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/input/commonjs-namespace.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/input/commonjs-options.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/input/esm-named-decipher.js create mode 100644 recipes/crypto-createcipheriv-migration/tests/input/esm-namespace.js create mode 100644 recipes/crypto-createcipheriv-migration/workflow.yaml diff --git a/package-lock.json b/package-lock.json index 944bf453..a4ac14c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -415,6 +415,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1469,6 +1470,10 @@ "resolved": "recipes/create-require-from-path", "link": true }, + "node_modules/@nodejs/crypto-createcipheriv-migration": { + "resolved": "recipes/crypto-createcipheriv-migration", + "link": true + }, "node_modules/@nodejs/crypto-fips-to-getFips": { "resolved": "recipes/crypto-fips-to-getFips", "link": true @@ -1551,6 +1556,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -2057,6 +2063,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001733", "electron-to-chromium": "^1.5.199", @@ -4287,6 +4294,17 @@ "@codemod.com/jssg-types": "^1.0.9" } }, + "recipes/crypto-createcipheriv-migration": { + "name": "@nodejs/crypto-createcipheriv-migration", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.9" + } + }, "recipes/crypto-fips": { "name": "@nodejs/crypto-fips", "version": "1.0.0", diff --git a/recipes/crypto-createcipheriv-migration/README.md b/recipes/crypto-createcipheriv-migration/README.md new file mode 100644 index 00000000..29ffb93f --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/README.md @@ -0,0 +1,34 @@ +# crypto-createcipheriv-migration + +> Migrates deprecated `crypto.createCipher()` / `crypto.createDecipher()` usage to the supported `crypto.createCipheriv()` / `crypto.createDecipheriv()` APIs with explicit key derivation and IV handling. + +## Why? + +Node.js removed `crypto.createCipher()` and `crypto.createDecipher()` in v22.0.0 (DEP0106). The legacy helpers derived keys with MD5 and no salt, and silently reused static IVs. This codemod replaces those calls with the modern, explicit APIs and scaffolds secure key derivation and IV management. + +## What it does + +- Detects CommonJS and ESM imports of `crypto` (including destructured bindings). +- Replaces invocations of `createCipher()` / `createDecipher()` with `createCipheriv()` / `createDecipheriv()`. +- Inserts scaffolding that derives keys with `crypto.scryptSync()` and generates random salts and IVs. +- Reminds developers to persist salt + IV for decryption and to adjust key/IV lengths per algorithm. +- Updates destructured imports to include the new helpers (`createCipheriv`, `createDecipheriv`, `randomBytes`, `scryptSync`). + +## Example + +```diff +-const cipher = crypto.createCipher(algorithm, password); ++const cipher = (() => { ++ const __dep0106Salt = crypto.randomBytes(16); ++ const __dep0106Key = crypto.scryptSync(password, __dep0106Salt, 32); ++ const __dep0106Iv = crypto.randomBytes(16); ++ // DEP0106: Persist __dep0106Salt and __dep0106Iv alongside the ciphertext so it can be decrypted later. ++ return crypto.createCipheriv(algorithm, __dep0106Key, __dep0106Iv); ++})(); +``` + +## Caveats + +- The codemod cannot guarantee algorithm-specific key/IV sizes. Review the generated `scryptSync` length and IV length defaults and adjust as needed. +- Decryption snippets include placeholders (`Buffer.alloc(16)`) that must be replaced with the salt and IV stored during encryption. +- If your project already wraps key derivation logic, you may prefer to adapt the generated scaffolding to call existing helpers. diff --git a/recipes/crypto-createcipheriv-migration/codemod.yaml b/recipes/crypto-createcipheriv-migration/codemod.yaml new file mode 100644 index 00000000..055df724 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/codemod.yaml @@ -0,0 +1,22 @@ +schema_version: "1.0" +name: "@nodejs/crypto-createcipheriv-migration" +version: 1.0.0 +description: Replace removed `crypto.createCipher()`/`createDecipher()` with `crypto.createCipheriv()`/`createDecipheriv()` and secure key derivation (DEP0106) +author: Augustin Mauroy +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - crypto + +registry: + access: public + visibility: public diff --git a/recipes/crypto-createcipheriv-migration/package.json b/recipes/crypto-createcipheriv-migration/package.json new file mode 100644 index 00000000..2dc423b3 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/crypto-createcipheriv-migration", + "version": "1.0.0", + "description": "Migrate deprecated crypto.createCipher()/createDecipher() (DEP0106) to crypto.createCipheriv()/createDecipheriv() with secure key derivation.", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/crypto-createcipheriv-migration", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Augustin Mauroy", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/crypto-createcipheriv-migration/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.9" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts new file mode 100644 index 00000000..128e8b69 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -0,0 +1,427 @@ +import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; +import { + getNodeImportCalls, + getNodeImportStatements, +} from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; + +type CallKind = 'cipher' | 'decipher'; + +type StatementChange = { + rename: Map; + additions: Set; +}; + +type BindingEntry = { + property: string; + local: string; +}; + +type CollectParams = { + rootNode: SgNode; + statement: SgNode; + binding: string; + kind: CallKind; + edits: Edit[]; + statementChanges: Map, StatementChange>; + seenCallRanges: Set; +}; + +/** + * Transform deprecated crypto.createCipher()/createDecipher() usage to the + * supported crypto.createCipheriv()/createDecipheriv() APIs. + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + const statementChanges = new Map, StatementChange>(); + const seenCallRanges = new Set(); + + for (const statement of collectCryptoStatements(root)) { + const cipherBinding = safeResolveBinding(statement, '$.createCipher'); + if (cipherBinding) { + collectCallEdits({ + rootNode, + statement, + binding: cipherBinding, + kind: 'cipher', + edits, + statementChanges, + seenCallRanges, + }); + } + + const decipherBinding = safeResolveBinding(statement, '$.createDecipher'); + if (decipherBinding) { + collectCallEdits({ + rootNode, + statement, + binding: decipherBinding, + kind: 'decipher', + edits, + statementChanges, + seenCallRanges, + }); + } + } + + for (const [statement, change] of statementChanges) { + const edit = applyStatementChanges(statement, change); + if (edit) edits.push(edit); + } + + if (edits.length === 0) return null; + + return rootNode.commitEdits(edits); +} + +function collectCallEdits({ + rootNode, + statement, + binding, + kind, + edits, + statementChanges, + seenCallRanges, +}: CollectParams) { + const patterns = [ + `${binding}($ALGORITHM, $PASSWORD, $OPTIONS)`, + `${binding}($ALGORITHM, $PASSWORD)`, + ]; + + const calls = rootNode.findAll({ + rule: { + any: patterns.map((pattern) => ({ pattern })), + kind: 'call_expression', + }, + }); + + for (const call of calls) { + const rangeKey = getRangeKey(call); + if (seenCallRanges.has(rangeKey)) continue; + seenCallRanges.add(rangeKey); + + const algorithmNode = call.getMatch('ALGORITHM'); + const passwordNode = call.getMatch('PASSWORD'); + + if (!algorithmNode || !passwordNode) continue; + + const algorithm = algorithmNode.text().trim(); + const password = passwordNode.text().trim(); + if (!algorithm || !password) continue; + + const optionsText = call.getMatch('OPTIONS')?.text()?.trim(); + + const replacement = + kind === 'cipher' + ? buildCipherReplacement({ + binding, + algorithm, + password, + options: optionsText, + }) + : buildDecipherReplacement({ + binding, + algorithm, + password, + options: optionsText, + }); + + edits.push(call.replace(replacement)); + + if (isDestructuredStatement(statement)) { + const change = ensureStatementChange(statementChanges, statement); + // Ensure the binding points to the iv-based API + const sourceName = kind === 'cipher' ? 'createCipher' : 'createDecipher'; + const targetName = `${sourceName}iv`; + change.rename.set(sourceName, targetName); + if (kind === 'cipher') { + change.additions.add('randomBytes'); + } + change.additions.add('scryptSync'); + } + } +} + +function buildCipherReplacement(params: { + binding: string; + algorithm: string; + password: string; + options?: string; +}): string { + const { binding, algorithm, password, options } = params; + const randomBytesCall = getMemberAccess(binding, 'randomBytes'); + const scryptCall = getMemberAccess(binding, 'scryptSync'); + const cipherCall = getCallableBinding(binding, 'createCipheriv'); + + const lines = [ + '(() => {', + '\tconst __dep0106Salt = ' + randomBytesCall + '(16);', + '\tconst __dep0106Key = ' + + scryptCall + + '(' + + password + + ', __dep0106Salt, 32);', + '\tconst __dep0106Iv = ' + randomBytesCall + '(16);', + '\t// DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later.', + '\t// DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm.', + '\treturn ' + + cipherCall + + '(' + + algorithm + + ', __dep0106Key, __dep0106Iv' + + (options ? ', ' + options : '') + + ');', + '})()', + ]; + + return lines.join('\n'); +} + +function buildDecipherReplacement(params: { + binding: string; + algorithm: string; + password: string; + options?: string; +}): string { + const { binding, algorithm, password, options } = params; + const scryptCall = getMemberAccess(binding, 'scryptSync'); + const decipherCall = getCallableBinding(binding, 'createDecipheriv'); + + const lines = [ + '(() => {', + '\t// DEP0106: Replace the placeholders below with the salt and IV that were stored with the ciphertext.', + '\tconst __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16);', + '\tconst __dep0106Iv = /* TODO: stored IV Buffer */ Buffer.alloc(16);', + '\tconst __dep0106Key = ' + + scryptCall + + '(' + + password + + ', __dep0106Salt, 32);', + '\t// DEP0106: Ensure __dep0106Salt and __dep0106Iv match the values used during encryption.', + '\treturn ' + + decipherCall + + '(' + + algorithm + + ', __dep0106Key, __dep0106Iv' + + (options ? ', ' + options : '') + + ');', + '})()', + ]; + + return lines.join('\n'); +} + +function getCallableBinding(binding: string, target: string): string { + const lastDot = binding.lastIndexOf('.'); + if (lastDot === -1) { + return binding; + } + return binding.slice(0, lastDot) + '.' + target; +} + +function getMemberAccess(binding: string, member: string): string { + const lastDot = binding.lastIndexOf('.'); + if (lastDot === -1) { + return member; + } + return binding.slice(0, lastDot) + '.' + member; +} + +function isDestructuredStatement(statement: SgNode): boolean { + return Boolean( + statement.find({ rule: { kind: 'object_pattern' } }) || + statement.find({ rule: { kind: 'named_imports' } }), + ); +} + +function ensureStatementChange( + statementChanges: Map, StatementChange>, + statement: SgNode, +): StatementChange { + let change = statementChanges.get(statement); + if (!change) { + change = { rename: new Map(), additions: new Set() }; + statementChanges.set(statement, change); + } + return change; +} + +function applyStatementChanges( + statement: SgNode, + change: StatementChange, +): Edit | undefined { + if (change.rename.size === 0 && change.additions.size === 0) { + return undefined; + } + + if ( + statement.kind() === 'import_statement' || + statement.kind() === 'import_clause' + ) { + return updateImportSpecifiers(statement, change); + } + + if (statement.find({ rule: { kind: 'object_pattern' } })) { + return updateRequirePattern(statement, change); + } + + return undefined; +} + +function updateImportSpecifiers( + statement: SgNode, + change: StatementChange, +): Edit | undefined { + const clause = + statement.kind() === 'import_clause' + ? statement + : statement.find({ rule: { kind: 'import_clause' } }); + if (!clause) return undefined; + + const namedImports = clause.find({ rule: { kind: 'named_imports' } }); + if (!namedImports) return undefined; + + const specNodes = namedImports.findAll({ + rule: { kind: 'import_specifier' }, + }); + if (specNodes.length === 0) return undefined; + + const entries: BindingEntry[] = specNodes.map((spec) => + parseImportSpecifier(spec.text()), + ); + let modified = false; + + for (const entry of entries) { + const newProperty = change.rename.get(entry.property); + if (newProperty && newProperty !== entry.property) { + entry.property = newProperty; + modified = true; + } + } + + for (const addition of change.additions) { + const exists = entries.some( + (entry) => entry.property === addition || entry.local === addition, + ); + if (!exists) { + entries.push({ property: addition, local: addition }); + modified = true; + } + } + + if (!modified) return undefined; + + const rendered = entries + .map((entry) => + entry.property === entry.local + ? entry.property + : `${entry.property} as ${entry.local}`, + ) + .join(', '); + + return namedImports.replace(`{ ${rendered} }`); +} + +function updateRequirePattern( + statement: SgNode, + change: StatementChange, +): Edit | undefined { + const objectPattern = statement.find({ rule: { kind: 'object_pattern' } }); + if (!objectPattern) return undefined; + + const specNodes = objectPattern.findAll({ + rule: { + any: [ + { kind: 'pair_pattern' }, + { kind: 'shorthand_property_identifier_pattern' }, + ], + }, + }); + if (specNodes.length === 0) return undefined; + + const entries: BindingEntry[] = specNodes.map((spec) => + parseRequireSpecifier(spec.text()), + ); + let modified = false; + + for (const entry of entries) { + const newProperty = change.rename.get(entry.property); + if (newProperty && newProperty !== entry.property) { + entry.property = newProperty; + modified = true; + } + } + + for (const addition of change.additions) { + const exists = entries.some( + (entry) => entry.property === addition || entry.local === addition, + ); + if (!exists) { + entries.push({ property: addition, local: addition }); + modified = true; + } + } + + if (!modified) return undefined; + + const rendered = entries + .map((entry) => + entry.property === entry.local + ? entry.property + : `${entry.property}: ${entry.local}`, + ) + .join(', '); + + return objectPattern.replace(`{ ${rendered} }`); +} + +function parseImportSpecifier(text: string): BindingEntry { + const parts = text + .split(/\s+as\s+/) + .map((value) => value.trim()) + .filter(Boolean); + if (parts.length === 2) { + return { property: parts[0], local: parts[1] }; + } + const name = parts[0] ?? text.trim(); + return { property: name, local: name }; +} + +function parseRequireSpecifier(text: string): BindingEntry { + const parts = text + .split(':') + .map((value) => value.trim()) + .filter(Boolean); + if (parts.length === 2) { + return { property: parts[0], local: parts[1] }; + } + const name = parts[0] ?? text.trim(); + return { property: name, local: name }; +} + +function collectCryptoStatements(root: SgRoot): SgNode[] { + return [ + ...getNodeImportStatements(root, 'crypto'), + ...getNodeImportCalls(root, 'crypto'), + ...getNodeRequireCalls(root, 'crypto'), + ]; +} + +function safeResolveBinding( + node: SgNode, + path: string, +): string | undefined { + try { + return resolveBindingPath(node, path) ?? undefined; + } catch { + return undefined; + } +} + +function getRangeKey(node: SgNode): string { + const range = node.range(); + return `${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`; +} diff --git a/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-alias.js b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-alias.js new file mode 100644 index 00000000..f7f41d99 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-alias.js @@ -0,0 +1,12 @@ +const { createCipheriv: makeCipher, randomBytes, scryptSync } = require("node:crypto"); + +function wrap(password) { + return (() => { + const __dep0106Salt = randomBytes(16); + const __dep0106Key = scryptSync(password, __dep0106Salt, 32); + const __dep0106Iv = randomBytes(16); + // DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later. + // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. + return makeCipher("aes-192-cbc", __dep0106Key, __dep0106Iv); +})(); +} diff --git a/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-decipher-destructured.js b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-decipher-destructured.js new file mode 100644 index 00000000..68cb18d1 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-decipher-destructured.js @@ -0,0 +1,10 @@ +const { createDecipheriv: createDecipher, scryptSync } = require("node:crypto"); + +const decipher = (() => { + // DEP0106: Replace the placeholders below with the salt and IV that were stored with the ciphertext. + const __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16); + const __dep0106Iv = /* TODO: stored IV Buffer */ Buffer.alloc(16); + const __dep0106Key = scryptSync("secret", __dep0106Salt, 32); + // DEP0106: Ensure __dep0106Salt and __dep0106Iv match the values used during encryption. + return createDecipher("aes-192-cbc", __dep0106Key, __dep0106Iv); +})(); diff --git a/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-decipher-namespace.js b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-decipher-namespace.js new file mode 100644 index 00000000..299735b5 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-decipher-namespace.js @@ -0,0 +1,10 @@ +const crypto = require("crypto"); + +const decipher = (() => { + // DEP0106: Replace the placeholders below with the salt and IV that were stored with the ciphertext. + const __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16); + const __dep0106Iv = /* TODO: stored IV Buffer */ Buffer.alloc(16); + const __dep0106Key = crypto.scryptSync("pw", __dep0106Salt, 32); + // DEP0106: Ensure __dep0106Salt and __dep0106Iv match the values used during encryption. + return crypto.createDecipheriv("aes-256-cbc", __dep0106Key, __dep0106Iv); +})(); diff --git a/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-destructured.js b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-destructured.js new file mode 100644 index 00000000..a145d728 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-destructured.js @@ -0,0 +1,10 @@ +const { createCipheriv: createCipher, randomBytes, scryptSync } = require("node:crypto"); + +const cipher = (() => { + const __dep0106Salt = randomBytes(16); + const __dep0106Key = scryptSync("password123", __dep0106Salt, 32); + const __dep0106Iv = randomBytes(16); + // DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later. + // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. + return createCipher("aes-128-cbc", __dep0106Key, __dep0106Iv); +})(); diff --git a/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-namespace.js b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-namespace.js new file mode 100644 index 00000000..fe64a8c0 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-namespace.js @@ -0,0 +1,12 @@ +const crypto = require("node:crypto"); + +const algorithm = "aes-256-cbc"; +const password = "s3cret"; +const cipher = (() => { + const __dep0106Salt = crypto.randomBytes(16); + const __dep0106Key = crypto.scryptSync(password, __dep0106Salt, 32); + const __dep0106Iv = crypto.randomBytes(16); + // DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later. + // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. + return crypto.createCipheriv(algorithm, __dep0106Key, __dep0106Iv); +})(); diff --git a/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-options.js b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-options.js new file mode 100644 index 00000000..1d9594f9 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/expected/commonjs-options.js @@ -0,0 +1,10 @@ +const crypto = require("node:crypto"); + +const cipher = (() => { + const __dep0106Salt = crypto.randomBytes(16); + const __dep0106Key = crypto.scryptSync("pw", __dep0106Salt, 32); + const __dep0106Iv = crypto.randomBytes(16); + // DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later. + // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. + return crypto.createCipheriv("aes-256-cbc", __dep0106Key, __dep0106Iv, { authTagLength: 16 }); +})(); diff --git a/recipes/crypto-createcipheriv-migration/tests/expected/esm-named-decipher.js b/recipes/crypto-createcipheriv-migration/tests/expected/esm-named-decipher.js new file mode 100644 index 00000000..e8fcb85f --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/expected/esm-named-decipher.js @@ -0,0 +1,10 @@ +import { createDecipheriv as createDecipher, scryptSync } from "node:crypto"; + +const decrypted = (() => { + // DEP0106: Replace the placeholders below with the salt and IV that were stored with the ciphertext. + const __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16); + const __dep0106Iv = /* TODO: stored IV Buffer */ Buffer.alloc(16); + const __dep0106Key = scryptSync("secret", __dep0106Salt, 32); + // DEP0106: Ensure __dep0106Salt and __dep0106Iv match the values used during encryption. + return createDecipher("aes-192-cbc", __dep0106Key, __dep0106Iv); +})(); diff --git a/recipes/crypto-createcipheriv-migration/tests/expected/esm-namespace.js b/recipes/crypto-createcipheriv-migration/tests/expected/esm-namespace.js new file mode 100644 index 00000000..50221891 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/expected/esm-namespace.js @@ -0,0 +1,10 @@ +import crypto from "node:crypto"; + +const encrypted = (() => { + const __dep0106Salt = crypto.randomBytes(16); + const __dep0106Key = crypto.scryptSync("pw", __dep0106Salt, 32); + const __dep0106Iv = crypto.randomBytes(16); + // DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later. + // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. + return crypto.createCipheriv("aes-256-cbc", __dep0106Key, __dep0106Iv); +})(); diff --git a/recipes/crypto-createcipheriv-migration/tests/input/commonjs-alias.js b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-alias.js new file mode 100644 index 00000000..3e6a6fb1 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-alias.js @@ -0,0 +1,5 @@ +const { createCipher: makeCipher } = require("node:crypto"); + +function wrap(password) { + return makeCipher("aes-192-cbc", password); +} diff --git a/recipes/crypto-createcipheriv-migration/tests/input/commonjs-decipher-destructured.js b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-decipher-destructured.js new file mode 100644 index 00000000..ece9f3e3 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-decipher-destructured.js @@ -0,0 +1,3 @@ +const { createDecipher } = require("node:crypto"); + +const decipher = createDecipher("aes-192-cbc", "secret"); diff --git a/recipes/crypto-createcipheriv-migration/tests/input/commonjs-decipher-namespace.js b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-decipher-namespace.js new file mode 100644 index 00000000..abe937d7 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-decipher-namespace.js @@ -0,0 +1,3 @@ +const crypto = require("crypto"); + +const decipher = crypto.createDecipher("aes-256-cbc", "pw"); diff --git a/recipes/crypto-createcipheriv-migration/tests/input/commonjs-destructured.js b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-destructured.js new file mode 100644 index 00000000..aafc9e44 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-destructured.js @@ -0,0 +1,3 @@ +const { createCipher } = require("node:crypto"); + +const cipher = createCipher("aes-128-cbc", "password123"); diff --git a/recipes/crypto-createcipheriv-migration/tests/input/commonjs-namespace.js b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-namespace.js new file mode 100644 index 00000000..b4273c2e --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-namespace.js @@ -0,0 +1,5 @@ +const crypto = require("node:crypto"); + +const algorithm = "aes-256-cbc"; +const password = "s3cret"; +const cipher = crypto.createCipher(algorithm, password); diff --git a/recipes/crypto-createcipheriv-migration/tests/input/commonjs-options.js b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-options.js new file mode 100644 index 00000000..84838491 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/input/commonjs-options.js @@ -0,0 +1,3 @@ +const crypto = require("node:crypto"); + +const cipher = crypto.createCipher("aes-256-cbc", "pw", { authTagLength: 16 }); diff --git a/recipes/crypto-createcipheriv-migration/tests/input/esm-named-decipher.js b/recipes/crypto-createcipheriv-migration/tests/input/esm-named-decipher.js new file mode 100644 index 00000000..ea789113 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/input/esm-named-decipher.js @@ -0,0 +1,3 @@ +import { createDecipher } from "node:crypto"; + +const decrypted = createDecipher("aes-192-cbc", "secret"); diff --git a/recipes/crypto-createcipheriv-migration/tests/input/esm-namespace.js b/recipes/crypto-createcipheriv-migration/tests/input/esm-namespace.js new file mode 100644 index 00000000..d88a788d --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/tests/input/esm-namespace.js @@ -0,0 +1,3 @@ +import crypto from "node:crypto"; + +const encrypted = crypto.createCipher("aes-256-cbc", "pw"); diff --git a/recipes/crypto-createcipheriv-migration/workflow.yaml b/recipes/crypto-createcipheriv-migration/workflow.yaml new file mode 100644 index 00000000..5bbe7e92 --- /dev/null +++ b/recipes/crypto-createcipheriv-migration/workflow.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: Migrate `crypto.createCipher()`/`createDecipher()` to iv variants with secure key derivation. + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript From 95e81002240d489505899672408b7aad1312941e Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:45:36 +0100 Subject: [PATCH 02/13] clean --- .../src/workflow.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index 128e8b69..e0fade88 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -1,11 +1,12 @@ -import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; +import { EOL } from 'node:os'; import { getNodeImportCalls, getNodeImportStatements, } from '@nodejs/codemod-utils/ast-grep/import-statement'; import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; type CallKind = 'cipher' | 'decipher'; @@ -158,13 +159,13 @@ function buildCipherReplacement(params: { const lines = [ '(() => {', - '\tconst __dep0106Salt = ' + randomBytesCall + '(16);', + `\tconst __dep0106Salt = ${randomBytesCall}(16);`, '\tconst __dep0106Key = ' + scryptCall + '(' + password + ', __dep0106Salt, 32);', - '\tconst __dep0106Iv = ' + randomBytesCall + '(16);', + `\tconst __dep0106Iv = ${randomBytesCall}(16);`, '\t// DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later.', '\t// DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm.', '\treturn ' + @@ -172,12 +173,12 @@ function buildCipherReplacement(params: { '(' + algorithm + ', __dep0106Key, __dep0106Iv' + - (options ? ', ' + options : '') + + (options ? `, ${options}` : '') + ');', '})()', ]; - return lines.join('\n'); + return lines.join(EOL); } function buildDecipherReplacement(params: { @@ -206,12 +207,12 @@ function buildDecipherReplacement(params: { '(' + algorithm + ', __dep0106Key, __dep0106Iv' + - (options ? ', ' + options : '') + + (options ? `, ${options}` : '') + ');', '})()', ]; - return lines.join('\n'); + return lines.join(EOL); } function getCallableBinding(binding: string, target: string): string { @@ -219,7 +220,7 @@ function getCallableBinding(binding: string, target: string): string { if (lastDot === -1) { return binding; } - return binding.slice(0, lastDot) + '.' + target; + return `${binding.slice(0, lastDot)}.${target}`; } function getMemberAccess(binding: string, member: string): string { @@ -227,7 +228,7 @@ function getMemberAccess(binding: string, member: string): string { if (lastDot === -1) { return member; } - return binding.slice(0, lastDot) + '.' + member; + return `${binding.slice(0, lastDot)}.${member}`; } function isDestructuredStatement(statement: SgNode): boolean { From 4b84f1267b8c291ecd99f76c91e4d78e10f402f7 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 20 Dec 2025 18:19:38 +0100 Subject: [PATCH 03/13] Update package-lock.json --- package-lock.json | 51 ++++++++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1fd81912..93cc7ed7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1496,6 +1496,10 @@ "resolved": "recipes/createCredentials-to-createSecureContext", "link": true }, + "node_modules/@nodejs/crypto-createcipheriv-migration": { + "resolved": "recipes/crypto-createcipheriv-migration", + "link": true + }, "node_modules/@nodejs/crypto-fips-to-getFips": { "resolved": "recipes/crypto-fips-to-getFips", "link": true @@ -4279,7 +4283,7 @@ }, "recipes/buffer-atob-btoa": { "name": "@nodejs/buffer-atob-btoa", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4317,7 +4321,7 @@ }, "recipes/create-require-from-path": { "name": "@nodejs/create-require-from-path", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4328,7 +4332,7 @@ }, "recipes/createCredentials-to-createSecureContext": { "name": "@nodejs/createcredentials-to-createsecurecontext", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4337,9 +4341,20 @@ "@codemod.com/jssg-types": "^1.3.1" } }, + "recipes/crypto-createcipheriv-migration": { + "name": "@nodejs/crypto-createcipheriv-migration", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.9" + } + }, "recipes/crypto-fips-to-getFips": { "name": "@nodejs/crypto-fips-to-getFips", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4372,7 +4387,7 @@ }, "recipes/fs-access-mode-constants": { "name": "@nodejs/fs-access-mode-constants", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4383,7 +4398,7 @@ }, "recipes/fs-truncate-fd-deprecation": { "name": "@nodejs/fs-truncate-fd-deprecation", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4394,7 +4409,7 @@ }, "recipes/http-classes-with-new": { "name": "@nodejs/http-classes-with-new", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4413,7 +4428,7 @@ }, "recipes/node-url-to-whatwg-url": { "name": "@nodejs/node-url-to-whatwg-url", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "devDependencies": { "@codemod.com/jssg-types": "^1.3.1" @@ -4421,7 +4436,7 @@ }, "recipes/process-assert-to-node-assert": { "name": "@nodejs/process-assert-to-node-assert", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4432,7 +4447,7 @@ }, "recipes/process-main-module": { "name": "@nodejs/process-main-module", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4443,7 +4458,7 @@ }, "recipes/repl-builtin-modules": { "name": "@nodejs/repl-builtin-modules", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4454,7 +4469,7 @@ }, "recipes/repl-classes-with-new": { "name": "@nodejs/repl-classes-with-new", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4465,7 +4480,7 @@ }, "recipes/rmdir": { "name": "@nodejs/rmdir", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4476,7 +4491,7 @@ }, "recipes/slow-buffer-to-buffer-alloc-unsafe-slow": { "name": "@nodejs/slow-buffer-to-buffer-alloc-unsafe-slow", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4487,7 +4502,7 @@ }, "recipes/tmpdir-to-tmpdir": { "name": "@nodejs/tmpdir-to-tmpdir", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4498,7 +4513,7 @@ }, "recipes/types-is-native-error": { "name": "@nodejs/types-is-native-error", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@nodejs/codemod-utils": "*" }, @@ -4530,7 +4545,7 @@ }, "recipes/util-log-to-console-log": { "name": "@nodejs/util-log-to-console-log", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" @@ -4541,7 +4556,7 @@ }, "recipes/util-print-to-console-log": { "name": "@nodejs/util-print-to-console-log", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@nodejs/codemod-utils": "*" From 4fb3589c9a2fb4e5979853fd3d33ba9102a21934 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 20 Dec 2025 18:29:35 +0100 Subject: [PATCH 04/13] WIP --- package-lock.json | 10 +++++----- recipes/crypto-createcipheriv-migration/package.json | 3 ++- .../crypto-createcipheriv-migration/src/workflow.ts | 5 +++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 93cc7ed7..31490e37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2381,10 +2381,9 @@ } }, "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", - "dev": true, + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" @@ -4346,7 +4345,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@nodejs/codemod-utils": "*" + "@nodejs/codemod-utils": "*", + "dedent": "^1.7.1" }, "devDependencies": { "@codemod.com/jssg-types": "^1.0.9" diff --git a/recipes/crypto-createcipheriv-migration/package.json b/recipes/crypto-createcipheriv-migration/package.json index 2dc423b3..cb9e4c7b 100644 --- a/recipes/crypto-createcipheriv-migration/package.json +++ b/recipes/crypto-createcipheriv-migration/package.json @@ -19,6 +19,7 @@ "@codemod.com/jssg-types": "^1.0.9" }, "dependencies": { - "@nodejs/codemod-utils": "*" + "@nodejs/codemod-utils": "*", + "dedent": "^1.7.1" } } diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index e0fade88..f24e926e 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -1,4 +1,5 @@ import { EOL } from 'node:os'; +import dedent from 'dedent'; import { getNodeImportCalls, getNodeImportStatements, @@ -194,8 +195,8 @@ function buildDecipherReplacement(params: { const lines = [ '(() => {', '\t// DEP0106: Replace the placeholders below with the salt and IV that were stored with the ciphertext.', - '\tconst __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16);', - '\tconst __dep0106Iv = /* TODO: stored IV Buffer */ Buffer.alloc(16);', + "\tconst __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16);", + "\tconst __dep0106Iv = /* TODO: stored IV Buffer */ Buffer.alloc(16);", '\tconst __dep0106Key = ' + scryptCall + '(' + From 1555cd63d242bc25dd8ac2bd973296fdebfb1712 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 20 Dec 2025 18:31:46 +0100 Subject: [PATCH 05/13] use dedent --- .../src/workflow.ts | 64 ++++++------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index f24e926e..9a469933 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -158,28 +158,16 @@ function buildCipherReplacement(params: { const scryptCall = getMemberAccess(binding, 'scryptSync'); const cipherCall = getCallableBinding(binding, 'createCipheriv'); - const lines = [ - '(() => {', - `\tconst __dep0106Salt = ${randomBytesCall}(16);`, - '\tconst __dep0106Key = ' + - scryptCall + - '(' + - password + - ', __dep0106Salt, 32);', - `\tconst __dep0106Iv = ${randomBytesCall}(16);`, - '\t// DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later.', - '\t// DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm.', - '\treturn ' + - cipherCall + - '(' + - algorithm + - ', __dep0106Key, __dep0106Iv' + - (options ? `, ${options}` : '') + - ');', - '})()', - ]; - - return lines.join(EOL); + return dedent(` + (() => { + const __dep0106Salt = ${randomBytesCall}(16); + const __dep0106Key = ${scryptCall}(${password}, __dep0106Salt, 32); + const __dep0106Iv = ${randomBytesCall}(16); + // DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later. + // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. + return ${cipherCall}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); + })() +`); } function buildDecipherReplacement(params: { @@ -192,28 +180,16 @@ function buildDecipherReplacement(params: { const scryptCall = getMemberAccess(binding, 'scryptSync'); const decipherCall = getCallableBinding(binding, 'createDecipheriv'); - const lines = [ - '(() => {', - '\t// DEP0106: Replace the placeholders below with the salt and IV that were stored with the ciphertext.', - "\tconst __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16);", - "\tconst __dep0106Iv = /* TODO: stored IV Buffer */ Buffer.alloc(16);", - '\tconst __dep0106Key = ' + - scryptCall + - '(' + - password + - ', __dep0106Salt, 32);', - '\t// DEP0106: Ensure __dep0106Salt and __dep0106Iv match the values used during encryption.', - '\treturn ' + - decipherCall + - '(' + - algorithm + - ', __dep0106Key, __dep0106Iv' + - (options ? `, ${options}` : '') + - ');', - '})()', - ]; - - return lines.join(EOL); + return dedent(` + (() => { + // DEP0106: Replace the placeholders below with the salt and IV that were stored with the ciphertext. + const __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16); + const __dep0106Iv = /* TODO: stored IV Buffer */ Buffer.alloc(16); + const __dep0106Key = ${scryptCall}(${password}, __dep0106Salt, 32); + // DEP0106: Ensure __dep0106Salt and __dep0106Iv match the values used during encryption. + return ${decipherCall}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); + })() +`); } function getCallableBinding(binding: string, target: string): string { From 4fb2e31ae276d980b0e537d9976f0abacd66e1a6 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 21 Dec 2025 10:35:35 +0100 Subject: [PATCH 06/13] Update workflow.ts --- .../src/workflow.ts | 384 +++++++----------- 1 file changed, 142 insertions(+), 242 deletions(-) diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index 9a469933..8245b548 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -1,4 +1,3 @@ -import { EOL } from 'node:os'; import dedent from 'dedent'; import { getNodeImportCalls, @@ -11,23 +10,12 @@ import type Js from '@codemod.com/jssg-types/langs/javascript'; type CallKind = 'cipher' | 'decipher'; -type StatementChange = { - rename: Map; - additions: Set; -}; - -type BindingEntry = { - property: string; - local: string; -}; - type CollectParams = { rootNode: SgNode; statement: SgNode; binding: string; kind: CallKind; edits: Edit[]; - statementChanges: Map, StatementChange>; seenCallRanges: Set; }; @@ -38,43 +26,29 @@ type CollectParams = { export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - const statementChanges = new Map, StatementChange>(); const seenCallRanges = new Set(); - for (const statement of collectCryptoStatements(root)) { - const cipherBinding = safeResolveBinding(statement, '$.createCipher'); - if (cipherBinding) { - collectCallEdits({ - rootNode, - statement, - binding: cipherBinding, - kind: 'cipher', - edits, - statementChanges, - seenCallRanges, - }); - } + const importStatements = [ + ...getNodeImportStatements(root, 'crypto'), + ...getNodeImportCalls(root, 'crypto'), + ...getNodeRequireCalls(root, 'crypto'), + ]; - const decipherBinding = safeResolveBinding(statement, '$.createDecipher'); - if (decipherBinding) { - collectCallEdits({ - rootNode, - statement, - binding: decipherBinding, - kind: 'decipher', - edits, - statementChanges, - seenCallRanges, - }); - } - } + if (!importStatements.length) return null; + + for (const statement of importStatements) { + const cipherBinding = resolveBindingPath(statement, '$.createCipher'); + collectCallEdits( + { rootNode, statement, binding: cipherBinding, kind: 'cipher', edits, seenCallRanges } + ); - for (const [statement, change] of statementChanges) { - const edit = applyStatementChanges(statement, change); - if (edit) edits.push(edit); + const decipherBinding = resolveBindingPath(statement, '$.createDecipher'); + collectCallEdits( + { rootNode, statement, binding: decipherBinding, kind: 'decipher', edits, seenCallRanges } + ); } - if (edits.length === 0) return null; + if (!edits.length) return null; return rootNode.commitEdits(edits); } @@ -85,9 +59,10 @@ function collectCallEdits({ binding, kind, edits, - statementChanges, seenCallRanges, }: CollectParams) { + if (!binding || binding === '') return; + const patterns = [ `${binding}($ALGORITHM, $PASSWORD, $OPTIONS)`, `${binding}($ALGORITHM, $PASSWORD)`, @@ -116,37 +91,48 @@ function collectCallEdits({ const optionsText = call.getMatch('OPTIONS')?.text()?.trim(); - const replacement = - kind === 'cipher' - ? buildCipherReplacement({ - binding, - algorithm, - password, - options: optionsText, - }) - : buildDecipherReplacement({ - binding, - algorithm, - password, - options: optionsText, - }); - - edits.push(call.replace(replacement)); - - if (isDestructuredStatement(statement)) { - const change = ensureStatementChange(statementChanges, statement); - // Ensure the binding points to the iv-based API - const sourceName = kind === 'cipher' ? 'createCipher' : 'createDecipher'; - const targetName = `${sourceName}iv`; - change.rename.set(sourceName, targetName); - if (kind === 'cipher') { - change.additions.add('randomBytes'); - } - change.additions.add('scryptSync'); + if (kind === 'cipher') { + const replacement = buildCipherReplacement({ + binding, + algorithm, + password, + options: optionsText, + }); + edits.push(call.replace(replacement)); + } else { + const replacement = buildDecipherReplacement({ + binding, + algorithm, + password, + options: optionsText, + }); + edits.push(call.replace(replacement)); + } + + // Update the corresponding import/require binding if present. + // Rename `createCipher`/`createDecipher` -> `createCipheriv`/`createDecipheriv` + // and add helper bindings (`scryptSync`, and `randomBytes` for cipher). + const sourceName = kind === 'cipher' ? 'createCipher' : 'createDecipher'; + const targetName = `${sourceName}iv`; + + const additions: string[] = kind === 'cipher' ? ['randomBytes', 'scryptSync'] : ['scryptSync']; + + // Preserve any local alias (e.g. `createCipher: makeCipher`) by + // constructing a property:local string for the renamed binding. + const local = findLocalSpecifierName(statement, sourceName); + + // Prefer an explicit update for destructured/named imports when + // present so we can preserve aliasing and ordering exactly. + const explicit = updateDestructuredStatement(statement, sourceName, targetName, local, additions); + + if (explicit) { + edits.push(explicit); } } } + + function buildCipherReplacement(params: { binding: string; algorithm: string; @@ -208,198 +194,112 @@ function getMemberAccess(binding: string, member: string): string { return `${binding.slice(0, lastDot)}.${member}`; } -function isDestructuredStatement(statement: SgNode): boolean { - return Boolean( - statement.find({ rule: { kind: 'object_pattern' } }) || - statement.find({ rule: { kind: 'named_imports' } }), - ); -} - -function ensureStatementChange( - statementChanges: Map, StatementChange>, +function updateDestructuredStatement( statement: SgNode, -): StatementChange { - let change = statementChanges.get(statement); - if (!change) { - change = { rename: new Map(), additions: new Set() }; - statementChanges.set(statement, change); - } - return change; -} - -function applyStatementChanges( - statement: SgNode, - change: StatementChange, + oldName: string, + targetName: string, + localName: string | undefined, + additions: string[], ): Edit | undefined { - if (change.rename.size === 0 && change.additions.size === 0) { - return undefined; - } - - if ( - statement.kind() === 'import_statement' || - statement.kind() === 'import_clause' - ) { - return updateImportSpecifiers(statement, change); + let namedImports = statement.find({ rule: { kind: 'named_imports' } }); + if (!namedImports) { + const clause = statement.find({ rule: { kind: 'import_clause' } }); + if (clause) namedImports = clause.find({ rule: { kind: 'named_imports' } }); } + if (namedImports) { + const isEsm = namedImports.parent()?.kind() === 'import_clause'; + + // Work on textual specifiers to preserve formatting and order. + const content = namedImports.text().replace(/^{\s*|\s*}$/g, ''); + const parts = content.split(',').map((p) => p.trim()).filter(Boolean); + const entries: string[] = parts.map((p) => { + if (new RegExp('^' + escapeRegExp(oldName) + '(\\b|\\s|:|\\s+as\\b)').test(p)) { + const local = localName ?? oldName; + return isEsm ? `${targetName} as ${local}` : `${targetName}: ${local}`; + } + return p; + }); - if (statement.find({ rule: { kind: 'object_pattern' } })) { - return updateRequirePattern(statement, change); + for (const a of additions) { + if (!entries.some((e) => new RegExp('\\b' + escapeRegExp(a) + '\\b').test(e))) entries.push(a); + } + return namedImports.replace(`{ ${entries.join(', ')} }`); } - return undefined; -} - -function updateImportSpecifiers( - statement: SgNode, - change: StatementChange, -): Edit | undefined { - const clause = - statement.kind() === 'import_clause' - ? statement - : statement.find({ rule: { kind: 'import_clause' } }); - if (!clause) return undefined; - - const namedImports = clause.find({ rule: { kind: 'named_imports' } }); - if (!namedImports) return undefined; - - const specNodes = namedImports.findAll({ - rule: { kind: 'import_specifier' }, - }); - if (specNodes.length === 0) return undefined; - - const entries: BindingEntry[] = specNodes.map((spec) => - parseImportSpecifier(spec.text()), - ); - let modified = false; - - for (const entry of entries) { - const newProperty = change.rename.get(entry.property); - if (newProperty && newProperty !== entry.property) { - entry.property = newProperty; - modified = true; + const objectPattern = statement.find({ rule: { kind: 'object_pattern' } }); + if (objectPattern) { + const pairs = objectPattern.findAll({ rule: { any: [{ kind: 'pair_pattern' }, { kind: 'shorthand_property_identifier_pattern' }] } }); + if (pairs.length === 0) return undefined; + + const entries: string[] = []; + for (const p of pairs) { + if (p.kind() === 'pair_pattern') { + const key = p.find({ rule: { kind: 'property_identifier' } }); + const value = p.children().find((c) => c.kind() === 'identifier'); + const prop = key.text(); + const local = value.text() ?? prop; + + const localToUse = localName ?? local; + entries.push(`${targetName}: ${localToUse}`); + } else { + const text = p.text(); + + if (text === oldName) { + const local = text; + const localToUse = localName ?? local; + entries.push(`${targetName}: ${localToUse}`); + } + } } - } - for (const addition of change.additions) { - const exists = entries.some( - (entry) => entry.property === addition || entry.local === addition, - ); - if (!exists) { - entries.push({ property: addition, local: addition }); - modified = true; + for (const a of additions) { + if (!entries.some((e) => e.includes(a))) entries.push(a); } + + return objectPattern.replace(`{ ${entries.join(', ')} }`); } - if (!modified) return undefined; + return undefined; +} - const rendered = entries - .map((entry) => - entry.property === entry.local - ? entry.property - : `${entry.property} as ${entry.local}`, - ) - .join(', '); +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} - return namedImports.replace(`{ ${rendered} }`); +function getRangeKey(node: SgNode): string { + const range = node.range(); + return `${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`; } -function updateRequirePattern( - statement: SgNode, - change: StatementChange, -): Edit | undefined { - const objectPattern = statement.find({ rule: { kind: 'object_pattern' } }); - if (!objectPattern) return undefined; +function findLocalSpecifierName(statement: SgNode, propertyName: string): string | undefined { + // pair_pattern: { prop: local } + const pairs = statement.findAll({ rule: { kind: 'pair_pattern' } }); + for (const pair of pairs) { + const key = pair.find({ rule: { kind: 'property_identifier' } }); - const specNodes = objectPattern.findAll({ - rule: { - any: [ - { kind: 'pair_pattern' }, - { kind: 'shorthand_property_identifier_pattern' }, - ], - }, - }); - if (specNodes.length === 0) return undefined; - - const entries: BindingEntry[] = specNodes.map((spec) => - parseRequireSpecifier(spec.text()), - ); - let modified = false; - - for (const entry of entries) { - const newProperty = change.rename.get(entry.property); - if (newProperty && newProperty !== entry.property) { - entry.property = newProperty; - modified = true; + if (key && key.text() === propertyName) { + const value = pair.children().find((c) => c.kind() === 'identifier'); + if (value) return value.text(); } } - for (const addition of change.additions) { - const exists = entries.some( - (entry) => entry.property === addition || entry.local === addition, - ); - if (!exists) { - entries.push({ property: addition, local: addition }); - modified = true; + // import_specifier: { name, alias } + const specs = statement.findAll({ rule: { kind: 'import_specifier' } }); + for (const s of specs) { + const nameNode = s.field && s.field('name'); + const aliasNode = s.field && s.field('alias'); + const idNode = s.find({ rule: { kind: 'identifier' } }); + const prop = (nameNode && nameNode.text()) || idNode?.text(); + + if (prop && prop === propertyName) { + if (aliasNode) return aliasNode.text(); + return prop; } } - if (!modified) return undefined; - - const rendered = entries - .map((entry) => - entry.property === entry.local - ? entry.property - : `${entry.property}: ${entry.local}`, - ) - .join(', '); + // shorthand destructure + const sh = statement.find({ rule: { kind: 'shorthand_property_identifier_pattern' } }); + if (sh && sh.text() === propertyName) return propertyName; - return objectPattern.replace(`{ ${rendered} }`); -} - -function parseImportSpecifier(text: string): BindingEntry { - const parts = text - .split(/\s+as\s+/) - .map((value) => value.trim()) - .filter(Boolean); - if (parts.length === 2) { - return { property: parts[0], local: parts[1] }; - } - const name = parts[0] ?? text.trim(); - return { property: name, local: name }; -} - -function parseRequireSpecifier(text: string): BindingEntry { - const parts = text - .split(':') - .map((value) => value.trim()) - .filter(Boolean); - if (parts.length === 2) { - return { property: parts[0], local: parts[1] }; - } - const name = parts[0] ?? text.trim(); - return { property: name, local: name }; -} - -function collectCryptoStatements(root: SgRoot): SgNode[] { - return [ - ...getNodeImportStatements(root, 'crypto'), - ...getNodeImportCalls(root, 'crypto'), - ...getNodeRequireCalls(root, 'crypto'), - ]; -} - -function safeResolveBinding( - node: SgNode, - path: string, -): string | undefined { - try { - return resolveBindingPath(node, path) ?? undefined; - } catch { - return undefined; - } -} - -function getRangeKey(node: SgNode): string { - const range = node.range(); - return `${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`; + return undefined; } From 8db3dd253db3042ff1b22f50b4106689760ef4f9 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 21 Dec 2025 10:36:49 +0100 Subject: [PATCH 07/13] simplify --- .../crypto-createcipheriv-migration/src/workflow.ts | 10 +++++----- utils/src/ast-grep/update-binding.test.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index 8245b548..034eefeb 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -213,7 +213,7 @@ function updateDestructuredStatement( const content = namedImports.text().replace(/^{\s*|\s*}$/g, ''); const parts = content.split(',').map((p) => p.trim()).filter(Boolean); const entries: string[] = parts.map((p) => { - if (new RegExp('^' + escapeRegExp(oldName) + '(\\b|\\s|:|\\s+as\\b)').test(p)) { + if (new RegExp(`^${escapeRegExp(oldName)}(\\b|\\s|:|\\s+as\\b)`).test(p)) { const local = localName ?? oldName; return isEsm ? `${targetName} as ${local}` : `${targetName}: ${local}`; } @@ -221,7 +221,7 @@ function updateDestructuredStatement( }); for (const a of additions) { - if (!entries.some((e) => new RegExp('\\b' + escapeRegExp(a) + '\\b').test(e))) entries.push(a); + if (!entries.some((e) => new RegExp(`\\b${escapeRegExp(a)}\\b`).test(e))) entries.push(a); } return namedImports.replace(`{ ${entries.join(', ')} }`); } @@ -286,10 +286,10 @@ function findLocalSpecifierName(statement: SgNode, propertyName: string): st // import_specifier: { name, alias } const specs = statement.findAll({ rule: { kind: 'import_specifier' } }); for (const s of specs) { - const nameNode = s.field && s.field('name'); - const aliasNode = s.field && s.field('alias'); + const nameNode = s.field?.('name'); + const aliasNode = s.field?.('alias'); const idNode = s.find({ rule: { kind: 'identifier' } }); - const prop = (nameNode && nameNode.text()) || idNode?.text(); + const prop = (nameNode?.text()) || idNode?.text(); if (prop && prop === propertyName) { if (aliasNode) return aliasNode.text(); diff --git a/utils/src/ast-grep/update-binding.test.ts b/utils/src/ast-grep/update-binding.test.ts index bb1e1b07..5fd5fef9 100644 --- a/utils/src/ast-grep/update-binding.test.ts +++ b/utils/src/ast-grep/update-binding.test.ts @@ -985,7 +985,7 @@ describe('update-binding', () => { assert.notEqual(change, undefined); assert.strictEqual(change?.lineToRemove, undefined); - const sourceCode = node.commitEdits([change!.edit!]); + const sourceCode = node.commitEdits([change.edit!]); assert.strictEqual( sourceCode, From ae6040b9535d558abe723a0bce4a71b13ad31fd9 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:04:29 +0100 Subject: [PATCH 08/13] Update workflow.ts Co-Authored-By: Jacob Smith <3012099+JakobJingleheimer@users.noreply.github.com> --- .../src/workflow.ts | 163 ++++++++++-------- 1 file changed, 91 insertions(+), 72 deletions(-) diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index 034eefeb..caad4380 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -1,9 +1,5 @@ import dedent from 'dedent'; -import { - getNodeImportCalls, - getNodeImportStatements, -} from '@nodejs/codemod-utils/ast-grep/import-statement'; -import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; import type Js from '@codemod.com/jssg-types/langs/javascript'; @@ -28,24 +24,30 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; const seenCallRanges = new Set(); - const importStatements = [ - ...getNodeImportStatements(root, 'crypto'), - ...getNodeImportCalls(root, 'crypto'), - ...getNodeRequireCalls(root, 'crypto'), - ]; + const importStatements = getModuleDependencies(root, 'crypto'); if (!importStatements.length) return null; for (const statement of importStatements) { const cipherBinding = resolveBindingPath(statement, '$.createCipher'); - collectCallEdits( - { rootNode, statement, binding: cipherBinding, kind: 'cipher', edits, seenCallRanges } - ); + collectCallEdits({ + rootNode, + statement, + binding: cipherBinding, + kind: 'cipher', + edits, + seenCallRanges, + }); const decipherBinding = resolveBindingPath(statement, '$.createDecipher'); - collectCallEdits( - { rootNode, statement, binding: decipherBinding, kind: 'decipher', edits, seenCallRanges } - ); + collectCallEdits({ + rootNode, + statement, + binding: decipherBinding, + kind: 'decipher', + edits, + seenCallRanges, + }); } if (!edits.length) return null; @@ -91,23 +93,16 @@ function collectCallEdits({ const optionsText = call.getMatch('OPTIONS')?.text()?.trim(); - if (kind === 'cipher') { - const replacement = buildCipherReplacement({ + const replacement = buildDeCipherReplacement( + { binding, algorithm, password, options: optionsText, - }); - edits.push(call.replace(replacement)); - } else { - const replacement = buildDecipherReplacement({ - binding, - algorithm, - password, - options: optionsText, - }); - edits.push(call.replace(replacement)); - } + }, + kind, + ); + edits.push(call.replace(replacement)); // Update the corresponding import/require binding if present. // Rename `createCipher`/`createDecipher` -> `createCipheriv`/`createDecipheriv` @@ -115,7 +110,8 @@ function collectCallEdits({ const sourceName = kind === 'cipher' ? 'createCipher' : 'createDecipher'; const targetName = `${sourceName}iv`; - const additions: string[] = kind === 'cipher' ? ['randomBytes', 'scryptSync'] : ['scryptSync']; + const additions: string[] = + kind === 'cipher' ? ['randomBytes', 'scryptSync'] : ['scryptSync']; // Preserve any local alias (e.g. `createCipher: makeCipher`) by // constructing a property:local string for the renamed binding. @@ -123,7 +119,13 @@ function collectCallEdits({ // Prefer an explicit update for destructured/named imports when // present so we can preserve aliasing and ordering exactly. - const explicit = updateDestructuredStatement(statement, sourceName, targetName, local, additions); + const explicit = updateDestructuredStatement( + statement, + sourceName, + targetName, + local, + additions, + ); if (explicit) { edits.push(explicit); @@ -131,40 +133,39 @@ function collectCallEdits({ } } - - -function buildCipherReplacement(params: { - binding: string; - algorithm: string; - password: string; - options?: string; -}): string { - const { binding, algorithm, password, options } = params; - const randomBytesCall = getMemberAccess(binding, 'randomBytes'); +function buildDeCipherReplacement( + { + binding, + algorithm, + password, + options, + }: { + binding: string; + algorithm: string; + password: string; + options?: string; + }, + kind: 'decipher' | 'cipher', +): string { const scryptCall = getMemberAccess(binding, 'scryptSync'); - const cipherCall = getCallableBinding(binding, 'createCipheriv'); - - return dedent(` - (() => { - const __dep0106Salt = ${randomBytesCall}(16); - const __dep0106Key = ${scryptCall}(${password}, __dep0106Salt, 32); - const __dep0106Iv = ${randomBytesCall}(16); - // DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later. - // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. - return ${cipherCall}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); - })() -`); -} - -function buildDecipherReplacement(params: { - binding: string; - algorithm: string; - password: string; - options?: string; -}): string { - const { binding, algorithm, password, options } = params; - const scryptCall = getMemberAccess(binding, 'scryptSync'); - const decipherCall = getCallableBinding(binding, 'createDecipheriv'); + const method = getCallableBinding( + binding, + kind === 'cipher' ? 'createCipheriv' : 'createDecipheriv', + ); + + if (kind === 'cipher') { + const randomBytesCall = getMemberAccess(binding, 'randomBytes'); + return dedent(` + (() => { + const __dep0106Salt = ${randomBytesCall}(16); + const __dep0106Key = ${scryptCall}(${password}, __dep0106Salt, 32); + const __dep0106Iv = ${randomBytesCall}(16); + // DEP0106: Persist __dep0106Salt and __dep0106Iv with the ciphertext so it can be decrypted later. + // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. + return ${method}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); + })() + `); + } return dedent(` (() => { @@ -173,7 +174,7 @@ function buildDecipherReplacement(params: { const __dep0106Iv = /* TODO: stored IV Buffer */ Buffer.alloc(16); const __dep0106Key = ${scryptCall}(${password}, __dep0106Salt, 32); // DEP0106: Ensure __dep0106Salt and __dep0106Iv match the values used during encryption. - return ${decipherCall}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); + return ${method}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); })() `); } @@ -211,9 +212,14 @@ function updateDestructuredStatement( // Work on textual specifiers to preserve formatting and order. const content = namedImports.text().replace(/^{\s*|\s*}$/g, ''); - const parts = content.split(',').map((p) => p.trim()).filter(Boolean); + const parts = content + .split(',') + .map((p) => p.trim()) + .filter(Boolean); const entries: string[] = parts.map((p) => { - if (new RegExp(`^${escapeRegExp(oldName)}(\\b|\\s|:|\\s+as\\b)`).test(p)) { + if ( + new RegExp(`^${escapeRegExp(oldName)}(\\b|\\s|:|\\s+as\\b)`).test(p) + ) { const local = localName ?? oldName; return isEsm ? `${targetName} as ${local}` : `${targetName}: ${local}`; } @@ -221,14 +227,22 @@ function updateDestructuredStatement( }); for (const a of additions) { - if (!entries.some((e) => new RegExp(`\\b${escapeRegExp(a)}\\b`).test(e))) entries.push(a); + if (!entries.some((e) => new RegExp(`\\b${escapeRegExp(a)}\\b`).test(e))) + entries.push(a); } return namedImports.replace(`{ ${entries.join(', ')} }`); } const objectPattern = statement.find({ rule: { kind: 'object_pattern' } }); if (objectPattern) { - const pairs = objectPattern.findAll({ rule: { any: [{ kind: 'pair_pattern' }, { kind: 'shorthand_property_identifier_pattern' }] } }); + const pairs = objectPattern.findAll({ + rule: { + any: [ + { kind: 'pair_pattern' }, + { kind: 'shorthand_property_identifier_pattern' }, + ], + }, + }); if (pairs.length === 0) return undefined; const entries: string[] = []; @@ -271,7 +285,10 @@ function getRangeKey(node: SgNode): string { return `${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`; } -function findLocalSpecifierName(statement: SgNode, propertyName: string): string | undefined { +function findLocalSpecifierName( + statement: SgNode, + propertyName: string, +): string | undefined { // pair_pattern: { prop: local } const pairs = statement.findAll({ rule: { kind: 'pair_pattern' } }); for (const pair of pairs) { @@ -289,7 +306,7 @@ function findLocalSpecifierName(statement: SgNode, propertyName: string): st const nameNode = s.field?.('name'); const aliasNode = s.field?.('alias'); const idNode = s.find({ rule: { kind: 'identifier' } }); - const prop = (nameNode?.text()) || idNode?.text(); + const prop = nameNode?.text() || idNode?.text(); if (prop && prop === propertyName) { if (aliasNode) return aliasNode.text(); @@ -298,7 +315,9 @@ function findLocalSpecifierName(statement: SgNode, propertyName: string): st } // shorthand destructure - const sh = statement.find({ rule: { kind: 'shorthand_property_identifier_pattern' } }); + const sh = statement.find({ + rule: { kind: 'shorthand_property_identifier_pattern' }, + }); if (sh && sh.text() === propertyName) return propertyName; return undefined; From dcb6b4bab39c8bc3344b1c1b6f20f058a5770695 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:28:39 +0100 Subject: [PATCH 09/13] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tobias Nießen --- recipes/crypto-createcipheriv-migration/README.md | 2 ++ recipes/crypto-createcipheriv-migration/workflow.yaml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/recipes/crypto-createcipheriv-migration/README.md b/recipes/crypto-createcipheriv-migration/README.md index 29ffb93f..413eab70 100644 --- a/recipes/crypto-createcipheriv-migration/README.md +++ b/recipes/crypto-createcipheriv-migration/README.md @@ -32,3 +32,5 @@ Node.js removed `crypto.createCipher()` and `crypto.createDecipher()` in v22.0.0 - The codemod cannot guarantee algorithm-specific key/IV sizes. Review the generated `scryptSync` length and IV length defaults and adjust as needed. - Decryption snippets include placeholders (`Buffer.alloc(16)`) that must be replaced with the salt and IV stored during encryption. - If your project already wraps key derivation logic, you may prefer to adapt the generated scaffolding to call existing helpers. +- The generated code is not backward-compatible and will be unable to decrypt data that was encrypted using `createCipher()`. +- The use of scrypt is one possible choice for deriving a key from a password, but you may wish to use Argon2id or PBKDF2 instead. diff --git a/recipes/crypto-createcipheriv-migration/workflow.yaml b/recipes/crypto-createcipheriv-migration/workflow.yaml index 5bbe7e92..e19982bc 100644 --- a/recipes/crypto-createcipheriv-migration/workflow.yaml +++ b/recipes/crypto-createcipheriv-migration/workflow.yaml @@ -7,7 +7,7 @@ nodes: name: Apply AST Transformations type: automatic steps: - - name: Migrate `crypto.createCipher()`/`createDecipher()` to iv variants with secure key derivation. + - name: Migrate `crypto.createCipher()`/`createDecipher()` to IV variants with secure key derivation. js-ast-grep: js_file: src/workflow.ts base_path: . From 93a0ee81f7a638f67601f7ce98e3ae37dbaf35b5 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:09:19 +0100 Subject: [PATCH 10/13] try fix windows --- .../src/workflow.ts | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index caad4380..e910bbee 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -13,6 +13,7 @@ type CollectParams = { kind: CallKind; edits: Edit[]; seenCallRanges: Set; + sourceLineEnding: string; }; /** @@ -23,6 +24,7 @@ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; const seenCallRanges = new Set(); + const sourceLineEnding = getLineEnding(rootNode.text()); const importStatements = getModuleDependencies(root, 'crypto'); @@ -37,6 +39,7 @@ export default function transform(root: SgRoot): string | null { kind: 'cipher', edits, seenCallRanges, + sourceLineEnding, }); const decipherBinding = resolveBindingPath(statement, '$.createDecipher'); @@ -47,6 +50,7 @@ export default function transform(root: SgRoot): string | null { kind: 'decipher', edits, seenCallRanges, + sourceLineEnding, }); } @@ -62,6 +66,7 @@ function collectCallEdits({ kind, edits, seenCallRanges, + sourceLineEnding, }: CollectParams) { if (!binding || binding === '') return; @@ -99,6 +104,7 @@ function collectCallEdits({ algorithm, password, options: optionsText, + sourceLineEnding, }, kind, ); @@ -139,11 +145,13 @@ function buildDeCipherReplacement( algorithm, password, options, + sourceLineEnding, }: { binding: string; algorithm: string; password: string; options?: string; + sourceLineEnding: string; }, kind: 'decipher' | 'cipher', ): string { @@ -155,7 +163,8 @@ function buildDeCipherReplacement( if (kind === 'cipher') { const randomBytesCall = getMemberAccess(binding, 'randomBytes'); - return dedent(` + return normalizeLineEndings( + dedent(` (() => { const __dep0106Salt = ${randomBytesCall}(16); const __dep0106Key = ${scryptCall}(${password}, __dep0106Salt, 32); @@ -164,10 +173,13 @@ function buildDeCipherReplacement( // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. return ${method}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); })() - `); + `), + sourceLineEnding, + ); } - return dedent(` + return normalizeLineEndings( + dedent(` (() => { // DEP0106: Replace the placeholders below with the salt and IV that were stored with the ciphertext. const __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16); @@ -176,7 +188,18 @@ function buildDeCipherReplacement( // DEP0106: Ensure __dep0106Salt and __dep0106Iv match the values used during encryption. return ${method}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); })() -`); +`), + sourceLineEnding, + ); +} + +function getLineEnding(source: string): string { + return source.includes('\r\n') ? '\r\n' : '\n'; +} + +function normalizeLineEndings(text: string, lineEnding: string): string { + if (lineEnding === '\n') return text; + return text.replace(/\n/g, lineEnding); } function getCallableBinding(binding: string, target: string): string { From d52614e0dbdd020cd3d509d0e7b1351dd3e51b80 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:17:03 +0100 Subject: [PATCH 11/13] Update workflow.ts --- .../src/workflow.ts | 103 +++++++----------- 1 file changed, 37 insertions(+), 66 deletions(-) diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index e910bbee..9ee890ab 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -1,3 +1,4 @@ +import { EOL } from 'node:os'; import dedent from 'dedent'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; @@ -12,8 +13,7 @@ type CollectParams = { binding: string; kind: CallKind; edits: Edit[]; - seenCallRanges: Set; - sourceLineEnding: string; + seenCallIds: Set; }; /** @@ -23,8 +23,7 @@ type CollectParams = { export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; - const seenCallRanges = new Set(); - const sourceLineEnding = getLineEnding(rootNode.text()); + const seenCallIds = new Set(); const importStatements = getModuleDependencies(root, 'crypto'); @@ -38,8 +37,7 @@ export default function transform(root: SgRoot): string | null { binding: cipherBinding, kind: 'cipher', edits, - seenCallRanges, - sourceLineEnding, + seenCallIds, }); const decipherBinding = resolveBindingPath(statement, '$.createDecipher'); @@ -49,8 +47,7 @@ export default function transform(root: SgRoot): string | null { binding: decipherBinding, kind: 'decipher', edits, - seenCallRanges, - sourceLineEnding, + seenCallIds, }); } @@ -65,8 +62,7 @@ function collectCallEdits({ binding, kind, edits, - seenCallRanges, - sourceLineEnding, + seenCallIds, }: CollectParams) { if (!binding || binding === '') return; @@ -83,9 +79,8 @@ function collectCallEdits({ }); for (const call of calls) { - const rangeKey = getRangeKey(call); - if (seenCallRanges.has(rangeKey)) continue; - seenCallRanges.add(rangeKey); + if (seenCallIds.has(call.id())) continue; + seenCallIds.add(call.id()); const algorithmNode = call.getMatch('ALGORITHM'); const passwordNode = call.getMatch('PASSWORD'); @@ -104,7 +99,6 @@ function collectCallEdits({ algorithm, password, options: optionsText, - sourceLineEnding, }, kind, ); @@ -145,13 +139,11 @@ function buildDeCipherReplacement( algorithm, password, options, - sourceLineEnding, }: { binding: string; algorithm: string; password: string; options?: string; - sourceLineEnding: string; }, kind: 'decipher' | 'cipher', ): string { @@ -163,8 +155,7 @@ function buildDeCipherReplacement( if (kind === 'cipher') { const randomBytesCall = getMemberAccess(binding, 'randomBytes'); - return normalizeLineEndings( - dedent(` + return toNativeEOL(dedent(` (() => { const __dep0106Salt = ${randomBytesCall}(16); const __dep0106Key = ${scryptCall}(${password}, __dep0106Salt, 32); @@ -173,13 +164,10 @@ function buildDeCipherReplacement( // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. return ${method}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); })() - `), - sourceLineEnding, - ); + `)); } - return normalizeLineEndings( - dedent(` + return toNativeEOL(dedent(` (() => { // DEP0106: Replace the placeholders below with the salt and IV that were stored with the ciphertext. const __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16); @@ -188,18 +176,11 @@ function buildDeCipherReplacement( // DEP0106: Ensure __dep0106Salt and __dep0106Iv match the values used during encryption. return ${method}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); })() -`), - sourceLineEnding, - ); -} - -function getLineEnding(source: string): string { - return source.includes('\r\n') ? '\r\n' : '\n'; +`)); } -function normalizeLineEndings(text: string, lineEnding: string): string { - if (lineEnding === '\n') return text; - return text.replace(/\n/g, lineEnding); +function toNativeEOL(text: string): string { + return EOL === '\n' ? text : text.replaceAll('\n', EOL); } function getCallableBinding(binding: string, target: string): string { @@ -232,26 +213,20 @@ function updateDestructuredStatement( } if (namedImports) { const isEsm = namedImports.parent()?.kind() === 'import_clause'; - - // Work on textual specifiers to preserve formatting and order. - const content = namedImports.text().replace(/^{\s*|\s*}$/g, ''); - const parts = content - .split(',') - .map((p) => p.trim()) - .filter(Boolean); - const entries: string[] = parts.map((p) => { - if ( - new RegExp(`^${escapeRegExp(oldName)}(\\b|\\s|:|\\s+as\\b)`).test(p) - ) { + const specifiers = namedImports.findAll({ rule: { kind: 'import_specifier' } }); + const existingNames = new Set( + specifiers.map((s) => s.field?.('name')?.text()).filter(Boolean), + ); + const entries: string[] = specifiers.map((s) => { + if (s.field?.('name')?.text() === oldName) { const local = localName ?? oldName; return isEsm ? `${targetName} as ${local}` : `${targetName}: ${local}`; } - return p; + return s.text(); }); for (const a of additions) { - if (!entries.some((e) => new RegExp(`\\b${escapeRegExp(a)}\\b`).test(e))) - entries.push(a); + if (!existingNames.has(a)) entries.push(a); } return namedImports.replace(`{ ${entries.join(', ')} }`); } @@ -268,29 +243,33 @@ function updateDestructuredStatement( }); if (pairs.length === 0) return undefined; + const existingNames = new Set(); const entries: string[] = []; for (const p of pairs) { if (p.kind() === 'pair_pattern') { const key = p.find({ rule: { kind: 'property_identifier' } }); - const value = p.children().find((c) => c.kind() === 'identifier'); - const prop = key.text(); - const local = value.text() ?? prop; - - const localToUse = localName ?? local; - entries.push(`${targetName}: ${localToUse}`); + const propName = key.text(); + existingNames.add(propName); + if (propName === oldName) { + const value = p.children().find((c) => c.kind() === 'identifier'); + const local = value?.text() ?? propName; + entries.push(`${targetName}: ${localName ?? local}`); + } else { + entries.push(p.text()); + } } else { const text = p.text(); - + existingNames.add(text); if (text === oldName) { - const local = text; - const localToUse = localName ?? local; - entries.push(`${targetName}: ${localToUse}`); + entries.push(`${targetName}: ${localName ?? text}`); + } else { + entries.push(text); } } } for (const a of additions) { - if (!entries.some((e) => e.includes(a))) entries.push(a); + if (!existingNames.has(a)) entries.push(a); } return objectPattern.replace(`{ ${entries.join(', ')} }`); @@ -299,14 +278,6 @@ function updateDestructuredStatement( return undefined; } -function escapeRegExp(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function getRangeKey(node: SgNode): string { - const range = node.range(); - return `${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`; -} function findLocalSpecifierName( statement: SgNode, From 7b0b13d7c5be73cd2f22a37feac453257426e00c Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:19:09 +0100 Subject: [PATCH 12/13] Update workflow.ts --- .../src/workflow.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index 9ee890ab..9bea55bc 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -155,7 +155,8 @@ function buildDeCipherReplacement( if (kind === 'cipher') { const randomBytesCall = getMemberAccess(binding, 'randomBytes'); - return toNativeEOL(dedent(` + return toNativeEOL( + dedent(` (() => { const __dep0106Salt = ${randomBytesCall}(16); const __dep0106Key = ${scryptCall}(${password}, __dep0106Salt, 32); @@ -164,10 +165,12 @@ function buildDeCipherReplacement( // DEP0106: Adjust the derived key length (32 bytes) and IV length to match the chosen algorithm. return ${method}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); })() - `)); + `), + ); } - return toNativeEOL(dedent(` + return toNativeEOL( + dedent(` (() => { // DEP0106: Replace the placeholders below with the salt and IV that were stored with the ciphertext. const __dep0106Salt = /* TODO: stored salt Buffer */ Buffer.alloc(16); @@ -176,7 +179,8 @@ function buildDeCipherReplacement( // DEP0106: Ensure __dep0106Salt and __dep0106Iv match the values used during encryption. return ${method}(${algorithm}, __dep0106Key, __dep0106Iv${options ? `, ${options}` : ''}); })() -`)); +`), + ); } function toNativeEOL(text: string): string { @@ -213,7 +217,9 @@ function updateDestructuredStatement( } if (namedImports) { const isEsm = namedImports.parent()?.kind() === 'import_clause'; - const specifiers = namedImports.findAll({ rule: { kind: 'import_specifier' } }); + const specifiers = namedImports.findAll({ + rule: { kind: 'import_specifier' }, + }); const existingNames = new Set( specifiers.map((s) => s.field?.('name')?.text()).filter(Boolean), ); @@ -278,7 +284,6 @@ function updateDestructuredStatement( return undefined; } - function findLocalSpecifierName( statement: SgNode, propertyName: string, From 733d6fb1271488aeb3b5b18025a14bde86d37248 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:22:22 +0100 Subject: [PATCH 13/13] simpify --- .../src/workflow.ts | 67 ++----------------- 1 file changed, 7 insertions(+), 60 deletions(-) diff --git a/recipes/crypto-createcipheriv-migration/src/workflow.ts b/recipes/crypto-createcipheriv-migration/src/workflow.ts index 9bea55bc..45491d24 100644 --- a/recipes/crypto-createcipheriv-migration/src/workflow.ts +++ b/recipes/crypto-createcipheriv-migration/src/workflow.ts @@ -113,17 +113,12 @@ function collectCallEdits({ const additions: string[] = kind === 'cipher' ? ['randomBytes', 'scryptSync'] : ['scryptSync']; - // Preserve any local alias (e.g. `createCipher: makeCipher`) by - // constructing a property:local string for the renamed binding. - const local = findLocalSpecifierName(statement, sourceName); - // Prefer an explicit update for destructured/named imports when // present so we can preserve aliasing and ordering exactly. const explicit = updateDestructuredStatement( statement, sourceName, targetName, - local, additions, ); @@ -207,25 +202,18 @@ function updateDestructuredStatement( statement: SgNode, oldName: string, targetName: string, - localName: string | undefined, additions: string[], ): Edit | undefined { - let namedImports = statement.find({ rule: { kind: 'named_imports' } }); - if (!namedImports) { - const clause = statement.find({ rule: { kind: 'import_clause' } }); - if (clause) namedImports = clause.find({ rule: { kind: 'named_imports' } }); - } + const namedImports = statement.find({ rule: { kind: 'named_imports' } }); if (namedImports) { const isEsm = namedImports.parent()?.kind() === 'import_clause'; - const specifiers = namedImports.findAll({ - rule: { kind: 'import_specifier' }, - }); + const specifiers = namedImports.findAll({ rule: { kind: 'import_specifier' } }); const existingNames = new Set( specifiers.map((s) => s.field?.('name')?.text()).filter(Boolean), ); - const entries: string[] = specifiers.map((s) => { + const entries = specifiers.map((s) => { if (s.field?.('name')?.text() === oldName) { - const local = localName ?? oldName; + const local = s.field?.('alias')?.text() ?? oldName; return isEsm ? `${targetName} as ${local}` : `${targetName}: ${local}`; } return s.text(); @@ -257,20 +245,15 @@ function updateDestructuredStatement( const propName = key.text(); existingNames.add(propName); if (propName === oldName) { - const value = p.children().find((c) => c.kind() === 'identifier'); - const local = value?.text() ?? propName; - entries.push(`${targetName}: ${localName ?? local}`); + const local = p.children().find((c) => c.kind() === 'identifier')?.text() ?? propName; + entries.push(`${targetName}: ${local}`); } else { entries.push(p.text()); } } else { const text = p.text(); existingNames.add(text); - if (text === oldName) { - entries.push(`${targetName}: ${localName ?? text}`); - } else { - entries.push(text); - } + entries.push(text === oldName ? `${targetName}: ${text}` : text); } } @@ -284,40 +267,4 @@ function updateDestructuredStatement( return undefined; } -function findLocalSpecifierName( - statement: SgNode, - propertyName: string, -): string | undefined { - // pair_pattern: { prop: local } - const pairs = statement.findAll({ rule: { kind: 'pair_pattern' } }); - for (const pair of pairs) { - const key = pair.find({ rule: { kind: 'property_identifier' } }); - - if (key && key.text() === propertyName) { - const value = pair.children().find((c) => c.kind() === 'identifier'); - if (value) return value.text(); - } - } - // import_specifier: { name, alias } - const specs = statement.findAll({ rule: { kind: 'import_specifier' } }); - for (const s of specs) { - const nameNode = s.field?.('name'); - const aliasNode = s.field?.('alias'); - const idNode = s.find({ rule: { kind: 'identifier' } }); - const prop = nameNode?.text() || idNode?.text(); - - if (prop && prop === propertyName) { - if (aliasNode) return aliasNode.text(); - return prop; - } - } - - // shorthand destructure - const sh = statement.find({ - rule: { kind: 'shorthand_property_identifier_pattern' }, - }); - if (sh && sh.text() === propertyName) return propertyName; - - return undefined; -}