rust-audit 0.2.3__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.
- rust_audit-0.2.3.data/data/share/rustaudit/resources/popular_crates.txt +293 -0
- rust_audit-0.2.3.data/data/share/rustaudit/resources/suspicious_names.txt +57 -0
- rust_audit-0.2.3.data/data/share/rustaudit/templates/deny.toml +27 -0
- rust_audit-0.2.3.dist-info/METADATA +109 -0
- rust_audit-0.2.3.dist-info/RECORD +10 -0
- rust_audit-0.2.3.dist-info/WHEEL +5 -0
- rust_audit-0.2.3.dist-info/entry_points.txt +3 -0
- rust_audit-0.2.3.dist-info/licenses/LICENSE +207 -0
- rust_audit-0.2.3.dist-info/top_level.txt +1 -0
- rust_audit.py +2996 -0
rust_audit.py
ADDED
|
@@ -0,0 +1,2996 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Rust Audit -- static auditor and auto-fixer for AI-generated Rust code.
|
|
3
|
+
|
|
4
|
+
Detects stubs, fakes, demo code, fake tests, panic/unsafe misuse, swallowed
|
|
5
|
+
errors, async pitfalls and hallucinated / typosquatted / fake dependencies.
|
|
6
|
+
|
|
7
|
+
Python 3.11+ standard library only. All output is in English.
|
|
8
|
+
|
|
9
|
+
Subcommands:
|
|
10
|
+
scan static analysis (offline by default, --online for registry checks)
|
|
11
|
+
fix apply safe fixes transactionally (validated, idempotent) or emit
|
|
12
|
+
assisted patch proposals as unified diffs
|
|
13
|
+
cargo evidence pipeline over real Rust tooling (clippy JSON, mutants,
|
|
14
|
+
coverage, OSV, guarddog, ...); missing tools are "skipped"
|
|
15
|
+
full scan + cargo merged report
|
|
16
|
+
selftest built-in rule smoke test
|
|
17
|
+
|
|
18
|
+
Every subcommand accepts --no-exec (static-only mode): no process is ever
|
|
19
|
+
spawned (zero-subprocess guarantee enforced at the single _spawn() entry
|
|
20
|
+
point), and every check that would have executed is recorded as skipped in
|
|
21
|
+
the not-executed report.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import bisect
|
|
27
|
+
import dataclasses
|
|
28
|
+
import difflib
|
|
29
|
+
import fnmatch
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
import time
|
|
39
|
+
try:
|
|
40
|
+
import tomllib
|
|
41
|
+
except ModuleNotFoundError: # Python < 3.11
|
|
42
|
+
sys.stderr.write(
|
|
43
|
+
"ERROR Rust Audit needs Python 3.11+ (tomllib); this interpreter is "
|
|
44
|
+
f"{sys.version.split()[0]}. Re-run with python3.13, python3.12 or python3.11.\n")
|
|
45
|
+
raise SystemExit(2)
|
|
46
|
+
import urllib.error
|
|
47
|
+
import urllib.request
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
VERSION = "0.2.3"
|
|
52
|
+
TOOL_NAME = "Rust Audit"
|
|
53
|
+
|
|
54
|
+
SEVERITY_RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
|
|
55
|
+
SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"]
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------
|
|
58
|
+
# Process-execution choke point. EVERY external command in this tool goes
|
|
59
|
+
# through _spawn() — the single subprocess.run call site of the whole file —
|
|
60
|
+
# so `--no-exec` (static-only mode) is a zero-subprocess guarantee enforced
|
|
61
|
+
# in code, not by convention: with the flag set, no path can create a process.
|
|
62
|
+
# `shutil.which` is pure PATH lookup (no execution) and stays allowed.
|
|
63
|
+
# --------------------------------------------------------------------------
|
|
64
|
+
NO_EXEC = False # set from --no-exec in main()
|
|
65
|
+
EXEC_BLOCKED_NOTE = "--no-exec: static-only mode (zero-subprocess guarantee)"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ExecBlocked(RuntimeError):
|
|
69
|
+
"""A process spawn was requested while --no-exec (static-only) is active."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _spawn(argv: list[str], *, cwd: Path, timeout: int):
|
|
73
|
+
"""The one and only subprocess.run call site of Rust Audit."""
|
|
74
|
+
if NO_EXEC:
|
|
75
|
+
raise ExecBlocked(" ".join(argv))
|
|
76
|
+
return subprocess.run(argv, cwd=cwd, capture_output=True, text=True,
|
|
77
|
+
encoding="utf-8", errors="replace", timeout=timeout)
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------
|
|
80
|
+
# Rule catalog. fix: "safe" (auto-applicable), "assisted" (diff proposal
|
|
81
|
+
# only), "none" (never auto-fixed: behaviour, concurrency, security).
|
|
82
|
+
# --------------------------------------------------------------------------
|
|
83
|
+
RULES: dict[str, dict] = {
|
|
84
|
+
"R01": {"tier": 1, "title": "Unimplemented stub", "severity": "critical", "fix": "none"},
|
|
85
|
+
"R02": {"tier": 1, "title": "Fake / meaningless test", "severity": "high", "fix": "none"},
|
|
86
|
+
"R03": {"tier": 1, "title": "Stubbed logic", "severity": "high", "fix": "none"},
|
|
87
|
+
"R04": {"tier": 1, "title": "Placeholder data used as real", "severity": "high", "fix": "none"},
|
|
88
|
+
"R05": {"tier": 1, "title": "Demo code", "severity": "medium", "fix": "none"},
|
|
89
|
+
"R06": {"tier": 1, "title": "Fake/test double outside tests", "severity": "medium", "fix": "none"},
|
|
90
|
+
"R07": {"tier": 1, "title": "Over-mocking / mock-only tests", "severity": "low", "fix": "none"},
|
|
91
|
+
"R08": {"tier": 1, "title": "Flaky / order-dependent test", "severity": "medium", "fix": "none"},
|
|
92
|
+
"R09": {"tier": 2, "title": "unwrap/expect/panic in production code", "severity": "high", "fix": "assisted"},
|
|
93
|
+
"R10": {"tier": 2, "title": "Swallowed error", "severity": "medium", "fix": "assisted"},
|
|
94
|
+
"R11": {"tier": 2, "title": "unsafe without justification", "severity": "high", "fix": "none"},
|
|
95
|
+
"R12": {"tier": 2, "title": "Hardcoded secret", "severity": "critical", "fix": "safe"},
|
|
96
|
+
"R13": {"tier": 2, "title": "Missing input validation", "severity": "medium", "fix": "none"},
|
|
97
|
+
"R14": {"tier": 3, "title": "clone() abuse", "severity": "medium", "fix": "none"},
|
|
98
|
+
"R15": {"tier": 3, "title": "Invented lifetimes", "severity": "low", "fix": "none"},
|
|
99
|
+
"R16": {"tier": 3, "title": "Async misuse (Tokio)", "severity": "high", "fix": "assisted"},
|
|
100
|
+
"R17": {"tier": 3, "title": "Arc<Mutex<>> overuse", "severity": "low", "fix": "none"},
|
|
101
|
+
"R18a": {"tier": 4, "title": "Unresolved external crate reference", "severity": "high", "fix": "none"},
|
|
102
|
+
"R18b": {"tier": 4, "title": "Standard module used as external crate", "severity": "high", "fix": "safe"},
|
|
103
|
+
"R18c": {"tier": 4, "title": "Near-miss crate name", "severity": "high", "fix": "none"},
|
|
104
|
+
"R18d": {"tier": 4, "title": "Suspicious / nonexistent / impossible crate or version", "severity": "critical", "fix": "none"},
|
|
105
|
+
"R18e": {"tier": 4, "title": "Declared dependency never used", "severity": "low", "fix": "none"},
|
|
106
|
+
"R19": {"tier": 4, "title": "Slopsquatting window (recently registered crate)", "severity": "high", "fix": "none"},
|
|
107
|
+
"R20": {"tier": 4, "title": "Potential typosquat of a popular crate", "severity": "high", "fix": "none"},
|
|
108
|
+
"R21": {"tier": 4, "title": "Risky build.rs / proc-macro", "severity": "high", "fix": "none"},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Modules commonly hallucinated as standalone crates (arxiv 2606.08444: 45% of
|
|
112
|
+
# crate hallucinations are std/internal modules referenced as external crates).
|
|
113
|
+
STD_MODULES = {
|
|
114
|
+
"alloc", "any", "arch", "ascii", "borrow", "boxed", "cell", "char", "clone",
|
|
115
|
+
"cmp", "collections", "convert", "default", "env", "error", "f32", "f64",
|
|
116
|
+
"ffi", "fmt", "fs", "future", "hash", "i8", "i16", "i32", "i64", "i128",
|
|
117
|
+
"io", "isize", "iter", "marker", "mem", "net", "num", "ops", "option",
|
|
118
|
+
"os", "panic", "path", "pin", "prelude", "process", "ptr", "rc", "result",
|
|
119
|
+
"slice", "str", "string", "sync", "task", "thread", "time", "u8", "u16",
|
|
120
|
+
"u32", "u64", "u128", "unit", "usize", "vec",
|
|
121
|
+
}
|
|
122
|
+
STD_CRATES = {"std", "core", "alloc", "proc_macro", "test"}
|
|
123
|
+
|
|
124
|
+
# Known major versions of popular crates: offline "impossible version" check.
|
|
125
|
+
KNOWN_MAJORS = {
|
|
126
|
+
"tokio": 1, "serde": 1, "serde_json": 1, "axum": 0, "hyper": 1,
|
|
127
|
+
"reqwest": 0, "clap": 4, "anyhow": 1, "thiserror": 1, "rand": 0,
|
|
128
|
+
"regex": 1, "log": 0, "env_logger": 0, "chrono": 0, "uuid": 1,
|
|
129
|
+
"once_cell": 1, "tracing": 0, "tower": 0, "tonic": 0, "prost": 0,
|
|
130
|
+
"sqlx": 0, "diesel": 2, "rocket": 0, "actix-web": 4, "warp": 0,
|
|
131
|
+
"futures": 0, "bytes": 1, "libc": 0, "parking_lot": 0, "lazy_static": 1,
|
|
132
|
+
"itertools": 0, "tempfile": 3, "url": 2, "semver": 1, "toml": 0,
|
|
133
|
+
"indexmap": 2, "hashbrown": 0, "smallvec": 1, "bitflags": 2, "syn": 2,
|
|
134
|
+
"quote": 1, "proc-macro2": 1, "async-trait": 0, "pin-project": 1,
|
|
135
|
+
"dashmap": 5, "criterion": 0, "cc": 1, "serde_derive": 1,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
SUSPICIOUS_NAME_RE = re.compile(
|
|
139
|
+
r"(?i)^(rust|async|simple|easy|smart|fast)[-_].*$|"
|
|
140
|
+
r".*[-_](utils?|helpers?|extensions?|extra|lite|lib|core)$"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
GENERIC_TEST_NAME_RE = re.compile(
|
|
144
|
+
r"(?i)^(test|it)[_-]?\d+$|^test_(ok|works|fine|good|basic|simple|pass|passes|it|kind)$"
|
|
145
|
+
r"|^works$|^test$"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
TEST_ATTR_RE = re.compile(
|
|
149
|
+
r"#\[\s*(?:tokio::|async_std::|core::prelude::v1::)?"
|
|
150
|
+
r"(?:test|bench|rstest|quickcheck|proptest)\b"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class Finding:
|
|
156
|
+
rule_id: str
|
|
157
|
+
severity: str
|
|
158
|
+
message: str
|
|
159
|
+
file: str
|
|
160
|
+
line: int
|
|
161
|
+
function: str = ""
|
|
162
|
+
evidence: str = ""
|
|
163
|
+
source: str = "static"
|
|
164
|
+
confidence: str = "high"
|
|
165
|
+
fix: str = "none"
|
|
166
|
+
fix_hint: str = ""
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def tier(self) -> int:
|
|
170
|
+
return RULES.get(self.rule_id, {}).get("tier", 0)
|
|
171
|
+
|
|
172
|
+
def to_dict(self) -> dict:
|
|
173
|
+
d = dataclasses.asdict(self)
|
|
174
|
+
d["tier"] = self.tier
|
|
175
|
+
return d
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class EvidenceRun:
|
|
180
|
+
tool: str
|
|
181
|
+
command: str
|
|
182
|
+
status: str # pass | fail | findings | skipped | error
|
|
183
|
+
exit_code: int | None = None
|
|
184
|
+
duration_s: float = 0.0
|
|
185
|
+
excerpt: str = ""
|
|
186
|
+
notes: str = ""
|
|
187
|
+
|
|
188
|
+
def to_dict(self) -> dict:
|
|
189
|
+
return dataclasses.asdict(self)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --------------------------------------------------------------------------
|
|
193
|
+
# Masking lexer: blanks comments and/or string/char literals while keeping
|
|
194
|
+
# offsets and line numbers intact, so pattern rules never match inside
|
|
195
|
+
# comments or string contents.
|
|
196
|
+
# --------------------------------------------------------------------------
|
|
197
|
+
def _blank(seg: str) -> str:
|
|
198
|
+
return "".join("\n" if c == "\n" else " " for c in seg)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def mask_source(src: str, mask_strings: bool = True, mask_comments: bool = True) -> str:
|
|
202
|
+
out: list[str] = []
|
|
203
|
+
i, n = 0, len(src)
|
|
204
|
+
while i < n:
|
|
205
|
+
ch = src[i]
|
|
206
|
+
nxt = src[i + 1] if i + 1 < n else ""
|
|
207
|
+
if ch == "/" and nxt == "*":
|
|
208
|
+
j = src.find("*/", i + 2)
|
|
209
|
+
j = n if j < 0 else j + 2
|
|
210
|
+
out.append(_blank(src[i:j]) if mask_comments else src[i:j])
|
|
211
|
+
i = j
|
|
212
|
+
elif ch == "/" and nxt == "/":
|
|
213
|
+
j = src.find("\n", i)
|
|
214
|
+
j = n if j < 0 else j
|
|
215
|
+
out.append(_blank(src[i:j]) if mask_comments else src[i:j])
|
|
216
|
+
i = j
|
|
217
|
+
elif ch == "b" and nxt in ("r", '"', "'"):
|
|
218
|
+
out.append(ch)
|
|
219
|
+
i += 1
|
|
220
|
+
elif ch == "r" and (nxt == '"' or nxt == "#"):
|
|
221
|
+
k, hashes = i + 1, 0
|
|
222
|
+
while k < n and src[k] == "#":
|
|
223
|
+
hashes += 1
|
|
224
|
+
k += 1
|
|
225
|
+
if k < n and src[k] == '"':
|
|
226
|
+
end = '"' + "#" * hashes
|
|
227
|
+
j = src.find(end, k + 1)
|
|
228
|
+
j = n if j < 0 else j + len(end)
|
|
229
|
+
out.append(_blank(src[i:j]) if mask_strings else src[i:j])
|
|
230
|
+
i = j
|
|
231
|
+
else:
|
|
232
|
+
out.append(ch)
|
|
233
|
+
i += 1
|
|
234
|
+
elif ch == '"':
|
|
235
|
+
j = i + 1
|
|
236
|
+
while j < n:
|
|
237
|
+
if src[j] == "\\":
|
|
238
|
+
j += 2
|
|
239
|
+
continue
|
|
240
|
+
if src[j] == '"':
|
|
241
|
+
j += 1
|
|
242
|
+
break
|
|
243
|
+
j += 1
|
|
244
|
+
out.append(_blank(src[i:j]) if mask_strings else src[i:j])
|
|
245
|
+
i = j
|
|
246
|
+
elif ch == "'":
|
|
247
|
+
if i + 1 < n and src[i + 1] == "\\":
|
|
248
|
+
j = src.find("'", i + 2)
|
|
249
|
+
j = n if j < 0 else j + 1
|
|
250
|
+
out.append(_blank(src[i:j]) if mask_strings else src[i:j])
|
|
251
|
+
i = j
|
|
252
|
+
elif i + 2 < n and src[i + 2] == "'":
|
|
253
|
+
out.append(_blank(src[i:i + 3]) if mask_strings else src[i:i + 3])
|
|
254
|
+
i += 3
|
|
255
|
+
else:
|
|
256
|
+
out.append(ch) # lifetime
|
|
257
|
+
i += 1
|
|
258
|
+
else:
|
|
259
|
+
out.append(ch)
|
|
260
|
+
i += 1
|
|
261
|
+
return "".join(out)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class Source:
|
|
265
|
+
"""One Rust source file with masked views and offset->line mapping."""
|
|
266
|
+
|
|
267
|
+
def __init__(self, path: str, text: str):
|
|
268
|
+
self.path = path
|
|
269
|
+
self.text = text
|
|
270
|
+
self.masked_all = mask_source(text, True, True) # code only
|
|
271
|
+
self.masked_comments = mask_source(text, False, True) # strings visible
|
|
272
|
+
self.line_starts = [0]
|
|
273
|
+
for idx, c in enumerate(text):
|
|
274
|
+
if c == "\n":
|
|
275
|
+
self.line_starts.append(idx + 1)
|
|
276
|
+
|
|
277
|
+
def line_of(self, offset: int) -> int:
|
|
278
|
+
return bisect.bisect_right(self.line_starts, offset)
|
|
279
|
+
|
|
280
|
+
def line_text(self, lineno: int) -> str:
|
|
281
|
+
if 1 <= lineno <= len(self.line_starts):
|
|
282
|
+
start = self.line_starts[lineno - 1]
|
|
283
|
+
end = self.text.find("\n", start)
|
|
284
|
+
end = len(self.text) if end < 0 else end
|
|
285
|
+
return self.text[start:end].strip()
|
|
286
|
+
return ""
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def match_pair(text: str, open_idx: int) -> int:
|
|
290
|
+
"""Index of the bracket matching text[open_idx] (parens/brackets/braces)."""
|
|
291
|
+
pairs = {"(": ")", "[": "]", "{": "}"}
|
|
292
|
+
opener = text[open_idx]
|
|
293
|
+
closer = pairs.get(opener)
|
|
294
|
+
if closer is None:
|
|
295
|
+
return -1
|
|
296
|
+
depth = 0
|
|
297
|
+
i, n = open_idx, len(text)
|
|
298
|
+
while i < n:
|
|
299
|
+
c = text[i]
|
|
300
|
+
if c == opener:
|
|
301
|
+
depth += 1
|
|
302
|
+
elif c == closer:
|
|
303
|
+
depth -= 1
|
|
304
|
+
if depth == 0:
|
|
305
|
+
return i
|
|
306
|
+
i += 1
|
|
307
|
+
return -1
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass
|
|
311
|
+
class FnInfo:
|
|
312
|
+
name: str
|
|
313
|
+
start: int
|
|
314
|
+
end: int
|
|
315
|
+
sig: str
|
|
316
|
+
body: str
|
|
317
|
+
body_start_line: int
|
|
318
|
+
attrs: list[str]
|
|
319
|
+
is_test: bool
|
|
320
|
+
is_async: bool
|
|
321
|
+
line: int
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass
|
|
325
|
+
class FileUnit:
|
|
326
|
+
rel: str
|
|
327
|
+
path: Path
|
|
328
|
+
src: Source
|
|
329
|
+
kind: str # lib | build | example | test | bench | other
|
|
330
|
+
fns: list[FnInfo]
|
|
331
|
+
test_regions: list[tuple[int, int]]
|
|
332
|
+
contract_regions: list[tuple[int, int]] # impl Trait for X / trait blocks
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
FN_RE = re.compile(r"\bfn\s+([A-Za-z_]\w*)\s*")
|
|
336
|
+
MOD_RE = re.compile(r"\bmod\s+([A-Za-z_]\w*)\s*\{")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _attrs_above(src: Source, line: int) -> list[str]:
|
|
340
|
+
attrs: list[str] = []
|
|
341
|
+
i = line - 1
|
|
342
|
+
while i >= 1:
|
|
343
|
+
text = src.line_text(i)
|
|
344
|
+
stripped = text.strip()
|
|
345
|
+
if stripped.startswith("#[") or stripped.startswith("#!["):
|
|
346
|
+
attrs.append(stripped)
|
|
347
|
+
i -= 1
|
|
348
|
+
elif stripped.startswith("//"):
|
|
349
|
+
i -= 1
|
|
350
|
+
elif stripped == "":
|
|
351
|
+
break
|
|
352
|
+
else:
|
|
353
|
+
break
|
|
354
|
+
return attrs
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _file_kind(rel: str) -> str:
|
|
358
|
+
p = rel.replace("\\", "/")
|
|
359
|
+
if p.endswith("build.rs") or re.search(r"(^|/)build-[^/]*\.rs$", p):
|
|
360
|
+
return "build"
|
|
361
|
+
if "/examples/" in f"/{p}" or p.startswith("examples/"):
|
|
362
|
+
return "example"
|
|
363
|
+
if "/benches/" in f"/{p}" or p.startswith("benches/"):
|
|
364
|
+
return "bench"
|
|
365
|
+
if "/tests/" in f"/{p}" or p.startswith("tests/"):
|
|
366
|
+
return "test"
|
|
367
|
+
return "lib"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def parse_file(rel: str, path: Path, text: str) -> FileUnit:
|
|
371
|
+
src = Source(rel, text)
|
|
372
|
+
masked = src.masked_all
|
|
373
|
+
kind = _file_kind(rel)
|
|
374
|
+
|
|
375
|
+
test_regions: list[tuple[int, int]] = []
|
|
376
|
+
for m in MOD_RE.finditer(masked):
|
|
377
|
+
name = m.group(1)
|
|
378
|
+
lookback = masked[max(0, m.start() - 200): m.start()]
|
|
379
|
+
cfg_test = "#[cfg(test)]" in lookback
|
|
380
|
+
if cfg_test or name in ("tests", "test"):
|
|
381
|
+
open_idx = masked.index("{", m.start())
|
|
382
|
+
close_idx = match_pair(masked, open_idx)
|
|
383
|
+
if close_idx > 0:
|
|
384
|
+
test_regions.append((m.start(), close_idx))
|
|
385
|
+
|
|
386
|
+
# Regions where a constant return or an ignored parameter fulfils a
|
|
387
|
+
# documented contract: `impl Trait for Type` blocks and `trait` blocks
|
|
388
|
+
# (default methods).
|
|
389
|
+
contract_regions: list[tuple[int, int]] = []
|
|
390
|
+
for m in re.finditer(r"\b(?:impl\b[^{};]*\bfor\b[^{};]*|trait\b[^{};]*)\{", masked):
|
|
391
|
+
close_idx = match_pair(masked, m.end() - 1)
|
|
392
|
+
if close_idx > 0:
|
|
393
|
+
contract_regions.append((m.start(), close_idx))
|
|
394
|
+
|
|
395
|
+
fns: list[FnInfo] = []
|
|
396
|
+
for m in FN_RE.finditer(masked):
|
|
397
|
+
name = m.group(1)
|
|
398
|
+
# find parameter list
|
|
399
|
+
i = m.end()
|
|
400
|
+
n = len(masked)
|
|
401
|
+
while i < n and masked[i] != "(":
|
|
402
|
+
if masked[i] in ";{":
|
|
403
|
+
i = -1
|
|
404
|
+
break
|
|
405
|
+
i += 1
|
|
406
|
+
if i < 0 or i >= n:
|
|
407
|
+
continue
|
|
408
|
+
close_paren = match_pair(masked, i)
|
|
409
|
+
if close_paren < 0:
|
|
410
|
+
continue
|
|
411
|
+
# find body open brace (or ';' for declarations without body)
|
|
412
|
+
j = close_paren + 1
|
|
413
|
+
depth = 0
|
|
414
|
+
open_brace = -1
|
|
415
|
+
is_decl = False
|
|
416
|
+
while j < n:
|
|
417
|
+
c = masked[j]
|
|
418
|
+
if depth == 0 and c == "{":
|
|
419
|
+
open_brace = j
|
|
420
|
+
break
|
|
421
|
+
if depth == 0 and c == ";":
|
|
422
|
+
is_decl = True
|
|
423
|
+
break
|
|
424
|
+
if c in "([{":
|
|
425
|
+
depth += 1
|
|
426
|
+
elif c in ")]}":
|
|
427
|
+
depth -= 1
|
|
428
|
+
if depth < 0:
|
|
429
|
+
break
|
|
430
|
+
j += 1
|
|
431
|
+
if is_decl or open_brace < 0:
|
|
432
|
+
continue
|
|
433
|
+
close_brace = match_pair(masked, open_brace)
|
|
434
|
+
if close_brace < 0:
|
|
435
|
+
continue
|
|
436
|
+
sig = masked[m.start():open_brace]
|
|
437
|
+
body = masked[open_brace + 1: close_brace]
|
|
438
|
+
line = src.line_of(m.start())
|
|
439
|
+
attrs = _attrs_above(src, line)
|
|
440
|
+
attr_blob = " ".join(attrs)
|
|
441
|
+
in_test_region = any(a <= m.start() <= b for a, b in test_regions)
|
|
442
|
+
is_test = bool(
|
|
443
|
+
TEST_ATTR_RE.search(attr_blob)
|
|
444
|
+
or "#[cfg(test)]" in attr_blob
|
|
445
|
+
or in_test_region
|
|
446
|
+
or kind == "test"
|
|
447
|
+
)
|
|
448
|
+
pre = masked[max(0, m.start() - 40): m.start()]
|
|
449
|
+
is_async = bool(re.search(r"\basync\s*$", pre))
|
|
450
|
+
fns.append(FnInfo(
|
|
451
|
+
name=name, start=m.start(), end=close_brace, sig=sig, body=body,
|
|
452
|
+
body_start_line=src.line_of(open_brace), attrs=attrs,
|
|
453
|
+
is_test=is_test, is_async=is_async, line=line,
|
|
454
|
+
))
|
|
455
|
+
# Inner functions are local helpers: inherit the enclosing function's
|
|
456
|
+
# context (e.g. compile-time type assertions inside a #[test] fn).
|
|
457
|
+
for i, fn in enumerate(fns):
|
|
458
|
+
for j, outer in enumerate(fns):
|
|
459
|
+
if i != j and outer.start < fn.start and fn.end <= outer.end \
|
|
460
|
+
and outer.is_test:
|
|
461
|
+
fn.is_test = True
|
|
462
|
+
break
|
|
463
|
+
return FileUnit(rel=rel, path=path, src=src, kind=kind, fns=fns,
|
|
464
|
+
test_regions=test_regions,
|
|
465
|
+
contract_regions=contract_regions)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def locate_fn(fns: list[FnInfo], offset: int) -> FnInfo | None:
|
|
469
|
+
best = None
|
|
470
|
+
for fn in fns:
|
|
471
|
+
if fn.start <= offset <= fn.end:
|
|
472
|
+
if best is None or (fn.end - fn.start) < (best.end - best.start):
|
|
473
|
+
best = fn
|
|
474
|
+
return best
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def macro_calls(masked: str, *names: str) -> list[tuple[str, int, int, str]]:
|
|
478
|
+
"""(macro_name, start, end, arg_text) for each macro invocation."""
|
|
479
|
+
res: list[tuple[str, int, int, str]] = []
|
|
480
|
+
for name in names:
|
|
481
|
+
for m in re.finditer(r"\b" + re.escape(name) + r"!\s*\(", masked):
|
|
482
|
+
open_idx = masked.index("(", m.start())
|
|
483
|
+
close_idx = match_pair(masked, open_idx)
|
|
484
|
+
if close_idx > 0:
|
|
485
|
+
res.append((name, m.start(), close_idx, masked[open_idx + 1: close_idx]))
|
|
486
|
+
return res
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# --------------------------------------------------------------------------
|
|
490
|
+
# Cargo.toml / Cargo.lock parsing
|
|
491
|
+
# --------------------------------------------------------------------------
|
|
492
|
+
def _norm(name: str) -> str:
|
|
493
|
+
return name.replace("-", "_")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@dataclass
|
|
497
|
+
class Dep:
|
|
498
|
+
key: str # key in Cargo.toml
|
|
499
|
+
package: str # registry name (respects `package = "..."`)
|
|
500
|
+
alias: str # name used in Rust code
|
|
501
|
+
kind: str # normal | dev | build | workspace
|
|
502
|
+
version_req: str
|
|
503
|
+
features: list[str]
|
|
504
|
+
optional: bool
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def parse_manifest(manifest_path: Path) -> tuple[list[Dep], dict]:
|
|
508
|
+
try:
|
|
509
|
+
data = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
|
|
510
|
+
except Exception:
|
|
511
|
+
return [], {}
|
|
512
|
+
deps: list[Dep] = []
|
|
513
|
+
sections = [
|
|
514
|
+
("dependencies", "normal"),
|
|
515
|
+
("dev-dependencies", "dev"),
|
|
516
|
+
("build-dependencies", "build"),
|
|
517
|
+
]
|
|
518
|
+
for sec, kind in sections:
|
|
519
|
+
for key, val in (data.get(sec) or {}).items():
|
|
520
|
+
deps.append(_mk_dep(key, val, kind))
|
|
521
|
+
for key, val in ((data.get("workspace") or {}).get("dependencies") or {}).items():
|
|
522
|
+
deps.append(_mk_dep(key, val, "workspace"))
|
|
523
|
+
for target in (data.get("target") or {}).values():
|
|
524
|
+
for sec, kind in sections:
|
|
525
|
+
for key, val in (target.get(sec) or {}).items():
|
|
526
|
+
deps.append(_mk_dep(key, val, kind))
|
|
527
|
+
return deps, data
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _mk_dep(key: str, val, kind: str) -> Dep:
|
|
531
|
+
if isinstance(val, str):
|
|
532
|
+
return Dep(key=key, package=key, alias=_norm(key), kind=kind,
|
|
533
|
+
version_req=val, features=[], optional=False)
|
|
534
|
+
if isinstance(val, dict):
|
|
535
|
+
package = val.get("package", key)
|
|
536
|
+
return Dep(
|
|
537
|
+
key=key, package=package, alias=_norm(key), kind=kind,
|
|
538
|
+
version_req=str(val.get("version", "")),
|
|
539
|
+
features=list(val.get("features") or []),
|
|
540
|
+
optional=bool(val.get("optional", False)),
|
|
541
|
+
)
|
|
542
|
+
return Dep(key=key, package=key, alias=_norm(key), kind=kind,
|
|
543
|
+
version_req="", features=[], optional=False)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def parse_lock(lock_path: Path) -> dict[str, str]:
|
|
547
|
+
"""crate name -> resolved version (first entry wins)."""
|
|
548
|
+
out: dict[str, str] = {}
|
|
549
|
+
if not lock_path.exists():
|
|
550
|
+
return out
|
|
551
|
+
try:
|
|
552
|
+
data = tomllib.loads(lock_path.read_text(encoding="utf-8"))
|
|
553
|
+
except Exception:
|
|
554
|
+
return out
|
|
555
|
+
for pkg in data.get("package") or []:
|
|
556
|
+
name = pkg.get("name")
|
|
557
|
+
if name and name not in out:
|
|
558
|
+
out[name] = str(pkg.get("version", ""))
|
|
559
|
+
return out
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def levenshtein(a: str, b: str) -> int:
|
|
563
|
+
if a == b:
|
|
564
|
+
return 0
|
|
565
|
+
if not a or not b:
|
|
566
|
+
return max(len(a), len(b))
|
|
567
|
+
prev = list(range(len(b) + 1))
|
|
568
|
+
for i, ca in enumerate(a, 1):
|
|
569
|
+
cur = [i]
|
|
570
|
+
for j, cb in enumerate(b, 1):
|
|
571
|
+
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
|
|
572
|
+
prev = cur
|
|
573
|
+
return prev[-1]
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# --------------------------------------------------------------------------
|
|
577
|
+
# Bundled resources (also loadable from the skill's resources/ directory)
|
|
578
|
+
# --------------------------------------------------------------------------
|
|
579
|
+
def _data_dir(name: str) -> Path:
|
|
580
|
+
"""Where the bundled data lives, in every install shape.
|
|
581
|
+
|
|
582
|
+
Checkout: `scripts/rust_audit.py` -> `../resources` (repo root).
|
|
583
|
+
Wheel: the module sits in site-packages, so the data installed by
|
|
584
|
+
`data-files` is under `<sys.prefix>/share/rustaudit/`.
|
|
585
|
+
"""
|
|
586
|
+
here = Path(__file__).resolve().parent
|
|
587
|
+
candidates = (here / name,
|
|
588
|
+
here.parent / name,
|
|
589
|
+
Path(sys.prefix) / "share" / "rustaudit" / name)
|
|
590
|
+
for candidate in candidates:
|
|
591
|
+
if candidate.is_dir():
|
|
592
|
+
return candidate
|
|
593
|
+
return candidates[0]
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _resource_dir() -> Path:
|
|
597
|
+
return _data_dir("resources")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _template_dir() -> Path:
|
|
601
|
+
return _data_dir("templates")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def load_name_list(filename: str, fallback: set[str]) -> set[str]:
|
|
605
|
+
p = _resource_dir() / filename
|
|
606
|
+
if p.exists():
|
|
607
|
+
return {
|
|
608
|
+
line.strip()
|
|
609
|
+
for line in p.read_text(encoding="utf-8").splitlines()
|
|
610
|
+
if line.strip() and not line.startswith("#")
|
|
611
|
+
}
|
|
612
|
+
return set(fallback)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
FALLBACK_POPULAR = {
|
|
616
|
+
"serde", "serde_json", "tokio", "rand", "clap", "anyhow", "thiserror",
|
|
617
|
+
"regex", "log", "chrono", "uuid", "tracing", "axum", "hyper", "reqwest",
|
|
618
|
+
"futures", "bytes", "libc", "parking_lot", "itertools", "once_cell",
|
|
619
|
+
"async-trait", "syn", "quote", "proc-macro2", "tempfile", "url", "toml",
|
|
620
|
+
"env_logger", "indexmap", "hashbrown", "smallvec", "bitflags", "cc",
|
|
621
|
+
"tower", "tonic", "prost", "sqlx", "diesel", "rocket", "actix-web",
|
|
622
|
+
"warp", "mime", "semver", "criterion", "lazy_static", "pin-project",
|
|
623
|
+
"dashmap", "tracing-subscriber", "serde_derive", "dotenv", "base64",
|
|
624
|
+
"hex", "sha2", "md5", "num-traits", "rayon", "crossbeam", "mio",
|
|
625
|
+
"socket2", "trust-dns-resolver", "rustls", "openssl", "native-tls",
|
|
626
|
+
}
|
|
627
|
+
FALLBACK_SUSPICIOUS = {
|
|
628
|
+
"rust-http-client", "rust-database", "rust-utils", "async-utils",
|
|
629
|
+
"async-helpers", "file-utils", "rust-orm", "rust-helpers", "easy-utils",
|
|
630
|
+
"tokio-utils-extra", "serde-helpers", "axum-extensions", "simple-crud",
|
|
631
|
+
"async-database-pool", "rust-file-utils", "smart-cache", "fast-json",
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class Registry:
|
|
636
|
+
def __init__(self, deps: list[Dep], corpus_extra: set[str] | None = None):
|
|
637
|
+
self.deps = deps
|
|
638
|
+
self.by_alias = {_norm(d.alias): d for d in deps}
|
|
639
|
+
self.by_package = {_norm(d.package): d for d in deps}
|
|
640
|
+
self.popular = load_name_list("popular_crates.txt", FALLBACK_POPULAR)
|
|
641
|
+
self.suspicious = load_name_list("suspicious_names.txt", FALLBACK_SUSPICIOUS)
|
|
642
|
+
if corpus_extra:
|
|
643
|
+
self.suspicious |= corpus_extra
|
|
644
|
+
|
|
645
|
+
def known_roots(self) -> set[str]:
|
|
646
|
+
roots = set(self.by_alias) | set(self.by_package)
|
|
647
|
+
roots |= {_norm(k) for k in self.by_alias}
|
|
648
|
+
return roots
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# --------------------------------------------------------------------------
|
|
652
|
+
# Rule checks (per file)
|
|
653
|
+
# --------------------------------------------------------------------------
|
|
654
|
+
def _ctx_label(unit: FileUnit, fn: FnInfo | None) -> tuple[str, str]:
|
|
655
|
+
if fn is not None:
|
|
656
|
+
return fn.name, ("test" if fn.is_test else unit.kind)
|
|
657
|
+
return "", unit.kind
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _prod_severity(unit: FileUnit, fn: FnInfo | None, prod: str, test: str = "info",
|
|
661
|
+
example: str = "info") -> str | None:
|
|
662
|
+
"""Severity by context; None means 'do not report'."""
|
|
663
|
+
if fn is not None and fn.is_test:
|
|
664
|
+
return None if test == "skip" else test
|
|
665
|
+
if unit.kind in ("example", "bench"):
|
|
666
|
+
return example
|
|
667
|
+
return prod
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def check_r01(unit: FileUnit, out: list[Finding]) -> None:
|
|
671
|
+
masked = unit.src.masked_all
|
|
672
|
+
for m in re.finditer(r"\b(todo!|unimplemented!)\s*\(", masked):
|
|
673
|
+
fn = locate_fn(unit.fns, m.start())
|
|
674
|
+
sev = _prod_severity(unit, fn, "critical", "info", "info")
|
|
675
|
+
if sev is None:
|
|
676
|
+
continue
|
|
677
|
+
name, _ = _ctx_label(unit, fn)
|
|
678
|
+
out.append(Finding(
|
|
679
|
+
"R01", sev,
|
|
680
|
+
f"`{m.group(1)}!()` marks this code as unimplemented (stub).",
|
|
681
|
+
unit.rel, unit.src.line_of(m.start()), name,
|
|
682
|
+
evidence=masked[m.start(): m.start() + 40].strip(),
|
|
683
|
+
fix="none",
|
|
684
|
+
fix_hint="Implement the behaviour or return an explicit error; never ship todo!()/unimplemented!().",
|
|
685
|
+
))
|
|
686
|
+
strings = unit.src.masked_comments
|
|
687
|
+
for m in re.finditer(r"panic!\s*\(\s*\"(?:not implemented|unimplemented|TODO|FIXME)\"", strings):
|
|
688
|
+
fn = locate_fn(unit.fns, m.start())
|
|
689
|
+
sev = _prod_severity(unit, fn, "critical", "info", "info")
|
|
690
|
+
if sev is None:
|
|
691
|
+
continue
|
|
692
|
+
name, _ = _ctx_label(unit, fn)
|
|
693
|
+
out.append(Finding(
|
|
694
|
+
"R01", sev, 'Placeholder panic!("not implemented").',
|
|
695
|
+
unit.rel, unit.src.line_of(m.start()), name,
|
|
696
|
+
evidence=m.group(0), fix="none",
|
|
697
|
+
fix_hint="Implement the behaviour or return a typed error.",
|
|
698
|
+
))
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _pure_args(args: str) -> bool:
|
|
702
|
+
"""True when the assert arguments can only read state.
|
|
703
|
+
|
|
704
|
+
Conservative: calls, field/method access (auto-deref may run code),
|
|
705
|
+
indexing, deref and `?` make the arguments effectful; float literals do
|
|
706
|
+
not (their `.` is a value, not a path). PartialEq impls are out of scope,
|
|
707
|
+
as in the Test Smells Catalog itself."""
|
|
708
|
+
scan = mask_source(args, True, True)
|
|
709
|
+
scan = re.sub(r"\d+\.\d+", "", scan)
|
|
710
|
+
return not re.search(r"[(\[.*?]", scan)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _only_asserts_between(text: str, spans: list[tuple[int, int]],
|
|
714
|
+
e1: int, s2: int) -> bool:
|
|
715
|
+
"""True when only other assert calls (and ; { }) separate the two spots."""
|
|
716
|
+
pos = e1
|
|
717
|
+
for a_start, a_end in spans:
|
|
718
|
+
if e1 <= a_start and a_end <= s2:
|
|
719
|
+
if not re.fullmatch(r"[\s;{}]*", text[pos:a_start]):
|
|
720
|
+
return False
|
|
721
|
+
pos = a_end
|
|
722
|
+
return bool(re.fullmatch(r"[\s;{}]*", text[pos:s2]))
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def check_r02(unit: FileUnit, out: list[Finding]) -> None:
|
|
726
|
+
for fn in unit.fns:
|
|
727
|
+
# Only functions carrying a real test attribute are tests: helpers
|
|
728
|
+
# inside test regions cannot be "tests that cannot fail".
|
|
729
|
+
if not fn.is_test or not TEST_ATTR_RE.search(" ".join(fn.attrs)):
|
|
730
|
+
continue
|
|
731
|
+
body, masked = fn.body, unit.src.masked_all
|
|
732
|
+
open_idx = masked.find("{", fn.start)
|
|
733
|
+
raw_body = unit.src.text[open_idx + 1: fn.end] if open_idx >= 0 else body
|
|
734
|
+
# Assertions are compared with their string literals visible: masked
|
|
735
|
+
# literals make distinct asserts look identical (false duplicates) and
|
|
736
|
+
# hide failure messages from the roulette check.
|
|
737
|
+
body_vis = mask_source(raw_body, False, True)
|
|
738
|
+
asserts = macro_calls(body_vis, "assert", "assert_eq", "assert_ne")
|
|
739
|
+
has_panic = bool(re.search(r"\b(panic!|unreachable!)\s*\(", body))
|
|
740
|
+
returns_result = "Result" in fn.sig
|
|
741
|
+
has_try = bool(re.search(r"\?\s*;", body))
|
|
742
|
+
# #[should_panic]: the panic IS the oracle. unwrap()/expect() panic on
|
|
743
|
+
# bad behaviour, so they are oracles too ("cannot fail" would be wrong).
|
|
744
|
+
should_panic = any("should_panic" in a for a in fn.attrs)
|
|
745
|
+
has_unwrap = bool(re.search(r"\.(?:unwrap|expect)\s*\(", body))
|
|
746
|
+
if not asserts and not has_panic and not should_panic \
|
|
747
|
+
and not has_unwrap and not (returns_result and has_try):
|
|
748
|
+
if "(" not in body:
|
|
749
|
+
# No call and no oracle: the test literally cannot fail.
|
|
750
|
+
out.append(Finding(
|
|
751
|
+
"R02", "high",
|
|
752
|
+
f"Test `{fn.name}` cannot fail (empty body: no call, no assertion).",
|
|
753
|
+
unit.rel, fn.line, fn.name, evidence="no call, no oracle in body",
|
|
754
|
+
fix="none",
|
|
755
|
+
fix_hint="Assert an observable behaviour; a test that cannot fail proves nothing.",
|
|
756
|
+
))
|
|
757
|
+
else:
|
|
758
|
+
# It calls helpers whose assertions may be the oracle; the tool
|
|
759
|
+
# cannot see through the call, so it reports only what is true.
|
|
760
|
+
out.append(Finding(
|
|
761
|
+
"R02", "low",
|
|
762
|
+
f"Test `{fn.name}` has no direct oracle in its body "
|
|
763
|
+
f"(assertions may live in helpers: verify it can fail).",
|
|
764
|
+
unit.rel, fn.line, fn.name, evidence="no assert*/panic! in body",
|
|
765
|
+
fix="none",
|
|
766
|
+
fix_hint="Assert an observable behaviour here, or rely on the "
|
|
767
|
+
"helper's assertions knowingly.",
|
|
768
|
+
))
|
|
769
|
+
for _, start, _, args in asserts:
|
|
770
|
+
compact = re.sub(r"\s+", " ", args).strip()
|
|
771
|
+
# Bare `false` is NOT tautological: in #[should_panic] tests it is
|
|
772
|
+
# the "fail if we got here" guard. Only always-PASSING forms count.
|
|
773
|
+
if re.fullmatch(
|
|
774
|
+
r"true|true\s*==\s*true|false\s*==\s*false|1\s*==\s*1|0\s*==\s*0",
|
|
775
|
+
compact) or re.fullmatch(r"(.+?)\s*,\s*\1", compact):
|
|
776
|
+
out.append(Finding(
|
|
777
|
+
"R02", "high",
|
|
778
|
+
f"Tautological assertion in test `{fn.name}` (always passes regardless of behaviour).",
|
|
779
|
+
unit.rel, fn.line + body[:start].count("\n"), fn.name,
|
|
780
|
+
evidence=compact[:60], fix="none",
|
|
781
|
+
fix_hint="Assert the real expected value of the behaviour under test.",
|
|
782
|
+
))
|
|
783
|
+
if GENERIC_TEST_NAME_RE.match(fn.name):
|
|
784
|
+
out.append(Finding(
|
|
785
|
+
"R02", "low",
|
|
786
|
+
f"Generic test name `{fn.name}` (does not state the behaviour being verified).",
|
|
787
|
+
unit.rel, fn.line, fn.name, evidence=fn.name, fix="none",
|
|
788
|
+
fix_hint="Rename to `test_<behaviour>_<condition>_<expected>`.",
|
|
789
|
+
))
|
|
790
|
+
# Assertion roulette: 3+ bare `assert!` without messages. In Rust,
|
|
791
|
+
# assert_eq!/assert_ne! print left/right values and every panic
|
|
792
|
+
# carries file:line, so they cannot be roulette.
|
|
793
|
+
no_msg = 0
|
|
794
|
+
for name, _, _, args in asserts:
|
|
795
|
+
if name == "assert" and not re.search(r",\s*\"", args):
|
|
796
|
+
no_msg += 1
|
|
797
|
+
if no_msg >= 3:
|
|
798
|
+
out.append(Finding(
|
|
799
|
+
"R02", "low",
|
|
800
|
+
f"Assertion roulette in `{fn.name}`: {no_msg} assertions without failure messages.",
|
|
801
|
+
unit.rel, fn.line, fn.name, evidence=f"{no_msg} bare asserts", fix="none",
|
|
802
|
+
fix_hint="Give each assertion an explanatory message.",
|
|
803
|
+
))
|
|
804
|
+
# Duplicate assertion: identical asserts repeated inside ONE scenario
|
|
805
|
+
# (nothing but other asserts between them). The catalog's fix is
|
|
806
|
+
# "vary the input" — a statement between the repeats means a new
|
|
807
|
+
# scenario, where repeating the expectation is intentional (ADR 0002).
|
|
808
|
+
# macro_calls groups by macro name, so sort by position; the key keeps
|
|
809
|
+
# the macro name (assert_ne! vs assert_eq! assert OPPOSITES). Its `end`
|
|
810
|
+
# is the INDEX of the closing paren, so spans grow by one to cover it:
|
|
811
|
+
# the gap between two calls must not start inside the first one.
|
|
812
|
+
asserts.sort(key=lambda t: t[1])
|
|
813
|
+
spans = [(s, e + 1) for _, s, e, _ in asserts]
|
|
814
|
+
occ: dict[str, list[tuple[int, int]]] = {}
|
|
815
|
+
pure: dict[str, bool] = {}
|
|
816
|
+
for name, start, end, args in asserts:
|
|
817
|
+
key = name + "!(" + re.sub(r"\s+", " ", args) + ")"
|
|
818
|
+
if key:
|
|
819
|
+
occ.setdefault(key, []).append((start, end + 1))
|
|
820
|
+
pure[key] = _pure_args(args)
|
|
821
|
+
for key, spots in occ.items():
|
|
822
|
+
if len(spots) < 2:
|
|
823
|
+
continue
|
|
824
|
+
run = best = 1
|
|
825
|
+
claim_pure = pure[key]
|
|
826
|
+
for (_, e1), (s2, _) in zip(spots, spots[1:]):
|
|
827
|
+
if _only_asserts_between(body_vis, spans, e1, s2):
|
|
828
|
+
# The interleaved asserts matter too: their expressions
|
|
829
|
+
# can advance state (recv_many, poll), turning the repeat
|
|
830
|
+
# into a deliberate re-check, not a redundant one.
|
|
831
|
+
inner = [a for _, s, e, a in asserts
|
|
832
|
+
if e1 <= s and e + 1 <= s2]
|
|
833
|
+
claim_pure = claim_pure and all(map(_pure_args, inner))
|
|
834
|
+
run += 1
|
|
835
|
+
best = max(best, run)
|
|
836
|
+
else:
|
|
837
|
+
run = 1
|
|
838
|
+
if best >= 2:
|
|
839
|
+
# Read-only repeated AND interleaved arguments make
|
|
840
|
+
# redundancy provable -> plain claim. Effectful ones can
|
|
841
|
+
# advance state (e.g. `stream.next().await`), so the claim
|
|
842
|
+
# carries only the provable repeat plus a hedge
|
|
843
|
+
# (ADR 0001: claim only what is provable).
|
|
844
|
+
hedge = "" if claim_pure else (
|
|
845
|
+
"; redundant only if the interleaved asserts do not "
|
|
846
|
+
"advance state")
|
|
847
|
+
out.append(Finding(
|
|
848
|
+
"R02", "low",
|
|
849
|
+
f"Duplicate assertion in `{fn.name}` ({best}x identical "
|
|
850
|
+
f"back-to-back{hedge}).",
|
|
851
|
+
unit.rel, fn.line, fn.name, evidence=key[:60], fix="none",
|
|
852
|
+
fix_hint="Remove the redundancy or vary the tested input.",
|
|
853
|
+
))
|
|
854
|
+
|
|
855
|
+
# magic numbers in expected results
|
|
856
|
+
magic = [a for _, _, _, a in asserts if re.search(r"^\s*-?\d+\s*,|\,\s*-?\d+\s*$", re.sub(r"\s+", " ", a))]
|
|
857
|
+
if len(magic) >= 2:
|
|
858
|
+
out.append(Finding(
|
|
859
|
+
"R02", "low",
|
|
860
|
+
f"Magic number test in `{fn.name}`: {len(magic)} assertions compare against bare numeric literals.",
|
|
861
|
+
unit.rel, fn.line, fn.name, evidence=str(len(magic)), fix="none",
|
|
862
|
+
fix_hint="Derive expected values from named constants or the specification.",
|
|
863
|
+
))
|
|
864
|
+
ignores = len(re.findall(r"#\[\s*ignore\s*\]", unit.src.masked_all))
|
|
865
|
+
if ignores >= 3:
|
|
866
|
+
out.append(Finding(
|
|
867
|
+
"R02", "low",
|
|
868
|
+
f"{ignores} tests marked #[ignore] in one file (suite is systematically disabled).",
|
|
869
|
+
unit.rel, 1, "", evidence=f"{ignores}x #[ignore]", fix="none",
|
|
870
|
+
fix_hint="Fix and re-enable ignored tests or delete them.",
|
|
871
|
+
))
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def check_r03(unit: FileUnit, out: list[Finding]) -> None:
|
|
875
|
+
for fn in unit.fns:
|
|
876
|
+
if fn.is_test or unit.kind in ("example", "bench"):
|
|
877
|
+
continue
|
|
878
|
+
masked = unit.src.masked_all
|
|
879
|
+
open_idx = masked.find("{", fn.start)
|
|
880
|
+
raw_body = unit.src.text[open_idx + 1: fn.end] if open_idx >= 0 else fn.body
|
|
881
|
+
# In `impl Trait for Type` blocks and `trait` default methods a
|
|
882
|
+
# constant return or a no-op body fulfils the documented contract
|
|
883
|
+
# (metadata getters, deliberate defaults), not stubbed logic.
|
|
884
|
+
in_contract = any(a <= fn.start <= b for a, b in unit.contract_regions)
|
|
885
|
+
# An empty generic fn is the compile-time type-assertion idiom
|
|
886
|
+
# (`fn check<T: Send>() {}`), not a stub.
|
|
887
|
+
is_type_assert = "<" in fn.sig.split("(", 1)[0]
|
|
888
|
+
# Only a truly empty `{}` is a stub: a body carrying a comment is a
|
|
889
|
+
# documented no-op and a literal body is a real return value.
|
|
890
|
+
if not raw_body.strip() and not in_contract and not is_type_assert:
|
|
891
|
+
out.append(Finding(
|
|
892
|
+
"R03", "high", f"Function `{fn.name}` has an empty body (stub).",
|
|
893
|
+
unit.rel, fn.line, fn.name, evidence="{}", fix="none",
|
|
894
|
+
fix_hint="Implement the function or make it return a typed 'unsupported' error.",
|
|
895
|
+
))
|
|
896
|
+
continue
|
|
897
|
+
visible = mask_source(raw_body, False, True) # strings visible, comments hidden
|
|
898
|
+
if not in_contract and re.fullmatch(
|
|
899
|
+
r"(?:return\s+)?(?:true|false|-?\d+(?:\.\d+)?[a-z]*|\"[^\"\n]*\"|"
|
|
900
|
+
r"'(?:\\.|[^\\'])'|Self|[A-Z][A-Z0-9_]{2,})\s*;?",
|
|
901
|
+
visible.strip()):
|
|
902
|
+
out.append(Finding(
|
|
903
|
+
"R03", "high",
|
|
904
|
+
f"Function `{fn.name}` always returns a constant (stubbed logic).",
|
|
905
|
+
unit.rel, fn.line, fn.name,
|
|
906
|
+
evidence=re.sub(r"\s+", " ", visible)[:60], fix="none",
|
|
907
|
+
fix_hint="Implement the real computation or document why a constant is correct.",
|
|
908
|
+
))
|
|
909
|
+
if re.search(r"\bif\s+(false|0\s*==\s*1|1\s*==\s*0)\b", fn.body):
|
|
910
|
+
out.append(Finding(
|
|
911
|
+
"R03", "high",
|
|
912
|
+
f"Function `{fn.name}` contains a placeholder `if false` branch (dead stub path).",
|
|
913
|
+
unit.rel, fn.line, fn.name, evidence="if false {", fix="none",
|
|
914
|
+
fix_hint="Remove the placeholder branch or wire the real feature flag.",
|
|
915
|
+
))
|
|
916
|
+
# Scoped to the parameter list so return types and where-clauses
|
|
917
|
+
# (`F: FnOnce...`, `S::Error`) never invent phantom params;
|
|
918
|
+
# underscore-prefixed params are intentional ignores, not stubs.
|
|
919
|
+
params = re.findall(r"(?:^|,)\s*(?:mut\s+)?([A-Za-z_]\w*)\s*:(?!:)",
|
|
920
|
+
fn.sig.split("(", 1)[-1].split(")", 1)[0])
|
|
921
|
+
params = [p for p in params if p not in ("self",) and not p.startswith("_")]
|
|
922
|
+
# `#[allow(unused_variables)]` declares the ignored params intentional.
|
|
923
|
+
if params and len(re.findall(r"\w+", fn.body)) < 30 \
|
|
924
|
+
and not any("unused_variables" in a for a in fn.attrs):
|
|
925
|
+
missing = [p for p in params if not re.search(r"\b" + re.escape(p) + r"\b", fn.body)]
|
|
926
|
+
if len(missing) == len(params):
|
|
927
|
+
out.append(Finding(
|
|
928
|
+
"R03", "high",
|
|
929
|
+
f"Function `{fn.name}` ignores all of its parameters (stub signature).",
|
|
930
|
+
unit.rel, fn.line, fn.name,
|
|
931
|
+
evidence=f"unused params: {', '.join(params)}", fix="none",
|
|
932
|
+
fix_hint="Use the parameters or remove them from the signature.",
|
|
933
|
+
))
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def check_r04(unit: FileUnit, out: list[Finding]) -> None:
|
|
937
|
+
strings = unit.src.masked_comments
|
|
938
|
+
for m in re.finditer(r"\"([^\"\\\n]{3,120})\"", strings):
|
|
939
|
+
val = m.group(1)
|
|
940
|
+
if re.search(r"(?i)\b(lorem ipsum|lorem|dummy|placeholder|sample value|test data|foo bar|fixme|xxx)\b", val) \
|
|
941
|
+
or val in ("example.com", "example.org", "example.net"):
|
|
942
|
+
fn = locate_fn(unit.fns, m.start())
|
|
943
|
+
sev = _prod_severity(unit, fn, "high", "skip", "info")
|
|
944
|
+
if sev is None:
|
|
945
|
+
continue
|
|
946
|
+
name, _ = _ctx_label(unit, fn)
|
|
947
|
+
out.append(Finding(
|
|
948
|
+
"R04", sev,
|
|
949
|
+
f"Placeholder string used as real data: \"{val[:50]}\".",
|
|
950
|
+
unit.rel, unit.src.line_of(m.start()), name,
|
|
951
|
+
evidence=val[:60], fix="none",
|
|
952
|
+
fix_hint="Use real data or read the value from configuration/env.",
|
|
953
|
+
))
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def check_r05(unit: FileUnit, out: list[Finding]) -> None:
|
|
957
|
+
stem = Path(unit.rel).stem.lower()
|
|
958
|
+
if re.search(r"(?i)^(demo|sample|placeholder|dummy)", stem):
|
|
959
|
+
out.append(Finding(
|
|
960
|
+
"R05", "low", f"Demo-style file name `{Path(unit.rel).name}`.",
|
|
961
|
+
unit.rel, 1, "", evidence=stem, fix="none",
|
|
962
|
+
fix_hint="Keep demo code out of production trees.",
|
|
963
|
+
))
|
|
964
|
+
for fn in unit.fns:
|
|
965
|
+
if fn.is_test:
|
|
966
|
+
continue
|
|
967
|
+
if re.search(r"(?i)^(demo|sample|placeholder|dummy|foo|bar)(_|$)|_(demo|sample|placeholder|dummy)$", fn.name):
|
|
968
|
+
sev = "info" if unit.kind in ("example", "bench") else "medium"
|
|
969
|
+
out.append(Finding(
|
|
970
|
+
"R05", sev, f"Demo-style function name `{fn.name}`.",
|
|
971
|
+
unit.rel, fn.line, fn.name, evidence=fn.name, fix="none",
|
|
972
|
+
fix_hint="Rename to describe the real behaviour or remove it.",
|
|
973
|
+
))
|
|
974
|
+
body = fn.body.strip()
|
|
975
|
+
if fn.name == "main" and body and re.fullmatch(
|
|
976
|
+
r"((println!|print!|dbg!|eprintln!)\s*\([^;]*\)\s*;\s*)+", re.sub(r"\s+", " ", body)):
|
|
977
|
+
out.append(Finding(
|
|
978
|
+
"R05", "info", "Trivial `fn main` that only prints (demo code).",
|
|
979
|
+
unit.rel, fn.line, fn.name, evidence=body[:40], fix="none",
|
|
980
|
+
fix_hint="Replace with the real entry point.",
|
|
981
|
+
))
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def check_r06(unit: FileUnit, out: list[Finding]) -> None:
|
|
985
|
+
for m in re.finditer(
|
|
986
|
+
r"\b(?:struct|enum|type|trait)\s+((?:Fake|Mock|InMemory|Dummy|Stub)[A-Za-z0-9_]*|"
|
|
987
|
+
r"(?:fake|mock|in_memory|dummy|stub)_[A-Za-z0-9_]*)\b", unit.src.masked_all):
|
|
988
|
+
fn = locate_fn(unit.fns, m.start())
|
|
989
|
+
if fn is not None and fn.is_test:
|
|
990
|
+
continue
|
|
991
|
+
if unit.kind in ("test",) or any(a <= m.start() <= b for a, b in unit.test_regions):
|
|
992
|
+
continue
|
|
993
|
+
out.append(Finding(
|
|
994
|
+
"R06", "medium",
|
|
995
|
+
f"Test double `{m.group(1)}` declared outside test code (fake leaking into production).",
|
|
996
|
+
unit.rel, unit.src.line_of(m.start()), "", evidence=m.group(1), fix="none",
|
|
997
|
+
fix_hint="Move fakes/mocks under #[cfg(test)] or a tests/ module.",
|
|
998
|
+
))
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def check_r07(unit: FileUnit, out: list[Finding]) -> None:
|
|
1002
|
+
for fn in unit.fns:
|
|
1003
|
+
if not fn.is_test:
|
|
1004
|
+
continue
|
|
1005
|
+
hits = re.findall(
|
|
1006
|
+
r"\bMock[A-Za-z0-9_]*\b|\bmock!\s*\(|\bassert_called\w*\b|\.verify\s*\(|\bwithf\s*\(",
|
|
1007
|
+
fn.body)
|
|
1008
|
+
if len(hits) > THRESHOLDS.get("mocks_per_test", 5):
|
|
1009
|
+
out.append(Finding(
|
|
1010
|
+
"R07", "low",
|
|
1011
|
+
f"Over-mocking in test `{fn.name}`: {len(hits)} mock interactions/verifications.",
|
|
1012
|
+
unit.rel, fn.line, fn.name, evidence=f"{len(hits)} mock hits", fix="none",
|
|
1013
|
+
fix_hint="Mock external boundaries only; assert observable behaviour, not call internals.",
|
|
1014
|
+
))
|
|
1015
|
+
if re.search(r"\bassert\w*!\s*\(\s*(?:\w+\.)?is_ok\(\)\s*\)", fn.body) and "Mock" in fn.body:
|
|
1016
|
+
out.append(Finding(
|
|
1017
|
+
"R07", "low",
|
|
1018
|
+
f"Test `{fn.name}` only asserts mock setup results (mock-only test).",
|
|
1019
|
+
unit.rel, fn.line, fn.name, evidence="assert!(mock.is_ok())", fix="none",
|
|
1020
|
+
fix_hint="Assert on real interactions with the system under test.",
|
|
1021
|
+
))
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def check_r08(unit: FileUnit, out: list[Finding]) -> None:
|
|
1025
|
+
for fn in unit.fns:
|
|
1026
|
+
if not fn.is_test:
|
|
1027
|
+
continue
|
|
1028
|
+
checks = [
|
|
1029
|
+
(r"\b(?:std::)?thread::sleep\s*\(|\bSleep\b", "medium",
|
|
1030
|
+
"Sleep in test (timing dependence -> flaky)."),
|
|
1031
|
+
(r"\bInstant::now\s*\(|\bSystemTime::now\s*\(", "medium",
|
|
1032
|
+
"Wall-clock/monotonic time read in test (flaky on slow machines)."),
|
|
1033
|
+
(r"\b(?:rand::|random\s*\()", "low",
|
|
1034
|
+
"Randomness in test without fixed seed (flaky)."),
|
|
1035
|
+
(r"\bstd::env::(?:set_var|remove_var)\b", "medium",
|
|
1036
|
+
"Test mutates process environment (order-dependent, unsafe in parallel tests)."),
|
|
1037
|
+
(r"\b(?:HashMap|HashSet)[^;]*\.iter\s*\(\)", "low",
|
|
1038
|
+
"Iteration over unordered HashMap/HashSet in test (order dependence)."),
|
|
1039
|
+
]
|
|
1040
|
+
for pattern, sev, msg in checks:
|
|
1041
|
+
if re.search(pattern, fn.body):
|
|
1042
|
+
out.append(Finding(
|
|
1043
|
+
"R08", sev, f"{msg} (`{fn.name}`)",
|
|
1044
|
+
unit.rel, fn.line, fn.name,
|
|
1045
|
+
evidence=re.search(pattern, fn.body).group(0)[:50], fix="none",
|
|
1046
|
+
fix_hint="Use deterministic time/random injection and sorted collections.",
|
|
1047
|
+
))
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def check_r09(unit: FileUnit, out: list[Finding]) -> None:
|
|
1051
|
+
for m in re.finditer(r"\.\s*(unwrap|expect)\s*\(|\bpanic!\s*\(", unit.src.masked_all):
|
|
1052
|
+
fn = locate_fn(unit.fns, m.start())
|
|
1053
|
+
sev = _prod_severity(unit, fn, "high", "skip", "info")
|
|
1054
|
+
if sev is None:
|
|
1055
|
+
continue
|
|
1056
|
+
name, _ = _ctx_label(unit, fn)
|
|
1057
|
+
what = m.group(0).strip()
|
|
1058
|
+
out.append(Finding(
|
|
1059
|
+
"R09", sev,
|
|
1060
|
+
f"`{what}` turns an Option/Result into a panic in production code.",
|
|
1061
|
+
unit.rel, unit.src.line_of(m.start()), name, evidence=what, fix="assisted",
|
|
1062
|
+
fix_hint="Propagate with `?`, match on the error, or provide a default explicitly.",
|
|
1063
|
+
))
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def check_r10(unit: FileUnit, out: list[Finding]) -> None:
|
|
1067
|
+
masked = unit.src.masked_all
|
|
1068
|
+
patterns = [
|
|
1069
|
+
(r"\blet\s+_\s*=", "Discarded value (`let _ =`) hides errors/ignored Results."),
|
|
1070
|
+
(r"\bif\s+let\s+Ok\s*\(\s*_\w*\s*\)", "Error branch silently ignored (`if let Ok(_)`)"),
|
|
1071
|
+
(r"\.ok\s*\(\)\s*;", "Result flattened with `.ok()` and dropped."),
|
|
1072
|
+
(r"unwrap_or_default\s*\(\)", "Failure converted to a silent default."),
|
|
1073
|
+
]
|
|
1074
|
+
for pattern, msg in patterns:
|
|
1075
|
+
for m in re.finditer(pattern, masked):
|
|
1076
|
+
fn = locate_fn(unit.fns, m.start())
|
|
1077
|
+
sev = _prod_severity(unit, fn, "medium", "skip", "info")
|
|
1078
|
+
if sev is None:
|
|
1079
|
+
continue
|
|
1080
|
+
name, _ = _ctx_label(unit, fn)
|
|
1081
|
+
out.append(Finding(
|
|
1082
|
+
"R10", sev, msg, unit.rel, unit.src.line_of(m.start()), name,
|
|
1083
|
+
evidence=m.group(0), fix="assisted",
|
|
1084
|
+
fix_hint="Handle or propagate the error explicitly; only discard with a stated reason.",
|
|
1085
|
+
))
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
UNSAFE_RE = re.compile(
|
|
1089
|
+
r"\bunsafe\b|\btransmute(?:_copy)?\s*\(|\bfrom_raw_parts(?:_mut)?\s*\(|"
|
|
1090
|
+
r"\*(?:const|mut)\b|extern\s+\"[^\"]*\"|\bstd::ptr\b|\bcore::ptr\b"
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def check_r11(unit: FileUnit, out: list[Finding]) -> None:
|
|
1095
|
+
for m in UNSAFE_RE.finditer(unit.src.masked_all):
|
|
1096
|
+
line = unit.src.line_of(m.start())
|
|
1097
|
+
above = "\n".join(unit.src.line_text(i) for i in range(max(1, line - 3), line))
|
|
1098
|
+
if re.search(r"(?i)\bSAFETY\b", above) or above.strip().startswith("///"):
|
|
1099
|
+
continue
|
|
1100
|
+
fn = locate_fn(unit.fns, m.start())
|
|
1101
|
+
sev = _prod_severity(unit, fn, "high", "skip", "info")
|
|
1102
|
+
if sev is None:
|
|
1103
|
+
continue
|
|
1104
|
+
name, _ = _ctx_label(unit, fn)
|
|
1105
|
+
out.append(Finding(
|
|
1106
|
+
"R11", sev,
|
|
1107
|
+
f"`{m.group(0).strip()}` used without a SAFETY justification comment.",
|
|
1108
|
+
unit.rel, line, name, evidence=m.group(0).strip(), fix="none",
|
|
1109
|
+
fix_hint="Avoid unsafe; if unavoidable, add `// SAFETY: ...` and test with Miri.",
|
|
1110
|
+
))
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
SECRET_PATTERNS = [
|
|
1114
|
+
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AWS access key id"),
|
|
1115
|
+
(re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), "GitHub token"),
|
|
1116
|
+
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "Slack token"),
|
|
1117
|
+
(re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----"), "private key"),
|
|
1118
|
+
(re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), "API secret key"),
|
|
1119
|
+
]
|
|
1120
|
+
SECRET_ASSIGN_RE = re.compile(
|
|
1121
|
+
r"(?i)\b(api[_-]?key|apikey|secret[_-]?key|access[_-]?key|password|passwd|auth[_-]?token|token)\s*[:=]\s*\"([^\"]{8,})\""
|
|
1122
|
+
)
|
|
1123
|
+
SECRET_LITERAL_RE = re.compile(r"\"[A-Za-z0-9+/=_%\-]{40,}\"|\"[0-9a-fA-F]{32,}\"")
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def check_r12(unit: FileUnit, out: list[Finding]) -> None:
|
|
1127
|
+
raw = unit.src.text
|
|
1128
|
+
for pattern, label in SECRET_PATTERNS:
|
|
1129
|
+
for m in pattern.finditer(raw):
|
|
1130
|
+
out.append(Finding(
|
|
1131
|
+
"R12", "critical", f"Hardcoded {label} in source.",
|
|
1132
|
+
unit.rel, unit.src.line_of(m.start()), "",
|
|
1133
|
+
evidence=m.group(0)[:12] + "…", fix="none",
|
|
1134
|
+
fix_hint="Move to an environment variable or secret manager and rotate the leaked value.",
|
|
1135
|
+
))
|
|
1136
|
+
for m in SECRET_ASSIGN_RE.finditer(raw):
|
|
1137
|
+
if "env::var" in raw[max(0, m.start() - 40): m.start()]:
|
|
1138
|
+
continue
|
|
1139
|
+
if unit.kind in ("test",):
|
|
1140
|
+
continue
|
|
1141
|
+
out.append(Finding(
|
|
1142
|
+
"R12", "critical",
|
|
1143
|
+
f"Hardcoded credential in assignment `{m.group(1)}`.",
|
|
1144
|
+
unit.rel, unit.src.line_of(m.start()), "",
|
|
1145
|
+
evidence=f"{m.group(1)} = \"…\"", fix="safe",
|
|
1146
|
+
fix_hint="Read from std::env::var (fix --safe rewrites the assignment and fills .env.example).",
|
|
1147
|
+
))
|
|
1148
|
+
for m in SECRET_LITERAL_RE.finditer(raw):
|
|
1149
|
+
fn = locate_fn(unit.fns, m.start())
|
|
1150
|
+
if fn is not None and fn.is_test:
|
|
1151
|
+
continue
|
|
1152
|
+
if unit.kind in ("test", "bench"):
|
|
1153
|
+
continue
|
|
1154
|
+
out.append(Finding(
|
|
1155
|
+
"R12", "critical", "Long high-entropy literal that looks like a hardcoded secret.",
|
|
1156
|
+
unit.rel, unit.src.line_of(m.start()), "",
|
|
1157
|
+
evidence=m.group(0)[:12] + "…", fix="none",
|
|
1158
|
+
fix_hint="Confirm it is not a credential; if it is, move it to env/secret store.",
|
|
1159
|
+
confidence="medium",
|
|
1160
|
+
))
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def check_r13(unit: FileUnit, out: list[Finding]) -> None:
|
|
1164
|
+
for fn in unit.fns:
|
|
1165
|
+
if fn.is_test or unit.kind in ("example", "bench"):
|
|
1166
|
+
continue
|
|
1167
|
+
body = fn.body
|
|
1168
|
+
if re.search(r"\benv::args\s*\(", body) and re.search(r"\.unwrap\s*\(|\.expect\s*\(|\[[^\]]+\]", body):
|
|
1169
|
+
out.append(Finding(
|
|
1170
|
+
"R13", "medium",
|
|
1171
|
+
f"`{fn.name}` reads CLI args without validation (panic on empty/invalid input).",
|
|
1172
|
+
unit.rel, fn.line, fn.name, evidence="env::args + unwrap/index", fix="none",
|
|
1173
|
+
fix_hint="Validate presence, type and bounds of every argument; return a typed error.",
|
|
1174
|
+
))
|
|
1175
|
+
for m in re.finditer(r"(from_str\s*\([^;]*?\)|\.parse\s*::<[^>]*>\s*\([^;]*?\))\s*\.\s*(unwrap|expect)\s*\(", body):
|
|
1176
|
+
out.append(Finding(
|
|
1177
|
+
"R13", "medium",
|
|
1178
|
+
f"`{fn.name}` parses untrusted input with unchecked `{m.group(2)}`.",
|
|
1179
|
+
unit.rel, fn.line + body[: m.start()].count("\n"), fn.name,
|
|
1180
|
+
evidence=re.sub(r"\s+", " ", m.group(0))[:60], fix="none",
|
|
1181
|
+
fix_hint="Handle the parse error explicitly and validate ranges/formats.",
|
|
1182
|
+
))
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def check_r14(unit: FileUnit, out: list[Finding]) -> None:
|
|
1186
|
+
for fn in unit.fns:
|
|
1187
|
+
if fn.is_test or unit.kind in ("example", "bench"):
|
|
1188
|
+
continue
|
|
1189
|
+
clones = re.findall(r"\.clone\s*\(\)", fn.body)
|
|
1190
|
+
if len(clones) > THRESHOLDS.get("clones_per_fn", 3):
|
|
1191
|
+
out.append(Finding(
|
|
1192
|
+
"R14", "medium",
|
|
1193
|
+
f"`{fn.name}` calls `.clone()` {len(clones)} times (borrow-checker fight, hidden cost).",
|
|
1194
|
+
unit.rel, fn.line, fn.name, evidence=f"{len(clones)}x .clone()", fix="none",
|
|
1195
|
+
fix_hint="Prefer borrowing (&/&mut) or restructure ownership; clone only with a real need.",
|
|
1196
|
+
))
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def check_r15(unit: FileUnit, out: list[Finding]) -> None:
|
|
1200
|
+
for fn in unit.fns:
|
|
1201
|
+
lifetimes = re.findall(r"'[a-z][a-z0-9_]*\b", fn.sig.split(")")[0] if ")" in fn.sig else fn.sig)
|
|
1202
|
+
uniq = set(lifetimes)
|
|
1203
|
+
if len(uniq) >= THRESHOLDS.get("lifetimes_per_fn", 3):
|
|
1204
|
+
out.append(Finding(
|
|
1205
|
+
"R15", "low",
|
|
1206
|
+
f"`{fn.name}` declares {len(uniq)} lifetimes (invented lifetimes to appease the borrow checker).",
|
|
1207
|
+
unit.rel, fn.line, fn.name, evidence=", ".join(sorted(uniq)), fix="none",
|
|
1208
|
+
fix_hint="Restructure the data instead of adding lifetimes.",
|
|
1209
|
+
))
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
TOKIO_FEATURE_NEEDS = {
|
|
1213
|
+
"net": "net", "time": "time", "fs": "fs", "sync": "sync", "process": "process",
|
|
1214
|
+
"io": "io-util", "signal": "signal", "stream": "stream",
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def check_r16(unit: FileUnit, out: list[Finding], deps: list[Dep]) -> None:
|
|
1219
|
+
for fn in unit.fns:
|
|
1220
|
+
if fn.is_async and not fn.is_test:
|
|
1221
|
+
if re.search(r"\b(?:std::)?thread::sleep\s*\(", fn.body):
|
|
1222
|
+
out.append(Finding(
|
|
1223
|
+
"R16", "high",
|
|
1224
|
+
f"`{fn.name}` blocks the Tokio runtime with `std::thread::sleep`.",
|
|
1225
|
+
unit.rel, fn.line, fn.name, evidence="thread::sleep in async fn", fix="assisted",
|
|
1226
|
+
fix_hint="Use `tokio::time::sleep(d).await` or `spawn_blocking` for blocking work.",
|
|
1227
|
+
))
|
|
1228
|
+
if re.search(r"\bstd::fs::|\bfs::read_to_string|\bfs::write\b", fn.body):
|
|
1229
|
+
out.append(Finding(
|
|
1230
|
+
"R16", "medium",
|
|
1231
|
+
f"`{fn.name}` performs blocking filesystem I/O inside async code.",
|
|
1232
|
+
unit.rel, fn.line, fn.name, evidence="std::fs in async fn", fix="none",
|
|
1233
|
+
fix_hint="Use tokio::fs or move the work to `tokio::task::spawn_blocking`.",
|
|
1234
|
+
))
|
|
1235
|
+
if re.search(r"\bblock_on\b", fn.body):
|
|
1236
|
+
out.append(Finding(
|
|
1237
|
+
"R16", "medium",
|
|
1238
|
+
f"`{fn.name}` calls `block_on` from inside async code (deadlock risk).",
|
|
1239
|
+
unit.rel, fn.line, fn.name, evidence="block_on", fix="none",
|
|
1240
|
+
fix_hint="Await the future instead of blocking the runtime thread.",
|
|
1241
|
+
))
|
|
1242
|
+
lock_m = re.search(r"\.(?:lock|read|write)\s*\(\s*\)", fn.body)
|
|
1243
|
+
if lock_m and ".await" in fn.body[lock_m.end():]:
|
|
1244
|
+
out.append(Finding(
|
|
1245
|
+
"R16", "high",
|
|
1246
|
+
f"`{fn.name}` holds a synchronous Mutex/RwLock guard across `.await` (deadlock/blocking).",
|
|
1247
|
+
unit.rel, fn.line, fn.name, evidence="lock() … .await", fix="none",
|
|
1248
|
+
fix_hint="Drop the guard before the await or use tokio::sync primitives.",
|
|
1249
|
+
))
|
|
1250
|
+
if not fn.is_test and re.search(r"\bfor\b[^{]*\{", fn.body):
|
|
1251
|
+
for_m = re.search(r"\bfor\b", fn.body)
|
|
1252
|
+
spawn_m = re.search(r"\btokio::spawn\s*\(", fn.body)
|
|
1253
|
+
if for_m and spawn_m and spawn_m.start() > for_m.start():
|
|
1254
|
+
out.append(Finding(
|
|
1255
|
+
"R16", "medium",
|
|
1256
|
+
f"`{fn.name}` spawns one task per loop iteration without bounds.",
|
|
1257
|
+
unit.rel, fn.line, fn.name, evidence="tokio::spawn in loop", fix="none",
|
|
1258
|
+
fix_hint="Bound concurrency with a Semaphore, worker pool or batches.",
|
|
1259
|
+
))
|
|
1260
|
+
# tokio feature completeness (cross-cutting, one finding per missing feature)
|
|
1261
|
+
tokio_dep = next((d for d in deps if _norm(d.package) == "tokio"), None)
|
|
1262
|
+
if tokio_dep is not None:
|
|
1263
|
+
features = {f.replace("-", "_") for f in tokio_dep.features}
|
|
1264
|
+
if "full" not in features and features != {"full"}:
|
|
1265
|
+
used = set()
|
|
1266
|
+
for m in re.finditer(r"\btokio::([a-z_]+)", unit.src.masked_all):
|
|
1267
|
+
used.add(m.group(1))
|
|
1268
|
+
if re.search(r"#\[\s*tokio::(main|test)\s*\]", unit.src.masked_all):
|
|
1269
|
+
used.update({"macros"})
|
|
1270
|
+
if re.search(r"\btokio::spawn\s*\(", unit.src.masked_all):
|
|
1271
|
+
used.add("rt")
|
|
1272
|
+
needed = set()
|
|
1273
|
+
for u in used:
|
|
1274
|
+
if u in TOKIO_FEATURE_NEEDS:
|
|
1275
|
+
needed.add(TOKIO_FEATURE_NEEDS[u])
|
|
1276
|
+
elif u in ("main", "test", "macros"):
|
|
1277
|
+
needed.add("macros")
|
|
1278
|
+
elif u in ("spawn", "rt"):
|
|
1279
|
+
needed.add("rt-multi-thread")
|
|
1280
|
+
if "rt" in used and "rt_multi_thread" not in features and "rt" not in features:
|
|
1281
|
+
needed.add("rt-multi-thread")
|
|
1282
|
+
missing = sorted(n for n in needed if n.replace("-", "_") not in features)
|
|
1283
|
+
if missing:
|
|
1284
|
+
out.append(Finding(
|
|
1285
|
+
"R16", "medium",
|
|
1286
|
+
f"Tokio features used but not declared in Cargo.toml: {', '.join(missing)}.",
|
|
1287
|
+
unit.rel, 1, "", evidence=f"used: {sorted(used)}", fix="none",
|
|
1288
|
+
fix_hint=f'Add features = [{", ".join(repr(f) for f in missing)}] to the tokio dependency.',
|
|
1289
|
+
))
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def check_r17(unit: FileUnit, out: list[Finding]) -> None:
|
|
1293
|
+
hits = re.findall(
|
|
1294
|
+
r"Arc\s*<\s*Mutex\s*<|Arc\s*<\s*std::sync::Mutex|Arc::new\s*\(\s*(?:std::sync::)?Mutex::new",
|
|
1295
|
+
unit.src.masked_all)
|
|
1296
|
+
if len(hits) >= THRESHOLDS.get("arc_mutex_per_file", 4):
|
|
1297
|
+
out.append(Finding(
|
|
1298
|
+
"R17", "low",
|
|
1299
|
+
f"`Arc<Mutex<..>>` used {len(hits)} times in one file (shared mutable state everywhere).",
|
|
1300
|
+
unit.rel, 1, "", evidence=f"{len(hits)} occurrences", fix="none",
|
|
1301
|
+
fix_hint="Prefer message passing (channels), actors or immutable shared data.",
|
|
1302
|
+
))
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def check_r21(unit: FileUnit, out: list[Finding]) -> None:
|
|
1306
|
+
if unit.kind != "build" and "proc-macro" not in unit.rel:
|
|
1307
|
+
# proc-macro crates are flagged at manifest level below
|
|
1308
|
+
pass
|
|
1309
|
+
if unit.kind == "build":
|
|
1310
|
+
for pattern, msg in [
|
|
1311
|
+
(r"Command::new\s*\(", "build.rs executes an external command at compile time"),
|
|
1312
|
+
(r"\bTcpStream\b|\bUdpSocket\b|\breqwest\b|\bhyper::|curl", "build.rs opens network connections"),
|
|
1313
|
+
(r"fs::write\b|File::create\s*\(|OpenOptions", "build.rs writes files during build"),
|
|
1314
|
+
]:
|
|
1315
|
+
m = re.search(pattern, unit.src.masked_all)
|
|
1316
|
+
if m:
|
|
1317
|
+
out.append(Finding(
|
|
1318
|
+
"R21", "high", f"{msg} (supply-chain risk: code runs on every build).",
|
|
1319
|
+
unit.rel, unit.src.line_of(m.start()), "main",
|
|
1320
|
+
evidence=m.group(0), fix="none",
|
|
1321
|
+
fix_hint="Inspect build.rs line by line; a hallucinated dependency often hides here.",
|
|
1322
|
+
))
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
# --------------------------------------------------------------------------
|
|
1326
|
+
# Cross-file crate reference rules (R18a-e, R20)
|
|
1327
|
+
# --------------------------------------------------------------------------
|
|
1328
|
+
USE_ROOT_RE = re.compile(r"\buse\s+([A-Za-z_]\w*)\s*::")
|
|
1329
|
+
EXTERN_RE = re.compile(r"\bextern\s+crate\s+([A-Za-z_]\w*)")
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def collect_use_roots(units: list[FileUnit]) -> dict[str, list[tuple[str, int, str]]]:
|
|
1333
|
+
roots: dict[str, list[tuple[str, int, str]]] = {}
|
|
1334
|
+
for unit in units:
|
|
1335
|
+
for m in list(USE_ROOT_RE.finditer(unit.src.masked_all)) + \
|
|
1336
|
+
list(EXTERN_RE.finditer(unit.src.masked_all)):
|
|
1337
|
+
root = m.group(1)
|
|
1338
|
+
roots.setdefault(root, []).append(
|
|
1339
|
+
(unit.rel, unit.src.line_of(m.start()), m.group(0)))
|
|
1340
|
+
return roots
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def local_modules(units: list[FileUnit]) -> set[str]:
|
|
1344
|
+
mods: set[str] = set()
|
|
1345
|
+
for unit in units:
|
|
1346
|
+
for m in MOD_RE.finditer(unit.src.masked_all):
|
|
1347
|
+
mods.add(m.group(1))
|
|
1348
|
+
stem = Path(unit.rel).stem
|
|
1349
|
+
if stem != "main" and stem != "lib":
|
|
1350
|
+
mods.add(_norm(stem))
|
|
1351
|
+
return mods
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def check_crate_refs(units: list[FileUnit], reg: Registry, out: list[Finding]) -> None:
|
|
1355
|
+
roots = collect_use_roots(units)
|
|
1356
|
+
mods = local_modules(units)
|
|
1357
|
+
known = reg.known_roots()
|
|
1358
|
+
popular = reg.popular
|
|
1359
|
+
|
|
1360
|
+
for root, sites in sorted(roots.items()):
|
|
1361
|
+
if root in STD_CRATES or root in {"self", "super", "crate"} or root in mods:
|
|
1362
|
+
continue
|
|
1363
|
+
rel, line, evidence = sites[0]
|
|
1364
|
+
if root in STD_MODULES:
|
|
1365
|
+
out.append(Finding(
|
|
1366
|
+
"R18b", "high",
|
|
1367
|
+
f"`{root}` is a standard-library module, not an external crate "
|
|
1368
|
+
f"(namespace confusion; {len(sites)} reference(s)).",
|
|
1369
|
+
rel, line, "", evidence=evidence, fix="safe",
|
|
1370
|
+
fix_hint=f"Qualify the path: `use std::{root}::...`.",
|
|
1371
|
+
))
|
|
1372
|
+
continue
|
|
1373
|
+
if _norm(root) in {_norm(p) for p in popular}:
|
|
1374
|
+
continue
|
|
1375
|
+
# near-miss against declared deps and popular crates; sorted() in each
|
|
1376
|
+
# group: `known`/`popular` are sets (hash-randomized order), so ties
|
|
1377
|
+
# at equal edit distance must resolve deterministically (declared
|
|
1378
|
+
# deps keep priority over popular crates).
|
|
1379
|
+
best, best_dist = None, 99
|
|
1380
|
+
for cand in sorted(known) + sorted(_norm(p) for p in popular):
|
|
1381
|
+
if cand == _norm(root):
|
|
1382
|
+
continue # never compare a name against itself
|
|
1383
|
+
d = levenshtein(_norm(root), cand)
|
|
1384
|
+
if d < best_dist:
|
|
1385
|
+
best, best_dist = cand, d
|
|
1386
|
+
if best is not None and 0 < best_dist <= 2:
|
|
1387
|
+
out.append(Finding(
|
|
1388
|
+
"R18c", "high",
|
|
1389
|
+
f"Crate `{root}` does not exist but is within {best_dist} edit(s) of `{best}` "
|
|
1390
|
+
f"(near-miss / hallucinated name).",
|
|
1391
|
+
rel, line, "", evidence=f"{root} ~ {best}", fix="none",
|
|
1392
|
+
fix_hint=f"Verify on https://crates.io/crates/{best} and https://docs.rs/{best}; "
|
|
1393
|
+
f"never rename automatically.",
|
|
1394
|
+
))
|
|
1395
|
+
continue
|
|
1396
|
+
if SUSPICIOUS_NAME_RE.match(root) or root in reg.suspicious:
|
|
1397
|
+
out.append(Finding(
|
|
1398
|
+
"R18d", "critical",
|
|
1399
|
+
f"Crate `{root}` matches a hallucinated/generic naming pattern "
|
|
1400
|
+
f"(possible slopsquatting bait; declared or not, verify before use).",
|
|
1401
|
+
rel, line, "", evidence=root, fix="none",
|
|
1402
|
+
fix_hint="Do not add this dependency. Verify the real crate on crates.io/docs.rs first.",
|
|
1403
|
+
))
|
|
1404
|
+
continue
|
|
1405
|
+
if root in known:
|
|
1406
|
+
continue
|
|
1407
|
+
out.append(Finding(
|
|
1408
|
+
"R18a", "high",
|
|
1409
|
+
f"`use {root}::...` references a crate that is not declared in Cargo.toml "
|
|
1410
|
+
f"(unresolved external identifier; compilation will fail).",
|
|
1411
|
+
rel, line, "", evidence=evidence, fix="none",
|
|
1412
|
+
fix_hint=f"Add a verified dependency (crates.io/docs.rs) or fix the path.",
|
|
1413
|
+
))
|
|
1414
|
+
|
|
1415
|
+
# declared-but-unused and version checks
|
|
1416
|
+
all_text = "\n".join(u.src.masked_all for u in units)
|
|
1417
|
+
build_text = "\n".join(u.src.masked_all for u in units if u.kind == "build")
|
|
1418
|
+
for dep in reg.deps:
|
|
1419
|
+
if dep.kind == "dev":
|
|
1420
|
+
probe = all_text
|
|
1421
|
+
elif dep.kind == "build":
|
|
1422
|
+
probe = build_text
|
|
1423
|
+
else:
|
|
1424
|
+
probe = all_text
|
|
1425
|
+
used = re.search(r"\b" + re.escape(_norm(dep.package)) + r"\b", probe) or \
|
|
1426
|
+
re.search(r"\b" + re.escape(_norm(dep.key)) + r"\b", probe) or \
|
|
1427
|
+
re.search(r"\b" + re.escape(dep.package.replace("-", "_")) + r"\b", probe)
|
|
1428
|
+
if not used and dep.package not in ("serde",):
|
|
1429
|
+
out.append(Finding(
|
|
1430
|
+
"R18e", "low",
|
|
1431
|
+
f"Dependency `{dep.package}` is declared but never referenced "
|
|
1432
|
+
f"(possibly an invented or leftover dependency).",
|
|
1433
|
+
"Cargo.toml", 1, "", evidence=f"{dep.kind}-dependency {dep.package}", fix="none",
|
|
1434
|
+
fix_hint="Remove it or confirm with `cargo machete` / `cargo udeps`.",
|
|
1435
|
+
source="static", confidence="medium",
|
|
1436
|
+
))
|
|
1437
|
+
# impossible version requirements
|
|
1438
|
+
m = re.match(r"\s*(?:\^|>=)?\s*(\d+)", dep.version_req or "")
|
|
1439
|
+
if m:
|
|
1440
|
+
major = int(m.group(1))
|
|
1441
|
+
known_major = KNOWN_MAJORS.get(_norm(dep.package))
|
|
1442
|
+
if major > 100 or (known_major is not None and major > known_major):
|
|
1443
|
+
out.append(Finding(
|
|
1444
|
+
"R18d", "critical",
|
|
1445
|
+
f"Impossible version requirement `{dep.version_req}` for `{dep.package}` "
|
|
1446
|
+
f"(published majors end at {known_major if known_major is not None else '2 digits'}).",
|
|
1447
|
+
"Cargo.toml", 1, "", evidence=f"{dep.package} = \"{dep.version_req}\"",
|
|
1448
|
+
fix="none",
|
|
1449
|
+
fix_hint="Check the real version on crates.io/docs.rs; the AI likely invented it.",
|
|
1450
|
+
))
|
|
1451
|
+
|
|
1452
|
+
# typosquat similarity (R20)
|
|
1453
|
+
# sorted(): `popular` is a set, so its iteration order is hash-randomized
|
|
1454
|
+
# per process; without a stable order, equal-distance candidates (fst ~
|
|
1455
|
+
# csv vs fst ~ pest) flip between runs and the report is not reproducible.
|
|
1456
|
+
pop_sorted = sorted(popular)
|
|
1457
|
+
for dep in reg.deps:
|
|
1458
|
+
name = _norm(dep.package)
|
|
1459
|
+
if name in {_norm(p) for p in popular}:
|
|
1460
|
+
continue
|
|
1461
|
+
best, best_dist = None, 99
|
|
1462
|
+
for p in pop_sorted:
|
|
1463
|
+
d = levenshtein(name, _norm(p))
|
|
1464
|
+
if d < best_dist:
|
|
1465
|
+
best, best_dist = p, d
|
|
1466
|
+
if best is not None and best_dist <= 2:
|
|
1467
|
+
out.append(Finding(
|
|
1468
|
+
"R20", "high",
|
|
1469
|
+
f"Dependency `{dep.package}` is not popular but is {best_dist} edit(s) away from "
|
|
1470
|
+
f"`{best}` (potential typosquat).",
|
|
1471
|
+
"Cargo.toml", 1, "", evidence=f"{dep.package} ~ {best}", fix="none",
|
|
1472
|
+
fix_hint=f"Confirm you meant https://crates.io/crates/{best}; check downloads, "
|
|
1473
|
+
f"repository and age of `{dep.package}` before using it.",
|
|
1474
|
+
))
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
# --------------------------------------------------------------------------
|
|
1478
|
+
# Online checks (crates.io API, sparse index, docs.rs, OSV) -- --online only
|
|
1479
|
+
# --------------------------------------------------------------------------
|
|
1480
|
+
UA = {"User-Agent": f"{TOOL_NAME}/{VERSION} (dependency verification)"}
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _http_json(url: str, payload: dict | None = None, timeout: float = 12.0):
|
|
1484
|
+
data = json.dumps(payload).encode() if payload is not None else None
|
|
1485
|
+
req = urllib.request.Request(url, data=data, headers={**UA, "Content-Type": "application/json"})
|
|
1486
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
1487
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _sparse_index(name: str) -> list[dict]:
|
|
1491
|
+
n = name.replace("-", "_").lower()
|
|
1492
|
+
if len(n) == 1:
|
|
1493
|
+
prefix = f"1"
|
|
1494
|
+
elif len(n) == 2:
|
|
1495
|
+
prefix = f"2"
|
|
1496
|
+
elif len(n) == 3:
|
|
1497
|
+
prefix = f"3/{n[0]}"
|
|
1498
|
+
else:
|
|
1499
|
+
prefix = f"{n[:2]}/{n[2:4]}"
|
|
1500
|
+
url = f"https://index.crates.io/{prefix}/{n}"
|
|
1501
|
+
req = urllib.request.Request(url, headers=UA)
|
|
1502
|
+
with urllib.request.urlopen(req, timeout=12.0) as resp:
|
|
1503
|
+
return [json.loads(line) for line in resp.read().decode("utf-8").splitlines() if line.strip()]
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def online_check_deps(deps: list[Dep], locked: dict[str, str], out: list[Finding]) -> list[dict]:
|
|
1507
|
+
"""Registry verification table + R18d/R19/OSV findings."""
|
|
1508
|
+
table: list[dict] = []
|
|
1509
|
+
for dep in deps:
|
|
1510
|
+
name = dep.package
|
|
1511
|
+
row: dict = {"crate": name, "version_req": dep.version_req,
|
|
1512
|
+
"crates.io": "", "docs.rs": "", "features": "", "status": ""}
|
|
1513
|
+
try:
|
|
1514
|
+
data = _http_json(f"https://crates.io/api/v1/crates/{name}")
|
|
1515
|
+
except urllib.error.HTTPError as e:
|
|
1516
|
+
if e.code == 404:
|
|
1517
|
+
row["status"] = "NOT FOUND on crates.io"
|
|
1518
|
+
out.append(Finding(
|
|
1519
|
+
"R18d", "critical",
|
|
1520
|
+
f"Crate `{name}` does not exist on crates.io (hallucinated/slopsquat candidate).",
|
|
1521
|
+
"Cargo.toml", 1, "", evidence=f"HTTP 404 for {name}",
|
|
1522
|
+
source="crates.io", fix="none",
|
|
1523
|
+
fix_hint="Remove the dependency. If an attacker registered this name, it is malware bait.",
|
|
1524
|
+
))
|
|
1525
|
+
table.append(row)
|
|
1526
|
+
continue
|
|
1527
|
+
row["status"] = f"crates.io error HTTP {e.code}"
|
|
1528
|
+
table.append(row)
|
|
1529
|
+
continue
|
|
1530
|
+
except Exception as e: # network failure must not fake a pass
|
|
1531
|
+
row["status"] = f"check failed: {e}"
|
|
1532
|
+
table.append(row)
|
|
1533
|
+
continue
|
|
1534
|
+
|
|
1535
|
+
crate = data.get("crate") or {}
|
|
1536
|
+
versions = data.get("versions") or []
|
|
1537
|
+
row["crates.io"] = f"https://crates.io/crates/{name}"
|
|
1538
|
+
row["downloads"] = crate.get("downloads")
|
|
1539
|
+
row["updated_at"] = crate.get("updated_at")
|
|
1540
|
+
row["repository"] = (crate.get("repository") or "")
|
|
1541
|
+
|
|
1542
|
+
resolved = locked.get(name) or locked.get(name.replace("_", "-")) or crate.get("max_version")
|
|
1543
|
+
row["version"] = resolved
|
|
1544
|
+
row["docs.rs"] = f"https://docs.rs/{name}/{resolved}" if resolved else f"https://docs.rs/{name}"
|
|
1545
|
+
|
|
1546
|
+
# yanked resolved version
|
|
1547
|
+
for v in versions:
|
|
1548
|
+
if str(v.get("num")) == str(resolved) and v.get("yanked"):
|
|
1549
|
+
out.append(Finding(
|
|
1550
|
+
"R18d", "high", f"Resolved version {name}@{resolved} is yanked.",
|
|
1551
|
+
"Cargo.lock", 1, "", evidence=f"{name} {resolved} yanked",
|
|
1552
|
+
source="crates.io", fix="none",
|
|
1553
|
+
fix_hint="Move to a non-yanked version and review why it was yanked.",
|
|
1554
|
+
))
|
|
1555
|
+
row["status"] = "yanked"
|
|
1556
|
+
|
|
1557
|
+
# slopsquatting window: brand-new crate + tiny downloads + generic name
|
|
1558
|
+
created = [v.get("created_at") or "" for v in versions]
|
|
1559
|
+
first = min(created) if created else ""
|
|
1560
|
+
try:
|
|
1561
|
+
first_ts = time.mktime(time.strptime(first[:10], "%Y-%m-%d"))
|
|
1562
|
+
age_days = (time.time() - first_ts) / 86400
|
|
1563
|
+
except Exception:
|
|
1564
|
+
age_days = None
|
|
1565
|
+
downloads = crate.get("downloads") or 0
|
|
1566
|
+
if age_days is not None and age_days < 90 and downloads < 10000 and \
|
|
1567
|
+
(SUSPICIOUS_NAME_RE.match(name) or name in Registry(deps).suspicious):
|
|
1568
|
+
out.append(Finding(
|
|
1569
|
+
"R19", "high",
|
|
1570
|
+
f"Crate `{name}` was first published {int(age_days)} days ago with "
|
|
1571
|
+
f"{downloads} downloads and a generic name (classic slopsquatting window).",
|
|
1572
|
+
"Cargo.toml", 1, "", evidence=f"first publish: {first[:10]}",
|
|
1573
|
+
source="crates.io", fix="none",
|
|
1574
|
+
fix_hint="Require human review of the crate source, repository and publisher before merging.",
|
|
1575
|
+
))
|
|
1576
|
+
row["status"] = (row["status"] + "; recent+suspicious").strip("; ")
|
|
1577
|
+
|
|
1578
|
+
# feature verification via sparse index
|
|
1579
|
+
try:
|
|
1580
|
+
entries = _sparse_index(name)
|
|
1581
|
+
entry = next((e for e in entries if e.get("vers") == resolved), entries[-1] if entries else None)
|
|
1582
|
+
if entry is not None:
|
|
1583
|
+
feats = set((entry.get("features") or {}).keys())
|
|
1584
|
+
feats |= {f.replace("-", "_") for f in feats}
|
|
1585
|
+
row["features"] = ", ".join(sorted(feats))[:120]
|
|
1586
|
+
missing = [f for f in dep.features
|
|
1587
|
+
if f not in feats and f.replace("-", "_") not in feats and f != "full"]
|
|
1588
|
+
if missing and dep.features:
|
|
1589
|
+
out.append(Finding(
|
|
1590
|
+
"R18d", "high",
|
|
1591
|
+
f"Features {missing} do not exist in {name}@{entry.get('vers')} "
|
|
1592
|
+
f"(invented feature names).",
|
|
1593
|
+
"Cargo.toml", 1, "", evidence=f"available: {sorted(set(feats))[:8]}",
|
|
1594
|
+
source="crates.io-index", fix="none",
|
|
1595
|
+
fix_hint="Check the exact feature list on docs.rs for that version.",
|
|
1596
|
+
))
|
|
1597
|
+
except Exception:
|
|
1598
|
+
row["features"] = "unverified (index unreachable)"
|
|
1599
|
+
|
|
1600
|
+
# docs.rs probe
|
|
1601
|
+
try:
|
|
1602
|
+
req = urllib.request.Request(row["docs.rs"], method="HEAD", headers=UA)
|
|
1603
|
+
with urllib.request.urlopen(req, timeout=10.0) as resp:
|
|
1604
|
+
row["docs.rs status"] = resp.status
|
|
1605
|
+
except urllib.error.HTTPError as e:
|
|
1606
|
+
if e.code == 404:
|
|
1607
|
+
out.append(Finding(
|
|
1608
|
+
"R18d", "medium",
|
|
1609
|
+
f"docs.rs has no build for {name}@{resolved} (unverifiable API).",
|
|
1610
|
+
"Cargo.toml", 1, "", evidence=row["docs.rs"],
|
|
1611
|
+
source="docs.rs", fix="none", confidence="medium",
|
|
1612
|
+
fix_hint="Confirm the API you use exists in this exact version.",
|
|
1613
|
+
))
|
|
1614
|
+
row["docs.rs status"] = 404
|
|
1615
|
+
except Exception:
|
|
1616
|
+
row["docs.rs status"] = "unreachable"
|
|
1617
|
+
|
|
1618
|
+
# OSV advisories for the resolved version
|
|
1619
|
+
if resolved:
|
|
1620
|
+
try:
|
|
1621
|
+
osv = _http_json("https://api.osv.dev/v1/query",
|
|
1622
|
+
{"package": {"name": name, "ecosystem": "crates.io"},
|
|
1623
|
+
"version": str(resolved)})
|
|
1624
|
+
vulns = osv.get("vulns") or []
|
|
1625
|
+
row["osv"] = [v.get("id") for v in vulns]
|
|
1626
|
+
for v in vulns:
|
|
1627
|
+
out.append(Finding(
|
|
1628
|
+
"R19", "high",
|
|
1629
|
+
f"OSV advisory {v.get('id')} affects {name}@{resolved}.",
|
|
1630
|
+
"Cargo.lock", 1, "", evidence=str(v.get("summary", ""))[:120],
|
|
1631
|
+
source="osv", fix="none",
|
|
1632
|
+
fix_hint="Upgrade to a fixed version or apply the advisory's guidance.",
|
|
1633
|
+
))
|
|
1634
|
+
except Exception:
|
|
1635
|
+
row["osv"] = "unverified"
|
|
1636
|
+
table.append(row)
|
|
1637
|
+
return table
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
# --------------------------------------------------------------------------
|
|
1641
|
+
# Configuration
|
|
1642
|
+
# --------------------------------------------------------------------------
|
|
1643
|
+
THRESHOLDS: dict = {
|
|
1644
|
+
"clones_per_fn": 3,
|
|
1645
|
+
"lifetimes_per_fn": 3,
|
|
1646
|
+
"arc_mutex_per_file": 4,
|
|
1647
|
+
"mocks_per_test": 5,
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
DEFAULT_CONFIG = {
|
|
1651
|
+
"rules": {"disabled": []},
|
|
1652
|
+
"thresholds": dict(THRESHOLDS),
|
|
1653
|
+
"exclude": ["target/*", "*/target/*", ".git/*", "*/.git/*", "report/*", "*/node_modules/*"],
|
|
1654
|
+
"corpus": "",
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
def load_config(root: Path, explicit: str | None) -> dict:
|
|
1659
|
+
cfg = json.loads(json.dumps(DEFAULT_CONFIG))
|
|
1660
|
+
candidates = []
|
|
1661
|
+
if explicit:
|
|
1662
|
+
candidates.append(Path(explicit))
|
|
1663
|
+
candidates += [root / "rust-audit.toml", root / ".rust-audit.toml"]
|
|
1664
|
+
for candidate in candidates:
|
|
1665
|
+
if candidate.exists():
|
|
1666
|
+
try:
|
|
1667
|
+
data = tomllib.loads(candidate.read_text(encoding="utf-8"))
|
|
1668
|
+
except Exception as exc:
|
|
1669
|
+
print(f"warning: cannot parse {candidate}: {exc}", file=sys.stderr)
|
|
1670
|
+
continue
|
|
1671
|
+
for key in ("rules", "thresholds", "exclude", "corpus"):
|
|
1672
|
+
if key in data:
|
|
1673
|
+
if isinstance(data[key], dict) and isinstance(cfg.get(key), dict):
|
|
1674
|
+
cfg[key].update(data[key])
|
|
1675
|
+
else:
|
|
1676
|
+
cfg[key] = data[key]
|
|
1677
|
+
break
|
|
1678
|
+
THRESHOLDS.update(cfg.get("thresholds") or {})
|
|
1679
|
+
return cfg
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
# --------------------------------------------------------------------------
|
|
1683
|
+
# Scan engine
|
|
1684
|
+
# --------------------------------------------------------------------------
|
|
1685
|
+
PER_FILE_CHECKS = [
|
|
1686
|
+
check_r01, check_r02, check_r03, check_r04, check_r05, check_r06,
|
|
1687
|
+
check_r07, check_r08, check_r09, check_r10, check_r11, check_r12,
|
|
1688
|
+
check_r13, check_r14, check_r15, check_r17, check_r21,
|
|
1689
|
+
]
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
def run_scan(root: Path, cfg: dict, online: bool = False):
|
|
1693
|
+
findings: list[Finding] = []
|
|
1694
|
+
units: list[FileUnit] = []
|
|
1695
|
+
excludes = list(cfg.get("exclude") or [])
|
|
1696
|
+
|
|
1697
|
+
def excluded(rel: str) -> bool:
|
|
1698
|
+
return any(fnmatch.fnmatch(rel, g) for g in excludes)
|
|
1699
|
+
|
|
1700
|
+
for path in sorted(root.rglob("*.rs")):
|
|
1701
|
+
rel = path.relative_to(root).as_posix()
|
|
1702
|
+
if excluded(rel):
|
|
1703
|
+
continue
|
|
1704
|
+
if path.with_suffix(".stderr").exists():
|
|
1705
|
+
continue # trybuild compile-fail fixture: it must NOT compile, so
|
|
1706
|
+
# no code-quality verdict on it means anything
|
|
1707
|
+
try:
|
|
1708
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1709
|
+
except OSError:
|
|
1710
|
+
continue
|
|
1711
|
+
unit = parse_file(rel, path, text)
|
|
1712
|
+
units.append(unit)
|
|
1713
|
+
for check in PER_FILE_CHECKS:
|
|
1714
|
+
if check is check_r16:
|
|
1715
|
+
continue
|
|
1716
|
+
check(unit, findings)
|
|
1717
|
+
|
|
1718
|
+
deps: list[Dep] = []
|
|
1719
|
+
locked: dict[str, str] = {}
|
|
1720
|
+
for manifest in sorted(root.rglob("Cargo.toml")):
|
|
1721
|
+
rel = manifest.relative_to(root).as_posix()
|
|
1722
|
+
if excluded(rel):
|
|
1723
|
+
continue
|
|
1724
|
+
found, _ = parse_manifest(manifest)
|
|
1725
|
+
deps += found
|
|
1726
|
+
for lock in sorted(root.rglob("Cargo.lock")):
|
|
1727
|
+
if not excluded(lock.relative_to(root).as_posix()):
|
|
1728
|
+
locked.update(parse_lock(lock))
|
|
1729
|
+
# de-duplicate deps by (package, kind)
|
|
1730
|
+
seen: set[tuple[str, str]] = set()
|
|
1731
|
+
uniq_deps: list[Dep] = []
|
|
1732
|
+
for d in deps:
|
|
1733
|
+
key = (_norm(d.package), d.kind)
|
|
1734
|
+
if key not in seen:
|
|
1735
|
+
seen.add(key)
|
|
1736
|
+
uniq_deps.append(d)
|
|
1737
|
+
deps = uniq_deps
|
|
1738
|
+
|
|
1739
|
+
for unit in units:
|
|
1740
|
+
check_r16(unit, findings, deps)
|
|
1741
|
+
|
|
1742
|
+
corpus: set[str] = set()
|
|
1743
|
+
corpus_file = cfg.get("corpus") or ""
|
|
1744
|
+
if corpus_file and Path(corpus_file).exists():
|
|
1745
|
+
corpus = {ln.strip() for ln in Path(corpus_file).read_text(encoding="utf-8").splitlines()
|
|
1746
|
+
if ln.strip() and not ln.startswith("#")}
|
|
1747
|
+
reg = Registry(deps, corpus)
|
|
1748
|
+
check_crate_refs(units, reg, findings)
|
|
1749
|
+
|
|
1750
|
+
online_table: list[dict] = []
|
|
1751
|
+
if online:
|
|
1752
|
+
online_table = online_check_deps(deps, locked, findings)
|
|
1753
|
+
|
|
1754
|
+
disabled = set(cfg.get("rules", {}).get("disabled") or [])
|
|
1755
|
+
findings = [f for f in findings if f.rule_id not in disabled]
|
|
1756
|
+
findings.sort(key=lambda f: (-SEVERITY_RANK.get(f.severity, 0), f.file, f.line, f.rule_id))
|
|
1757
|
+
return findings, units, reg, online_table
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def summarize(findings: list[Finding]) -> dict:
|
|
1761
|
+
by_sev = {s: 0 for s in SEVERITY_ORDER}
|
|
1762
|
+
by_rule: dict[str, int] = {}
|
|
1763
|
+
by_tier = {1: 0, 2: 0, 3: 0, 4: 0}
|
|
1764
|
+
for f in findings:
|
|
1765
|
+
by_sev[f.severity] = by_sev.get(f.severity, 0) + 1
|
|
1766
|
+
by_rule[f.rule_id] = by_rule.get(f.rule_id, 0) + 1
|
|
1767
|
+
by_tier[f.tier] = by_tier.get(f.tier, 0) + 1
|
|
1768
|
+
return {"total": len(findings), "by_severity": by_sev, "by_rule": by_rule,
|
|
1769
|
+
"by_tier": by_tier}
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
# --------------------------------------------------------------------------
|
|
1773
|
+
# Report writers: terminal, Markdown, JSON, SARIF 2.1.0
|
|
1774
|
+
# --------------------------------------------------------------------------
|
|
1775
|
+
def render_terminal(findings: list[Finding], online_table: list[dict],
|
|
1776
|
+
evidence: list[EvidenceRun], limit: int = 25) -> str:
|
|
1777
|
+
lines: list[str] = []
|
|
1778
|
+
summary = summarize(findings)
|
|
1779
|
+
lines.append(f"{TOOL_NAME} v{VERSION} — summary: {summary['total']} finding(s)")
|
|
1780
|
+
for sev in SEVERITY_ORDER:
|
|
1781
|
+
if summary["by_severity"].get(sev):
|
|
1782
|
+
lines.append(f" {sev.upper():8s} {summary['by_severity'][sev]}")
|
|
1783
|
+
lines.append("")
|
|
1784
|
+
for f in findings[:limit]:
|
|
1785
|
+
fn = f" ({f.function})" if f.function else ""
|
|
1786
|
+
lines.append(f"{f.severity.upper():8s} [{f.rule_id}] {f.file}:{f.line}{fn}")
|
|
1787
|
+
lines.append(f" {f.message}")
|
|
1788
|
+
if f.fix_hint:
|
|
1789
|
+
lines.append(f" fix: {f.fix_hint}")
|
|
1790
|
+
if len(findings) > limit:
|
|
1791
|
+
lines.append(f"... {len(findings) - limit} more finding(s) in the report files")
|
|
1792
|
+
if online_table:
|
|
1793
|
+
lines.append("")
|
|
1794
|
+
lines.append("dependency verification table:")
|
|
1795
|
+
for row in online_table:
|
|
1796
|
+
lines.append(f" {row.get('crate')}: {row.get('status') or 'ok'} "
|
|
1797
|
+
f"{row.get('crates.io', '')}")
|
|
1798
|
+
if evidence:
|
|
1799
|
+
lines.append("")
|
|
1800
|
+
lines.append("evidence runs:")
|
|
1801
|
+
for ev in evidence:
|
|
1802
|
+
lines.append(f" [{ev.status}] {ev.tool} ({ev.command}) exit={ev.exit_code}")
|
|
1803
|
+
return "\n".join(lines)
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def render_markdown(findings: list[Finding], online_table: list[dict],
|
|
1807
|
+
evidence: list[EvidenceRun], target: str) -> str:
|
|
1808
|
+
summary = summarize(findings)
|
|
1809
|
+
lines = [
|
|
1810
|
+
f"# {TOOL_NAME} report — `{target}`",
|
|
1811
|
+
"",
|
|
1812
|
+
f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')} — tool version {VERSION}",
|
|
1813
|
+
"",
|
|
1814
|
+
"## Summary",
|
|
1815
|
+
"",
|
|
1816
|
+
"| Severity | Count |",
|
|
1817
|
+
"|---|---|",
|
|
1818
|
+
]
|
|
1819
|
+
for sev in SEVERITY_ORDER:
|
|
1820
|
+
lines.append(f"| {sev} | {summary['by_severity'].get(sev, 0)} |")
|
|
1821
|
+
lines += ["", "| Tier | Count |", "|---|---|"]
|
|
1822
|
+
for tier in (1, 2, 3, 4):
|
|
1823
|
+
lines.append(f"| Tier {tier} | {summary['by_tier'].get(tier, 0)} |")
|
|
1824
|
+
|
|
1825
|
+
for tier, title in [(1, "Stubs, fakes, demo code and fake tests"),
|
|
1826
|
+
(2, "Panics, unsafe, swallowed errors, security"),
|
|
1827
|
+
(3, "Idiom and quality"),
|
|
1828
|
+
(4, "Fake/hallucinated dependencies and supply chain")]:
|
|
1829
|
+
tier_findings = [f for f in findings if f.tier == tier]
|
|
1830
|
+
if not tier_findings:
|
|
1831
|
+
continue
|
|
1832
|
+
lines += ["", f"## Tier {tier}: {title}", ""]
|
|
1833
|
+
for f in tier_findings:
|
|
1834
|
+
fn = f" in `{f.function}`" if f.function else ""
|
|
1835
|
+
lines += [
|
|
1836
|
+
f"### [{f.rule_id} · {f.severity}] {f.file}:{f.line}{fn}",
|
|
1837
|
+
"",
|
|
1838
|
+
f"- **Problem:** {f.message}",
|
|
1839
|
+
f"- **Evidence:** `{f.evidence}`" if f.evidence else "- **Evidence:** (see source)",
|
|
1840
|
+
f"- **Source:** {f.source} (confidence: {f.confidence})",
|
|
1841
|
+
f"- **Fix ({f.fix}):** {f.fix_hint}" if f.fix_hint else "",
|
|
1842
|
+
"",
|
|
1843
|
+
]
|
|
1844
|
+
|
|
1845
|
+
if online_table:
|
|
1846
|
+
lines += ["## Dependency verification table", "",
|
|
1847
|
+
"| Crate | Version | crates.io | docs.rs | Features | Status |",
|
|
1848
|
+
"|---|---|---|---|---|---|"]
|
|
1849
|
+
for row in online_table:
|
|
1850
|
+
crate = str(row.get("crate", ""))
|
|
1851
|
+
version = str(row.get("version", row.get("version_req", "")))
|
|
1852
|
+
crates_io = str(row.get("crates.io", ""))
|
|
1853
|
+
docs_rs = str(row.get("docs.rs", ""))
|
|
1854
|
+
features = str(row.get("features", ""))[:60]
|
|
1855
|
+
status = str(row.get("status") or "ok")
|
|
1856
|
+
lines.append(f"| {crate} | {version} | {crates_io} | {docs_rs} | {features} | {status} |")
|
|
1857
|
+
lines.append("")
|
|
1858
|
+
|
|
1859
|
+
if evidence:
|
|
1860
|
+
lines += ["## Evidence runs (tool pipeline)", "",
|
|
1861
|
+
"| Tool | Command | Status | Exit | Duration |",
|
|
1862
|
+
"|---|---|---|---|---|"]
|
|
1863
|
+
for ev in evidence:
|
|
1864
|
+
lines.append(f"| {ev.tool} | `{ev.command}` | {ev.status} | {ev.exit_code} | "
|
|
1865
|
+
f"{ev.duration_s:.1f}s |")
|
|
1866
|
+
lines.append("")
|
|
1867
|
+
for ev in evidence:
|
|
1868
|
+
if ev.excerpt:
|
|
1869
|
+
lines += [f"<details><summary>{ev.tool} output excerpt</summary>", "",
|
|
1870
|
+
"```", ev.excerpt[:3000], "```", "", "</details>", ""]
|
|
1871
|
+
return "\n".join(lines)
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def render_json(findings: list[Finding], online_table: list[dict],
|
|
1875
|
+
evidence: list[EvidenceRun], target: str) -> str:
|
|
1876
|
+
payload = {
|
|
1877
|
+
"tool": TOOL_NAME,
|
|
1878
|
+
"version": VERSION,
|
|
1879
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
1880
|
+
"target": target,
|
|
1881
|
+
"summary": summarize(findings),
|
|
1882
|
+
"findings": [f.to_dict() for f in findings],
|
|
1883
|
+
"online": online_table,
|
|
1884
|
+
"evidence_runs": [e.to_dict() for e in evidence],
|
|
1885
|
+
}
|
|
1886
|
+
return json.dumps(payload, indent=2, ensure_ascii=False)
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
def render_sarif(findings: list[Finding]) -> str:
|
|
1890
|
+
level_map = {"critical": "error", "high": "error", "medium": "warning",
|
|
1891
|
+
"low": "note", "info": "note"}
|
|
1892
|
+
rules = []
|
|
1893
|
+
for rid, meta in RULES.items():
|
|
1894
|
+
rules.append({
|
|
1895
|
+
"id": rid,
|
|
1896
|
+
"shortDescription": {"text": meta["title"]},
|
|
1897
|
+
"defaultConfiguration": {"level": level_map.get(meta["severity"], "warning")},
|
|
1898
|
+
"properties": {"tags": [f"tier{meta['tier']}", "rust", "ai-generated-code"],
|
|
1899
|
+
"precision": "medium"},
|
|
1900
|
+
})
|
|
1901
|
+
results = []
|
|
1902
|
+
for f in findings:
|
|
1903
|
+
fp = hashlib.sha1(
|
|
1904
|
+
f"{f.rule_id}|{f.file}|{f.line}|{f.evidence}".encode("utf-8")).hexdigest()[:16]
|
|
1905
|
+
results.append({
|
|
1906
|
+
"ruleId": f.rule_id,
|
|
1907
|
+
"level": level_map.get(f.severity, "warning"),
|
|
1908
|
+
"message": {"text": f.message},
|
|
1909
|
+
"locations": [{"physicalLocation": {
|
|
1910
|
+
"artifactLocation": {"uri": f.file.replace("\\", "/")},
|
|
1911
|
+
"region": {"startLine": max(1, f.line)},
|
|
1912
|
+
}}],
|
|
1913
|
+
"partialFingerprints": {"rustAiAudit": fp},
|
|
1914
|
+
})
|
|
1915
|
+
return json.dumps({
|
|
1916
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
1917
|
+
"version": "2.1.0",
|
|
1918
|
+
"runs": [{"tool": {"driver": {
|
|
1919
|
+
"name": TOOL_NAME,
|
|
1920
|
+
"version": VERSION,
|
|
1921
|
+
"informationUri": "https://github.com/rust-secure-code/",
|
|
1922
|
+
"rules": rules,
|
|
1923
|
+
}}, "results": results}],
|
|
1924
|
+
}, indent=2)
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
# --------------------------------------------------------------------------
|
|
1928
|
+
# Auto-fix: safe (transactional + validated + idempotent) and assisted diffs
|
|
1929
|
+
# --------------------------------------------------------------------------
|
|
1930
|
+
DENY_TEMPLATE = """[advisories]
|
|
1931
|
+
vulnerability = "deny"
|
|
1932
|
+
unmaintained = "warn"
|
|
1933
|
+
yanked = "deny"
|
|
1934
|
+
|
|
1935
|
+
[bans]
|
|
1936
|
+
multiple-versions = "warn"
|
|
1937
|
+
wildcards = "deny"
|
|
1938
|
+
|
|
1939
|
+
[sources]
|
|
1940
|
+
unknown-registry = "deny"
|
|
1941
|
+
unknown-git = "deny"
|
|
1942
|
+
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
|
1943
|
+
"""
|
|
1944
|
+
|
|
1945
|
+
SECRET_FIX_RE = re.compile(
|
|
1946
|
+
r"(?i)\blet\s+(api[_-]?key|apikey|secret[_-]?key|access[_-]?key|password|passwd|"
|
|
1947
|
+
r"auth[_-]?token|token)\s*=\s*\"[^\"]{8,}\"")
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
def _unified(rel: str, old: str, new: str) -> str:
|
|
1951
|
+
return "".join(difflib.unified_diff(
|
|
1952
|
+
old.splitlines(keepends=True), new.splitlines(keepends=True),
|
|
1953
|
+
fromfile=f"a/{rel}", tofile=f"b/{rel}"))
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def plan_safe_fixes(units: list[FileUnit], findings: list[Finding]) -> tuple[dict[str, str], dict[str, str]]:
|
|
1957
|
+
"""Returns ({rel: new_text}, {rel: unified_diff}) without touching disk."""
|
|
1958
|
+
edits: dict[str, str] = {}
|
|
1959
|
+
env_additions: set[str] = set()
|
|
1960
|
+
texts = {u.rel: u.src.text for u in units}
|
|
1961
|
+
by_rel = {u.rel: u for u in units}
|
|
1962
|
+
|
|
1963
|
+
for f in findings:
|
|
1964
|
+
if f.rule_id == "R18b" and f.fix == "safe":
|
|
1965
|
+
m = re.search(r"use\s+([A-Za-z_]\w*)\s*::", f.evidence or "")
|
|
1966
|
+
if not m:
|
|
1967
|
+
m = re.search(r"`([A-Za-z_]\w*)`", f.message)
|
|
1968
|
+
if not m:
|
|
1969
|
+
continue
|
|
1970
|
+
root = m.group(1)
|
|
1971
|
+
unit = by_rel.get(f.file)
|
|
1972
|
+
if unit is None:
|
|
1973
|
+
continue
|
|
1974
|
+
if re.search(r"\bmod\s+" + re.escape(root) + r"\b", unit.src.masked_all):
|
|
1975
|
+
continue # real local module: do not touch
|
|
1976
|
+
current = edits.get(f.file, texts[f.file])
|
|
1977
|
+
new = re.sub(r"(?m)^(\s*use\s+)" + re.escape(root) + r"(?=\s*::)",
|
|
1978
|
+
r"\1std::" + root, current)
|
|
1979
|
+
if new != current:
|
|
1980
|
+
edits[f.file] = new
|
|
1981
|
+
elif f.rule_id == "R12" and f.fix == "safe":
|
|
1982
|
+
unit = by_rel.get(f.file)
|
|
1983
|
+
if unit is None:
|
|
1984
|
+
continue
|
|
1985
|
+
current = edits.get(f.file, texts[f.file])
|
|
1986
|
+
|
|
1987
|
+
def _env_sub(m: re.Match) -> str:
|
|
1988
|
+
name = m.group(1)
|
|
1989
|
+
env_name = re.sub(r"[^A-Za-z0-9]", "_", name).upper()
|
|
1990
|
+
env_additions.add(f"{env_name}=")
|
|
1991
|
+
return f'let {name} = std::env::var("{env_name}").unwrap_or_default()'
|
|
1992
|
+
|
|
1993
|
+
new = SECRET_FIX_RE.sub(_env_sub, current)
|
|
1994
|
+
if new != current:
|
|
1995
|
+
edits[f.file] = new
|
|
1996
|
+
|
|
1997
|
+
diffs = {rel: _unified(rel, texts[rel], new) for rel, new in edits.items()}
|
|
1998
|
+
return edits, diffs, env_additions
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def _rewrite_sleeps(body: str) -> str:
|
|
2002
|
+
"""thread::sleep(EXPR) -> tokio::time::sleep(EXPR).await (proposal text)."""
|
|
2003
|
+
out = body
|
|
2004
|
+
while True:
|
|
2005
|
+
m = re.search(r"\b(?:std::)?thread::sleep\s*\(", out)
|
|
2006
|
+
if not m:
|
|
2007
|
+
return out
|
|
2008
|
+
open_idx = out.find("(", m.start())
|
|
2009
|
+
close_idx = match_pair(out, open_idx)
|
|
2010
|
+
if close_idx < 0:
|
|
2011
|
+
return out
|
|
2012
|
+
arg = out[open_idx + 1: close_idx]
|
|
2013
|
+
out = out[:m.start()] + f"tokio::time::sleep({arg}).await" + out[close_idx + 1:]
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
def plan_assisted_fixes(units: list[FileUnit], findings: list[Finding]) -> list[str]:
|
|
2017
|
+
"""Behaviour-changing rewrites are proposals only: unified diffs for review.
|
|
2018
|
+
|
|
2019
|
+
All edits are computed against the original text and applied in reverse
|
|
2020
|
+
offset order, so sequential rewrites never corrupt each other's offsets.
|
|
2021
|
+
"""
|
|
2022
|
+
diffs: list[str] = []
|
|
2023
|
+
wanted = {f.rule_id for f in findings}
|
|
2024
|
+
for unit in units:
|
|
2025
|
+
text = unit.src.text
|
|
2026
|
+
masked = unit.src.masked_all
|
|
2027
|
+
edits: list[tuple[int, int, str]] = [] # (start, end, replacement) on text
|
|
2028
|
+
for fn in unit.fns:
|
|
2029
|
+
if fn.is_test:
|
|
2030
|
+
continue
|
|
2031
|
+
open_idx = masked.find("{", fn.start)
|
|
2032
|
+
close_idx = match_pair(masked, open_idx) if open_idx >= 0 else -1
|
|
2033
|
+
if open_idx < 0 or close_idx < 0:
|
|
2034
|
+
continue
|
|
2035
|
+
body = text[open_idx + 1: close_idx]
|
|
2036
|
+
new_body = body
|
|
2037
|
+
if "R09" in wanted and re.search(r"\b(Result|Option)\b", fn.sig):
|
|
2038
|
+
new_body = re.sub(r"\.unwrap\(\)", "?", new_body)
|
|
2039
|
+
new_body = re.sub(r"\.expect\s*\([^)]*\)", "?", new_body)
|
|
2040
|
+
if "R10" in wanted and re.search(r"\bResult\b", fn.sig):
|
|
2041
|
+
new_body = re.sub(r"let\s+_\s*=\s*([^;]+);", r"\1?;", new_body, count=2)
|
|
2042
|
+
if "R16" in wanted and fn.is_async:
|
|
2043
|
+
new_body = _rewrite_sleeps(new_body)
|
|
2044
|
+
if new_body != body:
|
|
2045
|
+
edits.append((open_idx + 1, close_idx, new_body))
|
|
2046
|
+
if not edits:
|
|
2047
|
+
continue
|
|
2048
|
+
changed = text
|
|
2049
|
+
for start, end, repl in sorted(edits, key=lambda e: -e[0]):
|
|
2050
|
+
changed = changed[:start] + repl + changed[end:]
|
|
2051
|
+
if changed != text:
|
|
2052
|
+
diffs.append(_unified(unit.rel, text, changed))
|
|
2053
|
+
return diffs
|
|
2054
|
+
|
|
2055
|
+
|
|
2056
|
+
def run_validators(root: Path, budget: int = 600) -> list[EvidenceRun]:
|
|
2057
|
+
runs: list[EvidenceRun] = []
|
|
2058
|
+
if not (root / "Cargo.toml").exists() or not shutil.which("cargo"):
|
|
2059
|
+
runs.append(EvidenceRun("validation", "cargo fmt/check/test", "skipped",
|
|
2060
|
+
notes="no Cargo.toml or cargo toolchain; edits kept without compile validation"))
|
|
2061
|
+
return runs
|
|
2062
|
+
for name, argv in [("cargo fmt", ["cargo", "fmt", "--", "--check"]),
|
|
2063
|
+
("cargo check", ["cargo", "check", "--all-targets"]),
|
|
2064
|
+
("cargo test", ["cargo", "test"])]:
|
|
2065
|
+
t0 = time.time()
|
|
2066
|
+
try:
|
|
2067
|
+
proc = _spawn(argv, cwd=root, timeout=budget)
|
|
2068
|
+
excerpt = ((proc.stdout or "") + (proc.stderr or ""))[-3000:]
|
|
2069
|
+
runs.append(EvidenceRun(name, " ".join(argv),
|
|
2070
|
+
"pass" if proc.returncode == 0 else "fail",
|
|
2071
|
+
proc.returncode, time.time() - t0, excerpt))
|
|
2072
|
+
if proc.returncode != 0:
|
|
2073
|
+
break
|
|
2074
|
+
except ExecBlocked:
|
|
2075
|
+
runs.append(EvidenceRun(name, " ".join(argv), "skipped",
|
|
2076
|
+
notes=EXEC_BLOCKED_NOTE))
|
|
2077
|
+
except Exception as exc:
|
|
2078
|
+
runs.append(EvidenceRun(name, " ".join(argv), "error", None,
|
|
2079
|
+
time.time() - t0, str(exc)))
|
|
2080
|
+
break
|
|
2081
|
+
return runs
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def _rust_digest(root: Path) -> str:
|
|
2085
|
+
"""Content fingerprint of all .rs sources (fixer change detection)."""
|
|
2086
|
+
h = hashlib.sha256()
|
|
2087
|
+
for p in sorted(root.rglob("*.rs")):
|
|
2088
|
+
rel = p.relative_to(root)
|
|
2089
|
+
if p.is_symlink() or rel.parts[0] in {".git", "target"}:
|
|
2090
|
+
continue
|
|
2091
|
+
h.update(rel.as_posix().encode("utf-8"))
|
|
2092
|
+
h.update(p.read_bytes())
|
|
2093
|
+
return h.hexdigest()
|
|
2094
|
+
|
|
2095
|
+
|
|
2096
|
+
def run_safe_fixes(root: Path, findings: list[Finding], units: list[FileUnit],
|
|
2097
|
+
dry_run: bool = False, use_cargo: bool = True) -> dict:
|
|
2098
|
+
edits, diffs, env_additions = plan_safe_fixes(units, findings)
|
|
2099
|
+
report: dict = {"applied": [], "diffs": list(diffs.values()), "reverted": [],
|
|
2100
|
+
"evidence": [], "created": []}
|
|
2101
|
+
if dry_run:
|
|
2102
|
+
report["evidence"].append(EvidenceRun("fix --safe", "dry-run", "pass",
|
|
2103
|
+
notes="no files written"))
|
|
2104
|
+
return report
|
|
2105
|
+
|
|
2106
|
+
backups: dict[Path, str] = {}
|
|
2107
|
+
backup_dir = root / "report" / f"backup-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
2108
|
+
try:
|
|
2109
|
+
for rel, new in edits.items():
|
|
2110
|
+
path = root / rel
|
|
2111
|
+
backups[path] = path.read_text(encoding="utf-8")
|
|
2112
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
2113
|
+
(backup_dir / rel.replace("/", "__")).write_text(backups[path], encoding="utf-8")
|
|
2114
|
+
path.write_text(new, encoding="utf-8")
|
|
2115
|
+
report["applied"].append(rel)
|
|
2116
|
+
if env_additions:
|
|
2117
|
+
env_file = root / ".env.example"
|
|
2118
|
+
existing = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
|
|
2119
|
+
add = "\n".join(sorted(a for a in env_additions if a.split("=")[0] not in existing))
|
|
2120
|
+
if add:
|
|
2121
|
+
env_file.write_text(existing + ("\n" if existing else "") + add + "\n",
|
|
2122
|
+
encoding="utf-8")
|
|
2123
|
+
report["created"].append(".env.example")
|
|
2124
|
+
tier4 = any(f.tier == 4 for f in findings)
|
|
2125
|
+
deny = root / "deny.toml"
|
|
2126
|
+
if tier4 and not deny.exists():
|
|
2127
|
+
template = _template_dir() / "deny.toml"
|
|
2128
|
+
deny.write_text(template.read_text(encoding="utf-8") if template.exists() else DENY_TEMPLATE,
|
|
2129
|
+
encoding="utf-8")
|
|
2130
|
+
report["created"].append("deny.toml")
|
|
2131
|
+
if use_cargo and shutil.which("cargo") and (root / "Cargo.toml").exists():
|
|
2132
|
+
def _fixer(argv: list[str]) -> None:
|
|
2133
|
+
try:
|
|
2134
|
+
proc = _spawn(argv, cwd=root, timeout=900)
|
|
2135
|
+
report["evidence"].append(EvidenceRun(
|
|
2136
|
+
argv[1], " ".join(argv), "pass" if proc.returncode == 0 else "fail",
|
|
2137
|
+
proc.returncode, excerpt=((proc.stdout or "") + (proc.stderr or ""))[-1500:]))
|
|
2138
|
+
except ExecBlocked:
|
|
2139
|
+
report["evidence"].append(EvidenceRun(argv[1], " ".join(argv), "skipped",
|
|
2140
|
+
notes=EXEC_BLOCKED_NOTE))
|
|
2141
|
+
except Exception as exc:
|
|
2142
|
+
report["evidence"].append(EvidenceRun(argv[1], " ".join(argv), "error",
|
|
2143
|
+
notes=str(exc)))
|
|
2144
|
+
|
|
2145
|
+
# clippy --fix splices suggestions unformatted and can need extra
|
|
2146
|
+
# passes for overlapping fixes; loop it to a fixpoint (bounded) and
|
|
2147
|
+
# run fmt LAST, so one `fix` run converges and a second one applies
|
|
2148
|
+
# nothing (the documented idempotency).
|
|
2149
|
+
for _ in range(3):
|
|
2150
|
+
before = _rust_digest(root)
|
|
2151
|
+
_fixer(["cargo", "clippy", "--fix", "--allow-dirty",
|
|
2152
|
+
"--allow-staged", "--allow-no-vcs", "--all-targets"])
|
|
2153
|
+
if _rust_digest(root) == before:
|
|
2154
|
+
break
|
|
2155
|
+
_fixer(["cargo", "fmt"])
|
|
2156
|
+
validations = run_validators(root)
|
|
2157
|
+
report["evidence"].extend(validations)
|
|
2158
|
+
if any(v.status in ("fail", "error") for v in validations):
|
|
2159
|
+
for path, original in backups.items():
|
|
2160
|
+
path.write_text(original, encoding="utf-8")
|
|
2161
|
+
report["reverted"].append(path.relative_to(root).as_posix())
|
|
2162
|
+
report["applied"] = []
|
|
2163
|
+
except Exception as exc:
|
|
2164
|
+
for path, original in backups.items():
|
|
2165
|
+
path.write_text(original, encoding="utf-8")
|
|
2166
|
+
report["reverted"] = [str(p.relative_to(root)) for p in backups]
|
|
2167
|
+
report["applied"] = []
|
|
2168
|
+
report["evidence"].append(EvidenceRun("fix --safe", "apply", "error", notes=str(exc)))
|
|
2169
|
+
return report
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
# --------------------------------------------------------------------------
|
|
2173
|
+
# Evidence pipeline (cargo subcommand)
|
|
2174
|
+
# --------------------------------------------------------------------------
|
|
2175
|
+
CLIPPY_LINT_MAP = {
|
|
2176
|
+
"clippy::unwrap_used": "R09", "clippy::expect_used": "R09", "clippy::panic": "R09",
|
|
2177
|
+
"clippy::todo": "R01", "clippy::unimplemented": "R01",
|
|
2178
|
+
"clippy::indexing_slicing": "R13", "clippy::arithmetic_side_effects": "R13",
|
|
2179
|
+
"clippy::await_holding_lock": "R16", "clippy::mem_forget": "R11",
|
|
2180
|
+
"clippy::transmute_undefined_repr": "R11", "clippy::rc_buffer": "R17",
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
CLIPPY_FLAGS = ["--", "-W", "clippy::unwrap_used", "-W", "clippy::expect_used",
|
|
2184
|
+
"-W", "clippy::panic", "-W", "clippy::indexing_slicing",
|
|
2185
|
+
"-W", "clippy::todo", "-W", "clippy::unimplemented",
|
|
2186
|
+
"-W", "clippy::arithmetic_side_effects", "-W", "clippy::await_holding_lock"]
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
def _run_cmd(argv: list[str], cwd: Path, timeout: int) -> tuple[int | None, str, float]:
|
|
2190
|
+
t0 = time.time()
|
|
2191
|
+
try:
|
|
2192
|
+
# utf-8 + replace: cargo emits UTF-8 progress output that breaks cp1252
|
|
2193
|
+
proc = _spawn(argv, cwd=cwd, timeout=timeout)
|
|
2194
|
+
return proc.returncode, ((proc.stdout or "") + (proc.stderr or "")), time.time() - t0
|
|
2195
|
+
except ExecBlocked:
|
|
2196
|
+
return None, f"skipped: {EXEC_BLOCKED_NOTE}", 0.0
|
|
2197
|
+
except FileNotFoundError:
|
|
2198
|
+
return None, "", 0.0
|
|
2199
|
+
except subprocess.TimeoutExpired:
|
|
2200
|
+
return -9, "TIMEOUT", time.time() - t0
|
|
2201
|
+
except Exception as exc:
|
|
2202
|
+
return None, str(exc), time.time() - t0
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def _parse_clippy_json(output: str, findings: list[Finding]) -> None:
|
|
2206
|
+
for line in output.splitlines():
|
|
2207
|
+
line = line.strip()
|
|
2208
|
+
if not line.startswith("{"):
|
|
2209
|
+
continue
|
|
2210
|
+
try:
|
|
2211
|
+
obj = json.loads(line)
|
|
2212
|
+
except Exception:
|
|
2213
|
+
continue
|
|
2214
|
+
if obj.get("reason") != "compiler-message":
|
|
2215
|
+
continue
|
|
2216
|
+
msg = obj.get("message") or {}
|
|
2217
|
+
code = ((msg.get("code") or {}).get("code") or "") if isinstance(msg.get("code"), dict) else ""
|
|
2218
|
+
rid = CLIPPY_LINT_MAP.get(code)
|
|
2219
|
+
if not rid:
|
|
2220
|
+
continue
|
|
2221
|
+
span = next((s for s in (msg.get("spans") or []) if s.get("is_primary")), None)
|
|
2222
|
+
rel = span.get("file_name", "") if span else ""
|
|
2223
|
+
line_no = span.get("line_start", 1) if span else 1
|
|
2224
|
+
findings.append(Finding(
|
|
2225
|
+
rid, RULES[rid]["severity"], f"clippy {code}: {msg.get('message', '')}",
|
|
2226
|
+
rel, line_no, evidence=code, source="clippy", fix="none",
|
|
2227
|
+
fix_hint="Address the clippy diagnostic; it corroborates the static scan."))
|
|
2228
|
+
|
|
2229
|
+
|
|
2230
|
+
def collect_evidence(root: Path, deep: bool = False, supply_chain: bool = False,
|
|
2231
|
+
release: bool = False, miri: bool = False, budget: int = 600,
|
|
2232
|
+
findings: list[Finding] | None = None) -> list[EvidenceRun]:
|
|
2233
|
+
findings = findings if findings is not None else []
|
|
2234
|
+
runs: list[EvidenceRun] = []
|
|
2235
|
+
if NO_EXEC:
|
|
2236
|
+
runs.append(EvidenceRun("cargo pipeline", "(all evidence tools)", "skipped",
|
|
2237
|
+
notes=EXEC_BLOCKED_NOTE))
|
|
2238
|
+
return runs
|
|
2239
|
+
if not (root / "Cargo.toml").exists():
|
|
2240
|
+
runs.append(EvidenceRun("cargo", "cargo", "skipped", notes="no Cargo.toml in target"))
|
|
2241
|
+
return runs
|
|
2242
|
+
if not shutil.which("cargo"):
|
|
2243
|
+
runs.append(EvidenceRun("cargo", "cargo", "skipped", notes="cargo toolchain not installed"))
|
|
2244
|
+
return runs
|
|
2245
|
+
|
|
2246
|
+
# Tier A
|
|
2247
|
+
code, out, dur = _run_cmd(["cargo", "fmt", "--", "--check"], root, min(budget, 300))
|
|
2248
|
+
runs.append(EvidenceRun("cargo fmt", "cargo fmt -- --check",
|
|
2249
|
+
"pass" if code == 0 else ("skipped" if code is None else "fail"),
|
|
2250
|
+
code, dur, out[-2000:]))
|
|
2251
|
+
code, out, dur = _run_cmd(["cargo", "clippy", "--all-targets", "--message-format=json"] + CLIPPY_FLAGS,
|
|
2252
|
+
root, budget)
|
|
2253
|
+
before = len(findings)
|
|
2254
|
+
if code is not None:
|
|
2255
|
+
_parse_clippy_json(out, findings)
|
|
2256
|
+
runs.append(EvidenceRun("cargo clippy", "cargo clippy --all-targets --message-format=json",
|
|
2257
|
+
"findings" if len(findings) > before else
|
|
2258
|
+
("pass" if code == 0 else ("skipped" if code is None else "fail")),
|
|
2259
|
+
code, dur, out[-2000:], notes=f"{len(findings) - before} lint finding(s)"))
|
|
2260
|
+
code, out, dur = _run_cmd(["cargo", "test"], root, budget)
|
|
2261
|
+
runs.append(EvidenceRun("cargo test", "cargo test",
|
|
2262
|
+
"pass" if code == 0 else ("skipped" if code is None else "fail"),
|
|
2263
|
+
code, dur, out[-2000:]))
|
|
2264
|
+
if release:
|
|
2265
|
+
code, out, dur = _run_cmd(["cargo", "build", "--release"], root, budget)
|
|
2266
|
+
runs.append(EvidenceRun("cargo build --release", "cargo build --release",
|
|
2267
|
+
"pass" if code == 0 else ("skipped" if code is None else "fail"),
|
|
2268
|
+
code, dur, out[-1500:]))
|
|
2269
|
+
|
|
2270
|
+
# Tier B (deep)
|
|
2271
|
+
if deep:
|
|
2272
|
+
code, out, dur = _run_cmd(["cargo", "llvm-cov", "--json", "--output-path",
|
|
2273
|
+
str(root / "report" / "coverage.json")], root, budget)
|
|
2274
|
+
runs.append(EvidenceRun("cargo llvm-cov", "cargo llvm-cov --json",
|
|
2275
|
+
"pass" if code == 0 else ("skipped" if code is None else "fail"),
|
|
2276
|
+
code, dur, out[-1500:]))
|
|
2277
|
+
code, out, dur = _run_cmd(["cargo", "mutants", "--no-shuffle"], root, budget)
|
|
2278
|
+
status = "pass" if code == 0 else ("skipped" if code is None else "fail")
|
|
2279
|
+
outcomes = root / "mutants.out" / "outcomes.json"
|
|
2280
|
+
if outcomes.exists():
|
|
2281
|
+
try:
|
|
2282
|
+
data = json.loads(outcomes.read_text(encoding="utf-8"))
|
|
2283
|
+
missed = [o for o in data.get("outcomes", []) if o.get("outcome") == "missed"]
|
|
2284
|
+
if missed:
|
|
2285
|
+
status = "findings"
|
|
2286
|
+
findings.append(Finding(
|
|
2287
|
+
"R02", "high",
|
|
2288
|
+
f"{len(missed)} mutants survived the test suite (tests do not constrain behaviour).",
|
|
2289
|
+
"mutants.out/outcomes.json", 1, evidence=f"{len(missed)} missed mutants",
|
|
2290
|
+
source="mutants", fix="none",
|
|
2291
|
+
fix_hint="Add assertions that fail when the mutated behaviour changes."))
|
|
2292
|
+
except Exception:
|
|
2293
|
+
pass
|
|
2294
|
+
runs.append(EvidenceRun("cargo mutants", "cargo mutants --no-shuffle", status, code, dur,
|
|
2295
|
+
out[-1500:]))
|
|
2296
|
+
code, out, dur = _run_cmd(["cargo", "machete", "--json"], root, 300)
|
|
2297
|
+
if code is not None:
|
|
2298
|
+
try:
|
|
2299
|
+
data = json.loads(out)
|
|
2300
|
+
for manifest, unused in (data.get("unused_crates") or {}).items():
|
|
2301
|
+
for name in unused:
|
|
2302
|
+
findings.append(Finding(
|
|
2303
|
+
"R18e", "low", f"`{name}` declared in {manifest} but never used (cargo-machete).",
|
|
2304
|
+
manifest, 1, evidence=name, source="machete", fix="none",
|
|
2305
|
+
fix_hint="Remove the dependency or confirm with cargo udeps."))
|
|
2306
|
+
except Exception:
|
|
2307
|
+
pass
|
|
2308
|
+
runs.append(EvidenceRun("cargo machete", "cargo machete --json",
|
|
2309
|
+
"pass" if code == 0 else ("skipped" if code is None else "fail"),
|
|
2310
|
+
code, dur, out[-1200:]))
|
|
2311
|
+
if miri:
|
|
2312
|
+
code, out, dur = _run_cmd(["cargo", "+nightly", "miri", "test"], root, budget)
|
|
2313
|
+
runs.append(EvidenceRun("cargo miri", "cargo +nightly miri test",
|
|
2314
|
+
"pass" if code == 0 else ("skipped" if code is None else "fail"),
|
|
2315
|
+
code, dur, out[-1500:]))
|
|
2316
|
+
|
|
2317
|
+
# Tier C (supply chain)
|
|
2318
|
+
if supply_chain:
|
|
2319
|
+
chain = [
|
|
2320
|
+
("cargo audit", ["cargo", "audit"]),
|
|
2321
|
+
("cargo deny", ["cargo", "deny", "check"]),
|
|
2322
|
+
("cargo vet", ["cargo", "vet", "check", "--output-format=json"]),
|
|
2323
|
+
("cargo unmaintained", ["cargo", "unmaintained"]),
|
|
2324
|
+
("cargo supply-chain", ["cargo", "supply-chain", "list-owners"]),
|
|
2325
|
+
("guarddog", ["guarddog", "crates", "verify", "Cargo.lock"]),
|
|
2326
|
+
("osv-scanner", ["osv-scanner", "scan", "source", "-r", ".", "--format", "json"]),
|
|
2327
|
+
]
|
|
2328
|
+
for name, argv in chain:
|
|
2329
|
+
if not shutil.which(argv[0]):
|
|
2330
|
+
runs.append(EvidenceRun(name, " ".join(argv), "skipped",
|
|
2331
|
+
notes=f"{argv[0]} not installed"))
|
|
2332
|
+
continue
|
|
2333
|
+
code, out, dur = _run_cmd(argv, root, min(budget, 300))
|
|
2334
|
+
runs.append(EvidenceRun(name, " ".join(argv),
|
|
2335
|
+
"pass" if code == 0 else ("findings" if code == 1 else "fail"),
|
|
2336
|
+
code, dur, out[-1500:]))
|
|
2337
|
+
return runs
|
|
2338
|
+
|
|
2339
|
+
|
|
2340
|
+
# --------------------------------------------------------------------------
|
|
2341
|
+
# Selftest (embedded micro-corpus; one snippet per offline rule)
|
|
2342
|
+
# --------------------------------------------------------------------------
|
|
2343
|
+
SELFTEST_CARGO = """[package]
|
|
2344
|
+
name = "selftest"
|
|
2345
|
+
version = "0.1.0"
|
|
2346
|
+
edition = "2021"
|
|
2347
|
+
|
|
2348
|
+
[dependencies]
|
|
2349
|
+
serdee = "1"
|
|
2350
|
+
itertools = "0.10"
|
|
2351
|
+
tokio = { version = "25", features = ["http"] }
|
|
2352
|
+
"""
|
|
2353
|
+
|
|
2354
|
+
SELFTEST_FILES: dict[str, str] = {
|
|
2355
|
+
"src/r01.rs": "pub fn a() -> u32 { todo!(\"x\") }",
|
|
2356
|
+
"src/r02.rs": """#[cfg(test)]
|
|
2357
|
+
mod tests {
|
|
2358
|
+
#[test]
|
|
2359
|
+
fn test_ok() { assert!(true); }
|
|
2360
|
+
}
|
|
2361
|
+
""",
|
|
2362
|
+
"src/r03.rs": "pub fn is_valid(input: &str, limit: usize) -> bool { true }",
|
|
2363
|
+
"src/r04.rs": "pub fn bio() -> String { \"lorem ipsum\".to_string() }",
|
|
2364
|
+
"src/r05.rs": "pub fn demo_response() -> &'static str { \"ok\" }",
|
|
2365
|
+
"src/r06.rs": "pub struct InMemoryStore { pub rows: Vec<u8> }",
|
|
2366
|
+
"src/r07.rs": """#[cfg(test)]
|
|
2367
|
+
mod tests {
|
|
2368
|
+
#[test]
|
|
2369
|
+
fn heavy() {
|
|
2370
|
+
let m1 = MockA; let m2 = MockB; let m3 = MockC;
|
|
2371
|
+
let m4 = MockD; let m5 = MockE; let m6 = MockF;
|
|
2372
|
+
assert!(m1.ready());
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
""",
|
|
2376
|
+
"src/r08.rs": """#[cfg(test)]
|
|
2377
|
+
mod tests {
|
|
2378
|
+
#[test]
|
|
2379
|
+
fn flaky() {
|
|
2380
|
+
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
2381
|
+
assert!(true == true);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
""",
|
|
2385
|
+
"src/r09.rs": "pub fn parse(raw: &str) -> u32 { raw.parse::<u32>().unwrap() }",
|
|
2386
|
+
"src/r10.rs": "pub fn save(p: &str) -> std::io::Result<()> { let _ = std::fs::write(p, \"x\"); Ok(()) }",
|
|
2387
|
+
"src/r11.rs": "pub fn deref(p: *const u8) -> u8 { unsafe { std::ptr::read(p) } }",
|
|
2388
|
+
"src/r12.rs": "pub fn connect() { let password = \"supersecretpassword123\"; drop(password); }",
|
|
2389
|
+
"src/r13.rs": "pub fn port() -> u16 { let a: Vec<String> = std::env::args().collect(); a[1].parse::<u16>().unwrap() }",
|
|
2390
|
+
"src/r14.rs": "pub fn cl(v: Vec<u8>) -> Vec<u8> { let a = v.clone(); let b = a.clone(); let c = b.clone(); let d = c.clone(); d }",
|
|
2391
|
+
"src/r15.rs": "pub fn pick<'a, 'b, 'c>(x: &'a str, _y: &'b str, _z: &'c str) -> &'a str { x }",
|
|
2392
|
+
"src/r16.rs": "pub async fn slow() { std::thread::sleep(std::time::Duration::from_secs(1)); }",
|
|
2393
|
+
"src/r17.rs": """use std::sync::{Arc, Mutex};
|
|
2394
|
+
pub fn shared() -> Vec<Arc<Mutex<u32>>> {
|
|
2395
|
+
vec![Arc::new(Mutex::new(1)), Arc::new(Mutex::new(2)),
|
|
2396
|
+
Arc::new(Mutex::new(3)), Arc::new(Mutex::new(4))]
|
|
2397
|
+
}
|
|
2398
|
+
""",
|
|
2399
|
+
"src/r18a.rs": """use quantum_cache::Cache;
|
|
2400
|
+
pub fn q(_c: Cache) {}
|
|
2401
|
+
""",
|
|
2402
|
+
"src/r18b.rs": """use fs::File;
|
|
2403
|
+
pub fn open() -> Option<File> { None }
|
|
2404
|
+
""",
|
|
2405
|
+
"src/r18c.rs": """use serdee::Serialize;
|
|
2406
|
+
pub fn s<T: Serialize>(_t: T) {}
|
|
2407
|
+
""",
|
|
2408
|
+
"src/r20.rs": "pub fn nothing() {}",
|
|
2409
|
+
"src/r21.rs": "fn main() { std::process::Command::new(\"curl\").status().unwrap(); }",
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
SELFTEST_EXPECT = ["R01", "R02", "R03", "R04", "R05", "R06", "R07", "R08", "R09",
|
|
2413
|
+
"R10", "R11", "R12", "R13", "R14", "R15", "R16", "R17",
|
|
2414
|
+
"R18a", "R18b", "R18c", "R18d", "R18e", "R20", "R21"]
|
|
2415
|
+
|
|
2416
|
+
|
|
2417
|
+
def cmd_selftest(_args) -> int:
|
|
2418
|
+
with tempfile.TemporaryDirectory(prefix="rust-audit-") as tmp:
|
|
2419
|
+
root = Path(tmp)
|
|
2420
|
+
(root / "src").mkdir()
|
|
2421
|
+
(root / "Cargo.toml").write_text(SELFTEST_CARGO, encoding="utf-8")
|
|
2422
|
+
(root / "build.rs").write_text(
|
|
2423
|
+
'fn main() { std::fs::write("out.bin", "x").unwrap(); }\n', encoding="utf-8")
|
|
2424
|
+
for rel, content in SELFTEST_FILES.items():
|
|
2425
|
+
(root / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
2426
|
+
(root / rel).write_text(content, encoding="utf-8")
|
|
2427
|
+
cfg = load_config(root, None)
|
|
2428
|
+
findings, _, _, _ = run_scan(root, cfg, online=False)
|
|
2429
|
+
found = {f.rule_id for f in findings}
|
|
2430
|
+
missing = [r for r in SELFTEST_EXPECT if r not in found]
|
|
2431
|
+
print(f"selftest: {len(found)}/{len(SELFTEST_EXPECT)} rule ids triggered")
|
|
2432
|
+
for rid in sorted(found):
|
|
2433
|
+
print(f" [ok] {rid}")
|
|
2434
|
+
print(" [skipped] R19 (online-only: run `scan --online`)")
|
|
2435
|
+
if missing:
|
|
2436
|
+
print(f"FAIL: rules not triggered: {missing}")
|
|
2437
|
+
return 1
|
|
2438
|
+
print("selftest PASS")
|
|
2439
|
+
return 0
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
# --------------------------------------------------------------------------
|
|
2443
|
+
# Tool inventory, best-effort installer (setup) and the dated
|
|
2444
|
+
# "not-executed checks" report required for honest audit reporting.
|
|
2445
|
+
# --------------------------------------------------------------------------
|
|
2446
|
+
# (tool id, what check it unlocks, rule ids, how to enable it)
|
|
2447
|
+
TOOL_INVENTORY: list[tuple[str, str, str, str]] = [
|
|
2448
|
+
("cargo-mutants",
|
|
2449
|
+
"Mutation testing: proves whether tests can fail (detects tests that prove nothing)",
|
|
2450
|
+
"R02 evidence",
|
|
2451
|
+
"python rust_audit.py setup (or: cargo install cargo-mutants), then `cargo --deep`"),
|
|
2452
|
+
("cargo-llvm-cov",
|
|
2453
|
+
"Coverage: prioritise stub functions that no test ever exercises",
|
|
2454
|
+
"R01/R03 evidence",
|
|
2455
|
+
"python rust_audit.py setup (or: cargo install cargo-llvm-cov), then `cargo --deep`"),
|
|
2456
|
+
("cargo-udeps",
|
|
2457
|
+
"Unused-dependency confirmation (declared but never used)",
|
|
2458
|
+
"R18e evidence",
|
|
2459
|
+
"python rust_audit.py setup (or: cargo install cargo-udeps; run with `cargo +nightly udeps`)"),
|
|
2460
|
+
("guarddog",
|
|
2461
|
+
"Malicious-crate scan of Cargo.lock (source + metadata heuristics)",
|
|
2462
|
+
"R19 evidence",
|
|
2463
|
+
"python rust_audit.py setup (or: pipx install guarddog), then `cargo --supply-chain`"),
|
|
2464
|
+
("osv-scanner",
|
|
2465
|
+
"OSV advisory scan of the dependency tree",
|
|
2466
|
+
"R19 evidence",
|
|
2467
|
+
"python rust_audit.py setup (or install from https://github.com/google/osv-scanner/releases)"),
|
|
2468
|
+
("cargo-miri",
|
|
2469
|
+
"Undefined-behaviour detection for unsafe code (use-after-free, aliasing, ...)",
|
|
2470
|
+
"R11 evidence",
|
|
2471
|
+
"rustup +nightly component add miri, then `cargo --miri`"),
|
|
2472
|
+
("typomania",
|
|
2473
|
+
"Rust Foundation typosquatting corpus checks (R20 backend)",
|
|
2474
|
+
"R20 evidence",
|
|
2475
|
+
"library-only crate (no binary): the built-in similarity engine stays active as R20"),
|
|
2476
|
+
]
|
|
2477
|
+
|
|
2478
|
+
# (tool id, install strategies tried in order, note)
|
|
2479
|
+
INSTALL_TARGETS: list[tuple[str, list[list[str]], str]] = [
|
|
2480
|
+
("cargo-mutants",
|
|
2481
|
+
[["cargo", "install", "cargo-mutants", "--locked"], ["cargo", "install", "cargo-mutants"]],
|
|
2482
|
+
"large dependency tree; first install compiles for several minutes"),
|
|
2483
|
+
("cargo-llvm-cov",
|
|
2484
|
+
[["cargo", "install", "cargo-llvm-cov", "--locked"], ["cargo", "install", "cargo-llvm-cov"]],
|
|
2485
|
+
"requires the llvm-tools component for full coverage"),
|
|
2486
|
+
("cargo-udeps",
|
|
2487
|
+
[["cargo", "install", "cargo-udeps", "--locked"], ["cargo", "install", "cargo-udeps"],
|
|
2488
|
+
["cargo", "+nightly", "install", "cargo-udeps"]],
|
|
2489
|
+
"must be RUN with nightly: `cargo +nightly udeps`"),
|
|
2490
|
+
("guarddog",
|
|
2491
|
+
[[sys.executable, "-m", "pip", "install", "--user", "guarddog"],
|
|
2492
|
+
["pipx", "install", "guarddog"],
|
|
2493
|
+
[sys.executable, "-m", "pip", "install", "guarddog"]],
|
|
2494
|
+
"Python package (DataDog); pipx gives the cleanest install"),
|
|
2495
|
+
("osv-scanner",
|
|
2496
|
+
[["go", "install", "github.com/google/osv-scanner/v2/cmd/osv-scanner@latest"]],
|
|
2497
|
+
"Go binary: requires a Go toolchain, otherwise download the release binary"),
|
|
2498
|
+
("cargo-miri",
|
|
2499
|
+
[["rustup", "component", "add", "miri", "--toolchain", "nightly"],
|
|
2500
|
+
["rustup", "component", "add", "miri"]],
|
|
2501
|
+
"needs the nightly toolchain"),
|
|
2502
|
+
("typomania",
|
|
2503
|
+
[["cargo", "install", "typomania"],
|
|
2504
|
+
["cargo", "install", "--git", "https://github.com/rustfoundation/typomania"]],
|
|
2505
|
+
"library-only crate: install is expected to report 'no binaries'; R20 built-in engine covers the check"),
|
|
2506
|
+
]
|
|
2507
|
+
|
|
2508
|
+
|
|
2509
|
+
def _excerpt(out: str, limit: int = 220) -> str:
|
|
2510
|
+
"""Single-line head+tail excerpt of a tool output, surfacing the real error.
|
|
2511
|
+
|
|
2512
|
+
Single-line and pipe-free: the value goes straight into a Markdown table
|
|
2513
|
+
cell, where newlines and `|` would break the row.
|
|
2514
|
+
"""
|
|
2515
|
+
lines = [ln for ln in out.strip().splitlines()
|
|
2516
|
+
if not ln.strip().startswith("[notice]")]
|
|
2517
|
+
text = "\n".join(lines).strip()
|
|
2518
|
+
body = text if len(text) <= limit * 2 else f"{text[:limit]} … {text[-limit:]}"
|
|
2519
|
+
specific = [ln.strip() for ln in lines if re.search(
|
|
2520
|
+
r"(?i)error\[E\d+\]|cannot find|externally-managed|permission denied|"
|
|
2521
|
+
r"failed to build|could not build|no space left|command not found", ln)]
|
|
2522
|
+
generic = [ln.strip() for ln in lines
|
|
2523
|
+
if re.search(r"(?i)\berror\b|cannot", ln)
|
|
2524
|
+
and not re.match(r"(?i)\s*(note:|this error originates)", ln)]
|
|
2525
|
+
pick = specific or generic
|
|
2526
|
+
if pick and pick[-1] not in body:
|
|
2527
|
+
body = f"{pick[-1][:limit]} ⏎ {body}"
|
|
2528
|
+
return body.replace("\n", " ⏎ ").replace("|", "/")
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
def _attempts_file(out_dir: Path) -> Path:
|
|
2532
|
+
return out_dir / f"setup-attempts-{time.strftime('%Y-%m-%d')}.json"
|
|
2533
|
+
|
|
2534
|
+
|
|
2535
|
+
def _load_attempts(out_dir: Path) -> list[dict]:
|
|
2536
|
+
af = _attempts_file(out_dir)
|
|
2537
|
+
if af.exists():
|
|
2538
|
+
try:
|
|
2539
|
+
return json.loads(af.read_text(encoding="utf-8"))
|
|
2540
|
+
except Exception:
|
|
2541
|
+
return []
|
|
2542
|
+
return []
|
|
2543
|
+
|
|
2544
|
+
|
|
2545
|
+
def _run_probe(argv: list[str], timeout: int = 25) -> tuple[bool, str]:
|
|
2546
|
+
if NO_EXEC: # PATH lookup only: never spawn under --no-exec
|
|
2547
|
+
path = shutil.which(argv[0])
|
|
2548
|
+
return path is not None, (f"on PATH, not executed (--no-exec)" if path
|
|
2549
|
+
else "not on PATH")
|
|
2550
|
+
code, out, _dur = _run_cmd(argv, Path.cwd(), timeout)
|
|
2551
|
+
if code is None:
|
|
2552
|
+
return False, "runner not available"
|
|
2553
|
+
first = next((ln.strip() for ln in out.splitlines() if ln.strip()), "")
|
|
2554
|
+
return code == 0, first[:80]
|
|
2555
|
+
|
|
2556
|
+
|
|
2557
|
+
def collect_tool_rows() -> list[dict]:
|
|
2558
|
+
"""Probe the optional tooling once (cheap) for the not-executed report."""
|
|
2559
|
+
if NO_EXEC: # presence via PATH lookup only — probing would spawn processes
|
|
2560
|
+
rows = []
|
|
2561
|
+
for tool_id, purpose, rules, hint in TOOL_INVENTORY:
|
|
2562
|
+
where = shutil.which(tool_id)
|
|
2563
|
+
rows.append({"tool": tool_id, "purpose": purpose, "rules": rules,
|
|
2564
|
+
"status": (f"not probed (--no-exec); on PATH: {where}" if where
|
|
2565
|
+
else "not probed (--no-exec); not on PATH"),
|
|
2566
|
+
"enable": hint})
|
|
2567
|
+
return rows
|
|
2568
|
+
installed_cargo_bins: dict[str, str] = {}
|
|
2569
|
+
if shutil.which("cargo"):
|
|
2570
|
+
code, out, _ = _run_cmd(["cargo", "install", "--list"], Path.cwd(), 30)
|
|
2571
|
+
if code == 0:
|
|
2572
|
+
for ln in out.splitlines():
|
|
2573
|
+
m = re.match(r"^\s*([A-Za-z0-9_-]+)\s+(v?[0-9][^:]*)\s*:", ln)
|
|
2574
|
+
if m:
|
|
2575
|
+
installed_cargo_bins[m.group(1)] = m.group(2).strip()
|
|
2576
|
+
rows: list[dict] = []
|
|
2577
|
+
for tool_id, purpose, rules, hint in TOOL_INVENTORY:
|
|
2578
|
+
status = "NOT INSTALLED"
|
|
2579
|
+
if tool_id in installed_cargo_bins:
|
|
2580
|
+
status = f"installed ({installed_cargo_bins[tool_id]})"
|
|
2581
|
+
elif tool_id == "cargo-miri":
|
|
2582
|
+
ok, ver = _run_probe(["cargo", "+nightly", "miri", "--version"])
|
|
2583
|
+
status = f"installed ({ver})" if ok else "NOT INSTALLED (nightly component missing)"
|
|
2584
|
+
elif tool_id == "typomania":
|
|
2585
|
+
status = "NOT INSTALLED (library-only crate; built-in R20 engine active)"
|
|
2586
|
+
elif shutil.which(tool_id):
|
|
2587
|
+
ok, ver = _run_probe([tool_id, "--version"])
|
|
2588
|
+
status = f"installed ({ver})" if ok else "installed (version unknown)"
|
|
2589
|
+
rows.append({"tool": tool_id, "purpose": purpose, "rules": rules,
|
|
2590
|
+
"status": status, "enable": hint})
|
|
2591
|
+
return rows
|
|
2592
|
+
|
|
2593
|
+
|
|
2594
|
+
def write_gap_report(out_dir: Path, cmd: str, target: str,
|
|
2595
|
+
evidence: list[EvidenceRun], flags: dict,
|
|
2596
|
+
attempts: list[dict] | None = None) -> Path:
|
|
2597
|
+
"""Regenerate `not-executed-<date>.md`: every check that did NOT run, and why."""
|
|
2598
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
2599
|
+
date = time.strftime("%Y-%m-%d")
|
|
2600
|
+
path = out_dir / f"not-executed-{date}.md"
|
|
2601
|
+
if attempts is None:
|
|
2602
|
+
attempts = _load_attempts(out_dir) # accumulate across runs of the day
|
|
2603
|
+
rows = collect_tool_rows()
|
|
2604
|
+
lines = [
|
|
2605
|
+
f"# {TOOL_NAME} — NOT-EXECUTED checks report ({date})",
|
|
2606
|
+
"",
|
|
2607
|
+
f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')} — tool {VERSION} — command: `{cmd}` — target: `{target}`",
|
|
2608
|
+
"",
|
|
2609
|
+
"This file lists every check that could **not** be executed in this run",
|
|
2610
|
+
"(missing tool, network not used, or feature not requested) together with",
|
|
2611
|
+
"the reason and how to enable it. Regenerated on each run for the same date.",
|
|
2612
|
+
"",
|
|
2613
|
+
"## Optional tooling status",
|
|
2614
|
+
"",
|
|
2615
|
+
"| Tool | Unlocks | Rules | Status | How to enable |",
|
|
2616
|
+
"|---|---|---|---|---|",
|
|
2617
|
+
]
|
|
2618
|
+
for r in rows:
|
|
2619
|
+
lines.append(f"| {r['tool']} | {r['purpose']} | {r['rules']} | {r['status']} | {r['enable']} |")
|
|
2620
|
+
|
|
2621
|
+
lines += ["", "## Checks not executed in this run", ""]
|
|
2622
|
+
not_run: list[tuple[str, str]] = []
|
|
2623
|
+
if NO_EXEC:
|
|
2624
|
+
not_run.append((
|
|
2625
|
+
"ALL process execution: cargo evidence pipeline, fix compile "
|
|
2626
|
+
"validation, setup installs",
|
|
2627
|
+
"remove `--no-exec` (this static-only mode enforces the "
|
|
2628
|
+
"zero-subprocess guarantee)"))
|
|
2629
|
+
if not flags.get("online"):
|
|
2630
|
+
not_run.append((
|
|
2631
|
+
"Live registry verification: crate existence, yanked versions, feature "
|
|
2632
|
+
"names, docs.rs builds, OSV advisories (R18d/R19 online)",
|
|
2633
|
+
"run `scan --online` (requires network)"))
|
|
2634
|
+
if not flags.get("deep"):
|
|
2635
|
+
not_run.append(("Mutation testing + coverage + unused-deps evidence (`--deep`)",
|
|
2636
|
+
"run `cargo --deep` with the optional tools installed"))
|
|
2637
|
+
if not flags.get("miri"):
|
|
2638
|
+
not_run.append(("Miri undefined-behaviour run for unsafe code (R11 evidence)",
|
|
2639
|
+
"run `cargo --miri` with the nightly component installed"))
|
|
2640
|
+
if not flags.get("supply_chain"):
|
|
2641
|
+
not_run.append(("Supply-chain evidence: cargo-audit/deny/vet, guarddog, osv-scanner",
|
|
2642
|
+
"run `cargo --supply-chain`"))
|
|
2643
|
+
if flags.get("fix_validation_skipped"):
|
|
2644
|
+
not_run.append(("Compile validation of auto-fixes (fmt/check/test)",
|
|
2645
|
+
"run `fix` inside a compilable crate with the cargo toolchain installed"))
|
|
2646
|
+
for check, how in not_run:
|
|
2647
|
+
lines.append(f"- **{check}** — how to enable: {how}")
|
|
2648
|
+
|
|
2649
|
+
skipped = [e for e in evidence if e.status == "skipped"]
|
|
2650
|
+
lines += ["", "## Skipped tool runs (evidence pipeline)", ""]
|
|
2651
|
+
if skipped:
|
|
2652
|
+
lines += ["| Tool | Command | Reason |", "|---|---|---|"]
|
|
2653
|
+
for e in skipped:
|
|
2654
|
+
lines.append(f"| {e.tool} | `{e.command}` | {e.notes or 'not available'} |")
|
|
2655
|
+
else:
|
|
2656
|
+
lines.append("(none — every requested tool in the pipeline ran)")
|
|
2657
|
+
|
|
2658
|
+
lines += ["", "## Install attempts (setup)", ""]
|
|
2659
|
+
if attempts:
|
|
2660
|
+
lines += ["| Tool | Attempted | Result |", "|---|---|---|"]
|
|
2661
|
+
for a in attempts:
|
|
2662
|
+
result = str(a["result"]).replace("\n", " ⏎ ").replace("|", "/")
|
|
2663
|
+
attempt = str(a.get("attempt", "—"))
|
|
2664
|
+
lines.append(f"| {a['tool']} | `{attempt}` | {result} |")
|
|
2665
|
+
else:
|
|
2666
|
+
lines.append("(no install attempts in this run — use `python rust_audit.py setup`)")
|
|
2667
|
+
|
|
2668
|
+
cargo_ok, cargo_ver = _run_probe(["cargo", "--version"])
|
|
2669
|
+
lines += [
|
|
2670
|
+
"", "## Environment", "",
|
|
2671
|
+
f"- python: {sys.version.split()[0]} ({sys.platform})",
|
|
2672
|
+
f"- cargo: {cargo_ver if cargo_ok else 'NOT INSTALLED'}",
|
|
2673
|
+
f"- {TOOL_NAME}: {VERSION}",
|
|
2674
|
+
]
|
|
2675
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
2676
|
+
return path
|
|
2677
|
+
|
|
2678
|
+
|
|
2679
|
+
def cmd_setup(args) -> int:
|
|
2680
|
+
"""Best-effort installer for the optional evidence tools + not-executed report."""
|
|
2681
|
+
try:
|
|
2682
|
+
sys.stdout.reconfigure(line_buffering=True) # live progress in CI logs
|
|
2683
|
+
except Exception:
|
|
2684
|
+
pass
|
|
2685
|
+
only = {t.strip() for t in (args.only or "").split(",") if t.strip()}
|
|
2686
|
+
skip = {t.strip() for t in (args.skip or "").split(",") if t.strip()}
|
|
2687
|
+
attempts: list[dict] = []
|
|
2688
|
+
existing = {r["tool"]: r["status"] for r in collect_tool_rows()}
|
|
2689
|
+
print(f"{TOOL_NAME} setup — installing optional evidence tools (best effort)")
|
|
2690
|
+
for tool_id, strategies, note in INSTALL_TARGETS:
|
|
2691
|
+
if only and tool_id not in only:
|
|
2692
|
+
continue
|
|
2693
|
+
if tool_id in skip:
|
|
2694
|
+
continue
|
|
2695
|
+
if existing.get(tool_id, "").startswith("installed") and not args.reinstall:
|
|
2696
|
+
attempts.append({"tool": tool_id, "attempt": "—", "result": "already installed"})
|
|
2697
|
+
print(f" [ok] {tool_id}: already installed")
|
|
2698
|
+
continue
|
|
2699
|
+
if args.dry_run or NO_EXEC:
|
|
2700
|
+
shown = " | ".join(" ".join(s) for s in strategies)
|
|
2701
|
+
result = ("dry-run: not attempted" if args.dry_run
|
|
2702
|
+
else "skipped: " + EXEC_BLOCKED_NOTE)
|
|
2703
|
+
attempts.append({"tool": tool_id, "attempt": shown, "result": result})
|
|
2704
|
+
print(f" [plan] {tool_id}: would try: {shown}")
|
|
2705
|
+
continue
|
|
2706
|
+
done = False
|
|
2707
|
+
for argv in strategies:
|
|
2708
|
+
if not shutil.which(argv[0]):
|
|
2709
|
+
attempts.append({"tool": tool_id, "attempt": " ".join(argv),
|
|
2710
|
+
"result": f"skipped: `{argv[0]}` runner not available"})
|
|
2711
|
+
continue
|
|
2712
|
+
print(f" [running] {tool_id}: {' '.join(argv)}")
|
|
2713
|
+
code, out, dur = _run_cmd(argv, Path.cwd(), args.timeout)
|
|
2714
|
+
if code == 0:
|
|
2715
|
+
attempts.append({"tool": tool_id, "attempt": " ".join(argv),
|
|
2716
|
+
"result": f"installed OK ({dur:.0f}s)"})
|
|
2717
|
+
print(f" [ok] {tool_id}: installed ({dur:.0f}s)")
|
|
2718
|
+
done = True
|
|
2719
|
+
break
|
|
2720
|
+
reason = ("timed out" if code == -9 else f"exit {code}")
|
|
2721
|
+
attempts.append({"tool": tool_id, "attempt": " ".join(argv),
|
|
2722
|
+
"result": f"FAILED ({reason}): {_excerpt(out)}"})
|
|
2723
|
+
if not done and not any(a["tool"] == tool_id and a["result"].startswith("installed")
|
|
2724
|
+
for a in attempts):
|
|
2725
|
+
if not any(a["tool"] == tool_id and a["result"].startswith("dry-run")
|
|
2726
|
+
for a in attempts):
|
|
2727
|
+
print(f" [failed] {tool_id}: could not install — see not-executed report")
|
|
2728
|
+
print(f" note: {note}")
|
|
2729
|
+
out_dir = Path(args.out or "report")
|
|
2730
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
2731
|
+
history = _load_attempts(out_dir) + attempts
|
|
2732
|
+
_attempts_file(out_dir).write_text(
|
|
2733
|
+
json.dumps(history, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
2734
|
+
gap = write_gap_report(
|
|
2735
|
+
out_dir, "setup", str(Path(args.root).resolve()),
|
|
2736
|
+
[], {"online": False, "deep": False, "miri": False, "supply_chain": False},
|
|
2737
|
+
attempts=history)
|
|
2738
|
+
print(f"not-executed report: {gap}")
|
|
2739
|
+
return 0
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
# --------------------------------------------------------------------------
|
|
2743
|
+
# CLI
|
|
2744
|
+
# --------------------------------------------------------------------------
|
|
2745
|
+
def _filter_rules(findings: list[Finding], rules_arg: str | None) -> list[Finding]:
|
|
2746
|
+
if not rules_arg:
|
|
2747
|
+
return findings
|
|
2748
|
+
wanted = {r.strip().upper() for r in rules_arg.split(",") if r.strip()}
|
|
2749
|
+
return [f for f in findings if f.rule_id in wanted or any(f.rule_id.startswith(w) for w in wanted)]
|
|
2750
|
+
|
|
2751
|
+
|
|
2752
|
+
def _emit_reports(findings, online_table, evidence, args) -> None:
|
|
2753
|
+
formats = [f.strip() for f in (args.format or "terminal").split(",") if f.strip()]
|
|
2754
|
+
out_dir = Path(args.out or "report")
|
|
2755
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
2756
|
+
target = str(args.root)
|
|
2757
|
+
if "terminal" in formats:
|
|
2758
|
+
print(render_terminal(findings, online_table, evidence))
|
|
2759
|
+
if "md" in formats or "markdown" in formats:
|
|
2760
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
2761
|
+
path = out_dir / f"rust-audit-{stamp}.md"
|
|
2762
|
+
path.write_text(render_markdown(findings, online_table, evidence, target), encoding="utf-8")
|
|
2763
|
+
print(f"wrote {path}")
|
|
2764
|
+
if "json" in formats:
|
|
2765
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
2766
|
+
path = out_dir / f"rust-audit-{stamp}.json"
|
|
2767
|
+
path.write_text(render_json(findings, online_table, evidence, target), encoding="utf-8")
|
|
2768
|
+
print(f"wrote {path}")
|
|
2769
|
+
if "sarif" in formats:
|
|
2770
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
2771
|
+
path = out_dir / f"rust-audit-{stamp}.sarif"
|
|
2772
|
+
path.write_text(render_sarif(findings), encoding="utf-8")
|
|
2773
|
+
print(f"wrote {path}")
|
|
2774
|
+
|
|
2775
|
+
|
|
2776
|
+
def _require_root(root: Path, need_sources: bool) -> int | None:
|
|
2777
|
+
"""Exit-code 2 contract: refuse to silently audit the wrong directory."""
|
|
2778
|
+
if not root.is_dir():
|
|
2779
|
+
print(f"error: target directory not found: {root}", file=sys.stderr)
|
|
2780
|
+
return 2
|
|
2781
|
+
if need_sources and not any(root.rglob("*.rs")) and not any(root.rglob("Cargo.toml")):
|
|
2782
|
+
print(f"error: no Rust sources or Cargo.toml under {root} (wrong path?)",
|
|
2783
|
+
file=sys.stderr)
|
|
2784
|
+
return 2
|
|
2785
|
+
return None
|
|
2786
|
+
|
|
2787
|
+
|
|
2788
|
+
def cmd_scan(args) -> int:
|
|
2789
|
+
root = Path(args.root).resolve()
|
|
2790
|
+
bad = _require_root(root, need_sources=True)
|
|
2791
|
+
if bad is not None:
|
|
2792
|
+
return bad
|
|
2793
|
+
cfg = load_config(root, args.config)
|
|
2794
|
+
if args.exclude:
|
|
2795
|
+
cfg["exclude"] = list(cfg.get("exclude") or []) + [g.strip() for g in args.exclude.split(",")]
|
|
2796
|
+
findings, units, _, online_table = run_scan(root, cfg, online=args.online)
|
|
2797
|
+
findings = _filter_rules(findings, args.rules)
|
|
2798
|
+
_emit_reports(findings, online_table, [], args)
|
|
2799
|
+
if not args.no_gap_report:
|
|
2800
|
+
gap = write_gap_report(Path(args.out or "report"), "scan", str(root), [],
|
|
2801
|
+
{"online": args.online, "deep": False, "miri": False,
|
|
2802
|
+
"supply_chain": False})
|
|
2803
|
+
print(f"not-executed report: {gap}")
|
|
2804
|
+
fail_on = "medium" if args.strict else (args.fail_on or "high")
|
|
2805
|
+
if any(SEVERITY_RANK.get(f.severity, 0) >= SEVERITY_RANK[fail_on] for f in findings):
|
|
2806
|
+
return 1
|
|
2807
|
+
return 0
|
|
2808
|
+
|
|
2809
|
+
|
|
2810
|
+
def cmd_fix(args) -> int:
|
|
2811
|
+
root = Path(args.root).resolve()
|
|
2812
|
+
bad = _require_root(root, need_sources=True)
|
|
2813
|
+
if bad is not None:
|
|
2814
|
+
return bad
|
|
2815
|
+
cfg = load_config(root, args.config)
|
|
2816
|
+
findings, units, _, _ = run_scan(root, cfg, online=False)
|
|
2817
|
+
findings = _filter_rules(findings, args.rules)
|
|
2818
|
+
rc = 0
|
|
2819
|
+
fix_report: dict = {"evidence": []}
|
|
2820
|
+
if args.assisted:
|
|
2821
|
+
diffs = plan_assisted_fixes(units, findings)
|
|
2822
|
+
print("assisted patch proposals (NOT applied; review each diff):")
|
|
2823
|
+
for d in diffs:
|
|
2824
|
+
print(d)
|
|
2825
|
+
if not diffs:
|
|
2826
|
+
print("(no assisted fixes applicable)")
|
|
2827
|
+
else:
|
|
2828
|
+
report = run_safe_fixes(root, findings, units, dry_run=args.dry_run,
|
|
2829
|
+
use_cargo=not args.no_cargo)
|
|
2830
|
+
fix_report = report
|
|
2831
|
+
print(f"safe fixes applied: {len(report['applied'])} file(s)")
|
|
2832
|
+
for rel in report["applied"]:
|
|
2833
|
+
print(f" edited {rel}")
|
|
2834
|
+
for rel in report["created"]:
|
|
2835
|
+
print(f" created {rel}")
|
|
2836
|
+
for rel in report["reverted"]:
|
|
2837
|
+
print(f" REVERTED {rel} (validation failed)")
|
|
2838
|
+
rc = 1
|
|
2839
|
+
if args.dry_run or args.diff:
|
|
2840
|
+
for d in report["diffs"]:
|
|
2841
|
+
print(d)
|
|
2842
|
+
for ev in report["evidence"]:
|
|
2843
|
+
print(f" [{ev.status}] {ev.tool}: {ev.notes or ev.command}")
|
|
2844
|
+
# idempotency check: a second planning pass must produce no edits
|
|
2845
|
+
if not args.dry_run and report["applied"]:
|
|
2846
|
+
findings2, units2, _, _ = run_scan(root, cfg, online=False)
|
|
2847
|
+
edits2, _, _ = plan_safe_fixes(units2, findings2)
|
|
2848
|
+
if edits2:
|
|
2849
|
+
print(f"warning: {len(edits2)} file(s) still have applicable safe fixes (not idempotent)")
|
|
2850
|
+
else:
|
|
2851
|
+
print("idempotency check: no further safe fixes apply")
|
|
2852
|
+
if not args.no_gap_report:
|
|
2853
|
+
validation_skipped = args.assisted or args.no_cargo or args.dry_run or any(
|
|
2854
|
+
e.status == "skipped" for e in fix_report.get("evidence", []))
|
|
2855
|
+
gap = write_gap_report(Path(args.out or "report"), "fix", str(root),
|
|
2856
|
+
fix_report.get("evidence", []),
|
|
2857
|
+
{"online": False, "deep": False, "miri": False,
|
|
2858
|
+
"supply_chain": False,
|
|
2859
|
+
"fix_validation_skipped": validation_skipped})
|
|
2860
|
+
print(f"not-executed report: {gap}")
|
|
2861
|
+
return rc
|
|
2862
|
+
|
|
2863
|
+
|
|
2864
|
+
def cmd_cargo(args) -> int:
|
|
2865
|
+
root = Path(args.root).resolve()
|
|
2866
|
+
bad = _require_root(root, need_sources=False)
|
|
2867
|
+
if bad is not None:
|
|
2868
|
+
return bad
|
|
2869
|
+
findings: list[Finding] = []
|
|
2870
|
+
runs = collect_evidence(root, deep=args.deep, supply_chain=args.supply_chain,
|
|
2871
|
+
release=args.release, miri=args.miri, budget=args.budget,
|
|
2872
|
+
findings=findings)
|
|
2873
|
+
_emit_reports(findings, [], runs, args)
|
|
2874
|
+
if not args.no_gap_report:
|
|
2875
|
+
gap = write_gap_report(Path(args.out or "report"), "cargo", str(root), runs,
|
|
2876
|
+
{"online": False, "deep": args.deep, "miri": args.miri,
|
|
2877
|
+
"supply_chain": args.supply_chain})
|
|
2878
|
+
print(f"not-executed report: {gap}")
|
|
2879
|
+
return 1 if any(SEVERITY_RANK.get(f.severity, 0) >= SEVERITY_RANK[args.fail_on or "high"]
|
|
2880
|
+
for f in findings) else 0
|
|
2881
|
+
|
|
2882
|
+
|
|
2883
|
+
def cmd_full(args) -> int:
|
|
2884
|
+
root = Path(args.root).resolve()
|
|
2885
|
+
bad = _require_root(root, need_sources=True)
|
|
2886
|
+
if bad is not None:
|
|
2887
|
+
return bad
|
|
2888
|
+
cfg = load_config(root, args.config)
|
|
2889
|
+
findings, units, _, online_table = run_scan(root, cfg, online=args.online)
|
|
2890
|
+
runs = collect_evidence(root, deep=args.deep, supply_chain=True,
|
|
2891
|
+
release=args.release, miri=args.miri, budget=args.budget,
|
|
2892
|
+
findings=findings)
|
|
2893
|
+
findings = _filter_rules(findings, args.rules)
|
|
2894
|
+
findings.sort(key=lambda f: (-SEVERITY_RANK.get(f.severity, 0), f.file, f.line, f.rule_id))
|
|
2895
|
+
_emit_reports(findings, online_table, runs, args)
|
|
2896
|
+
if not args.no_gap_report:
|
|
2897
|
+
gap = write_gap_report(Path(args.out or "report"), "full", str(root), runs,
|
|
2898
|
+
{"online": args.online, "deep": args.deep, "miri": args.miri,
|
|
2899
|
+
"supply_chain": True})
|
|
2900
|
+
print(f"not-executed report: {gap}")
|
|
2901
|
+
fail_on = "medium" if args.strict else (args.fail_on or "high")
|
|
2902
|
+
return 1 if any(SEVERITY_RANK.get(f.severity, 0) >= SEVERITY_RANK[fail_on]
|
|
2903
|
+
for f in findings) else 0
|
|
2904
|
+
|
|
2905
|
+
|
|
2906
|
+
def _add_common(p) -> None:
|
|
2907
|
+
p.add_argument("root", nargs="?", default=".", help="target directory (default: .)")
|
|
2908
|
+
p.add_argument("--format", default="terminal", help="terminal,md,json,sarif (comma list)")
|
|
2909
|
+
p.add_argument("--out", default="report", help="report output directory")
|
|
2910
|
+
p.add_argument("--fail-on", default="high", choices=list(SEVERITY_RANK),
|
|
2911
|
+
help="exit 1 when findings reach this severity")
|
|
2912
|
+
p.add_argument("--rules", help="only these rule ids (comma list, prefixes ok)")
|
|
2913
|
+
p.add_argument("--config", help="path to rust-audit.toml")
|
|
2914
|
+
p.add_argument("--exclude", help="extra exclude globs (comma list)")
|
|
2915
|
+
p.add_argument("--strict", action="store_true", help="fail on medium and above")
|
|
2916
|
+
p.add_argument("--no-gap-report", action="store_true",
|
|
2917
|
+
help="do not write the dated not-executed checks report")
|
|
2918
|
+
p.add_argument("--no-exec", action="store_true",
|
|
2919
|
+
help="static-only mode: never spawn a process (zero-subprocess "
|
|
2920
|
+
"guarantee enforced in code; pipeline/validator runs are "
|
|
2921
|
+
"recorded as skipped)")
|
|
2922
|
+
|
|
2923
|
+
|
|
2924
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2925
|
+
parser = argparse.ArgumentParser(prog=TOOL_NAME, description=__doc__)
|
|
2926
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
2927
|
+
|
|
2928
|
+
p_scan = sub.add_parser("scan", help="static analysis of Rust sources and Cargo manifests")
|
|
2929
|
+
_add_common(p_scan)
|
|
2930
|
+
p_scan.add_argument("--online", action="store_true",
|
|
2931
|
+
help="verify crates against crates.io/docs.rs/OSV (network)")
|
|
2932
|
+
p_scan.set_defaults(func=cmd_scan)
|
|
2933
|
+
|
|
2934
|
+
p_fix = sub.add_parser("fix", help="apply safe fixes or propose assisted patches")
|
|
2935
|
+
_add_common(p_fix)
|
|
2936
|
+
p_fix.add_argument("--safe", action="store_true", help="apply machine-safe fixes (default)")
|
|
2937
|
+
p_fix.add_argument("--assisted", action="store_true",
|
|
2938
|
+
help="emit unified diffs for behaviour-changing fixes (never applied)")
|
|
2939
|
+
p_fix.add_argument("--dry-run", action="store_true", help="show diffs, write nothing")
|
|
2940
|
+
p_fix.add_argument("--diff", action="store_true", help="print the diffs of applied fixes")
|
|
2941
|
+
p_fix.add_argument("--no-cargo", action="store_true",
|
|
2942
|
+
help="skip cargo fmt/clippy --fix integration")
|
|
2943
|
+
p_fix.set_defaults(func=cmd_fix)
|
|
2944
|
+
|
|
2945
|
+
p_cargo = sub.add_parser("cargo", help="evidence pipeline over installed Rust tooling")
|
|
2946
|
+
_add_common(p_cargo)
|
|
2947
|
+
p_cargo.add_argument("--release", action="store_true", help="also cargo build --release")
|
|
2948
|
+
p_cargo.add_argument("--miri", action="store_true", help="also cargo +nightly miri test")
|
|
2949
|
+
p_cargo.add_argument("--deep", action="store_true",
|
|
2950
|
+
help="coverage, mutation testing and unused-dependency checks")
|
|
2951
|
+
p_cargo.add_argument("--supply-chain", action="store_true",
|
|
2952
|
+
help="audit, deny, vet, guarddog, osv-scanner")
|
|
2953
|
+
p_cargo.add_argument("--budget", type=int, default=600, help="per-tool timeout seconds")
|
|
2954
|
+
p_cargo.set_defaults(func=cmd_cargo)
|
|
2955
|
+
|
|
2956
|
+
p_full = sub.add_parser("full", help="scan + cargo merged report")
|
|
2957
|
+
_add_common(p_full)
|
|
2958
|
+
p_full.add_argument("--online", action="store_true")
|
|
2959
|
+
p_full.add_argument("--release", action="store_true")
|
|
2960
|
+
p_full.add_argument("--miri", action="store_true")
|
|
2961
|
+
p_full.add_argument("--deep", action="store_true")
|
|
2962
|
+
p_full.add_argument("--budget", type=int, default=600)
|
|
2963
|
+
p_full.set_defaults(func=cmd_full)
|
|
2964
|
+
|
|
2965
|
+
p_setup = sub.add_parser(
|
|
2966
|
+
"setup", help="best-effort install of the optional evidence tools "
|
|
2967
|
+
"(cargo-mutants, cargo-llvm-cov, cargo-udeps, guarddog, "
|
|
2968
|
+
"osv-scanner, miri, typomania) + not-executed report")
|
|
2969
|
+
p_setup.add_argument("root", nargs="?", default=".")
|
|
2970
|
+
p_setup.add_argument("--out", default="report", help="report output directory")
|
|
2971
|
+
p_setup.add_argument("--timeout", type=int, default=600,
|
|
2972
|
+
help="per-tool install timeout in seconds")
|
|
2973
|
+
p_setup.add_argument("--only", help="comma list of tool ids to install")
|
|
2974
|
+
p_setup.add_argument("--skip", help="comma list of tool ids to skip")
|
|
2975
|
+
p_setup.add_argument("--reinstall", action="store_true",
|
|
2976
|
+
help="attempt install even if already present")
|
|
2977
|
+
p_setup.add_argument("--dry-run", action="store_true",
|
|
2978
|
+
help="show what would be attempted, install nothing")
|
|
2979
|
+
p_setup.add_argument("--no-exec", action="store_true",
|
|
2980
|
+
help="static-only mode: never spawn a process")
|
|
2981
|
+
p_setup.set_defaults(func=cmd_setup)
|
|
2982
|
+
|
|
2983
|
+
p_self = sub.add_parser("selftest", help="built-in rule smoke test")
|
|
2984
|
+
p_self.add_argument("--no-exec", action="store_true",
|
|
2985
|
+
help="accepted for uniformity; selftest never spawns anyway")
|
|
2986
|
+
p_self.set_defaults(func=cmd_selftest)
|
|
2987
|
+
|
|
2988
|
+
args = parser.parse_args(argv)
|
|
2989
|
+
global NO_EXEC
|
|
2990
|
+
NO_EXEC = bool(getattr(args, "no_exec", False))
|
|
2991
|
+
return args.func(args)
|
|
2992
|
+
|
|
2993
|
+
|
|
2994
|
+
if __name__ == "__main__":
|
|
2995
|
+
sys.exit(main())
|
|
2996
|
+
|