diff --git a/recipes/tls-parse-cert-string/codemod.yaml b/recipes/tls-parse-cert-string/codemod.yaml new file mode 100644 index 00000000..0a434a83 --- /dev/null +++ b/recipes/tls-parse-cert-string/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@nodejs/tls-parse-cert-string" +version: "1.0.0" +description: Handle DEP0076 by removing tls.parseCertString() usage and suggesting safer alternatives. +author: Kevin Sailema +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - dep0076 + - tls + - parseCertString + - migration + +registry: + access: public + visibility: public diff --git a/recipes/tls-parse-cert-string/package.json b/recipes/tls-parse-cert-string/package.json new file mode 100644 index 00000000..655f3682 --- /dev/null +++ b/recipes/tls-parse-cert-string/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/tls-parse-cert-string", + "version": "1.0.0", + "description": "Handle DEP0076 by removing tls.parseCertString() usage and suggesting safer alternatives.", + "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/tls-parse-cert-string", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Kevin Sailema", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations", + "devDependencies": { + "@codemod.com/jssg-types": "^1.5.0" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} \ No newline at end of file diff --git a/recipes/tls-parse-cert-string/src/workflow.ts b/recipes/tls-parse-cert-string/src/workflow.ts new file mode 100644 index 00000000..59303849 --- /dev/null +++ b/recipes/tls-parse-cert-string/src/workflow.ts @@ -0,0 +1,127 @@ +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; +import { removeBinding } from '@nodejs/codemod-utils/ast-grep/remove-binding'; +import { removeLines } from '@nodejs/codemod-utils/ast-grep/remove-lines'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import type { Edit, Range, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type JS from '@codemod.com/jssg-types/langs/javascript'; + +const COMMENT_ALREADY_PARSED = + '/* DEP0076: cert.subject/cert.issuer are already parsed */'; +const COMMENT_MANUAL_PARSE = + '/* DEP0076: use node:crypto X509Certificate for robust parsing */'; + +function stripOuterParens(text: string): string { + let value = text.trim(); + while (value.startsWith('(') && value.endsWith(')')) { + value = value.slice(1, -1).trim(); + } + return value; +} + +function isAlreadyParsedCertField(argText: string): boolean { + const normalized = stripOuterParens(argText); + return /\.(subject|issuer)$/.test(normalized); +} + +function buildReplacement(argText: string): string { + if (isAlreadyParsedCertField(argText)) { + return `${COMMENT_ALREADY_PARSED} ${argText}`; + } + + return `${COMMENT_MANUAL_PARSE} Object.fromEntries(String(${argText}).split('/').filter(Boolean).map((pair) => pair.split('=')))`; +} + +function trimSingleLeadingBlankLine(sourceCode: string): string { + if (sourceCode.startsWith('\n')) { + return sourceCode.slice(1); + } + return sourceCode; +} + +function isInsideNode(node: SgNode, container: SgNode): boolean { + for (const ancestor of node.ancestors()) { + if (ancestor.id() === container.id()) return true; + } + return false; +} + +function isInsideAnyCall(node: SgNode, calls: SgNode[]): boolean { + for (const callNode of calls) { + if (node.id() === callNode.id()) return true; + if (isInsideNode(node, callNode)) return true; + } + return false; +} + +function hasNonCallUsage( + rootNode: SgNode, + statement: SgNode, + binding: string, +): boolean { + const occurrences = rootNode.findAll({ + rule: { + pattern: binding, + }, + }); + + const callOccurrences = rootNode.findAll({ + rule: { + pattern: `${binding}($$$ARGS)`, + }, + }); + + for (const occurrence of occurrences) { + if (isInsideNode(occurrence, statement)) continue; + if (isInsideAnyCall(occurrence, callOccurrences)) continue; + return true; + } + + return false; +} + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + const linesToRemove: Range[] = []; + + const tlsImports = getModuleDependencies(root, 'tls'); + if (!tlsImports.length) return null; + + const parseBindings = new Set(); + for (const stmt of tlsImports) { + const binding = resolveBindingPath(stmt, '$.parseCertString'); + if (!binding) continue; + parseBindings.add(binding); + } + + if (!parseBindings.size) return null; + + for (const binding of parseBindings) { + const callNodes = rootNode.findAll({ + rule: { + pattern: `${binding}($ARG)`, + }, + }); + + for (const callNode of callNodes) { + const arg = callNode.getMatch('ARG'); + if (!arg) continue; + edits.push(callNode.replace(buildReplacement(arg.text()))); + } + } + + for (const stmt of tlsImports) { + const binding = resolveBindingPath(stmt, '$.parseCertString'); + if (!binding || binding.includes('.')) continue; + if (hasNonCallUsage(rootNode, stmt, binding)) continue; + + const result = removeBinding(stmt, binding); + if (result?.edit) edits.push(result.edit); + if (result?.lineToRemove) linesToRemove.push(result.lineToRemove); + } + + if (!edits.length && !linesToRemove.length) return null; + + const sourceCode = rootNode.commitEdits(edits); + return trimSingleLeadingBlankLine(removeLines(sourceCode, linesToRemove)); +} diff --git a/recipes/tls-parse-cert-string/tests/expected/01-cjs-basic.js b/recipes/tls-parse-cert-string/tests/expected/01-cjs-basic.js new file mode 100644 index 00000000..aa452ce6 --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/expected/01-cjs-basic.js @@ -0,0 +1,5 @@ +const tls = require('node:tls'); + +const subject = 'C=US/ST=California/L=San Francisco/O=Example/CN=example.com'; +const parsed = /* DEP0076: use node:crypto X509Certificate for robust parsing */ Object.fromEntries(String(subject).split('/').filter(Boolean).map((pair) => pair.split('='))); +console.log(parsed); diff --git a/recipes/tls-parse-cert-string/tests/expected/02-cert-subject.js b/recipes/tls-parse-cert-string/tests/expected/02-cert-subject.js new file mode 100644 index 00000000..a4095e0f --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/expected/02-cert-subject.js @@ -0,0 +1,7 @@ +const tls = require('node:tls'); + +const socket = tls.connect(443, 'example.com', () => { + const cert = socket.getPeerCertificate(); + const subject = /* DEP0076: cert.subject/cert.issuer are already parsed */ cert.subject; + console.log(subject); +}); diff --git a/recipes/tls-parse-cert-string/tests/expected/03-cert-issuer.js b/recipes/tls-parse-cert-string/tests/expected/03-cert-issuer.js new file mode 100644 index 00000000..c33d19bf --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/expected/03-cert-issuer.js @@ -0,0 +1,4 @@ +const tls = require('node:tls'); + +const cert = socket.getPeerCertificate(); +const issuer = /* DEP0076: cert.subject/cert.issuer are already parsed */ cert.issuer; diff --git a/recipes/tls-parse-cert-string/tests/expected/04-esm-default.mjs b/recipes/tls-parse-cert-string/tests/expected/04-esm-default.mjs new file mode 100644 index 00000000..c27c6a62 --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/expected/04-esm-default.mjs @@ -0,0 +1,3 @@ +import tls from 'node:tls'; + +const parsed = /* DEP0076: use node:crypto X509Certificate for robust parsing */ Object.fromEntries(String('CN=example.com/O=Example').split('/').filter(Boolean).map((pair) => pair.split('='))); diff --git a/recipes/tls-parse-cert-string/tests/expected/05-cjs-destructured.js b/recipes/tls-parse-cert-string/tests/expected/05-cjs-destructured.js new file mode 100644 index 00000000..7009e4b8 --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/expected/05-cjs-destructured.js @@ -0,0 +1 @@ +const result = /* DEP0076: use node:crypto X509Certificate for robust parsing */ Object.fromEntries(String('C=US/CN=test').split('/').filter(Boolean).map((pair) => pair.split('='))); diff --git a/recipes/tls-parse-cert-string/tests/expected/06-cjs-destructured-alias.js b/recipes/tls-parse-cert-string/tests/expected/06-cjs-destructured-alias.js new file mode 100644 index 00000000..09a9efab --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/expected/06-cjs-destructured-alias.js @@ -0,0 +1,4 @@ +const { createServer } = require('node:tls'); + +const parsed = /* DEP0076: use node:crypto X509Certificate for robust parsing */ Object.fromEntries(String('C=US/CN=test').split('/').filter(Boolean).map((pair) => pair.split('='))); +createServer(() => {}); diff --git a/recipes/tls-parse-cert-string/tests/expected/07-esm-named.js b/recipes/tls-parse-cert-string/tests/expected/07-esm-named.js new file mode 100644 index 00000000..3fc1c5ac --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/expected/07-esm-named.js @@ -0,0 +1,4 @@ +import { connect } from 'node:tls'; + +const out = /* DEP0076: use node:crypto X509Certificate for robust parsing */ Object.fromEntries(String('CN=example.com/O=Example').split('/').filter(Boolean).map((pair) => pair.split('='))); +connect(443, 'example.com'); diff --git a/recipes/tls-parse-cert-string/tests/expected/08-namespace-alias.js b/recipes/tls-parse-cert-string/tests/expected/08-namespace-alias.js new file mode 100644 index 00000000..813398b0 --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/expected/08-namespace-alias.js @@ -0,0 +1,3 @@ +import * as secureTls from 'node:tls'; + +const parsed = /* DEP0076: use node:crypto X509Certificate for robust parsing */ Object.fromEntries(String(subject).split('/').filter(Boolean).map((pair) => pair.split('='))); diff --git a/recipes/tls-parse-cert-string/tests/expected/09-not-from-tls.js b/recipes/tls-parse-cert-string/tests/expected/09-not-from-tls.js new file mode 100644 index 00000000..a91dd3dc --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/expected/09-not-from-tls.js @@ -0,0 +1,7 @@ +const parser = { + parseCertString(value) { + return value; + }, +}; + +const out = parser.parseCertString('CN=example.com'); diff --git a/recipes/tls-parse-cert-string/tests/expected/10-preserve-binding-on-reference.js b/recipes/tls-parse-cert-string/tests/expected/10-preserve-binding-on-reference.js new file mode 100644 index 00000000..50eeab30 --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/expected/10-preserve-binding-on-reference.js @@ -0,0 +1,4 @@ +const { parseCertString } = require('node:tls'); + +const parser = parseCertString; +const parsed = /* DEP0076: use node:crypto X509Certificate for robust parsing */ Object.fromEntries(String('C=US/CN=test').split('/').filter(Boolean).map((pair) => pair.split('='))); diff --git a/recipes/tls-parse-cert-string/tests/input/01-cjs-basic.js b/recipes/tls-parse-cert-string/tests/input/01-cjs-basic.js new file mode 100644 index 00000000..2c6cc355 --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/input/01-cjs-basic.js @@ -0,0 +1,5 @@ +const tls = require('node:tls'); + +const subject = 'C=US/ST=California/L=San Francisco/O=Example/CN=example.com'; +const parsed = tls.parseCertString(subject); +console.log(parsed); diff --git a/recipes/tls-parse-cert-string/tests/input/02-cert-subject.js b/recipes/tls-parse-cert-string/tests/input/02-cert-subject.js new file mode 100644 index 00000000..47426ef7 --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/input/02-cert-subject.js @@ -0,0 +1,7 @@ +const tls = require('node:tls'); + +const socket = tls.connect(443, 'example.com', () => { + const cert = socket.getPeerCertificate(); + const subject = tls.parseCertString(cert.subject); + console.log(subject); +}); diff --git a/recipes/tls-parse-cert-string/tests/input/03-cert-issuer.js b/recipes/tls-parse-cert-string/tests/input/03-cert-issuer.js new file mode 100644 index 00000000..1196899a --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/input/03-cert-issuer.js @@ -0,0 +1,4 @@ +const tls = require('node:tls'); + +const cert = socket.getPeerCertificate(); +const issuer = tls.parseCertString(cert.issuer); diff --git a/recipes/tls-parse-cert-string/tests/input/04-esm-default.mjs b/recipes/tls-parse-cert-string/tests/input/04-esm-default.mjs new file mode 100644 index 00000000..e2e48268 --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/input/04-esm-default.mjs @@ -0,0 +1,3 @@ +import tls from 'node:tls'; + +const parsed = tls.parseCertString('CN=example.com/O=Example'); diff --git a/recipes/tls-parse-cert-string/tests/input/05-cjs-destructured.js b/recipes/tls-parse-cert-string/tests/input/05-cjs-destructured.js new file mode 100644 index 00000000..17202cd2 --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/input/05-cjs-destructured.js @@ -0,0 +1,3 @@ +const { parseCertString } = require('node:tls'); + +const result = parseCertString('C=US/CN=test'); diff --git a/recipes/tls-parse-cert-string/tests/input/06-cjs-destructured-alias.js b/recipes/tls-parse-cert-string/tests/input/06-cjs-destructured-alias.js new file mode 100644 index 00000000..d997beda --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/input/06-cjs-destructured-alias.js @@ -0,0 +1,4 @@ +const { parseCertString: pcs, createServer } = require('node:tls'); + +const parsed = pcs('C=US/CN=test'); +createServer(() => {}); diff --git a/recipes/tls-parse-cert-string/tests/input/07-esm-named.js b/recipes/tls-parse-cert-string/tests/input/07-esm-named.js new file mode 100644 index 00000000..78f5558b --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/input/07-esm-named.js @@ -0,0 +1,4 @@ +import { parseCertString, connect } from 'node:tls'; + +const out = parseCertString('CN=example.com/O=Example'); +connect(443, 'example.com'); diff --git a/recipes/tls-parse-cert-string/tests/input/08-namespace-alias.js b/recipes/tls-parse-cert-string/tests/input/08-namespace-alias.js new file mode 100644 index 00000000..d3c729de --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/input/08-namespace-alias.js @@ -0,0 +1,3 @@ +import * as secureTls from 'node:tls'; + +const parsed = secureTls.parseCertString(subject); diff --git a/recipes/tls-parse-cert-string/tests/input/09-not-from-tls.js b/recipes/tls-parse-cert-string/tests/input/09-not-from-tls.js new file mode 100644 index 00000000..a91dd3dc --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/input/09-not-from-tls.js @@ -0,0 +1,7 @@ +const parser = { + parseCertString(value) { + return value; + }, +}; + +const out = parser.parseCertString('CN=example.com'); diff --git a/recipes/tls-parse-cert-string/tests/input/10-preserve-binding-on-reference.js b/recipes/tls-parse-cert-string/tests/input/10-preserve-binding-on-reference.js new file mode 100644 index 00000000..c337ff06 --- /dev/null +++ b/recipes/tls-parse-cert-string/tests/input/10-preserve-binding-on-reference.js @@ -0,0 +1,4 @@ +const { parseCertString } = require('node:tls'); + +const parser = parseCertString; +const parsed = parseCertString('C=US/CN=test'); diff --git a/recipes/tls-parse-cert-string/workflow.yaml b/recipes/tls-parse-cert-string/workflow.yaml new file mode 100644 index 00000000..c2c02fa9 --- /dev/null +++ b/recipes/tls-parse-cert-string/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: Remove tls.parseCertString() usage and replace with safer guidance. + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript