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
package/tools/audit.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Modular Audit Orchestrator — Python
|
|
4
|
+
Discovers check_*.py and scan_*.py guard scripts at runtime, runs each with
|
|
5
|
+
graceful degradation, and generates an English audit report (05-audit-report.md).
|
|
6
|
+
Self-contained: uses only Python stdlib.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import pathlib
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import traceback
|
|
17
|
+
|
|
18
|
+
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def find_active_sdd(change_name: str | None = None) -> pathlib.Path | None:
|
|
24
|
+
"""Find the active SDD directory by name or pick the sole candidate."""
|
|
25
|
+
sdds_dir = REPO_ROOT / "sdds"
|
|
26
|
+
if not sdds_dir.exists():
|
|
27
|
+
return None
|
|
28
|
+
if change_name:
|
|
29
|
+
target = sdds_dir / change_name
|
|
30
|
+
return target if target.is_dir() else None
|
|
31
|
+
candidates = [p for p in sdds_dir.iterdir() if p.is_dir()]
|
|
32
|
+
if len(candidates) == 1:
|
|
33
|
+
return candidates[0]
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def discover_guards(tools_dir: pathlib.Path) -> list[pathlib.Path]:
|
|
38
|
+
"""
|
|
39
|
+
Discover all check_*.py and scan_*.py guard scripts in tools/.
|
|
40
|
+
Excludes audit.py itself. Returns sorted list for deterministic ordering.
|
|
41
|
+
"""
|
|
42
|
+
guards: list[pathlib.Path] = []
|
|
43
|
+
for f in sorted(tools_dir.iterdir()):
|
|
44
|
+
if not f.is_file():
|
|
45
|
+
continue
|
|
46
|
+
name = f.name
|
|
47
|
+
if (name.startswith("check_") or name.startswith("scan_") or name.startswith("verify_")) and name.endswith(".py"):
|
|
48
|
+
guards.append(f)
|
|
49
|
+
return guards
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
|
|
53
|
+
"""
|
|
54
|
+
Run a single guard module by dynamic import and call its check() function.
|
|
55
|
+
Returns a normalized result dict on success, or a skip result on failure.
|
|
56
|
+
"""
|
|
57
|
+
guard_name = guard_path.stem # e.g. "check_scope"
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
spec = importlib.util.spec_from_file_location(guard_name, str(guard_path))
|
|
61
|
+
if spec is None or spec.loader is None:
|
|
62
|
+
return {
|
|
63
|
+
"exit_code": 0, "skipped": True, "name": guard_name,
|
|
64
|
+
"data": {"message": f"Cannot load module spec for {guard_name}"},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
mod = importlib.util.module_from_spec(spec)
|
|
68
|
+
spec.loader.exec_module(mod)
|
|
69
|
+
|
|
70
|
+
fn = getattr(mod, "check", None)
|
|
71
|
+
if fn is None or not callable(fn):
|
|
72
|
+
return {
|
|
73
|
+
"exit_code": 0, "skipped": True, "name": guard_name,
|
|
74
|
+
"data": {"message": f"Guard {guard_name} does not export a check() function"},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
result = fn(target_dir)
|
|
78
|
+
|
|
79
|
+
# Normalize result shape
|
|
80
|
+
if not isinstance(result, dict):
|
|
81
|
+
return {
|
|
82
|
+
"exit_code": 0, "skipped": True, "name": guard_name,
|
|
83
|
+
"data": {"message": f"Guard {guard_name} returned non-dict: {type(result).__name__}"},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"exit_code": result.get("exit_code", 0),
|
|
88
|
+
"skipped": bool(result.get("skipped", False)),
|
|
89
|
+
"name": result.get("name", guard_name),
|
|
90
|
+
"data": result.get("data", result),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
return {
|
|
95
|
+
"exit_code": 0, "skipped": True, "name": guard_name,
|
|
96
|
+
"data": {"message": f"Guard unavailable: {exc}"},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Report Generation ────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _format_guard_section(name: str, result: dict) -> str:
|
|
103
|
+
"""Format a single guard result into a markdown section."""
|
|
104
|
+
lines: list[str] = []
|
|
105
|
+
skipped = result.get("skipped", False)
|
|
106
|
+
exit_code = result.get("exit_code", 0)
|
|
107
|
+
data = result.get("data", {})
|
|
108
|
+
|
|
109
|
+
if skipped:
|
|
110
|
+
icon, status = "\u23ed\ufe0f", "SKIPPED"
|
|
111
|
+
elif exit_code == 0:
|
|
112
|
+
icon, status = "\u2705", "PASSED"
|
|
113
|
+
else:
|
|
114
|
+
icon, status = "\u274c", "FAILED"
|
|
115
|
+
|
|
116
|
+
title = name.replace("_", " ").title()
|
|
117
|
+
lines.append(f"### {icon} {title} \u2014 {status}")
|
|
118
|
+
lines.append("")
|
|
119
|
+
|
|
120
|
+
if skipped:
|
|
121
|
+
lines.append(f"> Skipped: {data.get('message', 'Dependencies not available')}")
|
|
122
|
+
elif exit_code == 0:
|
|
123
|
+
lines.append(f"> {data.get('message', 'All checks passed')}")
|
|
124
|
+
else:
|
|
125
|
+
lines.append(f"> {data.get('message', 'Issues found')}")
|
|
126
|
+
lines.append("")
|
|
127
|
+
|
|
128
|
+
# Format violations or findings
|
|
129
|
+
violations = data.get("violations") or data.get("findings") or {}
|
|
130
|
+
if isinstance(violations, dict):
|
|
131
|
+
for filepath, items in violations.items():
|
|
132
|
+
if not isinstance(items, list):
|
|
133
|
+
continue
|
|
134
|
+
for item in items:
|
|
135
|
+
loc = f" (line {item.get('lineNo', '')})" if item.get("lineNo") else ""
|
|
136
|
+
label = item.get("label") or item.get("rule") or item.get("module") or ""
|
|
137
|
+
msg = item.get("reason") or item.get("message") or ""
|
|
138
|
+
lines.append(f"- `{filepath}`{loc}: {f'[{label}] ' if label else ''}{msg}")
|
|
139
|
+
|
|
140
|
+
# Handle list-style data (e.g. scope unauthorized files)
|
|
141
|
+
for key in ("unrelated", "warnings", "authorized"):
|
|
142
|
+
items = data.get(key, [])
|
|
143
|
+
if isinstance(items, list) and items:
|
|
144
|
+
icon_map = {"unrelated": "\u274c", "warnings": "\u26a0\ufe0f", "authorized": "\u2705"}
|
|
145
|
+
label_map = {"unrelated": "Not in SDD scope", "warnings": "Related warnings", "authorized": "Authorized"}
|
|
146
|
+
lines.append(f"\n{icon_map.get(key, '')} **{label_map.get(key, key)}:**")
|
|
147
|
+
for f in items:
|
|
148
|
+
lines.append(f" - `{f}`")
|
|
149
|
+
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
|
|
154
|
+
"""Generate the 05-audit-report.md from aggregated guard results."""
|
|
155
|
+
task_name = sdd_dir.name
|
|
156
|
+
passed = sum(1 for r in results if not r.get("skipped") and r.get("exit_code") == 0)
|
|
157
|
+
failed = sum(1 for r in results if not r.get("skipped") and r.get("exit_code") != 0)
|
|
158
|
+
skipped = sum(1 for r in results if r.get("skipped"))
|
|
159
|
+
total = len(results)
|
|
160
|
+
|
|
161
|
+
sections: list[str] = []
|
|
162
|
+
sections.append(f"# Code Audit Report: {task_name}")
|
|
163
|
+
sections.append("")
|
|
164
|
+
sections.append("> **Note for reviewer:** This report summarizes security, architecture,")
|
|
165
|
+
sections.append("> scope, migration safety, and TDD coverage to facilitate code review.")
|
|
166
|
+
sections.append("")
|
|
167
|
+
sections.append("---")
|
|
168
|
+
sections.append("")
|
|
169
|
+
sections.append("## Summary")
|
|
170
|
+
sections.append("")
|
|
171
|
+
sections.append("| Metric | Count |")
|
|
172
|
+
sections.append("|--------|-------|")
|
|
173
|
+
sections.append(f"| Total guards | {total} |")
|
|
174
|
+
sections.append(f"| Passed | {passed} |")
|
|
175
|
+
sections.append(f"| Failed | {failed} |")
|
|
176
|
+
sections.append(f"| Skipped | {skipped} |")
|
|
177
|
+
sections.append("")
|
|
178
|
+
sections.append("---")
|
|
179
|
+
sections.append("")
|
|
180
|
+
|
|
181
|
+
for result in results:
|
|
182
|
+
sections.append(_format_guard_section(result.get("name", "unknown"), result))
|
|
183
|
+
sections.append("")
|
|
184
|
+
sections.append("---")
|
|
185
|
+
sections.append("")
|
|
186
|
+
|
|
187
|
+
return "\n".join(sections)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── Console Summary ──────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def print_summary(results: list[dict], report_path: pathlib.Path) -> None:
|
|
193
|
+
"""Print an educational summary to the terminal."""
|
|
194
|
+
print("\n" + "=" * 65)
|
|
195
|
+
print(" CLC FORGE \u2014 EDUCATIONAL AUDIT SUMMARY")
|
|
196
|
+
print("=" * 65 + "\n")
|
|
197
|
+
|
|
198
|
+
for result in results:
|
|
199
|
+
skipped = result.get("skipped", False)
|
|
200
|
+
exit_code = result.get("exit_code", 0)
|
|
201
|
+
name = result.get("name", "unknown").replace("_", " ")
|
|
202
|
+
msg = result.get("data", {}).get("message", "")
|
|
203
|
+
|
|
204
|
+
if skipped:
|
|
205
|
+
icon = "\u23ed\ufe0f"
|
|
206
|
+
elif exit_code == 0:
|
|
207
|
+
icon = "\u2705"
|
|
208
|
+
else:
|
|
209
|
+
icon = "\u274c"
|
|
210
|
+
|
|
211
|
+
print(f" {icon} {name}: {msg}")
|
|
212
|
+
|
|
213
|
+
print(f"\n Report: {report_path}")
|
|
214
|
+
print("=" * 65 + "\n")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── Main ─────────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def main(target_dir: str | None = None) -> int:
|
|
220
|
+
"""Run all discovered guards and produce the audit report."""
|
|
221
|
+
tools_dir = pathlib.Path(__file__).resolve().parent
|
|
222
|
+
dir_path = target_dir or os.getcwd()
|
|
223
|
+
|
|
224
|
+
sdd_dir = find_active_sdd()
|
|
225
|
+
if sdd_dir is None:
|
|
226
|
+
# Create a default SDD dir for the report
|
|
227
|
+
sdds_dir = REPO_ROOT / "sdds"
|
|
228
|
+
sdds_dir.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
sdd_dir = sdds_dir / "audit-run"
|
|
230
|
+
sdd_dir.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
|
|
232
|
+
print(f"\nRunning CLC Forge audit for: {sdd_dir.name}\n")
|
|
233
|
+
|
|
234
|
+
guard_paths = discover_guards(tools_dir)
|
|
235
|
+
|
|
236
|
+
if not guard_paths:
|
|
237
|
+
print("No guard tools found in tools/. Nothing to audit.")
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
print(
|
|
241
|
+
f"Discovered {len(guard_paths)} guard(s): "
|
|
242
|
+
+ ", ".join(p.stem for p in guard_paths)
|
|
243
|
+
+ "\n"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
results: list[dict] = []
|
|
247
|
+
for guard_path in guard_paths:
|
|
248
|
+
result = run_guard(guard_path, dir_path)
|
|
249
|
+
results.append(result)
|
|
250
|
+
|
|
251
|
+
# Generate and write report
|
|
252
|
+
report_content = generate_report(results, sdd_dir)
|
|
253
|
+
report_path = sdd_dir / "05-audit-report.md"
|
|
254
|
+
report_path.write_text(report_content, encoding="utf-8")
|
|
255
|
+
|
|
256
|
+
# Print summary
|
|
257
|
+
print_summary(results, report_path)
|
|
258
|
+
|
|
259
|
+
# Exit with failure if any guard failed (not skipped)
|
|
260
|
+
has_failure = any(
|
|
261
|
+
not r.get("skipped") and r.get("exit_code", 0) != 0
|
|
262
|
+
for r in results
|
|
263
|
+
)
|
|
264
|
+
return 1 if has_failure else 0
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
try:
|
|
269
|
+
target = sys.argv[1] if len(sys.argv) > 1 else None
|
|
270
|
+
sys.exit(main(target))
|
|
271
|
+
except Exception:
|
|
272
|
+
traceback.print_exc()
|
|
273
|
+
sys.exit(0) # Graceful degradation
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Celery Guard
|
|
4
|
+
Checks for Celery task 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 CeleryVisitor(ast.NodeVisitor):
|
|
42
|
+
"""AST visitor that detects Celery task 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 _is_broker_url(self, node: ast.expr) -> bool:
|
|
64
|
+
"""Check if expression looks like a broker URL."""
|
|
65
|
+
return self._is_redis_url(node)
|
|
66
|
+
|
|
67
|
+
def visit_Call(self, node: ast.Call):
|
|
68
|
+
# Check for Celery() instantiation with broker_url or result_backend
|
|
69
|
+
if isinstance(node.func, ast.Name) and node.func.id == 'Celery':
|
|
70
|
+
for keyword in node.keywords:
|
|
71
|
+
if keyword.arg == 'broker_url':
|
|
72
|
+
if self._is_broker_url(keyword.value):
|
|
73
|
+
self._add_violation(
|
|
74
|
+
node.lineno or 0,
|
|
75
|
+
"hardcoded_broker_url",
|
|
76
|
+
f"Celery broker_url directly in code"
|
|
77
|
+
)
|
|
78
|
+
if keyword.arg == 'result_backend':
|
|
79
|
+
if not isinstance(keyword.value, ast.Constant) or keyword.value.value is None:
|
|
80
|
+
pass # Skip None defaults
|
|
81
|
+
elif isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, str):
|
|
82
|
+
val = keyword.value.value.lower()
|
|
83
|
+
if val and val not in ('None', 'null', ''):
|
|
84
|
+
self._add_violation(
|
|
85
|
+
node.lineno or 0,
|
|
86
|
+
"hardcoded_result_backend",
|
|
87
|
+
f"Celery result_backend directly in code"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Check for @shared_task or @celery.task decorators
|
|
91
|
+
# Handle @shared_task(...) or @shared_task
|
|
92
|
+
try:
|
|
93
|
+
for decorator in node.decorator_list:
|
|
94
|
+
dec_name = ''
|
|
95
|
+
dec_args: list[ast.expr] = []
|
|
96
|
+
if isinstance(decorator, ast.Call):
|
|
97
|
+
if isinstance(decorator.func, ast.Name):
|
|
98
|
+
dec_name = decorator.func.id
|
|
99
|
+
elif isinstance(decorator.func, ast.Attribute):
|
|
100
|
+
dec_name = decorator.func.attr
|
|
101
|
+
dec_args = decorator.args
|
|
102
|
+
elif isinstance(decorator, ast.Name):
|
|
103
|
+
dec_name = decorator.id
|
|
104
|
+
elif isinstance(decorator, ast.Attribute):
|
|
105
|
+
dec_name = decorator.attr
|
|
106
|
+
|
|
107
|
+
if dec_name in ('shared_task', 'task'):
|
|
108
|
+
# Check if ignore_result=True is missing
|
|
109
|
+
ignore_found = False
|
|
110
|
+
for arg in dec_args:
|
|
111
|
+
if isinstance(arg, ast.keyword) and arg.arg == 'ignore_result':
|
|
112
|
+
if isinstance(arg.value, ast.Constant) and arg.value.value is True:
|
|
113
|
+
ignore_found = True
|
|
114
|
+
if not ignore_found:
|
|
115
|
+
# Check decorators with keyword args
|
|
116
|
+
for decorator_node in node.decorator_list:
|
|
117
|
+
if isinstance(decorator_node, ast.Call):
|
|
118
|
+
for kw in decorator_node.keywords:
|
|
119
|
+
if kw.arg == 'ignore_result':
|
|
120
|
+
if isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
121
|
+
ignore_found = True
|
|
122
|
+
if not ignore_found:
|
|
123
|
+
snippet = f"@{dec_name} without ignore_result=True"
|
|
124
|
+
self._add_violation(node.lineno or 0, "missing_ignore_result", snippet)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
self.generic_visit(node)
|
|
129
|
+
|
|
130
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
131
|
+
# Check function body for broker URL strings
|
|
132
|
+
for stmt in ast.walk(node):
|
|
133
|
+
if isinstance(stmt, ast.Assign):
|
|
134
|
+
for target in stmt.targets:
|
|
135
|
+
if isinstance(target, ast.Name):
|
|
136
|
+
var_name = target.id.lower()
|
|
137
|
+
if 'broker' in var_name or 'url' in var_name:
|
|
138
|
+
if isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str):
|
|
139
|
+
val = stmt.value.value.lower()
|
|
140
|
+
if 'redis://' in val or 'rediss://' in val:
|
|
141
|
+
self._add_violation(
|
|
142
|
+
stmt.lineno or 0,
|
|
143
|
+
"broker_url_in_variable",
|
|
144
|
+
f"Broker URL assigned to variable {target.id}"
|
|
145
|
+
)
|
|
146
|
+
self.generic_visit(node)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check(target_dir: str | None = None) -> dict:
|
|
150
|
+
"""
|
|
151
|
+
Scan changed Python files for Celery security issues.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
dict with keys: exit_code, skipped, name, data
|
|
155
|
+
"""
|
|
156
|
+
dir_path = target_dir or os.getcwd()
|
|
157
|
+
|
|
158
|
+
files = _get_changed_files(dir_path)
|
|
159
|
+
if files is None:
|
|
160
|
+
return {
|
|
161
|
+
"exit_code": 0, "skipped": True, "name": "celery_guard",
|
|
162
|
+
"data": {"findings": [], "message": "No git diff available"},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if not files:
|
|
166
|
+
return {
|
|
167
|
+
"exit_code": 0, "skipped": False, "name": "celery_guard",
|
|
168
|
+
"data": {"findings": [], "message": "No changed Python files to scan"},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
all_violations: list[dict] = []
|
|
172
|
+
|
|
173
|
+
for filepath in files:
|
|
174
|
+
full_path = Path(dir_path) / filepath
|
|
175
|
+
if not full_path.exists():
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
180
|
+
except (OSError, PermissionError):
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
tree = ast.parse(content, filename=str(full_path))
|
|
185
|
+
except SyntaxError:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
visitor = CeleryVisitor(filepath)
|
|
189
|
+
visitor.visit(tree)
|
|
190
|
+
all_violations.extend(visitor.violations)
|
|
191
|
+
|
|
192
|
+
total = len(all_violations)
|
|
193
|
+
if total:
|
|
194
|
+
msg = f"{total} Celery violation(s) found"
|
|
195
|
+
else:
|
|
196
|
+
msg = "No Celery violations found"
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"exit_code": 1 if total else 0,
|
|
200
|
+
"skipped": False,
|
|
201
|
+
"name": "celery_guard",
|
|
202
|
+
"data": {"findings": all_violations, "message": msg},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
result = check(sys.argv[1] if len(sys.argv) > 1 else None)
|
|
208
|
+
print(json.dumps(result, indent=2))
|
|
209
|
+
sys.exit(result["exit_code"])
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Accessibility & ARIA Guard
|
|
4
|
+
* Scans changed .tsx/.jsx files for missing alt tags, aria-labels,
|
|
5
|
+
* and semantic HTML violations.
|
|
6
|
+
* Self-contained: uses only Node.js built-ins.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} targetDir - Directory to scan (defaults to cwd)
|
|
9
|
+
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
10
|
+
*/
|
|
11
|
+
const check = async function checkA11y(targetDir) {
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { execSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const dir = targetDir || process.cwd();
|
|
17
|
+
|
|
18
|
+
let files;
|
|
19
|
+
try {
|
|
20
|
+
const out = execSync('git diff --name-only HEAD~1', {
|
|
21
|
+
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
22
|
+
});
|
|
23
|
+
files = out.split('\n').filter(f => /\.(tsx|jsx)$/.test(f.trim()));
|
|
24
|
+
} catch {
|
|
25
|
+
try {
|
|
26
|
+
const out = execSync('git diff --name-only --cached', {
|
|
27
|
+
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
28
|
+
});
|
|
29
|
+
files = out.split('\n').filter(f => /\.(tsx|jsx)$/.test(f.trim()));
|
|
30
|
+
} catch {
|
|
31
|
+
return { exitCode: 0, skipped: true, name: 'check_a11y', data: { violations: {}, message: 'No git diff available' } };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!files || files.length === 0) {
|
|
36
|
+
return { exitCode: 0, skipped: false, name: 'check_a11y', data: { violations: {}, message: 'No frontend files changed' } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const violations = {};
|
|
40
|
+
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
const filePath = path.join(dir, file);
|
|
43
|
+
if (!fs.existsSync(filePath)) continue;
|
|
44
|
+
|
|
45
|
+
let content;
|
|
46
|
+
try {
|
|
47
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lines = content.split('\n');
|
|
53
|
+
const fileViolations = [];
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < lines.length; i++) {
|
|
56
|
+
const line = lines[i];
|
|
57
|
+
|
|
58
|
+
// Check for <img without alt
|
|
59
|
+
if (/<img\b[^>]*(?!.*\balt\s*=)[^>]*>/i.test(line) && !/\balt\s*=/.test(line)) {
|
|
60
|
+
fileViolations.push({ lineNo: i + 1, rule: 'img-alt', message: 'Image missing alt attribute' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check for <input without aria-label or aria-labelledby
|
|
64
|
+
if (/<input\b/i.test(line) && !/aria-label|aria-labelledby|aria-describedby/.test(line)) {
|
|
65
|
+
fileViolations.push({ lineNo: i + 1, rule: 'input-label', message: 'Input missing aria-label or aria-labelledby' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check for button/link with only icon and no aria-label
|
|
69
|
+
if (/<button[^>]*>\s*(?:<[A-Z]|<span[^>]*className=['"][^'"]*(?:icon|Icon))/i.test(line) && !/aria-label/.test(line)) {
|
|
70
|
+
fileViolations.push({ lineNo: i + 1, rule: 'icon-button', message: 'Icon-only button missing aria-label' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check for role="button" without keyboard handler
|
|
74
|
+
if (/role\s*=\s*['"]button['"]/i.test(line) && !/onKeyDown|onKeyUp|onKeyPress/i.test(line)) {
|
|
75
|
+
fileViolations.push({ lineNo: i + 1, rule: 'role-button-keyboard', message: 'role="button" without keyboard handler' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check for <a> with href but missing accessible text for icon-only links
|
|
79
|
+
if (/<a\b[^>]*>\s*<[A-Z]/i.test(line) && !/aria-label/.test(line)) {
|
|
80
|
+
fileViolations.push({ lineNo: i + 1, rule: 'link-accessible', message: 'Link with component child may need aria-label' });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (fileViolations.length > 0) {
|
|
85
|
+
violations[file] = fileViolations;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const totalCount = Object.values(violations).flat().length;
|
|
90
|
+
return {
|
|
91
|
+
exitCode: totalCount > 0 ? 1 : 0,
|
|
92
|
+
skipped: false,
|
|
93
|
+
name: 'check_a11y',
|
|
94
|
+
data: {
|
|
95
|
+
violations,
|
|
96
|
+
message: totalCount > 0
|
|
97
|
+
? `${totalCount} a11y issue(s) found in ${Object.keys(violations).length} file(s)`
|
|
98
|
+
: 'All a11y checks passed',
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
module.exports = check;
|
|
103
|
+
|
|
104
|
+
if (require.main === module) {
|
|
105
|
+
check(process.argv[2]).then(result => {
|
|
106
|
+
console.log(JSON.stringify(result, null, 2));
|
|
107
|
+
process.exit(result.exitCode);
|
|
108
|
+
});
|
|
109
|
+
}
|