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.
Files changed (71) hide show
  1. package/AGENTS.md +124 -0
  2. package/README.es.md +166 -0
  3. package/README.md +167 -0
  4. package/bin/cli.js +12 -0
  5. package/docs/Journal/001-adr-clckernel-governance.md +18 -0
  6. package/package.json +44 -0
  7. package/src/adapters/astro.js +67 -0
  8. package/src/adapters/base.js +109 -0
  9. package/src/adapters/django.js +70 -0
  10. package/src/adapters/fastapi.js +69 -0
  11. package/src/adapters/go.js +50 -0
  12. package/src/adapters/laravel.js +52 -0
  13. package/src/adapters/nextjs.js +72 -0
  14. package/src/adapters/rails.js +54 -0
  15. package/src/adapters/rust.js +51 -0
  16. package/src/catalog.js +217 -0
  17. package/src/config.js +250 -0
  18. package/src/detector.js +85 -0
  19. package/src/doctor.js +125 -0
  20. package/src/generator.js +473 -0
  21. package/src/index.js +52 -0
  22. package/src/technologies/detector.js +243 -0
  23. package/src/technologies/guards/_shared.js +66 -0
  24. package/src/ui.js +101 -0
  25. package/src/yaml.js +173 -0
  26. package/test/adapters/astro.test.js +102 -0
  27. package/test/adapters/base.test.js +86 -0
  28. package/test/adapters/django.test.js +95 -0
  29. package/test/adapters/fastapi.test.js +88 -0
  30. package/test/adapters/go.test.js +81 -0
  31. package/test/adapters/laravel.test.js +106 -0
  32. package/test/adapters/nextjs.test.js +113 -0
  33. package/test/adapters/rails.test.js +108 -0
  34. package/test/adapters/rust.test.js +83 -0
  35. package/test/catalog.test.js +170 -0
  36. package/test/config.test.js +512 -0
  37. package/test/detector.test.js +345 -0
  38. package/test/doctor.test.js +302 -0
  39. package/test/generator.test.js +419 -0
  40. package/test/guards_new.test.js +95 -0
  41. package/test/helpers.js +36 -0
  42. package/test/integration/cli-flow.test.js +270 -0
  43. package/test/technologies/celery_guard.test.js +185 -0
  44. package/test/technologies/detector.test.js +342 -0
  45. package/test/technologies/docker_guard.test.js +187 -0
  46. package/test/technologies/integration.test.js +123 -0
  47. package/test/technologies/postgres_guard.test.js +87 -0
  48. package/test/technologies/redis_guard.test.js +128 -0
  49. package/tools/audit.js +219 -0
  50. package/tools/audit.py +273 -0
  51. package/tools/celery_guard.py +209 -0
  52. package/tools/check_a11y.js +109 -0
  53. package/tools/check_api_contracts.js +139 -0
  54. package/tools/check_architecture.js +139 -0
  55. package/tools/check_architecture.py +264 -0
  56. package/tools/check_custom.js +163 -0
  57. package/tools/check_custom.py +395 -0
  58. package/tools/check_db_efficiency.py +190 -0
  59. package/tools/check_migrations.py +223 -0
  60. package/tools/check_performance.js +142 -0
  61. package/tools/check_responsive.js +131 -0
  62. package/tools/check_scope.py +180 -0
  63. package/tools/check_seo.js +139 -0
  64. package/tools/check_storybook.js +108 -0
  65. package/tools/check_ui_reuse.js +135 -0
  66. package/tools/docker_guard.py +210 -0
  67. package/tools/postgres_guard.py +192 -0
  68. package/tools/redis_guard.py +197 -0
  69. package/tools/scan_secrets.js +109 -0
  70. package/tools/scan_secrets.py +153 -0
  71. 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"])