├── .github ├── release.yml └── workflows │ ├── automerge.yml │ ├── build.yml │ ├── codeql-analysis.yml │ └── publish.yml ├── .gitignore ├── .husky └── pre-commit ├── .node-version ├── .prettierrc.json ├── CODEOWNERS ├── LICENSE ├── README.md ├── jest.config.js ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── renovate.json ├── rollup.config.mjs ├── src ├── eslint-plugin-redos-detector.test.ts └── eslint-plugin-redos-detector.ts ├── tsconfig.json └── tsconfig.test.json /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - skip-release-notes 5 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: Auto Merge Dependency Updates 2 | 3 | on: 4 | - pull_request_target 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | jobs: 11 | run: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: tjenkinson/gh-action-auto-merge-dependency-updates@v1 15 | with: 16 | use-auto-merge: true 17 | allowed-actors: renovate[bot] 18 | package-block-list: redos-detector 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: ['18', '20', '21'] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - name: npm install, build and test 27 | run: | 28 | npm ci 29 | npm run prettier:check 30 | npm run build 31 | npm run test 32 | env: 33 | CI: true 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '0 3 * * 1' 10 | 11 | permissions: 12 | actions: read 13 | security-events: write 14 | 15 | jobs: 16 | analyze: 17 | name: Analyze 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: ['javascript'] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: security-and-quality 34 | 35 | - name: Perform CodeQL Analysis 36 | uses: github/codeql-action/analyze@v3 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | id-token: write 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version-file: '.node-version' 21 | - name: set version 22 | run: | 23 | npm version --no-git-tag-version "$TAG" 24 | env: 25 | CI: true 26 | TAG: ${{ github.event.release.tag_name }} 27 | - name: npm install and build 28 | run: | 29 | npm ci 30 | npm run build 31 | env: 32 | CI: true 33 | - name: publish 34 | run: | 35 | echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc 36 | npm publish --provenance 37 | env: 38 | CI: true 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | .DS_STORE 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | set -e 5 | npm run lint-staged 6 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @tjenkinson 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tom Jenkinson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-redos-detector 2 | 3 | An ESLint plugin that detects vulnerable regex using "[RedosDetector](https://github.com/tjenkinson/redos-detector)". It processes all [RegExp](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp) literals. I.e. `/ab+c/` but not `new RegExp('ab+c')`. 4 | 5 | ## Installation 6 | 7 | You'll first need to install [ESLint](https://github.com/eslint/eslint): 8 | 9 | ```sh 10 | npm i eslint --save-dev 11 | ``` 12 | 13 | Next, install [`eslint-plugin-redos-detector`](https://www.npmjs.com/package/eslint-plugin-redos-detector): 14 | 15 | ```sh 16 | npm i --save-dev eslint-plugin-redos-detector 17 | ``` 18 | 19 | ## Usage 20 | 21 | Add `redos-detector` to the plugins section of your `.eslintrc` configuration file. 22 | 23 | ```json 24 | { 25 | "plugins": ["redos-detector"] 26 | } 27 | ``` 28 | 29 | Then configure the rule under the rules section. 30 | 31 | ```json 32 | { 33 | "rules": { 34 | "redos-detector/no-unsafe-regex": "error" 35 | } 36 | } 37 | ``` 38 | 39 | Or do the following to provide options. 40 | 41 | ```json 42 | { 43 | "rules": { 44 | "redos-detector/no-unsafe-regex": [ 45 | "error", 46 | { 47 | "ignoreError": true 48 | } 49 | ] 50 | } 51 | } 52 | ``` 53 | 54 | ### Options 55 | 56 | - `ignoreError`: If `true` any error getting results be ignored. It's possible for the detection to fail with some patterns, or if the patten is malformed or uses unsupported features. See [this doc](https://github.com/tjenkinson/redos-detector/blob/main/README.md#options) for the type of errors. _(Default: `false`)_ 57 | - `maxSteps`: See the option in [this doc](https://github.com/tjenkinson/redos-detector/blob/main/README.md#options) with the same name. _(Default: See linked doc)_ 58 | - `maxScore`: See the option in [this doc](https://github.com/tjenkinson/redos-detector/blob/main/README.md#options) with the same name. _(Default: See linked doc)_ 59 | - `timeout`: See the option in [this doc](https://github.com/tjenkinson/redos-detector/blob/main/README.md#options) with the same name. _(Default: See linked doc)_ 60 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | testEnvironment: 'node', 5 | rootDir: 'src', 6 | restoreMocks: true, 7 | transform: { 8 | '^.+\\.ts$': [ 9 | 'ts-jest', 10 | { 11 | tsconfig: path.resolve(__dirname, './tsconfig.test.json'), 12 | }, 13 | ], 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | const micromatch = require('micromatch'); 2 | const prettier = require('prettier'); 3 | 4 | const addQuotes = (a) => `"${a}"`; 5 | 6 | module.exports = async (allStagedFiles) => { 7 | const prettierSupportedExtensions = ( 8 | await prettier.getSupportInfo() 9 | ).languages 10 | .map(({ extensions }) => extensions) 11 | .flat(); 12 | 13 | const prettierFiles = micromatch( 14 | allStagedFiles, 15 | prettierSupportedExtensions.map((extension) => `**/*${extension}`), 16 | ); 17 | return prettierFiles.length 18 | ? [`prettier --write ${prettierFiles.map(addQuotes).join(' ')}`] 19 | : []; 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-redos-detector", 3 | "description": "An eslint plugin that detects vulnerable regex using \"https://github.com/tjenkinson/redos-detector\".", 4 | "main": "dist/eslint-plugin-redos-detector.js", 5 | "types": "dist/eslint-plugin-redos-detector.d.ts", 6 | "keywords": [ 7 | "eslint", 8 | "eslintplugin", 9 | "eslint-plugin", 10 | "regex", 11 | "redos", 12 | "pattern", 13 | "redos-detector" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/tjenkinson/eslint-plugin-redos-detector.git" 18 | }, 19 | "author": "Tom Jenkinson ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/tjenkinson/eslint-plugin-redos-detector/issues" 23 | }, 24 | "homepage": "https://github.com/tjenkinson/eslint-plugin-redos-detector#readme", 25 | "files": [ 26 | "dist" 27 | ], 28 | "scripts": { 29 | "build": "rm -rf dist && rollup --config rollup.config.mjs", 30 | "watch": "rollup --config rollup.config.mjs --watch", 31 | "prettier": "prettier --write .", 32 | "prettier:check": "prettier --check .", 33 | "test": "jest", 34 | "prepare": "husky install", 35 | "lint-staged": "lint-staged" 36 | }, 37 | "devDependencies": { 38 | "@rollup/plugin-node-resolve": "15.3.1", 39 | "@rollup/plugin-typescript": "11.1.6", 40 | "@types/eslint": "8.56.12", 41 | "@types/jest": "29.5.14", 42 | "@types/node": "20.17.57", 43 | "eslint": "8.57.1", 44 | "husky": "8.0.3", 45 | "jest": "29.7.0", 46 | "lint-staged": "15.5.2", 47 | "micromatch": "4.0.8", 48 | "prettier": "3.5.3", 49 | "redos-detector": "6.1.2", 50 | "rollup": "4.41.1", 51 | "ts-jest": "29.3.4", 52 | "tslib": "2.8.1", 53 | "typescript": "5.8.3" 54 | }, 55 | "peerDependencies": { 56 | "eslint": ">=6" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:recommended"], 3 | "labels": ["dependencies", "skip-release-notes"], 4 | "prHourlyLimit": 0, 5 | "prConcurrentLimit": 0, 6 | "prCreation": "immediate", 7 | "minimumReleaseAge": "7 days", 8 | "internalChecksFilter": "strict", 9 | "vulnerabilityAlerts": { 10 | "enabled": true 11 | }, 12 | "rangeStrategy": "bump", 13 | "packageRules": [ 14 | { 15 | "labels": ["dependencies"], 16 | "matchPackageNames": ["redos-detector"], 17 | "minimumReleaseAge": null 18 | }, 19 | { 20 | "matchDepTypes": ["devDependencies"], 21 | "rangeStrategy": "pin" 22 | }, 23 | { 24 | "matchDepTypes": ["peerDependencies"], 25 | "rangeStrategy": "widen" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | 4 | function buildConfig({ input, output }) { 5 | return { 6 | input, 7 | plugins: [typescript(), resolve()], 8 | onLog: (level, log, handler) => { 9 | if (level === 'warn') { 10 | // treat warnings as errors 11 | handler('error', log); 12 | } else { 13 | handler(level, log); 14 | } 15 | }, 16 | output, 17 | }; 18 | } 19 | export default [ 20 | buildConfig({ 21 | input: 'src/eslint-plugin-redos-detector.ts', 22 | output: [ 23 | { 24 | file: 'dist/eslint-plugin-redos-detector.js', 25 | format: 'commonjs', 26 | }, 27 | ], 28 | }), 29 | ]; 30 | -------------------------------------------------------------------------------- /src/eslint-plugin-redos-detector.test.ts: -------------------------------------------------------------------------------- 1 | import { rules } from './eslint-plugin-redos-detector'; 2 | import { RuleTester } from 'eslint'; 3 | 4 | const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 'latest' } }); 5 | 6 | describe('Eslint Plugin Redos Detector', () => { 7 | describe(`'no-unsafe-regex' rule`, () => { 8 | [true, false].forEach((ignoreError) => { 9 | describe(`${ignoreError ? 'with' : 'without'} "ignoreError"`, () => { 10 | const valid: RuleTester.ValidTestCase[] = [ 11 | { 12 | code: '/a/', 13 | options: [{ ignoreError }], 14 | }, 15 | { 16 | code: '/a/i', 17 | options: [{ ignoreError }], 18 | }, 19 | { 20 | code: '/a/u', 21 | options: [{ ignoreError }], 22 | }, 23 | { 24 | code: '/a/s', 25 | options: [{ ignoreError }], 26 | }, 27 | { 28 | code: '/a/m', 29 | options: [{ ignoreError }], 30 | }, 31 | { 32 | code: '/a/y', 33 | options: [{ ignoreError }], 34 | }, 35 | { 36 | code: '/a/d', 37 | options: [{ ignoreError }], 38 | }, 39 | ...(ignoreError 40 | ? [ 41 | { 42 | code: '/a{1,99999}/', 43 | options: [{ ignoreError, maxSteps: 1 }], 44 | }, 45 | { 46 | code: '/a/iu', 47 | options: [{ ignoreError, maxSteps: 1 }], 48 | }, 49 | ] 50 | : []), 51 | ]; 52 | 53 | const invalid: RuleTester.InvalidTestCase[] = [ 54 | { 55 | code: '/a+a+/', 56 | options: [{ ignoreError }], 57 | errors: 1, 58 | }, 59 | { 60 | code: '/a+A+/i', 61 | options: [{ ignoreError }], 62 | errors: 1, 63 | }, 64 | { 65 | code: '/a+a+/m', 66 | options: [{ ignoreError }], 67 | errors: 1, 68 | }, 69 | { 70 | code: '/a+a+/y', 71 | options: [{ ignoreError }], 72 | errors: 1, 73 | }, 74 | { 75 | code: '/a+a+/d', 76 | options: [{ ignoreError }], 77 | errors: 1, 78 | }, 79 | ...(!ignoreError 80 | ? [ 81 | { 82 | code: '/a{1,99999}/', 83 | options: [{ ignoreError, maxSteps: 1 }], 84 | errors: 1, 85 | }, 86 | { 87 | code: '/a/iu', 88 | options: [{ ignoreError, maxSteps: 1 }], 89 | errors: 1, 90 | }, 91 | ] 92 | : []), 93 | ]; 94 | 95 | ruleTester.run('no-unsafe-regex', rules['no-unsafe-regex'], { 96 | valid, 97 | invalid, 98 | }); 99 | }); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/eslint-plugin-redos-detector.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from 'eslint'; 2 | import { isSafePattern, RedosDetectorResult, toFriendly } from 'redos-detector'; 3 | import * as ESTree from 'estree'; 4 | 5 | export type Options = { 6 | maxSteps?: number; 7 | maxScore?: number; 8 | timeout?: number; 9 | ignoreError?: boolean; 10 | }; 11 | 12 | export const rules: Record = { 13 | 'no-unsafe-regex': { 14 | meta: { 15 | type: 'problem', 16 | docs: { 17 | description: 'disallow regex that is susceptible to ReDoS attacks', 18 | url: 'https://github.com/tjenkinson/eslint-plugin-redos-detector', 19 | }, 20 | schema: [ 21 | { 22 | type: 'object', 23 | properties: { 24 | maxSteps: { 25 | type: 'number', 26 | minimum: 1, 27 | }, 28 | maxScore: { 29 | type: 'number', 30 | minimum: 0, 31 | }, 32 | timeout: { 33 | type: 'number', 34 | minimum: 1, 35 | }, 36 | ignoreError: { 37 | type: 'boolean', 38 | }, 39 | }, 40 | additionalProperties: false, 41 | }, 42 | ], 43 | }, 44 | create: (context: Rule.RuleContext) => { 45 | const { 46 | maxSteps, 47 | maxScore, 48 | timeout, 49 | ignoreError = false, 50 | }: Options = context.options[0] || {}; 51 | 52 | return { 53 | Literal(node) { 54 | const regexNode = node as ESTree.RegExpLiteral; 55 | if (!regexNode.regex) { 56 | return; 57 | } 58 | const { pattern, flags } = regexNode.regex; 59 | 60 | let caseInsensitive = false; 61 | let unicode = false; 62 | let dotAll = false; 63 | let multiLine = false; 64 | 65 | for (const flag of flags.split('')) { 66 | if (flag === 'i') { 67 | caseInsensitive = true; 68 | } else if (flag === 'u') { 69 | unicode = true; 70 | } else if (flag === 's') { 71 | dotAll = true; 72 | } else if (flag === 'm') { 73 | multiLine = true; 74 | } else if (flag !== 'g' && flag !== 'y' && flag !== 'd') { 75 | if (!ignoreError) { 76 | context.report({ 77 | node, 78 | message: `Flag "${flag}" is not supported.`, 79 | }); 80 | } 81 | return; 82 | } 83 | } 84 | 85 | if (caseInsensitive && unicode) { 86 | if (!ignoreError) { 87 | context.report({ 88 | node, 89 | message: `Flag "i" and "u" are not supported together.`, 90 | }); 91 | } 92 | return; 93 | } 94 | 95 | let result: RedosDetectorResult; 96 | try { 97 | result = isSafePattern(pattern, { 98 | maxSteps, 99 | maxScore, 100 | timeout, 101 | caseInsensitive, 102 | unicode, 103 | dotAll, 104 | multiLine, 105 | }); 106 | } catch (e: any) { 107 | if (!ignoreError) { 108 | context.report({ 109 | node, 110 | message: `Error checking regex.\n\n${e.message || '[Unknown]'}`, 111 | }); 112 | } 113 | return; 114 | } 115 | if (result.safe) { 116 | return; 117 | } 118 | let report = !!result.trails.length || (result.error && !ignoreError); 119 | if (report) { 120 | context.report({ node, message: toFriendly(result) }); 121 | } 122 | }, 123 | }; 124 | }, 125 | }, 126 | }; 127 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "target": "ES5", 5 | "lib": ["dom", "es2019"], 6 | "types": ["node"], 7 | "rootDir": "src", 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "noFallthroughCasesInSwitch": true, 11 | "strictNullChecks": true, 12 | "esModuleInterop": true, 13 | "downlevelIteration": true, 14 | "resolveJsonModule": true, 15 | "declaration": true, 16 | "declarationDir": "/" 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "**/*.test.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jest"] 5 | }, 6 | "exclude": ["node_modules"] 7 | } 8 | --------------------------------------------------------------------------------