clckernel 1.2.6
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.
- package/AGENTS.md +124 -0
- package/README.es.md +166 -0
- package/README.md +167 -0
- package/bin/cli.js +12 -0
- package/docs/Journal/001-adr-clckernel-governance.md +18 -0
- package/package.json +44 -0
- package/src/adapters/astro.js +67 -0
- package/src/adapters/base.js +109 -0
- package/src/adapters/django.js +70 -0
- package/src/adapters/fastapi.js +69 -0
- package/src/adapters/go.js +50 -0
- package/src/adapters/laravel.js +52 -0
- package/src/adapters/nextjs.js +72 -0
- package/src/adapters/rails.js +54 -0
- package/src/adapters/rust.js +51 -0
- package/src/catalog.js +217 -0
- package/src/config.js +250 -0
- package/src/detector.js +85 -0
- package/src/doctor.js +125 -0
- package/src/generator.js +473 -0
- package/src/index.js +52 -0
- package/src/technologies/detector.js +243 -0
- package/src/technologies/guards/_shared.js +66 -0
- package/src/ui.js +101 -0
- package/src/yaml.js +173 -0
- package/test/adapters/astro.test.js +102 -0
- package/test/adapters/base.test.js +86 -0
- package/test/adapters/django.test.js +95 -0
- package/test/adapters/fastapi.test.js +88 -0
- package/test/adapters/go.test.js +81 -0
- package/test/adapters/laravel.test.js +106 -0
- package/test/adapters/nextjs.test.js +113 -0
- package/test/adapters/rails.test.js +108 -0
- package/test/adapters/rust.test.js +83 -0
- package/test/catalog.test.js +170 -0
- package/test/config.test.js +512 -0
- package/test/detector.test.js +345 -0
- package/test/doctor.test.js +302 -0
- package/test/generator.test.js +419 -0
- package/test/guards_new.test.js +95 -0
- package/test/helpers.js +36 -0
- package/test/integration/cli-flow.test.js +270 -0
- package/test/technologies/celery_guard.test.js +185 -0
- package/test/technologies/detector.test.js +342 -0
- package/test/technologies/docker_guard.test.js +187 -0
- package/test/technologies/integration.test.js +123 -0
- package/test/technologies/postgres_guard.test.js +87 -0
- package/test/technologies/redis_guard.test.js +128 -0
- package/tools/audit.js +219 -0
- package/tools/audit.py +273 -0
- package/tools/celery_guard.py +209 -0
- package/tools/check_a11y.js +109 -0
- package/tools/check_api_contracts.js +139 -0
- package/tools/check_architecture.js +139 -0
- package/tools/check_architecture.py +264 -0
- package/tools/check_custom.js +163 -0
- package/tools/check_custom.py +395 -0
- package/tools/check_db_efficiency.py +190 -0
- package/tools/check_migrations.py +223 -0
- package/tools/check_performance.js +142 -0
- package/tools/check_responsive.js +131 -0
- package/tools/check_scope.py +180 -0
- package/tools/check_seo.js +139 -0
- package/tools/check_storybook.js +108 -0
- package/tools/check_ui_reuse.js +135 -0
- package/tools/docker_guard.py +210 -0
- package/tools/postgres_guard.py +192 -0
- package/tools/redis_guard.py +197 -0
- package/tools/scan_secrets.js +109 -0
- package/tools/scan_secrets.py +153 -0
- package/tools/verify_tdd.py +212 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Docker Guard
|
|
4
|
+
Checks for Docker security issues in changed Dockerfile and docker-compose files.
|
|
5
|
+
Self-contained: uses only Python stdlib (pathlib, subprocess, json, sys, re).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_changed_dockerfiles(target_dir: str) -> list[str] | None:
|
|
18
|
+
"""Return list of changed Docker-related files from git diff, or None on failure."""
|
|
19
|
+
for ref in ("HEAD~1", "--cached"):
|
|
20
|
+
try:
|
|
21
|
+
args = ["git", "diff", "--name-only"]
|
|
22
|
+
if ref == "--cached":
|
|
23
|
+
args.append("--cached")
|
|
24
|
+
else:
|
|
25
|
+
args.append(ref)
|
|
26
|
+
out = subprocess.check_output(
|
|
27
|
+
args, cwd=target_dir,
|
|
28
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
29
|
+
)
|
|
30
|
+
files = [
|
|
31
|
+
f.strip() for f in out.splitlines()
|
|
32
|
+
if f.strip().endswith(('Dockerfile', 'docker-compose.yml', 'docker-compose.yaml'))
|
|
33
|
+
or 'Dockerfile' in f
|
|
34
|
+
or 'docker-compose' in f
|
|
35
|
+
]
|
|
36
|
+
if files:
|
|
37
|
+
return files
|
|
38
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
39
|
+
continue
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_file_content(full_path: Path) -> str | None:
|
|
44
|
+
"""Read file content safely, return None on failure."""
|
|
45
|
+
try:
|
|
46
|
+
return full_path.read_text(encoding="utf-8", errors="replace")
|
|
47
|
+
except (OSError, PermissionError):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check_dockerfile(content: str, filepath: str) -> list[dict]:
|
|
52
|
+
"""Check a Dockerfile for security issues."""
|
|
53
|
+
violations: list[dict] = []
|
|
54
|
+
lines = content.splitlines()
|
|
55
|
+
|
|
56
|
+
has_user = False
|
|
57
|
+
user_is_root = False
|
|
58
|
+
has_healthcheck = False
|
|
59
|
+
env_secrets: list[tuple[int, str]] = []
|
|
60
|
+
|
|
61
|
+
secret_patterns = [
|
|
62
|
+
re.compile(r'^\s*ENV\s+(AWS_SECRET_ACCESS_KEY|DB_PASSWORD|SECRET_KEY|PASSWORD|TOKEN|API_KEY)\s*=', re.IGNORECASE),
|
|
63
|
+
re.compile(r'^\s*ENV\s+\w*SECRET\w*\s*=', re.IGNORECASE),
|
|
64
|
+
re.compile(r'^\s*ENV\s+\w*PASSWORD\w*\s*=', re.IGNORECASE),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
for lineno, line in enumerate(lines, start=1):
|
|
68
|
+
stripped = line.strip()
|
|
69
|
+
|
|
70
|
+
# Check USER directive
|
|
71
|
+
if stripped.startswith('USER '):
|
|
72
|
+
has_user = True
|
|
73
|
+
user_val = stripped[5:].split()[0] if len(stripped) > 5 else ''
|
|
74
|
+
if user_val.lower() in ('root', '0', 'root:x:0:0'):
|
|
75
|
+
user_is_root = True
|
|
76
|
+
|
|
77
|
+
# Check for HEALTHCHECK
|
|
78
|
+
if stripped.startswith('HEALTHCHECK '):
|
|
79
|
+
has_healthcheck = True
|
|
80
|
+
|
|
81
|
+
# Check for ENV secrets
|
|
82
|
+
for pattern in secret_patterns:
|
|
83
|
+
if pattern.match(stripped):
|
|
84
|
+
env_secrets.append((lineno, stripped))
|
|
85
|
+
|
|
86
|
+
if not has_user:
|
|
87
|
+
violations.append({
|
|
88
|
+
"file": filepath,
|
|
89
|
+
"lineNo": 0,
|
|
90
|
+
"issue": "missing_user",
|
|
91
|
+
"snippet": "No USER directive found - container runs as root",
|
|
92
|
+
})
|
|
93
|
+
elif user_is_root:
|
|
94
|
+
violations.append({
|
|
95
|
+
"file": filepath,
|
|
96
|
+
"lineNo": 0,
|
|
97
|
+
"issue": "user_is_root",
|
|
98
|
+
"snippet": "USER directive runs as root - use non-root user",
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
for lineno, snippet in env_secrets:
|
|
102
|
+
violations.append({
|
|
103
|
+
"file": filepath,
|
|
104
|
+
"lineNo": lineno,
|
|
105
|
+
"issue": "env_secret",
|
|
106
|
+
"snippet": snippet[:120],
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if not has_healthcheck:
|
|
110
|
+
violations.append({
|
|
111
|
+
"file": filepath,
|
|
112
|
+
"lineNo": 0,
|
|
113
|
+
"issue": "missing_healthcheck",
|
|
114
|
+
"snippet": "No HEALTHCHECK directive found",
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return violations
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_docker_compose(content: str, filepath: str) -> list[dict]:
|
|
121
|
+
"""Check a docker-compose file for security issues."""
|
|
122
|
+
violations: list[dict] = []
|
|
123
|
+
|
|
124
|
+
# Look for hardcoded secrets in environment variables
|
|
125
|
+
secret_patterns = [
|
|
126
|
+
re.compile(r'^\s*-\s+(AWS_SECRET_ACCESS_KEY|DB_PASSWORD|SECRET_KEY|PASSWORD|TOKEN|API_KEY)\s*=\s*["\']', re.IGNORECASE),
|
|
127
|
+
re.compile(r'^\s*-\s+\w*SECRET\w*\s*=\s*["\']', re.IGNORECASE),
|
|
128
|
+
re.compile(r'^\s*-\s+\w*PASSWORD\w*\s*=\s*["\']', re.IGNORECASE),
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
privilege_pattern = re.compile(r'^\s*privileged:\s*true', re.IGNORECASE)
|
|
132
|
+
|
|
133
|
+
lines = content.splitlines()
|
|
134
|
+
for lineno, line in enumerate(lines, start=1):
|
|
135
|
+
for pattern in secret_patterns:
|
|
136
|
+
if pattern.match(line):
|
|
137
|
+
violations.append({
|
|
138
|
+
"file": filepath,
|
|
139
|
+
"lineNo": lineno,
|
|
140
|
+
"issue": "compose_env_secret",
|
|
141
|
+
"snippet": line.strip()[:120],
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
if privilege_pattern.match(line.strip()):
|
|
145
|
+
violations.append({
|
|
146
|
+
"file": filepath,
|
|
147
|
+
"lineNo": lineno,
|
|
148
|
+
"issue": "privileged_container",
|
|
149
|
+
"snippet": line.strip(),
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
return violations
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def check(target_dir: str | None = None) -> dict:
|
|
156
|
+
"""
|
|
157
|
+
Scan changed Docker files for security issues.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
dict with keys: exit_code, skipped, name, data
|
|
161
|
+
"""
|
|
162
|
+
dir_path = target_dir or os.getcwd()
|
|
163
|
+
|
|
164
|
+
files = _get_changed_dockerfiles(dir_path)
|
|
165
|
+
if files is None:
|
|
166
|
+
return {
|
|
167
|
+
"exit_code": 0, "skipped": True, "name": "docker_guard",
|
|
168
|
+
"data": {"findings": [], "message": "No git diff available"},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if not files:
|
|
172
|
+
return {
|
|
173
|
+
"exit_code": 0, "skipped": False, "name": "docker_guard",
|
|
174
|
+
"data": {"findings": [], "message": "No changed Docker files to scan"},
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
all_violations: list[dict] = []
|
|
178
|
+
|
|
179
|
+
for filepath in files:
|
|
180
|
+
full_path = Path(dir_path) / filepath
|
|
181
|
+
if not full_path.exists():
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
content = _read_file_content(full_path)
|
|
185
|
+
if content is None:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
if 'docker-compose' in filepath:
|
|
189
|
+
all_violations.extend(_check_docker_compose(content, filepath))
|
|
190
|
+
else:
|
|
191
|
+
all_violations.extend(_check_dockerfile(content, filepath))
|
|
192
|
+
|
|
193
|
+
total = len(all_violations)
|
|
194
|
+
if total:
|
|
195
|
+
msg = f"{total} Docker violation(s) found"
|
|
196
|
+
else:
|
|
197
|
+
msg = "No Docker violations found"
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
"exit_code": 1 if total else 0,
|
|
201
|
+
"skipped": False,
|
|
202
|
+
"name": "docker_guard",
|
|
203
|
+
"data": {"findings": all_violations, "message": msg},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
result = check(sys.argv[1] if len(sys.argv) > 1 else None)
|
|
209
|
+
print(json.dumps(result, indent=2))
|
|
210
|
+
sys.exit(result["exit_code"])
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PostgreSQL Guard
|
|
4
|
+
Checks for PostgreSQL security issues (SQL injection, hstore misuse) in changed Python files.
|
|
5
|
+
Self-contained: uses only Python stdlib (ast, pathlib, subprocess, json, sys).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_changed_files(target_dir: str) -> list[str] | None:
|
|
18
|
+
"""Return list of changed Python files from git diff, or None on failure."""
|
|
19
|
+
for ref in ("HEAD~1", "--cached"):
|
|
20
|
+
try:
|
|
21
|
+
args = ["git", "diff", "--name-only"]
|
|
22
|
+
if ref == "--cached":
|
|
23
|
+
args.append("--cached")
|
|
24
|
+
else:
|
|
25
|
+
args.append(ref)
|
|
26
|
+
out = subprocess.check_output(
|
|
27
|
+
args, cwd=target_dir,
|
|
28
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
29
|
+
)
|
|
30
|
+
files = [
|
|
31
|
+
f.strip() for f in out.splitlines()
|
|
32
|
+
if f.strip().endswith('.py')
|
|
33
|
+
]
|
|
34
|
+
if files:
|
|
35
|
+
return files
|
|
36
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
37
|
+
continue
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PostgresVisitor(ast.NodeVisitor):
|
|
42
|
+
"""AST visitor that detects PostgreSQL security issues."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, filepath: str):
|
|
45
|
+
self.filepath = filepath
|
|
46
|
+
self.violations: list[dict] = []
|
|
47
|
+
|
|
48
|
+
def _add_violation(self, lineno: int, issue: str, snippet: str):
|
|
49
|
+
self.violations.append({
|
|
50
|
+
"file": self.filepath,
|
|
51
|
+
"lineNo": lineno,
|
|
52
|
+
"issue": issue,
|
|
53
|
+
"snippet": snippet[:120],
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
def _is_sql_method(self, node: ast.expr) -> bool:
|
|
57
|
+
"""Check if expression is a SQL-related method call."""
|
|
58
|
+
if isinstance(node, ast.Attribute):
|
|
59
|
+
return node.attr in ('execute', 'executemany', 'callproc', 'cursor')
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def _has_formatted_sql(self, node: ast.Call) -> bool:
|
|
63
|
+
"""Check if a SQL call has formatted (injected) string arguments."""
|
|
64
|
+
for arg in node.args:
|
|
65
|
+
# f-string SQL
|
|
66
|
+
if isinstance(arg, ast.JoinedStr):
|
|
67
|
+
return True
|
|
68
|
+
# % formatted string
|
|
69
|
+
if isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Mod):
|
|
70
|
+
return True
|
|
71
|
+
# str.format() call
|
|
72
|
+
if isinstance(arg, ast.Call):
|
|
73
|
+
if isinstance(arg.func, ast.Attribute) and arg.func.attr == 'format':
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def _check_hstore_raw(self, node: ast.Call) -> bool:
|
|
78
|
+
"""Check for raw hstore usage that bypasses parameterized queries."""
|
|
79
|
+
# Look for cursor.execute("...::hstore...") patterns
|
|
80
|
+
for arg in node.args:
|
|
81
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
82
|
+
if '::hstore' in arg.value or "hstore('" in arg.value:
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
def visit_Call(self, node: ast.Call):
|
|
87
|
+
# Check for cursor.execute(), connection.execute(), etc.
|
|
88
|
+
if self._is_sql_method(node):
|
|
89
|
+
# Check for raw SQL with string formatting (f-string, %, .format())
|
|
90
|
+
if self._has_formatted_sql(node):
|
|
91
|
+
self._add_violation(
|
|
92
|
+
node.lineno or 0,
|
|
93
|
+
"sql_string_formatting",
|
|
94
|
+
"SQL query uses string formatting (f-string, %, .format()) - SQL injection risk"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Check for raw hstore usage
|
|
98
|
+
if self._check_hstore_raw(node):
|
|
99
|
+
self._add_violation(
|
|
100
|
+
node.lineno or 0,
|
|
101
|
+
"hstore_raw_usage",
|
|
102
|
+
"Raw hstore expression bypasses parameterized queries"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Check for string concatenation in SQL
|
|
106
|
+
for arg in node.args:
|
|
107
|
+
if isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add):
|
|
108
|
+
# String concatenation like "SELECT * FROM " + table
|
|
109
|
+
if isinstance(arg.left, ast.Constant) or isinstance(arg.right, ast.Constant):
|
|
110
|
+
self._add_violation(
|
|
111
|
+
node.lineno or 0,
|
|
112
|
+
"sql_string_concatenation",
|
|
113
|
+
"SQL query uses string concatenation - SQL injection risk"
|
|
114
|
+
)
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
# Check for psycopg2 sql.SQL() with .format() or .compose()
|
|
118
|
+
if isinstance(node.func, ast.Attribute):
|
|
119
|
+
if node.func.attr in ('sql', 'SQL'):
|
|
120
|
+
# Check if composed with format
|
|
121
|
+
for keyword in node.keywords:
|
|
122
|
+
if keyword.arg in ('format', 'compose'):
|
|
123
|
+
self._add_violation(
|
|
124
|
+
node.lineno or 0,
|
|
125
|
+
"sql_composition_risk",
|
|
126
|
+
"psycopg2 sql.SQL composition may bypass escaping"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
self.generic_visit(node)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def check(target_dir: str | None = None) -> dict:
|
|
133
|
+
"""
|
|
134
|
+
Scan changed Python files for PostgreSQL security issues.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
dict with keys: exit_code, skipped, name, data
|
|
138
|
+
"""
|
|
139
|
+
dir_path = target_dir or os.getcwd()
|
|
140
|
+
|
|
141
|
+
files = _get_changed_files(dir_path)
|
|
142
|
+
if files is None:
|
|
143
|
+
return {
|
|
144
|
+
"exit_code": 0, "skipped": True, "name": "postgres_guard",
|
|
145
|
+
"data": {"findings": [], "message": "No git diff available"},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if not files:
|
|
149
|
+
return {
|
|
150
|
+
"exit_code": 0, "skipped": False, "name": "postgres_guard",
|
|
151
|
+
"data": {"findings": [], "message": "No changed Python files to scan"},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
all_violations: list[dict] = []
|
|
155
|
+
|
|
156
|
+
for filepath in files:
|
|
157
|
+
full_path = Path(dir_path) / filepath
|
|
158
|
+
if not full_path.exists():
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
163
|
+
except (OSError, PermissionError):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
tree = ast.parse(content, filename=str(full_path))
|
|
168
|
+
except SyntaxError:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
visitor = PostgresVisitor(filepath)
|
|
172
|
+
visitor.visit(tree)
|
|
173
|
+
all_violations.extend(visitor.violations)
|
|
174
|
+
|
|
175
|
+
total = len(all_violations)
|
|
176
|
+
if total:
|
|
177
|
+
msg = f"{total} PostgreSQL violation(s) found"
|
|
178
|
+
else:
|
|
179
|
+
msg = "No PostgreSQL violations found"
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"exit_code": 1 if total else 0,
|
|
183
|
+
"skipped": False,
|
|
184
|
+
"name": "postgres_guard",
|
|
185
|
+
"data": {"findings": all_violations, "message": msg},
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
result = check(sys.argv[1] if len(sys.argv) > 1 else None)
|
|
191
|
+
print(json.dumps(result, indent=2))
|
|
192
|
+
sys.exit(result["exit_code"])
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Redis Guard
|
|
4
|
+
Checks for Redis configuration security issues in changed Python files.
|
|
5
|
+
Self-contained: uses only Python stdlib (ast, pathlib, subprocess, json, sys).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_changed_files(target_dir: str) -> list[str] | None:
|
|
18
|
+
"""Return list of changed Python files from git diff, or None on failure."""
|
|
19
|
+
for ref in ("HEAD~1", "--cached"):
|
|
20
|
+
try:
|
|
21
|
+
args = ["git", "diff", "--name-only"]
|
|
22
|
+
if ref == "--cached":
|
|
23
|
+
args.append("--cached")
|
|
24
|
+
else:
|
|
25
|
+
args.append(ref)
|
|
26
|
+
out = subprocess.check_output(
|
|
27
|
+
args, cwd=target_dir,
|
|
28
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
29
|
+
)
|
|
30
|
+
files = [
|
|
31
|
+
f.strip() for f in out.splitlines()
|
|
32
|
+
if f.strip().endswith('.py')
|
|
33
|
+
]
|
|
34
|
+
if files:
|
|
35
|
+
return files
|
|
36
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
37
|
+
continue
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RedisVisitor(ast.NodeVisitor):
|
|
42
|
+
"""AST visitor that detects Redis configuration security issues."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, filepath: str):
|
|
45
|
+
self.filepath = filepath
|
|
46
|
+
self.violations: list[dict] = []
|
|
47
|
+
|
|
48
|
+
def _add_violation(self, lineno: int, issue: str, snippet: str):
|
|
49
|
+
self.violations.append({
|
|
50
|
+
"file": self.filepath,
|
|
51
|
+
"lineNo": lineno,
|
|
52
|
+
"issue": issue,
|
|
53
|
+
"snippet": snippet[:120],
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
def _is_redis_url(self, node: ast.expr) -> bool:
|
|
57
|
+
"""Check if expression looks like a redis:// URL."""
|
|
58
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
59
|
+
val = node.value.lower()
|
|
60
|
+
return 'redis://' in val or 'rediss://' in val
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def _has_password_check(self, keywords: list[ast.keyword]) -> bool:
|
|
64
|
+
"""Check if password or auth is provided in Redis connection keywords."""
|
|
65
|
+
for kw in keywords:
|
|
66
|
+
if kw.arg in ('password', 'secret', 'ssl_password'):
|
|
67
|
+
if isinstance(kw.value, ast.Constant) and kw.value.value:
|
|
68
|
+
return True
|
|
69
|
+
# Check for str() wrapper which might hide empty password
|
|
70
|
+
if isinstance(kw.value, ast.Call):
|
|
71
|
+
return False # Treat wrapped values as potentially missing
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def visit_Call(self, node: ast.Call):
|
|
75
|
+
# Check for Redis() or redis.Redis() instantiation
|
|
76
|
+
is_redis_call = False
|
|
77
|
+
if isinstance(node.func, ast.Name) and node.func.id == 'Redis':
|
|
78
|
+
is_redis_call = True
|
|
79
|
+
elif isinstance(node.func, ast.Attribute) and node.func.attr == 'Redis':
|
|
80
|
+
is_redis_call = True
|
|
81
|
+
|
|
82
|
+
if is_redis_call:
|
|
83
|
+
has_decode = False
|
|
84
|
+
has_timeout = False
|
|
85
|
+
has_password = self._has_password_check(node.keywords)
|
|
86
|
+
has_hardcoded_url = False
|
|
87
|
+
|
|
88
|
+
for keyword in node.keywords:
|
|
89
|
+
if keyword.arg == 'decode_responses':
|
|
90
|
+
if isinstance(keyword.value, ast.Constant) and keyword.value.value is True:
|
|
91
|
+
has_decode = True
|
|
92
|
+
elif keyword.arg == 'socket_connect_timeout':
|
|
93
|
+
has_timeout = True
|
|
94
|
+
elif keyword.arg == 'host' and not has_password:
|
|
95
|
+
# Host without password on decode_responses is risky
|
|
96
|
+
pass
|
|
97
|
+
elif keyword.arg in ('url', 'connection_pool'):
|
|
98
|
+
# url or connection_pool might contain credentials
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# Check positional args for decode_responses
|
|
102
|
+
if node.args:
|
|
103
|
+
# First positional arg could be host or url
|
|
104
|
+
if len(node.args) >= 1:
|
|
105
|
+
arg = node.args[0]
|
|
106
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
107
|
+
if self._is_redis_url(arg):
|
|
108
|
+
has_hardcoded_url = True
|
|
109
|
+
|
|
110
|
+
# decode_responses=True without password is a security risk
|
|
111
|
+
if has_decode and not has_password:
|
|
112
|
+
self._add_violation(
|
|
113
|
+
node.lineno or 0,
|
|
114
|
+
"decode_responses_without_password",
|
|
115
|
+
"Redis decode_responses=True without password authentication"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Missing socket_connect_timeout
|
|
119
|
+
if not has_timeout:
|
|
120
|
+
self._add_violation(
|
|
121
|
+
node.lineno or 0,
|
|
122
|
+
"missing_socket_connect_timeout",
|
|
123
|
+
"Redis connection missing socket_connect_timeout"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Hardcoded Redis URL
|
|
127
|
+
if has_hardcoded_url:
|
|
128
|
+
self._add_violation(
|
|
129
|
+
node.lineno or 0,
|
|
130
|
+
"hardcoded_redis_url",
|
|
131
|
+
"Hardcoded redis:// URL in Redis() call"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self.generic_visit(node)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check(target_dir: str | None = None) -> dict:
|
|
138
|
+
"""
|
|
139
|
+
Scan changed Python files for Redis security issues.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
dict with keys: exit_code, skipped, name, data
|
|
143
|
+
"""
|
|
144
|
+
dir_path = target_dir or os.getcwd()
|
|
145
|
+
|
|
146
|
+
files = _get_changed_files(dir_path)
|
|
147
|
+
if files is None:
|
|
148
|
+
return {
|
|
149
|
+
"exit_code": 0, "skipped": True, "name": "redis_guard",
|
|
150
|
+
"data": {"findings": [], "message": "No git diff available"},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if not files:
|
|
154
|
+
return {
|
|
155
|
+
"exit_code": 0, "skipped": False, "name": "redis_guard",
|
|
156
|
+
"data": {"findings": [], "message": "No changed Python files to scan"},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
all_violations: list[dict] = []
|
|
160
|
+
|
|
161
|
+
for filepath in files:
|
|
162
|
+
full_path = Path(dir_path) / filepath
|
|
163
|
+
if not full_path.exists():
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
168
|
+
except (OSError, PermissionError):
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
tree = ast.parse(content, filename=str(full_path))
|
|
173
|
+
except SyntaxError:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
visitor = RedisVisitor(filepath)
|
|
177
|
+
visitor.visit(tree)
|
|
178
|
+
all_violations.extend(visitor.violations)
|
|
179
|
+
|
|
180
|
+
total = len(all_violations)
|
|
181
|
+
if total:
|
|
182
|
+
msg = f"{total} Redis violation(s) found"
|
|
183
|
+
else:
|
|
184
|
+
msg = "No Redis violations found"
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"exit_code": 1 if total else 0,
|
|
188
|
+
"skipped": False,
|
|
189
|
+
"name": "redis_guard",
|
|
190
|
+
"data": {"findings": all_violations, "message": msg},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
result = check(sys.argv[1] if len(sys.argv) > 1 else None)
|
|
196
|
+
print(json.dumps(result, indent=2))
|
|
197
|
+
sys.exit(result["exit_code"])
|