redos-linter 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redos_linter-0.1.0/PKG-INFO +133 -0
- redos_linter-0.1.0/README.md +123 -0
- redos_linter-0.1.0/pyproject.toml +60 -0
- redos_linter-0.1.0/src/redos_linter/__init__.py +338 -0
- redos_linter-0.1.0/src/redos_linter/__main__.py +5 -0
- redos_linter-0.1.0/src/redos_linter/checker.js +37 -0
- redos_linter-0.1.0/src/redos_linter/py.typed +0 -0
- redos_linter-0.1.0/src/redos_linter/recheck-entry.js +1 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: redos-linter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Lev Vereshchagin
|
|
6
|
+
Author-email: Lev Vereshchagin <mail@vrslev.com>
|
|
7
|
+
Requires-Dist: deno>=2.6.8
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# ReDoS Linter
|
|
12
|
+
|
|
13
|
+
A Python linter that detects Regular Expression Denial of Service (ReDoS) vulnerabilities in your code. ReDoS attacks occur when malicious input causes exponential backtracking in regular expressions, leading to denial of service.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- Scans Python files for regular expressions
|
|
18
|
+
- Detects vulnerable regex patterns using the [recheck](https://github.com/makenowjust-labs/recheck) engine
|
|
19
|
+
- Provides detailed attack vectors when vulnerabilities are found
|
|
20
|
+
- Supports both file and directory scanning
|
|
21
|
+
- Clean, colored output for better readability
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install redos-linter
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### Command Line
|
|
32
|
+
|
|
33
|
+
Check specific files or directories:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Check a single file
|
|
37
|
+
redos-linter myfile.py
|
|
38
|
+
|
|
39
|
+
# Check multiple files
|
|
40
|
+
redos-linter file1.py file2.py
|
|
41
|
+
|
|
42
|
+
# Check a directory (recursively scans all .py files)
|
|
43
|
+
redos-linter src/
|
|
44
|
+
|
|
45
|
+
# Check multiple directories
|
|
46
|
+
redos-linter src/ tests/
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Python Module
|
|
50
|
+
|
|
51
|
+
You can also run it as a Python module:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python -m redos_linter src/
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Output
|
|
58
|
+
|
|
59
|
+
The linter provides clear output indicating:
|
|
60
|
+
|
|
61
|
+
- ✅ **Safe**: No ReDoS vulnerabilities detected
|
|
62
|
+
- ❌ **Vulnerable**: ReDoS vulnerability found with attack vector details
|
|
63
|
+
|
|
64
|
+
When vulnerabilities are detected, the output includes:
|
|
65
|
+
- The vulnerable regular expression
|
|
66
|
+
- File location (line and column)
|
|
67
|
+
- Source code context
|
|
68
|
+
- Attack string that can trigger the ReDoS
|
|
69
|
+
- Pump strings for the attack
|
|
70
|
+
|
|
71
|
+
## Examples of Vulnerable Patterns
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import re
|
|
75
|
+
|
|
76
|
+
# Exponential backtracking due to nested quantifiers
|
|
77
|
+
vulnerable_1 = re.compile(r"^(a+)+$")
|
|
78
|
+
|
|
79
|
+
# Exponential backtracking due to overlapping quantifiers
|
|
80
|
+
vulnerable_2 = re.compile(r"(a|aa)+")
|
|
81
|
+
|
|
82
|
+
# Complex nested pattern
|
|
83
|
+
vulnerable_3 = re.compile(r"([a-z]+)+$")
|
|
84
|
+
|
|
85
|
+
# Real-world example
|
|
86
|
+
vulnerable_4 = re.compile(r"^(name|email|phone),([a-zA-Z0-9_]+,)*([a-zA-Z0-9_]+)$")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Examples of Safe Patterns
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import re
|
|
93
|
+
|
|
94
|
+
# Simple safe regex
|
|
95
|
+
safe_1 = re.compile(r"^[a-zA-Z0-9_]+$")
|
|
96
|
+
|
|
97
|
+
# Email pattern (properly structured)
|
|
98
|
+
safe_2 = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
|
|
99
|
+
|
|
100
|
+
# Non-overlapping alternation
|
|
101
|
+
safe_3 = re.compile(r"^(cat|dog)$")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
Install in development mode:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Clone the repository
|
|
110
|
+
git clone <repository-url>
|
|
111
|
+
cd redos-linter
|
|
112
|
+
|
|
113
|
+
# Install in development mode
|
|
114
|
+
uv sync
|
|
115
|
+
|
|
116
|
+
# Run tests
|
|
117
|
+
uv run pytest
|
|
118
|
+
|
|
119
|
+
# Run linter on test file
|
|
120
|
+
uv run python -m redos_linter test.py
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## How It Works
|
|
124
|
+
|
|
125
|
+
1. **AST Analysis**: Extracts all regular expression literals from Python source code using AST parsing
|
|
126
|
+
2. **ReDoS Detection**: Uses the recheck engine to analyze each regex for potential exponential backtracking
|
|
127
|
+
3. **Attack Generation**: When vulnerabilities are found, generates specific attack strings that demonstrate the issue
|
|
128
|
+
4. **Reporting**: Provides clear, actionable output with source context and attack vectors
|
|
129
|
+
|
|
130
|
+
## Requirements
|
|
131
|
+
|
|
132
|
+
- Python 3.10+
|
|
133
|
+
- Deno runtime (automatically managed via the deno Python package)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# ReDoS Linter
|
|
2
|
+
|
|
3
|
+
A Python linter that detects Regular Expression Denial of Service (ReDoS) vulnerabilities in your code. ReDoS attacks occur when malicious input causes exponential backtracking in regular expressions, leading to denial of service.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Scans Python files for regular expressions
|
|
8
|
+
- Detects vulnerable regex patterns using the [recheck](https://github.com/makenowjust-labs/recheck) engine
|
|
9
|
+
- Provides detailed attack vectors when vulnerabilities are found
|
|
10
|
+
- Supports both file and directory scanning
|
|
11
|
+
- Clean, colored output for better readability
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install redos-linter
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Command Line
|
|
22
|
+
|
|
23
|
+
Check specific files or directories:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Check a single file
|
|
27
|
+
redos-linter myfile.py
|
|
28
|
+
|
|
29
|
+
# Check multiple files
|
|
30
|
+
redos-linter file1.py file2.py
|
|
31
|
+
|
|
32
|
+
# Check a directory (recursively scans all .py files)
|
|
33
|
+
redos-linter src/
|
|
34
|
+
|
|
35
|
+
# Check multiple directories
|
|
36
|
+
redos-linter src/ tests/
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Python Module
|
|
40
|
+
|
|
41
|
+
You can also run it as a Python module:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python -m redos_linter src/
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Output
|
|
48
|
+
|
|
49
|
+
The linter provides clear output indicating:
|
|
50
|
+
|
|
51
|
+
- ✅ **Safe**: No ReDoS vulnerabilities detected
|
|
52
|
+
- ❌ **Vulnerable**: ReDoS vulnerability found with attack vector details
|
|
53
|
+
|
|
54
|
+
When vulnerabilities are detected, the output includes:
|
|
55
|
+
- The vulnerable regular expression
|
|
56
|
+
- File location (line and column)
|
|
57
|
+
- Source code context
|
|
58
|
+
- Attack string that can trigger the ReDoS
|
|
59
|
+
- Pump strings for the attack
|
|
60
|
+
|
|
61
|
+
## Examples of Vulnerable Patterns
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import re
|
|
65
|
+
|
|
66
|
+
# Exponential backtracking due to nested quantifiers
|
|
67
|
+
vulnerable_1 = re.compile(r"^(a+)+$")
|
|
68
|
+
|
|
69
|
+
# Exponential backtracking due to overlapping quantifiers
|
|
70
|
+
vulnerable_2 = re.compile(r"(a|aa)+")
|
|
71
|
+
|
|
72
|
+
# Complex nested pattern
|
|
73
|
+
vulnerable_3 = re.compile(r"([a-z]+)+$")
|
|
74
|
+
|
|
75
|
+
# Real-world example
|
|
76
|
+
vulnerable_4 = re.compile(r"^(name|email|phone),([a-zA-Z0-9_]+,)*([a-zA-Z0-9_]+)$")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Examples of Safe Patterns
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import re
|
|
83
|
+
|
|
84
|
+
# Simple safe regex
|
|
85
|
+
safe_1 = re.compile(r"^[a-zA-Z0-9_]+$")
|
|
86
|
+
|
|
87
|
+
# Email pattern (properly structured)
|
|
88
|
+
safe_2 = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
|
|
89
|
+
|
|
90
|
+
# Non-overlapping alternation
|
|
91
|
+
safe_3 = re.compile(r"^(cat|dog)$")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
Install in development mode:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Clone the repository
|
|
100
|
+
git clone <repository-url>
|
|
101
|
+
cd redos-linter
|
|
102
|
+
|
|
103
|
+
# Install in development mode
|
|
104
|
+
uv sync
|
|
105
|
+
|
|
106
|
+
# Run tests
|
|
107
|
+
uv run pytest
|
|
108
|
+
|
|
109
|
+
# Run linter on test file
|
|
110
|
+
uv run python -m redos_linter test.py
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## How It Works
|
|
114
|
+
|
|
115
|
+
1. **AST Analysis**: Extracts all regular expression literals from Python source code using AST parsing
|
|
116
|
+
2. **ReDoS Detection**: Uses the recheck engine to analyze each regex for potential exponential backtracking
|
|
117
|
+
3. **Attack Generation**: When vulnerabilities are found, generates specific attack strings that demonstrate the issue
|
|
118
|
+
4. **Reporting**: Provides clear, actionable output with source context and attack vectors
|
|
119
|
+
|
|
120
|
+
## Requirements
|
|
121
|
+
|
|
122
|
+
- Python 3.10+
|
|
123
|
+
- Deno runtime (automatically managed via the deno Python package)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "redos-linter"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Lev Vereshchagin", email = "mail@vrslev.com" }]
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = ["deno>=2.6.8"]
|
|
9
|
+
|
|
10
|
+
[project.scripts]
|
|
11
|
+
redos-linter = "redos_linter:main"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["uv_build"]
|
|
15
|
+
build-backend = "uv_build"
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = ["mypy", "ruff", "pytest", "pytest-mock", "pytest-cov"]
|
|
19
|
+
|
|
20
|
+
[tool.ruff]
|
|
21
|
+
fix = true
|
|
22
|
+
unsafe-fixes = true
|
|
23
|
+
line-length = 120
|
|
24
|
+
|
|
25
|
+
[tool.ruff.format]
|
|
26
|
+
docstring-code-format = true
|
|
27
|
+
|
|
28
|
+
[tool.ruff.lint]
|
|
29
|
+
select = ["ALL"]
|
|
30
|
+
ignore = [
|
|
31
|
+
"EM",
|
|
32
|
+
"FBT",
|
|
33
|
+
"TRY003",
|
|
34
|
+
"D1",
|
|
35
|
+
"D203",
|
|
36
|
+
"D213",
|
|
37
|
+
"G004",
|
|
38
|
+
"FA",
|
|
39
|
+
"COM812",
|
|
40
|
+
"ISC001",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint.isort]
|
|
44
|
+
no-lines-before = ["standard-library", "local-folder"]
|
|
45
|
+
known-third-party = []
|
|
46
|
+
known-local-folder = []
|
|
47
|
+
lines-after-imports = 2
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
50
|
+
"tests/*.py" = ["S101", "S311"]
|
|
51
|
+
|
|
52
|
+
[tool.mypy]
|
|
53
|
+
strict = true
|
|
54
|
+
|
|
55
|
+
[tool.flake8]
|
|
56
|
+
select = ["COP"]
|
|
57
|
+
exclude = [".venv"]
|
|
58
|
+
|
|
59
|
+
[tool.coverage.report]
|
|
60
|
+
exclude_also = ["if typing.TYPE_CHECKING:"]
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import ast
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TypedDict, cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import deno # type: ignore[import-untyped]
|
|
13
|
+
except ImportError:
|
|
14
|
+
deno = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ANSI color codes for better output
|
|
18
|
+
class Colors:
|
|
19
|
+
RED = "\033[91m"
|
|
20
|
+
GREEN = "\033[92m"
|
|
21
|
+
YELLOW = "\033[93m"
|
|
22
|
+
BLUE = "\033[94m"
|
|
23
|
+
MAGENTA = "\033[95m"
|
|
24
|
+
CYAN = "\033[96m"
|
|
25
|
+
WHITE = "\033[97m"
|
|
26
|
+
BOLD = "\033[1m"
|
|
27
|
+
UNDERLINE = "\033[4m"
|
|
28
|
+
END = "\033[0m"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def use_colors() -> bool:
|
|
32
|
+
"""Check if colors should be used (TTY and not disabled by environment)."""
|
|
33
|
+
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_deno_path() -> str:
|
|
37
|
+
python_executable = sys.executable
|
|
38
|
+
bin_dir = Path(python_executable).parent
|
|
39
|
+
deno_path = bin_dir / "deno"
|
|
40
|
+
if deno_path.exists():
|
|
41
|
+
return str(deno_path)
|
|
42
|
+
|
|
43
|
+
if deno is None:
|
|
44
|
+
raise FileNotFoundError("Could not find the deno executable: deno package not installed")
|
|
45
|
+
|
|
46
|
+
deno_dir = Path(deno.__file__).parent
|
|
47
|
+
deno_path = deno_dir / "bin" / "deno"
|
|
48
|
+
if deno_path.exists():
|
|
49
|
+
return str(deno_path)
|
|
50
|
+
|
|
51
|
+
raise FileNotFoundError("Could not find the deno executable.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RegexInfo(TypedDict):
|
|
55
|
+
regex: str
|
|
56
|
+
line: int
|
|
57
|
+
col: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RegexExtractor(ast.NodeVisitor):
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self.regexes: list[RegexInfo] = []
|
|
63
|
+
|
|
64
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
65
|
+
if (
|
|
66
|
+
(
|
|
67
|
+
isinstance(node.func, ast.Attribute)
|
|
68
|
+
and isinstance(node.func.value, ast.Name)
|
|
69
|
+
and node.func.value.id == "re"
|
|
70
|
+
and node.func.attr
|
|
71
|
+
in (
|
|
72
|
+
"compile",
|
|
73
|
+
"search",
|
|
74
|
+
"match",
|
|
75
|
+
"fullmatch",
|
|
76
|
+
"split",
|
|
77
|
+
"findall",
|
|
78
|
+
"finditer",
|
|
79
|
+
"sub",
|
|
80
|
+
"subn",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
and node.args
|
|
84
|
+
and isinstance(node.args[0], ast.Constant)
|
|
85
|
+
and isinstance(node.args[0].value, str)
|
|
86
|
+
):
|
|
87
|
+
self.regexes.append(
|
|
88
|
+
{
|
|
89
|
+
"regex": node.args[0].value,
|
|
90
|
+
"line": node.lineno,
|
|
91
|
+
"col": node.col_offset,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
self.generic_visit(node)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RegexInfoWithContext(TypedDict):
|
|
98
|
+
regex: str
|
|
99
|
+
line: int
|
|
100
|
+
col: int
|
|
101
|
+
source_lines: list[str]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def extract_regexes_from_file(filepath: str) -> list[RegexInfoWithContext]:
|
|
105
|
+
with Path(filepath).open() as f:
|
|
106
|
+
code = f.read()
|
|
107
|
+
tree = ast.parse(code, filename=filepath)
|
|
108
|
+
extractor = RegexExtractor()
|
|
109
|
+
extractor.visit(tree)
|
|
110
|
+
|
|
111
|
+
lines = code.splitlines()
|
|
112
|
+
return [
|
|
113
|
+
RegexInfoWithContext(
|
|
114
|
+
regex=ri["regex"],
|
|
115
|
+
line=ri["line"],
|
|
116
|
+
col=ri["col"],
|
|
117
|
+
source_lines=get_source_context(lines, ri["line"]),
|
|
118
|
+
)
|
|
119
|
+
for ri in extractor.regexes
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_source_context(lines: list[str], line_num: int, context: int = 2) -> list[str]:
|
|
124
|
+
"""Get source lines with context (before and after the target line)."""
|
|
125
|
+
start = max(0, line_num - context - 1) # -1 because line_num is 1-indexed
|
|
126
|
+
end = min(len(lines), line_num + context)
|
|
127
|
+
|
|
128
|
+
context_lines = []
|
|
129
|
+
for i in range(start, end):
|
|
130
|
+
prefix = ">>> " if i == line_num - 1 else " "
|
|
131
|
+
context_lines.append(f"{prefix}{i + 1:3d}: {lines[i]}")
|
|
132
|
+
|
|
133
|
+
return context_lines
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def collect_files(paths: list[str]) -> list[str]:
|
|
137
|
+
"""Collect Python files from the given paths."""
|
|
138
|
+
files_to_check: list[str] = []
|
|
139
|
+
for p in paths:
|
|
140
|
+
path = Path(p)
|
|
141
|
+
if path.is_dir():
|
|
142
|
+
files_to_check.extend(str(f) for f in path.rglob("*.py"))
|
|
143
|
+
else:
|
|
144
|
+
files_to_check.append(p)
|
|
145
|
+
return [f for f in files_to_check if ".venv" not in f and "node_modules" not in f]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class RegexInfoWithFile(TypedDict):
|
|
149
|
+
regex: str
|
|
150
|
+
filePath: str
|
|
151
|
+
line: int
|
|
152
|
+
col: int
|
|
153
|
+
source_lines: list[str]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def collect_all_regexes(files: list[str]) -> list[RegexInfoWithFile]:
|
|
157
|
+
"""Extract all regexes from the given files."""
|
|
158
|
+
regexes_with_paths: list[RegexInfoWithFile] = []
|
|
159
|
+
for file_path in files:
|
|
160
|
+
regexes = extract_regexes_from_file(file_path)
|
|
161
|
+
regexes_with_paths.extend(
|
|
162
|
+
{
|
|
163
|
+
"regex": regex_info["regex"],
|
|
164
|
+
"filePath": file_path,
|
|
165
|
+
"line": regex_info["line"],
|
|
166
|
+
"col": regex_info["col"],
|
|
167
|
+
"source_lines": regex_info["source_lines"],
|
|
168
|
+
}
|
|
169
|
+
for regex_info in regexes
|
|
170
|
+
)
|
|
171
|
+
return regexes_with_paths
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class RecheckResult(TypedDict):
|
|
175
|
+
status: str
|
|
176
|
+
sourceLines: list[str]
|
|
177
|
+
regex: str
|
|
178
|
+
filePath: str
|
|
179
|
+
line: int
|
|
180
|
+
col: int
|
|
181
|
+
attack: dict[str, object | None]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class AttackInfo(TypedDict):
|
|
185
|
+
string: str
|
|
186
|
+
base: int
|
|
187
|
+
pumps: list[dict[str, str]]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def check_regexes_with_deno(regexes: list[RegexInfoWithFile]) -> list[RecheckResult] | None:
|
|
191
|
+
"""Check regexes for vulnerabilities using Deno."""
|
|
192
|
+
deno_path = get_deno_path()
|
|
193
|
+
checker_path = Path(__file__).parent / "checker.js"
|
|
194
|
+
bundle_path = Path(__file__).parent / "recheck.bundle.js"
|
|
195
|
+
|
|
196
|
+
env = os.environ.copy()
|
|
197
|
+
env["RECHECK_BACKEND"] = "pure"
|
|
198
|
+
|
|
199
|
+
process = subprocess.run( # noqa: S603
|
|
200
|
+
[deno_path, "run", "--allow-read", str(checker_path), str(bundle_path)],
|
|
201
|
+
input=json.dumps(regexes).encode("utf-8"),
|
|
202
|
+
capture_output=True,
|
|
203
|
+
env=env,
|
|
204
|
+
check=False,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if process.stderr:
|
|
208
|
+
if use_colors():
|
|
209
|
+
sys.stderr.write(f"{Colors.RED}Error: {Colors.END}{process.stderr.decode('utf-8')}")
|
|
210
|
+
else:
|
|
211
|
+
sys.stderr.write(f"Error: {process.stderr.decode('utf-8')}")
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
output = process.stdout.decode("utf-8")
|
|
215
|
+
if not output:
|
|
216
|
+
if use_colors():
|
|
217
|
+
sys.stdout.write(f"{Colors.GREEN}No vulnerable regexes found.{Colors.END}\n")
|
|
218
|
+
else:
|
|
219
|
+
sys.stdout.write("No vulnerable regexes found.\n")
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
return cast("list[RecheckResult] | None", json.loads(output))
|
|
224
|
+
except json.JSONDecodeError:
|
|
225
|
+
if use_colors():
|
|
226
|
+
sys.stderr.write(f"{Colors.RED}Error: Invalid response from checker{Colors.END}\n")
|
|
227
|
+
else:
|
|
228
|
+
sys.stderr.write("Error: Invalid response from checker\n")
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main() -> None: # noqa: PLR0912,PLR0915,C901
|
|
233
|
+
"""Run the ReDoS linter."""
|
|
234
|
+
parser = argparse.ArgumentParser(
|
|
235
|
+
description="ReDoS Linter - Detects Regular Expression Denial of Service vulnerabilities"
|
|
236
|
+
)
|
|
237
|
+
parser.add_argument(
|
|
238
|
+
"paths",
|
|
239
|
+
metavar="path",
|
|
240
|
+
type=str,
|
|
241
|
+
nargs="+",
|
|
242
|
+
help="Files or directories to check",
|
|
243
|
+
)
|
|
244
|
+
args = parser.parse_args()
|
|
245
|
+
|
|
246
|
+
files_to_check = collect_files(args.paths)
|
|
247
|
+
regexes_with_paths = collect_all_regexes(files_to_check)
|
|
248
|
+
|
|
249
|
+
if not regexes_with_paths:
|
|
250
|
+
if use_colors():
|
|
251
|
+
sys.stdout.write(f"{Colors.GREEN}No vulnerable regexes found.{Colors.END}\n")
|
|
252
|
+
else:
|
|
253
|
+
sys.stdout.write("No vulnerable regexes found.\n")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
results = check_regexes_with_deno(regexes_with_paths)
|
|
257
|
+
if results is None:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
total_regexes = len(results)
|
|
261
|
+
vulnerable_count = sum(1 for r in results if r["status"] == "vulnerable")
|
|
262
|
+
|
|
263
|
+
suffix = "s" if total_regexes != 1 else ""
|
|
264
|
+
analyzing_msg = f"Analyzing {total_regexes} regular expression{suffix}...\n\n"
|
|
265
|
+
if use_colors():
|
|
266
|
+
sys.stdout.write(f"{Colors.BLUE}{analyzing_msg}{Colors.END}")
|
|
267
|
+
else:
|
|
268
|
+
sys.stdout.write(analyzing_msg)
|
|
269
|
+
|
|
270
|
+
for result in results:
|
|
271
|
+
if result["status"] == "vulnerable":
|
|
272
|
+
attack: AttackInfo | None = result.get("attack") # type: ignore[assignment]
|
|
273
|
+
location = f"{result.get('filePath', 'unknown')}:{result.get('line', '?')}:{result.get('col', '?')}"
|
|
274
|
+
if use_colors():
|
|
275
|
+
sys.stdout.write(f"{Colors.RED}VULNERABLE:{Colors.END} {location}\n")
|
|
276
|
+
sys.stdout.write(f" {Colors.YELLOW}Pattern:{Colors.END} {Colors.CYAN}{result['regex']}{Colors.END}\n")
|
|
277
|
+
sys.stdout.write(
|
|
278
|
+
f" {Colors.YELLOW}Issue:{Colors.END} Exponential backtracking due to nested quantifiers\n"
|
|
279
|
+
)
|
|
280
|
+
if attack:
|
|
281
|
+
attack_str = json.dumps(attack.get("string", "unknown"))
|
|
282
|
+
sys.stdout.write(
|
|
283
|
+
f" {Colors.YELLOW}Attack string:{Colors.END} {Colors.MAGENTA}{attack_str}{Colors.END}\n"
|
|
284
|
+
)
|
|
285
|
+
if attack.get("pumps") and attack["pumps"]:
|
|
286
|
+
pump = attack["pumps"][0]
|
|
287
|
+
pump_msg = (
|
|
288
|
+
f'Repeating {Colors.CYAN}"{pump["pump"]}"{Colors.END} causes catastrophic backtracking'
|
|
289
|
+
)
|
|
290
|
+
sys.stdout.write(f" {Colors.YELLOW}Exploit:{Colors.END} {pump_msg}\n")
|
|
291
|
+
complexity = attack.get("base", "unknown")
|
|
292
|
+
sys.stdout.write(
|
|
293
|
+
f" {Colors.YELLOW}Complexity:{Colors.END} {complexity} character repetitions\n"
|
|
294
|
+
)
|
|
295
|
+
sys.stdout.write(f" {Colors.YELLOW}Source context:{Colors.END}\n")
|
|
296
|
+
for line in result.get("sourceLines", []):
|
|
297
|
+
sys.stdout.write(f" {line}\n")
|
|
298
|
+
sys.stdout.write("\n")
|
|
299
|
+
else:
|
|
300
|
+
sys.stdout.write(f"VULNERABLE: {location}\n")
|
|
301
|
+
sys.stdout.write(f" Pattern: {result['regex']}\n")
|
|
302
|
+
sys.stdout.write(" Issue: Exponential backtracking due to nested quantifiers\n")
|
|
303
|
+
if attack:
|
|
304
|
+
sys.stdout.write(f" Attack string: {json.dumps(attack.get('string', 'unknown'))}\n")
|
|
305
|
+
if attack.get("pumps") and attack["pumps"]:
|
|
306
|
+
pump = attack["pumps"][0]
|
|
307
|
+
sys.stdout.write(f' Exploit: Repeating "{pump["pump"]}" causes catastrophic backtracking\n')
|
|
308
|
+
sys.stdout.write(f" Complexity: {attack.get('base', 'unknown')} character repetitions\n")
|
|
309
|
+
sys.stdout.write(" Source context:\n")
|
|
310
|
+
for line in result.get("sourceLines", []):
|
|
311
|
+
sys.stdout.write(f" {line}\n")
|
|
312
|
+
sys.stdout.write("\n")
|
|
313
|
+
|
|
314
|
+
if vulnerable_count == 0:
|
|
315
|
+
safe_msg = f"All {total_regexes} regex{'es' if total_regexes != 1 else ''} appear safe from ReDoS attacks.\n"
|
|
316
|
+
if use_colors():
|
|
317
|
+
sys.stdout.write(f"{Colors.GREEN}{safe_msg}{Colors.END}")
|
|
318
|
+
else:
|
|
319
|
+
sys.stdout.write(safe_msg)
|
|
320
|
+
else:
|
|
321
|
+
vuln_msg = f"Found {vulnerable_count} vulnerable regex{'es' if vulnerable_count != 1 else ''} out of {total_regexes} total.\n" # noqa: E501
|
|
322
|
+
if use_colors():
|
|
323
|
+
sys.stdout.write(f"{Colors.RED}{vuln_msg}{Colors.END}")
|
|
324
|
+
else:
|
|
325
|
+
sys.stdout.write(vuln_msg)
|
|
326
|
+
sys.stdout.write("\n")
|
|
327
|
+
if use_colors():
|
|
328
|
+
sys.stdout.write(f"{Colors.BLUE}Recommendations:{Colors.END}\n")
|
|
329
|
+
else:
|
|
330
|
+
sys.stdout.write("Recommendations:\n")
|
|
331
|
+
sys.stdout.write(" - Use atomic grouping or possessive quantifiers where possible\n")
|
|
332
|
+
sys.stdout.write(" - Avoid nested quantifiers like (a+)+ or (a*)*\n")
|
|
333
|
+
sys.stdout.write(" - Consider using re.compile with re.IGNORECASE carefully\n")
|
|
334
|
+
sys.stdout.write(" - Test regexes with long, malformed input strings\n")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__":
|
|
338
|
+
main()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const bundlePath = Deno.args[0];
|
|
2
|
+
const { recheck } = await import(bundlePath);
|
|
3
|
+
|
|
4
|
+
function main(content) {
|
|
5
|
+
const regexesWithPaths = JSON.parse(content);
|
|
6
|
+
const results = [];
|
|
7
|
+
|
|
8
|
+
for (const item of regexesWithPaths) {
|
|
9
|
+
const { regex, filePath, line, col, source_lines } = item;
|
|
10
|
+
const result = recheck.checkSync(regex, '');
|
|
11
|
+
results.push({
|
|
12
|
+
regex: regex,
|
|
13
|
+
filePath: filePath,
|
|
14
|
+
line: line,
|
|
15
|
+
col: col,
|
|
16
|
+
sourceLines: source_lines,
|
|
17
|
+
status: result.status,
|
|
18
|
+
attack: result.attack
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
console.log(JSON.stringify(results));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
(async () => {
|
|
26
|
+
const reader = Deno.stdin.readable.getReader();
|
|
27
|
+
let content = '';
|
|
28
|
+
const decoder = new TextDecoder();
|
|
29
|
+
while (true) {
|
|
30
|
+
const { done, value } = await reader.read();
|
|
31
|
+
if (done) {
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
content += decoder.decode(value);
|
|
35
|
+
}
|
|
36
|
+
main(content);
|
|
37
|
+
})();
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as recheck from 'recheck';
|