wizard-central-toolkit 0.2.0__py3-none-any.whl
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.
- wiz_central_toolkit/__init__.py +2 -0
- wiz_central_toolkit/bannerwiz/__main__.py +6 -0
- wiz_central_toolkit/bannerwiz/cli.py +79 -0
- wiz_central_toolkit/bannerwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/bannerwiz/lib/banner_wizard.py +999 -0
- wiz_central_toolkit/bannerwiz/lib/banner_wrapper.py +199 -0
- wiz_central_toolkit/bannerwiz/lib/cli.py +79 -0
- wiz_central_toolkit/codeguardwiz/__init__.py +1 -0
- wiz_central_toolkit/codeguardwiz/__main__.py +19 -0
- wiz_central_toolkit/codeguardwiz/cli.py +971 -0
- wiz_central_toolkit/codeguardwiz/codeguard_wizard.py +269 -0
- wiz_central_toolkit/codeguardwiz/rust_engine.py +348 -0
- wiz_central_toolkit/codeguardwiz/static_engine.py +307 -0
- wiz_central_toolkit/common/__init__.py +1 -0
- wiz_central_toolkit/debugwiz/__init__.py +1 -0
- wiz_central_toolkit/debugwiz/__main__.py +360 -0
- wiz_central_toolkit/debugwiz/debug_wizard.py +364 -0
- wiz_central_toolkit/diffwiz/__init__.py +0 -0
- wiz_central_toolkit/diffwiz/__main__.py +21 -0
- wiz_central_toolkit/diffwiz/cli.py +134 -0
- wiz_central_toolkit/diffwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/diffwiz/lib/cli.py +144 -0
- wiz_central_toolkit/diffwiz/lib/diff_wizard.py +89 -0
- wiz_central_toolkit/godfatherwiz/__init__.py +0 -0
- wiz_central_toolkit/godfatherwiz/__main__.py +18 -0
- wiz_central_toolkit/godfatherwiz/ai.py +136 -0
- wiz_central_toolkit/godfatherwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/godfatherwiz/lib/cli.py +925 -0
- wiz_central_toolkit/godfatherwiz/lib/godfather_wizard.py +1552 -0
- wiz_central_toolkit/jsonwiz/__init__.py +0 -0
- wiz_central_toolkit/jsonwiz/__main__.py +21 -0
- wiz_central_toolkit/jsonwiz/cli.py +4 -0
- wiz_central_toolkit/jsonwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/jsonwiz/lib/cli.py +138 -0
- wiz_central_toolkit/jsonwiz/lib/json_wizard.py +133 -0
- wiz_central_toolkit/pythonwiz/__init__.py +0 -0
- wiz_central_toolkit/pythonwiz/__main__.py +21 -0
- wiz_central_toolkit/pythonwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/pythonwiz/lib/cli.py +247 -0
- wiz_central_toolkit/pythonwiz/lib/python_wizard.py +353 -0
- wiz_central_toolkit/readwiz/__init__.py +5 -0
- wiz_central_toolkit/readwiz/__main__.py +15 -0
- wiz_central_toolkit/readwiz/cli.py +46 -0
- wiz_central_toolkit/readwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/readwiz/lib/cli.py +47 -0
- wiz_central_toolkit/readwiz/lib/read_wizard.py +121 -0
- wiz_central_toolkit/rustwiz/__init__.py +5 -0
- wiz_central_toolkit/rustwiz/__main__.py +7 -0
- wiz_central_toolkit/rustwiz/cli.py +4 -0
- wiz_central_toolkit/rustwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/rustwiz/lib/cli.py +53 -0
- wiz_central_toolkit/rustwiz/lib/rust_wizard.py +128 -0
- wiz_central_toolkit/tmuxwiz/__init__.py +0 -0
- wiz_central_toolkit/tmuxwiz/__main__.py +15 -0
- wiz_central_toolkit/tmuxwiz/cli.py +7 -0
- wiz_central_toolkit/tmuxwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/tmuxwiz/lib/cli.py +10 -0
- wiz_central_toolkit/tmuxwiz/lib/tmux_wizard.py +229 -0
- wiz_central_toolkit/todowiz/__init__.py +0 -0
- wiz_central_toolkit/todowiz/__main__.py +22 -0
- wiz_central_toolkit/todowiz/cli.py +4 -0
- wiz_central_toolkit/todowiz/lib/__init__.py +1 -0
- wiz_central_toolkit/todowiz/lib/cli.py +82 -0
- wiz_central_toolkit/todowiz/lib/todo_wizard.py +134 -0
- wiz_central_toolkit/tools_registry.py +62 -0
- wiz_central_toolkit/wiz/__init__.py +0 -0
- wiz_central_toolkit/wiz/__main__.py +141 -0
- wiz_central_toolkit/writewiz/__init__.py +1 -0
- wiz_central_toolkit/writewiz/__main__.py +9 -0
- wiz_central_toolkit/writewiz/cli.py +264 -0
- wiz_central_toolkit/writewiz/write_wizard.py +384 -0
- wizard_central_toolkit-0.2.0.dist-info/METADATA +203 -0
- wizard_central_toolkit-0.2.0.dist-info/RECORD +77 -0
- wizard_central_toolkit-0.2.0.dist-info/WHEEL +5 -0
- wizard_central_toolkit-0.2.0.dist-info/entry_points.txt +50 -0
- wizard_central_toolkit-0.2.0.dist-info/licenses/LICENSE +201 -0
- wizard_central_toolkit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeGuard Wizard — universal code auditor & auto-fixer.
|
|
3
|
+
Naturalized citizen: stdlib-only (no requests), live daemon roster (no
|
|
4
|
+
hardcoded models), localhost default. Verdicts are plain text.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import urllib.request
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
OLLAMA_BASE = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")
|
|
15
|
+
|
|
16
|
+
# Seat preferences, honored ONLY if the live daemon actually serves them.
|
|
17
|
+
AUDIT_PREFS = ["qwen2.5-coder:3b-instruct", "stable-code:latest",
|
|
18
|
+
"qwen2.5-coder:1.5b-instruct"]
|
|
19
|
+
FIX_PREFS = ["debug-seat:latest", "ops-seat:latest", "scholar-seat:latest",
|
|
20
|
+
"qwen2.5-coder:3b-instruct"]
|
|
21
|
+
|
|
22
|
+
EXT_MAP = {
|
|
23
|
+
".py": "python", ".js": "javascript", ".ts": "typescript", ".jsx": "javascript",
|
|
24
|
+
".tsx": "typescript", ".java": "java", ".c": "c", ".cpp": "cpp", ".cc": "cpp",
|
|
25
|
+
".h": "c", ".hpp": "cpp", ".cs": "csharp", ".go": "go", ".rs": "rust",
|
|
26
|
+
".rb": "ruby", ".php": "php", ".swift": "swift", ".kt": "kotlin",
|
|
27
|
+
".scala": "scala", ".lua": "lua", ".pl": "perl", ".r": "r",
|
|
28
|
+
".sh": "bash", ".bash": "bash", ".zsh": "bash", ".fish": "bash",
|
|
29
|
+
".ps1": "powershell", ".bat": "batch", ".cmd": "batch",
|
|
30
|
+
".sql": "sql", ".html": "html", ".css": "css", ".scss": "scss",
|
|
31
|
+
".yml": "yaml", ".yaml": "yaml", ".json": "json", ".xml": "xml",
|
|
32
|
+
".toml": "toml", ".ini": "ini", ".cfg": "ini",
|
|
33
|
+
}
|
|
34
|
+
SHEBANG_PATTERNS = {
|
|
35
|
+
"python": ["python", "python3"],
|
|
36
|
+
"bash": ["bash", "sh", "zsh", "fish"],
|
|
37
|
+
"ruby": ["ruby"], "perl": ["perl"], "node": ["node"],
|
|
38
|
+
"lua": ["lua"], "php": ["php"],
|
|
39
|
+
"powershell": ["pwsh", "powershell"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_roster() -> list:
|
|
44
|
+
"""Ground truth: models actually served by the live daemon."""
|
|
45
|
+
try:
|
|
46
|
+
with urllib.request.urlopen(f"{OLLAMA_BASE}/api/tags", timeout=5) as r:
|
|
47
|
+
return sorted(m.get("name", "?")
|
|
48
|
+
for m in json.loads(r.read()).get("models", []))
|
|
49
|
+
except Exception:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pick_seat(roster, prefs, env_var):
|
|
54
|
+
"""Env override wins; then preference order; then nothing (honest FAIL)."""
|
|
55
|
+
override = os.environ.get(env_var, "").strip()
|
|
56
|
+
if override:
|
|
57
|
+
return override if override in roster or roster == [] else None
|
|
58
|
+
for pref in prefs:
|
|
59
|
+
for name in roster:
|
|
60
|
+
if name == pref or name.startswith(pref.split(":")[0]):
|
|
61
|
+
return name
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def detect_language(filepath: str) -> str:
|
|
66
|
+
"""Extension, then shebang, then content sniffing."""
|
|
67
|
+
p = Path(filepath)
|
|
68
|
+
ext = p.suffix.lower()
|
|
69
|
+
if ext in EXT_MAP:
|
|
70
|
+
return EXT_MAP[ext]
|
|
71
|
+
name = p.name.lower()
|
|
72
|
+
if name == "dockerfile":
|
|
73
|
+
return "dockerfile"
|
|
74
|
+
if name == "makefile":
|
|
75
|
+
return "makefile"
|
|
76
|
+
try:
|
|
77
|
+
with open(filepath, "r", errors="ignore") as f:
|
|
78
|
+
first_line = f.readline().strip()
|
|
79
|
+
if first_line.startswith("#!"):
|
|
80
|
+
shebang = first_line.lower()
|
|
81
|
+
for lang, markers in SHEBANG_PATTERNS.items():
|
|
82
|
+
if any(m in shebang for m in markers):
|
|
83
|
+
return lang
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
try:
|
|
87
|
+
with open(filepath, encoding="utf-8", errors="ignore") as fh:
|
|
88
|
+
content = fh.read(2048).lower()
|
|
89
|
+
sigs = {
|
|
90
|
+
"python": ["def ", "import ", "class ", "self.", "print(", "if __name__"],
|
|
91
|
+
"javascript": ["function ", "const ", "let ", "var ", "console.log", "=>"],
|
|
92
|
+
"go": ["func ", "package ", "import (", "fmt.Print"],
|
|
93
|
+
"rust": ["fn ", "let mut", "impl ", "pub fn", "println!"],
|
|
94
|
+
"ruby": ["def ", "puts ", "require ", "module "],
|
|
95
|
+
"php": ["<?php", "function ", "$this->", "echo "],
|
|
96
|
+
"c": ["#include", "int main", "printf(", "struct "],
|
|
97
|
+
"cpp": ["#include", "std::", "int main", "cout <<"],
|
|
98
|
+
}
|
|
99
|
+
scores = {lang: sum(content.count(kw) for kw in kws)
|
|
100
|
+
for lang, kws in sigs.items()}
|
|
101
|
+
best = max(scores, key=lambda k: scores[k]) if scores else None
|
|
102
|
+
if best and scores[best] >= 3:
|
|
103
|
+
return best
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
return "unknown"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _call_ollama(model: str, prompt: str, timeout: int = 120) -> str:
|
|
110
|
+
"""Stdlib-only daemon call. Returns '' on any failure."""
|
|
111
|
+
try:
|
|
112
|
+
payload = json.dumps({"model": model, "prompt": prompt,
|
|
113
|
+
"stream": False}).encode()
|
|
114
|
+
req = urllib.request.Request(f"{OLLAMA_BASE}/api/generate", data=payload,
|
|
115
|
+
headers={"Content-Type": "application/json"})
|
|
116
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
117
|
+
return json.loads(r.read()).get("response", "").strip()
|
|
118
|
+
except Exception:
|
|
119
|
+
return ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _try_parse_json(text: str):
|
|
123
|
+
try:
|
|
124
|
+
return json.loads(text.strip())
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
pass
|
|
127
|
+
match = re.search(r"\{[\s\S]*\}", text)
|
|
128
|
+
if match:
|
|
129
|
+
try:
|
|
130
|
+
return json.loads(match.group())
|
|
131
|
+
except json.JSONDecodeError:
|
|
132
|
+
pass
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _hash(content: str) -> str:
|
|
137
|
+
return hashlib.sha256(content.encode()).hexdigest()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_audit_prompt(language, code, filepath):
|
|
141
|
+
return f"""You are a senior code auditor and security engineer. Analyze the following {language} code.
|
|
142
|
+
|
|
143
|
+
FILE: {filepath}
|
|
144
|
+
LANGUAGE: {language}
|
|
145
|
+
|
|
146
|
+
Audit for: security flaws, logic bugs, edge cases, quality issues, design concerns.
|
|
147
|
+
|
|
148
|
+
Format your response EXACTLY as this JSON structure:
|
|
149
|
+
{{
|
|
150
|
+
"language": "{language}",
|
|
151
|
+
"issues": [
|
|
152
|
+
{{
|
|
153
|
+
"severity": "CRITICAL|HIGH|MEDIUM|LOW",
|
|
154
|
+
"category": "security|logic|edge_case|quality|design",
|
|
155
|
+
"line_range": [start_line, end_line],
|
|
156
|
+
"title": "Short title",
|
|
157
|
+
"description": "Detailed explanation",
|
|
158
|
+
"fix": "Concrete fix suggestion"
|
|
159
|
+
}}
|
|
160
|
+
],
|
|
161
|
+
"summary": "Overall assessment paragraph",
|
|
162
|
+
"score": 0-100
|
|
163
|
+
}}
|
|
164
|
+
|
|
165
|
+
CODE TO AUDIT:
|
|
166
|
+
---
|
|
167
|
+
{code}
|
|
168
|
+
---"""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_fix_prompt(language, code, filepath, issues_json):
|
|
172
|
+
return f"""You are an expert {language} developer. Fix ALL the issues identified in the audit.
|
|
173
|
+
|
|
174
|
+
Return ONLY the complete corrected code — no markdown fences, no explanations, no preamble.
|
|
175
|
+
Preserve comments and formatting not part of an issue. Add imports if a fix requires them.
|
|
176
|
+
|
|
177
|
+
ORIGINAL FILE: {filepath}
|
|
178
|
+
|
|
179
|
+
AUDIT REPORT:
|
|
180
|
+
{issues_json}
|
|
181
|
+
|
|
182
|
+
ORIGINAL CODE:
|
|
183
|
+
{code}
|
|
184
|
+
|
|
185
|
+
OUTPUT THE FULL CORRECTED CODE NOW:"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def read_code(filepath):
|
|
189
|
+
try:
|
|
190
|
+
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
|
191
|
+
return f.read()
|
|
192
|
+
except OSError:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def audit(filepath: str, model: str | None = None) -> dict:
|
|
197
|
+
"""Audit one file. Returns a result dict; status OK/FAILED."""
|
|
198
|
+
code = read_code(filepath)
|
|
199
|
+
if code is None:
|
|
200
|
+
return {"status": "FAILED", "reason": "cannot read file"}
|
|
201
|
+
language = detect_language(filepath)
|
|
202
|
+
if language == "unknown":
|
|
203
|
+
return {"status": "FAILED", "reason": "cannot detect language"}
|
|
204
|
+
roster = load_roster()
|
|
205
|
+
audit_model = model or pick_seat(roster, AUDIT_PREFS, "CODEGUARD_AUDIT_MODEL")
|
|
206
|
+
if not audit_model:
|
|
207
|
+
return {"status": "FAILED",
|
|
208
|
+
"reason": "no audit seat: daemon unreachable or no coder seat served",
|
|
209
|
+
"roster_size": len(roster)}
|
|
210
|
+
raw = _call_ollama(audit_model,
|
|
211
|
+
build_audit_prompt(language, code, filepath))
|
|
212
|
+
if not raw:
|
|
213
|
+
return {"status": "FAILED", "reason": "ollama audit call failed",
|
|
214
|
+
"model": audit_model}
|
|
215
|
+
audit_data = _try_parse_json(raw)
|
|
216
|
+
if audit_data is None:
|
|
217
|
+
return {"status": "FAILED", "reason": "unparseable audit response",
|
|
218
|
+
"raw": raw[:400]}
|
|
219
|
+
counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
|
220
|
+
for issue in audit_data.get("issues", []):
|
|
221
|
+
sev = issue.get("severity", "LOW")
|
|
222
|
+
if sev in counts:
|
|
223
|
+
counts[sev] += 1
|
|
224
|
+
return {"status": "OK", "filepath": filepath, "language": language,
|
|
225
|
+
"model": audit_model, "code_hash": _hash(code),
|
|
226
|
+
"audit": audit_data, "issues_count": counts,
|
|
227
|
+
"timestamp": datetime.now().isoformat()}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def fix(
|
|
231
|
+
filepath: str, audit_result: dict | None = None, model: str | None = None
|
|
232
|
+
) -> dict:
|
|
233
|
+
"""Generate fixed code for one file. Does NOT write — caller consents."""
|
|
234
|
+
code = read_code(filepath)
|
|
235
|
+
if code is None:
|
|
236
|
+
return {"status": "FAILED", "reason": "cannot read file"}
|
|
237
|
+
original_hash = _hash(code)
|
|
238
|
+
if audit_result is None:
|
|
239
|
+
audit_result = audit(filepath)
|
|
240
|
+
if audit_result["status"] != "OK":
|
|
241
|
+
return {"status": "FAILED", "reason": "audit failed before fix"}
|
|
242
|
+
issues = audit_result.get("audit", {}).get("issues", [])
|
|
243
|
+
if not issues:
|
|
244
|
+
return {"status": "OK", "filepath": filepath,
|
|
245
|
+
"original_hash": original_hash, "fixed_hash": original_hash,
|
|
246
|
+
"fixed_code": code, "issues_fixed": 0,
|
|
247
|
+
"message": "no issues to fix"}
|
|
248
|
+
roster = load_roster()
|
|
249
|
+
fix_model = model or pick_seat(roster, FIX_PREFS, "CODEGUARD_FIX_MODEL")
|
|
250
|
+
if not fix_model:
|
|
251
|
+
return {"status": "FAILED",
|
|
252
|
+
"reason": "no fix seat: daemon unreachable or no seat served"}
|
|
253
|
+
language = audit_result.get("language", "unknown")
|
|
254
|
+
prompt = build_fix_prompt(language, code, filepath,
|
|
255
|
+
json.dumps(issues, indent=2))
|
|
256
|
+
fixed_code = _call_ollama(fix_model, prompt, timeout=180)
|
|
257
|
+
if not fixed_code:
|
|
258
|
+
return {"status": "FAILED", "reason": "ollama fix call failed"}
|
|
259
|
+
if fixed_code.startswith("```"):
|
|
260
|
+
lines = fixed_code.split("\n")
|
|
261
|
+
fixed_code = "\n".join(lines[1:-1] if lines[-1].startswith("```")
|
|
262
|
+
else lines[1:])
|
|
263
|
+
fixed_code = fixed_code.strip()
|
|
264
|
+
return {"status": "OK", "filepath": filepath,
|
|
265
|
+
"original_hash": original_hash, "fixed_hash": _hash(fixed_code),
|
|
266
|
+
"fixed_code": fixed_code, "fix_model": fix_model,
|
|
267
|
+
"issues_fixed": len(issues),
|
|
268
|
+
"hashes_match": original_hash == _hash(fixed_code),
|
|
269
|
+
"timestamp": datetime.now().isoformat()}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""codeguardwiz rust engine — the Rust half of the seatless audit.
|
|
2
|
+
|
|
3
|
+
Standard library only. The static engine's sibling: same finding
|
|
4
|
+
shape, same severity scale, same score inputs, so audit / explain /
|
|
5
|
+
watch / fix consume Rust findings with no downstream changes. It
|
|
6
|
+
never calls a model, never writes a file, and — by design — never
|
|
7
|
+
executes anything in the project it audits: no cargo, no rustc, no
|
|
8
|
+
subprocess against the target at all. An audit tool must not run
|
|
9
|
+
the code it is auditing (a `cargo check` would execute build
|
|
10
|
+
scripts), so there is deliberately no toolchain pass.
|
|
11
|
+
|
|
12
|
+
Findings are plain dicts, exactly the static engine's shape:
|
|
13
|
+
{"path", "line", "rule", "name", "severity", "detail"}
|
|
14
|
+
with severity one of "high" | "medium" | "low".
|
|
15
|
+
|
|
16
|
+
Severity mapping (the design's words onto this scale): the
|
|
17
|
+
critical-class rules (shell construction, hardcoded secrets) sit at
|
|
18
|
+
"high" beside their Python twins CG004/CG002; transmute/unchecked
|
|
19
|
+
is "high" too — undefined behaviour outranks a warning. The
|
|
20
|
+
warning-class rules (unsafe regions, unwrap/expect, unpinned
|
|
21
|
+
dependencies) sit at "medium", where the Python scale keeps its
|
|
22
|
+
conditional risks (pickle, weak hash, bare except). The marker
|
|
23
|
+
rule is "low", beside CG009's TODO markers.
|
|
24
|
+
|
|
25
|
+
Scanning honesty: Rust has no standard-library AST, so this engine
|
|
26
|
+
is a line scanner, not a parser. Full-line `//` comments are
|
|
27
|
+
skipped for the code rules (the Python side's code rules read the
|
|
28
|
+
AST, where comments never fire). Patterns inside string literals
|
|
29
|
+
or /* */ block comments can still fire — the same honesty level as
|
|
30
|
+
the Python side's text lane, and the reason every finding names
|
|
31
|
+
its line for a human to judge.
|
|
32
|
+
"""
|
|
33
|
+
import re
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
ENGINE_LABEL = "static (built-in rules)"
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------- rules
|
|
39
|
+
|
|
40
|
+
_RULE_DEFS = [
|
|
41
|
+
("RS001", "unsafe-block", "medium",
|
|
42
|
+
"An unsafe block (or unsafe fn) is where Rust's memory-safety "
|
|
43
|
+
"guarantees stop being checked by the compiler and become the "
|
|
44
|
+
"programmer's promise instead. Unsafe is sometimes necessary — "
|
|
45
|
+
"FFI, low-level data structures — but every unsafe region is a "
|
|
46
|
+
"place a reviewer should be able to find and question, so the "
|
|
47
|
+
"audit keeps them all visible."),
|
|
48
|
+
("RS002", "unwrap-expect", "medium",
|
|
49
|
+
"unwrap() and expect() turn a missing value (None) or a "
|
|
50
|
+
"failure (Err) into a panic that unwinds the program. Where "
|
|
51
|
+
"the value genuinely cannot be absent they are merely terse; "
|
|
52
|
+
"anywhere input, files, or the network are involved they are "
|
|
53
|
+
"crash sites waiting for the one case nobody tested. Handle "
|
|
54
|
+
"the None/Err, or use `?` and let the caller decide."),
|
|
55
|
+
("RS003", "panic-marker", "low",
|
|
56
|
+
"panic!, todo!, and unimplemented! announce in the source "
|
|
57
|
+
"that a path crashes by design or is not finished yet. Like "
|
|
58
|
+
"a TODO comment (CG009) they are promises the code has not "
|
|
59
|
+
"kept — listed so they stay visible instead of being "
|
|
60
|
+
"discovered at runtime."),
|
|
61
|
+
("RS004", "shell-construction", "high",
|
|
62
|
+
"A command handed to a shell (sh, bash, cmd) is interpreted "
|
|
63
|
+
"as text: metacharacters in any part of it become commands "
|
|
64
|
+
"in their own right, and a command string assembled with "
|
|
65
|
+
"format! has the same shape — what runs is decided by data "
|
|
66
|
+
"at runtime. Run the program directly with an argument list "
|
|
67
|
+
"(Command::new(\"prog\").arg(...)) so arguments stay "
|
|
68
|
+
"arguments."),
|
|
69
|
+
("RS005", "unchecked-conversion", "high",
|
|
70
|
+
"transmute and the *_unchecked family switch off the safety "
|
|
71
|
+
"checks on a conversion: transmute reinterprets bytes as "
|
|
72
|
+
"another type with no validity guarantee, and get_unchecked "
|
|
73
|
+
"/ from_utf8_unchecked / unwrap_unchecked assume conditions "
|
|
74
|
+
"the compiler no longer verifies. When the assumption is "
|
|
75
|
+
"wrong the result is undefined behaviour, not an error "
|
|
76
|
+
"message. Prefer the checked conversion."),
|
|
77
|
+
("RS006", "hardcoded-secret", "high",
|
|
78
|
+
"A password, token, or key written as a string literal "
|
|
79
|
+
"directly in the source. Anyone who can read the code owns "
|
|
80
|
+
"the secret, and the secret travels with every copy of the "
|
|
81
|
+
"file. Move it to the environment or a config the code reads "
|
|
82
|
+
"at runtime. The audit names the variable and the pattern "
|
|
83
|
+
"kind only — never the value."),
|
|
84
|
+
("RS007", "unpinned-dependency", "medium",
|
|
85
|
+
"A dependency that can silently change under the build: a "
|
|
86
|
+
"\"*\" version accepts whatever is newest on the day of the "
|
|
87
|
+
"build, and a git dependency with no rev tracks a moving "
|
|
88
|
+
"branch. Either way the same source can build against "
|
|
89
|
+
"different code tomorrow — including code nobody reviewed. "
|
|
90
|
+
"Pin a version, or pin a rev."),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Rule:
|
|
95
|
+
__slots__ = ("id", "name", "severity", "explanation")
|
|
96
|
+
|
|
97
|
+
def __init__(self, rid, name, severity, explanation):
|
|
98
|
+
self.id = rid
|
|
99
|
+
self.name = name
|
|
100
|
+
self.severity = severity
|
|
101
|
+
self.explanation = explanation
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
RULES = [Rule(*d) for d in _RULE_DEFS]
|
|
105
|
+
RULE_BY_ID = {r.id: r for r in RULES}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_rule(query):
|
|
109
|
+
"""Look a rule up by id (RS002), exact name, or name fragment."""
|
|
110
|
+
q = str(query).strip()
|
|
111
|
+
if not q:
|
|
112
|
+
return None
|
|
113
|
+
if q.upper() in RULE_BY_ID:
|
|
114
|
+
return RULE_BY_ID[q.upper()]
|
|
115
|
+
ql = q.lower()
|
|
116
|
+
for r in RULES:
|
|
117
|
+
if r.name == ql:
|
|
118
|
+
return r
|
|
119
|
+
for r in RULES:
|
|
120
|
+
if ql in r.name:
|
|
121
|
+
return r
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------- scanning
|
|
126
|
+
|
|
127
|
+
_UNSAFE = re.compile(r"\bunsafe\s*(\{|fn\b)")
|
|
128
|
+
_UNWRAP = re.compile(r"\.(unwrap|expect)\s*\(")
|
|
129
|
+
_PANIC = re.compile(r"\b(panic|todo|unimplemented)!\s*[\(\[\{]")
|
|
130
|
+
_SHELL_NEW = re.compile(r"Command::new\s*\(\s*\"(sh|bash|cmd)\"")
|
|
131
|
+
_SHELL_FMT = re.compile(r"Command::new\s*\(\s*format!\s*\(")
|
|
132
|
+
_SHELL_ARGC = re.compile(r"\.arg\s*\(\s*\"(-c|/C)\"\s*\)")
|
|
133
|
+
_TRANSMUTE = re.compile(r"\btransmute(_copy)?\b")
|
|
134
|
+
_UNCHECKED = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*_unchecked)\s*\(")
|
|
135
|
+
_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
136
|
+
|
|
137
|
+
# The same secret-name vocabulary the static engine uses for CG002.
|
|
138
|
+
_SECRET_NAME = re.compile(
|
|
139
|
+
r"(password|passwd|pwd|secret|api[_-]?key|access[_-]?token"
|
|
140
|
+
r"|auth[_-]?token|private[_-]?key)", re.IGNORECASE)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _finding(path, line, rule, detail):
|
|
144
|
+
return {"path": str(path), "line": line, "rule": rule.id,
|
|
145
|
+
"name": rule.name, "severity": rule.severity,
|
|
146
|
+
"detail": detail}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_secret_name(name):
|
|
150
|
+
return bool(_SECRET_NAME.search(name)) or name.lower() == "token" \
|
|
151
|
+
or name.lower().endswith("_token")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _secret_literal_on_line(text):
|
|
155
|
+
"""Name of a secret-shaped variable given a string literal here.
|
|
156
|
+
|
|
157
|
+
Mirrors CG002's two shapes in Rust dress: `let NAME = "..."`,
|
|
158
|
+
`const NAME: Type = "..."`, and the struct-field form
|
|
159
|
+
`name: "..."`. Returns the variable name only — the literal
|
|
160
|
+
itself never leaves this function, so no finding can carry it.
|
|
161
|
+
"""
|
|
162
|
+
for m in _IDENT.finditer(text):
|
|
163
|
+
name = m.group(0)
|
|
164
|
+
if not _is_secret_name(name):
|
|
165
|
+
continue
|
|
166
|
+
tail = text[m.end():].lstrip()
|
|
167
|
+
if tail.startswith(":"):
|
|
168
|
+
tail = tail[1:].lstrip()
|
|
169
|
+
if not tail.startswith('"'):
|
|
170
|
+
eq = tail.find("=")
|
|
171
|
+
if eq == -1:
|
|
172
|
+
continue
|
|
173
|
+
tail = tail[eq + 1:].lstrip()
|
|
174
|
+
elif tail.startswith("="):
|
|
175
|
+
if tail.startswith("=="):
|
|
176
|
+
continue
|
|
177
|
+
tail = tail[1:].lstrip()
|
|
178
|
+
else:
|
|
179
|
+
continue
|
|
180
|
+
# a non-empty string literal: opening quote, then a character
|
|
181
|
+
# that is not the closing quote
|
|
182
|
+
if len(tail) >= 2 and tail[0] == '"' and tail[1] != '"':
|
|
183
|
+
return name
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def scan_source(source, path="<source>"):
|
|
188
|
+
"""All findings in one Rust source string, sorted by line."""
|
|
189
|
+
findings = []
|
|
190
|
+
for i, text in enumerate(source.splitlines(), 1):
|
|
191
|
+
if text.strip().startswith("//"):
|
|
192
|
+
continue # full-line comment: the code rules do not fire
|
|
193
|
+
m = _UNSAFE.search(text)
|
|
194
|
+
if m:
|
|
195
|
+
what = "unsafe fn" if m.group(1) == "fn" else "unsafe block"
|
|
196
|
+
findings.append(_finding(
|
|
197
|
+
path, i, RULE_BY_ID["RS001"],
|
|
198
|
+
f"{what}: the compiler's memory-safety checks are "
|
|
199
|
+
"suspended here"))
|
|
200
|
+
for m in _UNWRAP.finditer(text):
|
|
201
|
+
findings.append(_finding(
|
|
202
|
+
path, i, RULE_BY_ID["RS002"],
|
|
203
|
+
f".{m.group(1)}() panics when the value is None "
|
|
204
|
+
"or Err"))
|
|
205
|
+
for m in _PANIC.finditer(text):
|
|
206
|
+
findings.append(_finding(
|
|
207
|
+
path, i, RULE_BY_ID["RS003"],
|
|
208
|
+
f"{m.group(1)}! left in the code — an explicit "
|
|
209
|
+
"panic/unfinished marker"))
|
|
210
|
+
m = _SHELL_NEW.search(text)
|
|
211
|
+
if m:
|
|
212
|
+
findings.append(_finding(
|
|
213
|
+
path, i, RULE_BY_ID["RS004"],
|
|
214
|
+
f"Command::new(\"{m.group(1)}\") spawns a shell: "
|
|
215
|
+
"the string it is handed will be interpreted, "
|
|
216
|
+
"not just run"))
|
|
217
|
+
elif _SHELL_FMT.search(text):
|
|
218
|
+
findings.append(_finding(
|
|
219
|
+
path, i, RULE_BY_ID["RS004"],
|
|
220
|
+
"the program to run is assembled with format!: a "
|
|
221
|
+
"string built at runtime decides what executes"))
|
|
222
|
+
m = _SHELL_ARGC.search(text)
|
|
223
|
+
if m:
|
|
224
|
+
findings.append(_finding(
|
|
225
|
+
path, i, RULE_BY_ID["RS004"],
|
|
226
|
+
f"shell flag \"{m.group(1)}\" passed as an argument: "
|
|
227
|
+
"a shell will interpret the command string "
|
|
228
|
+
"that follows"))
|
|
229
|
+
m = _TRANSMUTE.search(text)
|
|
230
|
+
if m:
|
|
231
|
+
findings.append(_finding(
|
|
232
|
+
path, i, RULE_BY_ID["RS005"],
|
|
233
|
+
f"'{m.group(0)}' reinterprets bytes as another "
|
|
234
|
+
"type with no validity check"))
|
|
235
|
+
else:
|
|
236
|
+
m = _UNCHECKED.search(text)
|
|
237
|
+
if m:
|
|
238
|
+
findings.append(_finding(
|
|
239
|
+
path, i, RULE_BY_ID["RS005"],
|
|
240
|
+
f"'{m.group(1)}' skips the checked conversion: "
|
|
241
|
+
"a wrong assumption is undefined behaviour"))
|
|
242
|
+
name = _secret_literal_on_line(text)
|
|
243
|
+
if name:
|
|
244
|
+
findings.append(_finding(
|
|
245
|
+
path, i, RULE_BY_ID["RS006"],
|
|
246
|
+
f"string literal assigned to '{name}'"))
|
|
247
|
+
findings.sort(key=lambda f: (f["line"], f["rule"]))
|
|
248
|
+
return findings
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------- Cargo.toml
|
|
252
|
+
|
|
253
|
+
_SECTION = re.compile(r"^\s*\[([^\]]+)\]")
|
|
254
|
+
_DEP_WILD = re.compile(r"^([A-Za-z0-9_-]+)\s*=\s*\"\*\"")
|
|
255
|
+
_DEP_NAME = re.compile(r"^([A-Za-z0-9_-]+)\s*=\s*\{(.*)\}\s*$")
|
|
256
|
+
_VERSION_WILD = re.compile(r"version\s*=\s*\"\*\"")
|
|
257
|
+
_GIT_DEP = re.compile(r"\bgit\s*=")
|
|
258
|
+
_REV_PIN = re.compile(r"\brev\s*=")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def scan_toml_source(source, path="<source>"):
|
|
262
|
+
"""RS007 findings in one Cargo.toml source string."""
|
|
263
|
+
findings = []
|
|
264
|
+
in_deps = False
|
|
265
|
+
for i, text in enumerate(source.splitlines(), 1):
|
|
266
|
+
stripped = text.strip()
|
|
267
|
+
if stripped.startswith("#"):
|
|
268
|
+
continue
|
|
269
|
+
m = _SECTION.match(text)
|
|
270
|
+
if m:
|
|
271
|
+
in_deps = "dependencies" in m.group(1)
|
|
272
|
+
continue
|
|
273
|
+
if not in_deps or not stripped:
|
|
274
|
+
continue
|
|
275
|
+
m = _DEP_WILD.match(stripped)
|
|
276
|
+
if m:
|
|
277
|
+
findings.append(_finding(
|
|
278
|
+
path, i, RULE_BY_ID["RS007"],
|
|
279
|
+
f"dependency '{m.group(1)}' has version \"*\": it "
|
|
280
|
+
"accepts whatever is newest on build day"))
|
|
281
|
+
continue
|
|
282
|
+
m = _DEP_NAME.match(stripped)
|
|
283
|
+
if m:
|
|
284
|
+
name, body = m.group(1), m.group(2)
|
|
285
|
+
if _VERSION_WILD.search(body):
|
|
286
|
+
findings.append(_finding(
|
|
287
|
+
path, i, RULE_BY_ID["RS007"],
|
|
288
|
+
f"dependency '{name}' has version \"*\": it "
|
|
289
|
+
"accepts whatever is newest on build day"))
|
|
290
|
+
elif _GIT_DEP.search(body) and not _REV_PIN.search(body):
|
|
291
|
+
findings.append(_finding(
|
|
292
|
+
path, i, RULE_BY_ID["RS007"],
|
|
293
|
+
f"git dependency '{name}' has no pinned rev: "
|
|
294
|
+
"it tracks whatever the branch points at"))
|
|
295
|
+
findings.sort(key=lambda f: (f["line"], f["rule"]))
|
|
296
|
+
return findings
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def scan_file(path):
|
|
300
|
+
"""Findings for one file on disk. Raises OSError if unreadable."""
|
|
301
|
+
source = Path(path).read_text(encoding="utf-8", errors="ignore")
|
|
302
|
+
if Path(path).name == "Cargo.toml":
|
|
303
|
+
return scan_toml_source(source, str(path))
|
|
304
|
+
return scan_source(source, str(path))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def scan_toml_file(path):
|
|
308
|
+
"""RS007 findings for one Cargo.toml on disk."""
|
|
309
|
+
source = Path(path).read_text(encoding="utf-8", errors="ignore")
|
|
310
|
+
return scan_toml_source(source, str(path))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def collect_files(target):
|
|
314
|
+
"""The .rs files and Cargo.toml manifests a file-or-dir target names."""
|
|
315
|
+
p = Path(target)
|
|
316
|
+
if p.is_file():
|
|
317
|
+
return [p] if p.suffix == ".rs" or p.name == "Cargo.toml" else []
|
|
318
|
+
if p.is_dir():
|
|
319
|
+
out = [f for f in p.rglob("*.rs")
|
|
320
|
+
if "__pycache__" not in str(f) and ".venv" not in str(f)]
|
|
321
|
+
out += [f for f in p.rglob("Cargo.toml")
|
|
322
|
+
if "__pycache__" not in str(f) and ".venv" not in str(f)]
|
|
323
|
+
return sorted(out)
|
|
324
|
+
return []
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def counts(findings):
|
|
328
|
+
out = {"high": 0, "medium": 0, "low": 0}
|
|
329
|
+
for f in findings:
|
|
330
|
+
out[f["severity"]] += 1
|
|
331
|
+
return out
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------- fixing
|
|
335
|
+
|
|
336
|
+
FIXABLE_RULES = ()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def apply_fixes(source, findings):
|
|
340
|
+
"""No Rust finding has a defensible deterministic rewrite.
|
|
341
|
+
|
|
342
|
+
The static engine stages exactly one mechanical rewrite
|
|
343
|
+
(CG007, the bare `except:`); there is no Rust equivalent —
|
|
344
|
+
nobody's `unwrap()` gets rewritten by a pattern. Rust findings
|
|
345
|
+
are reported and explained; a rewrite proposal is the model
|
|
346
|
+
engine's job. Returns (source, 0), unchanged, always.
|
|
347
|
+
"""
|
|
348
|
+
return source, 0
|