deploy-guard-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deploy_guard/__init__.py +10 -0
- deploy_guard/__main__.py +4 -0
- deploy_guard/analysis/__init__.py +39 -0
- deploy_guard/analysis/callgraph.py +149 -0
- deploy_guard/analysis/context.py +31 -0
- deploy_guard/analysis/findings.py +333 -0
- deploy_guard/analysis/nullability.py +461 -0
- deploy_guard/analysis/paths.py +219 -0
- deploy_guard/cli.py +190 -0
- deploy_guard/config.py +72 -0
- deploy_guard/engine.py +270 -0
- deploy_guard/explain/__init__.py +16 -0
- deploy_guard/explain/explainer.py +504 -0
- deploy_guard/explain/render.py +60 -0
- deploy_guard/explain/traceback_parse.py +89 -0
- deploy_guard/frontend/__init__.py +10 -0
- deploy_guard/frontend/python_cfg.py +332 -0
- deploy_guard/frontend/python_frontend.py +182 -0
- deploy_guard/generators/__init__.py +11 -0
- deploy_guard/generators/base.py +12 -0
- deploy_guard/generators/import_smoke.py +94 -0
- deploy_guard/ingest/__init__.py +5 -0
- deploy_guard/ingest/discover.py +166 -0
- deploy_guard/ir/__init__.py +24 -0
- deploy_guard/ir/cfg.py +114 -0
- deploy_guard/ir/model.py +128 -0
- deploy_guard/py.typed +0 -0
- deploy_guard/report/__init__.py +5 -0
- deploy_guard/report/render.py +262 -0
- deploy_guard/store.py +50 -0
- deploy_guard_engine-0.1.0.dist-info/METADATA +163 -0
- deploy_guard_engine-0.1.0.dist-info/RECORD +35 -0
- deploy_guard_engine-0.1.0.dist-info/WHEEL +4 -0
- deploy_guard_engine-0.1.0.dist-info/entry_points.txt +3 -0
- deploy_guard_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Turn a parsed traceback into an explanation, using the project's analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from deploy_guard.analysis.findings import Finding, analyze_function
|
|
11
|
+
from deploy_guard.analysis.nullability import (
|
|
12
|
+
NullabilityAnalysis,
|
|
13
|
+
_witness_conditions,
|
|
14
|
+
)
|
|
15
|
+
from deploy_guard.analysis.paths import behavior_spec
|
|
16
|
+
from deploy_guard.explain.traceback_parse import Frame, ParsedTraceback
|
|
17
|
+
from deploy_guard.frontend import build_cfg
|
|
18
|
+
from deploy_guard.ir.cfg import CFG, Terminator
|
|
19
|
+
from deploy_guard.ir.model import FunctionDef, Module, Project
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CrashSite:
|
|
24
|
+
module: Module
|
|
25
|
+
fn: FunctionDef | None
|
|
26
|
+
lineno: int
|
|
27
|
+
source: str
|
|
28
|
+
cfg: object = None # CFG, set during analysis
|
|
29
|
+
ctx: object = None # AnalysisContext, set during analysis
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Explanation:
|
|
34
|
+
exc_type: str
|
|
35
|
+
exc_message: str
|
|
36
|
+
site: CrashSite | None = None
|
|
37
|
+
why: list[str] = field(default_factory=list)
|
|
38
|
+
witness: list[str] = field(default_factory=list)
|
|
39
|
+
call_chain: list[str] = field(default_factory=list)
|
|
40
|
+
linked_finding: Finding | None = None
|
|
41
|
+
fix_hint: str | None = None
|
|
42
|
+
notes: list[str] = field(default_factory=list)
|
|
43
|
+
none_var: str | None = None # the variable found to be None, if any
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def explain_traceback(tb: ParsedTraceback, project: Project, ctx=None) -> Explanation:
|
|
47
|
+
exp = Explanation(exc_type=tb.exc_type, exc_message=tb.exc_message)
|
|
48
|
+
|
|
49
|
+
if not tb.ok:
|
|
50
|
+
exp.notes.append(
|
|
51
|
+
"Could not find an exception line. Paste the whole traceback, "
|
|
52
|
+
"including the final `SomeError: message` line."
|
|
53
|
+
)
|
|
54
|
+
return exp
|
|
55
|
+
|
|
56
|
+
if ctx is None:
|
|
57
|
+
try:
|
|
58
|
+
from deploy_guard.engine import build_context
|
|
59
|
+
|
|
60
|
+
ctx = build_context(project)
|
|
61
|
+
except Exception: # pragma: no cover
|
|
62
|
+
ctx = None
|
|
63
|
+
|
|
64
|
+
resolved = [_resolve_frame(f, project) for f in tb.frames]
|
|
65
|
+
exp.call_chain = _format_chain(resolved)
|
|
66
|
+
|
|
67
|
+
site = None
|
|
68
|
+
for frame, module, fn in reversed(resolved):
|
|
69
|
+
if module is not None:
|
|
70
|
+
site = CrashSite(module=module, fn=fn, lineno=frame.lineno, source=frame.source)
|
|
71
|
+
break
|
|
72
|
+
exp.site = site
|
|
73
|
+
|
|
74
|
+
if site is None:
|
|
75
|
+
exp.notes.append(
|
|
76
|
+
"No frame in the traceback points into this project. Check --project, "
|
|
77
|
+
"or the crash is entirely inside a third-party library."
|
|
78
|
+
)
|
|
79
|
+
return exp
|
|
80
|
+
|
|
81
|
+
if site.fn is None or not isinstance(
|
|
82
|
+
site.fn.raw, (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
83
|
+
):
|
|
84
|
+
exp.notes.append(
|
|
85
|
+
f"Line {site.lineno} is not inside a top-level function the engine "
|
|
86
|
+
"models (module scope, or a function nested in another). Explanation "
|
|
87
|
+
"is limited to the traceback structure."
|
|
88
|
+
)
|
|
89
|
+
return exp
|
|
90
|
+
|
|
91
|
+
cfg = build_cfg(site.fn)
|
|
92
|
+
bid = _block_for_line(cfg, site.lineno)
|
|
93
|
+
if bid is not None:
|
|
94
|
+
exp.witness = _witness_conditions(cfg, bid)
|
|
95
|
+
|
|
96
|
+
site.cfg = cfg
|
|
97
|
+
site.ctx = ctx
|
|
98
|
+
handler = _HANDLERS.get(tb.exc_short, _explain_generic)
|
|
99
|
+
handler(exp, tb, site, cfg)
|
|
100
|
+
|
|
101
|
+
# Trace a None that came in as a parameter back to the caller frame.
|
|
102
|
+
_trace_none_across_frames(exp, tb, resolved, ctx)
|
|
103
|
+
|
|
104
|
+
# Link to a full-scan finding at the same spot.
|
|
105
|
+
try:
|
|
106
|
+
spec = behavior_spec(site.fn, cfg)
|
|
107
|
+
for fnd in analyze_function(site.fn, cfg, spec, ctx):
|
|
108
|
+
if abs(fnd.lineno - site.lineno) <= 1:
|
|
109
|
+
exp.linked_finding = fnd
|
|
110
|
+
break
|
|
111
|
+
except Exception: # pragma: no cover - never let linking break explain
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
return exp
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _trace_none_across_frames(exp: Explanation, tb, resolved, ctx) -> None:
|
|
118
|
+
"""If the crash variable is a parameter, name the caller frame's call site
|
|
119
|
+
and which argument it passed."""
|
|
120
|
+
site = exp.site
|
|
121
|
+
if site is None or site.fn is None:
|
|
122
|
+
return
|
|
123
|
+
var = exp.none_var
|
|
124
|
+
if var is None:
|
|
125
|
+
return
|
|
126
|
+
param_names = [p.name for p in site.fn.params]
|
|
127
|
+
if var not in param_names:
|
|
128
|
+
return
|
|
129
|
+
param_index = param_names.index(var)
|
|
130
|
+
|
|
131
|
+
# the frame directly above the crash frame in the traceback
|
|
132
|
+
project_frames = [(f, m, fn) for (f, m, fn) in resolved if m is not None]
|
|
133
|
+
if len(project_frames) < 2:
|
|
134
|
+
exp.why.append(
|
|
135
|
+
f"`{var}` is a parameter - it was already None when this function "
|
|
136
|
+
"was called. Check the call site one frame up."
|
|
137
|
+
)
|
|
138
|
+
return
|
|
139
|
+
caller_frame, _cm, caller_fn = project_frames[-2]
|
|
140
|
+
if caller_fn is None or ctx is None:
|
|
141
|
+
return
|
|
142
|
+
for site_call in ctx.callgraph.callees_of(caller_fn.qualname):
|
|
143
|
+
if site_call.callee_qualname != site.fn.qualname:
|
|
144
|
+
continue
|
|
145
|
+
call = site_call.node
|
|
146
|
+
arg_src = None
|
|
147
|
+
if param_index < len(call.args):
|
|
148
|
+
try:
|
|
149
|
+
arg_src = ast.unparse(call.args[param_index])
|
|
150
|
+
except Exception:
|
|
151
|
+
arg_src = None
|
|
152
|
+
else:
|
|
153
|
+
for kw in call.keywords:
|
|
154
|
+
if kw.arg == var:
|
|
155
|
+
try:
|
|
156
|
+
arg_src = ast.unparse(kw.value)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
loc = f"{caller_fn.qualname.rsplit('.', 1)[-1]}:{site_call.lineno}"
|
|
160
|
+
if arg_src:
|
|
161
|
+
exp.why.append(
|
|
162
|
+
f"`{var}` is a parameter; {loc} passes `{arg_src}` for it - "
|
|
163
|
+
"that expression is what can be None."
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
exp.why.append(f"`{var}` is a parameter passed by {loc}.")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------
|
|
171
|
+
# frame resolution
|
|
172
|
+
# --------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def _resolve_frame(
|
|
175
|
+
frame: Frame, project: Project
|
|
176
|
+
) -> tuple[Frame, Module | None, FunctionDef | None]:
|
|
177
|
+
fpath = Path(frame.file)
|
|
178
|
+
for m in project.modules:
|
|
179
|
+
try:
|
|
180
|
+
if m.path == fpath or m.path.resolve() == fpath.resolve():
|
|
181
|
+
return frame, m, _fn_at(m, frame.lineno)
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
best: Module | None = None
|
|
186
|
+
best_score = 0
|
|
187
|
+
fparts = fpath.parts
|
|
188
|
+
for m in project.modules:
|
|
189
|
+
score = _common_suffix_len(fparts, m.path.parts)
|
|
190
|
+
if score > best_score:
|
|
191
|
+
best_score, best = score, m
|
|
192
|
+
if best is not None and best_score >= 1:
|
|
193
|
+
return frame, best, _fn_at(best, frame.lineno)
|
|
194
|
+
return frame, None, None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _common_suffix_len(a: tuple[str, ...], b: tuple[str, ...]) -> int:
|
|
198
|
+
n = 0
|
|
199
|
+
for x, y in zip(reversed(a), reversed(b)):
|
|
200
|
+
if x != y:
|
|
201
|
+
break
|
|
202
|
+
n += 1
|
|
203
|
+
return n
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _fn_at(module: Module, lineno: int) -> FunctionDef | None:
|
|
207
|
+
candidates = [
|
|
208
|
+
fn
|
|
209
|
+
for fn in module.iter_functions()
|
|
210
|
+
if fn.span.lineno <= lineno <= max(fn.span.end_lineno, fn.span.lineno)
|
|
211
|
+
]
|
|
212
|
+
if not candidates:
|
|
213
|
+
return None
|
|
214
|
+
return max(candidates, key=lambda fn: fn.span.lineno) # innermost
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# --------------------------------------------------------------------------
|
|
218
|
+
# CFG helpers
|
|
219
|
+
# --------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def _block_for_line(cfg: CFG, lineno: int) -> int | None:
|
|
222
|
+
exact = [
|
|
223
|
+
b.id
|
|
224
|
+
for b in cfg.iter_blocks()
|
|
225
|
+
if any(s.lineno == lineno for s in b.statements)
|
|
226
|
+
]
|
|
227
|
+
if exact:
|
|
228
|
+
return exact[0]
|
|
229
|
+
# nearest statement at or before the line
|
|
230
|
+
best_id, best_line = None, -1
|
|
231
|
+
for b in cfg.iter_blocks():
|
|
232
|
+
for s in b.statements:
|
|
233
|
+
if s.lineno <= lineno and s.lineno > best_line:
|
|
234
|
+
best_id, best_line = b.id, s.lineno
|
|
235
|
+
return best_id
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _statements_on_line(cfg: CFG, lineno: int) -> list[ast.AST]:
|
|
239
|
+
return [
|
|
240
|
+
s.node
|
|
241
|
+
for b in cfg.iter_blocks()
|
|
242
|
+
for s in b.statements
|
|
243
|
+
if s.lineno == lineno
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _names_subscripted_or_attr_on_line(cfg: CFG, lineno: int) -> list[str]:
|
|
248
|
+
out: list[str] = []
|
|
249
|
+
for node in _statements_on_line(cfg, lineno):
|
|
250
|
+
for sub in ast.walk(node):
|
|
251
|
+
if isinstance(sub, (ast.Attribute, ast.Subscript)) and isinstance(
|
|
252
|
+
sub.value, ast.Name
|
|
253
|
+
):
|
|
254
|
+
if sub.value.id not in out:
|
|
255
|
+
out.append(sub.value.id)
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# --------------------------------------------------------------------------
|
|
260
|
+
# per-exception explanations
|
|
261
|
+
# --------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def _explain_none(exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG) -> None:
|
|
264
|
+
attr = None
|
|
265
|
+
m = re.search(r"has no attribute '([^']+)'", tb.exc_message)
|
|
266
|
+
if m:
|
|
267
|
+
attr = m.group(1)
|
|
268
|
+
|
|
269
|
+
hits = [
|
|
270
|
+
nf
|
|
271
|
+
for nf in NullabilityAnalysis(site.fn, cfg, site.ctx).find_none_derefs()
|
|
272
|
+
if abs(nf.lineno - site.lineno) <= 1
|
|
273
|
+
]
|
|
274
|
+
if hits:
|
|
275
|
+
nf = hits[0]
|
|
276
|
+
exp.none_var = nf.var
|
|
277
|
+
exp.why.append(
|
|
278
|
+
f"`{nf.var}` can be None at line {site.lineno} - {nf.reason}."
|
|
279
|
+
)
|
|
280
|
+
if nf.witness:
|
|
281
|
+
exp.why.append("It reaches this line when: " + " and ".join(nf.witness))
|
|
282
|
+
|
|
283
|
+
if _none_check_is_after_deref(cfg, site.lineno, nf.var):
|
|
284
|
+
exp.why.append(
|
|
285
|
+
f"This line *does* check `{nf.var} is None`, but only after "
|
|
286
|
+
f"dereferencing it - `and`/`or` evaluate left to right, so the "
|
|
287
|
+
f"attribute access runs first and raises before the check."
|
|
288
|
+
)
|
|
289
|
+
exp.fix_hint = (
|
|
290
|
+
f"reorder the check so `{nf.var} is None` comes first: "
|
|
291
|
+
f"`if {nf.var} is None or {nf.var}...:`"
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
exp.fix_hint = (
|
|
295
|
+
f"guard it: `if {nf.var} is not None:` before line {site.lineno}, "
|
|
296
|
+
f"or give it a safe default."
|
|
297
|
+
)
|
|
298
|
+
else:
|
|
299
|
+
cands = _names_subscripted_or_attr_on_line(cfg, site.lineno)
|
|
300
|
+
if cands:
|
|
301
|
+
exp.none_var = cands[0]
|
|
302
|
+
target = (
|
|
303
|
+
f"`{cands[0]}`" if cands else ("the value" + (f".{attr}" if attr else ""))
|
|
304
|
+
)
|
|
305
|
+
exp.why.append(
|
|
306
|
+
f"{target} is None at this line. The engine could not prove where the "
|
|
307
|
+
"None came from - it most likely is the return value of a call it "
|
|
308
|
+
"cannot see through (a library, or a function that returns None on "
|
|
309
|
+
"some path)."
|
|
310
|
+
)
|
|
311
|
+
exp.why.append(
|
|
312
|
+
"Check what feeds it: arguments passed in, and the result of the last "
|
|
313
|
+
"assignment before this line."
|
|
314
|
+
)
|
|
315
|
+
if cands:
|
|
316
|
+
exp.fix_hint = f"guard `{cands[0]}` before it is used on line {site.lineno}."
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _explain_key(exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG) -> None:
|
|
320
|
+
key = tb.exc_message.strip().strip("'\"")
|
|
321
|
+
subs = []
|
|
322
|
+
for node in _statements_on_line(cfg, site.lineno):
|
|
323
|
+
for sub in ast.walk(node):
|
|
324
|
+
if isinstance(sub, ast.Subscript):
|
|
325
|
+
try:
|
|
326
|
+
subs.append(ast.unparse(sub))
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
where = f" ({', '.join(subs)})" if subs else ""
|
|
330
|
+
exp.why.append(
|
|
331
|
+
f"A dictionary/mapping is indexed with a key that is not present: "
|
|
332
|
+
f"`{key}`{where}."
|
|
333
|
+
)
|
|
334
|
+
guarded = _line_has_guard_for_key(cfg, key)
|
|
335
|
+
if guarded:
|
|
336
|
+
exp.why.append(
|
|
337
|
+
f"Other paths in this function do check for `{key}` first "
|
|
338
|
+
f"({guarded}) - this path does not."
|
|
339
|
+
)
|
|
340
|
+
exp.fix_hint = (
|
|
341
|
+
f"use `.get({key!r})` and handle None, or check `{key!r} in <mapping>` first."
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _explain_index(exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG) -> None:
|
|
346
|
+
exp.why.append(
|
|
347
|
+
"A sequence is indexed past its length on this path - the code does not "
|
|
348
|
+
"check that the item exists before subscripting it "
|
|
349
|
+
"(e.g. `re.search(...).group(1)`, `parts[1]`, `rows[0]`)."
|
|
350
|
+
)
|
|
351
|
+
exp.fix_hint = "check the length / truthiness of the sequence before indexing."
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _explain_zerodiv(
|
|
355
|
+
exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG
|
|
356
|
+
) -> None:
|
|
357
|
+
divisors = []
|
|
358
|
+
for node in _statements_on_line(cfg, site.lineno):
|
|
359
|
+
for sub in ast.walk(node):
|
|
360
|
+
if isinstance(sub, ast.BinOp) and isinstance(
|
|
361
|
+
sub.op, (ast.Div, ast.FloorDiv, ast.Mod)
|
|
362
|
+
):
|
|
363
|
+
try:
|
|
364
|
+
divisors.append(ast.unparse(sub.right))
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
where = f" - divisor: `{'`, `'.join(divisors)}`" if divisors else ""
|
|
368
|
+
exp.why.append(f"A division/modulo on this line has a zero right-hand side{where}.")
|
|
369
|
+
exp.fix_hint = "guard the divisor: `x / d if d else <fallback>`."
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _explain_unbound(
|
|
373
|
+
exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG
|
|
374
|
+
) -> None:
|
|
375
|
+
m = re.search(r"variable '([^']+)'", tb.exc_message) or re.search(
|
|
376
|
+
r"name '([^']+)'", tb.exc_message
|
|
377
|
+
)
|
|
378
|
+
var = m.group(1) if m else "the variable"
|
|
379
|
+
exp.why.append(
|
|
380
|
+
f"`{var}` is only assigned on some branches of this function, and this "
|
|
381
|
+
"path reaches its use without assigning it."
|
|
382
|
+
)
|
|
383
|
+
if exp.witness:
|
|
384
|
+
exp.why.append("The path that skips the assignment: " + " and ".join(exp.witness))
|
|
385
|
+
exp.fix_hint = f"initialise `{var}` once at the top of the function."
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _explain_value(
|
|
389
|
+
exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG
|
|
390
|
+
) -> None:
|
|
391
|
+
if "invalid literal for int()" in tb.exc_message or "could not convert" in tb.exc_message:
|
|
392
|
+
conv = []
|
|
393
|
+
for node in _statements_on_line(cfg, site.lineno):
|
|
394
|
+
for sub in ast.walk(node):
|
|
395
|
+
if (
|
|
396
|
+
isinstance(sub, ast.Call)
|
|
397
|
+
and isinstance(sub.func, ast.Name)
|
|
398
|
+
and sub.func.id in ("int", "float")
|
|
399
|
+
):
|
|
400
|
+
try:
|
|
401
|
+
conv.append(ast.unparse(sub))
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
where = f" ({', '.join(conv)})" if conv else ""
|
|
405
|
+
exp.why.append(
|
|
406
|
+
f"A numeric conversion{where} is given a string the code never "
|
|
407
|
+
"validates - it can contain non-numeric characters."
|
|
408
|
+
)
|
|
409
|
+
exp.fix_hint = "validate with a regex / try-except before converting."
|
|
410
|
+
else:
|
|
411
|
+
_explain_generic(exp, tb, site, cfg)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _explain_generic(
|
|
415
|
+
exp: Explanation, tb: ParsedTraceback, site: CrashSite, cfg: CFG
|
|
416
|
+
) -> None:
|
|
417
|
+
raises = []
|
|
418
|
+
for b in cfg.iter_blocks():
|
|
419
|
+
if b.terminator == Terminator.RAISE and b.term_node is not None:
|
|
420
|
+
try:
|
|
421
|
+
raises.append((b.first_line, ast.unparse(b.term_node)))
|
|
422
|
+
except Exception:
|
|
423
|
+
pass
|
|
424
|
+
if raises:
|
|
425
|
+
exp.why.append(
|
|
426
|
+
"This function raises explicitly at: "
|
|
427
|
+
+ "; ".join(f"line {ln}: {src}" for ln, src in sorted(raises) if ln)
|
|
428
|
+
)
|
|
429
|
+
exp.why.append(
|
|
430
|
+
f"No dedicated explainer for {tb.exc_type} yet - use the call chain above "
|
|
431
|
+
"and the branch conditions to trace which inputs reach line "
|
|
432
|
+
f"{site.lineno}."
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _none_check_is_after_deref(cfg: CFG, lineno: int, var: str) -> bool:
|
|
437
|
+
"""True if a statement on ``lineno`` is a BoolOp where an operand
|
|
438
|
+
dereferences ``var`` and a *later* operand tests ``var is [not] None``."""
|
|
439
|
+
for node in _statements_on_line(cfg, lineno):
|
|
440
|
+
for boolop in ast.walk(node):
|
|
441
|
+
if not isinstance(boolop, ast.BoolOp):
|
|
442
|
+
continue
|
|
443
|
+
saw_deref = False
|
|
444
|
+
for operand in boolop.values:
|
|
445
|
+
derefs_here = any(
|
|
446
|
+
isinstance(s, (ast.Attribute, ast.Subscript))
|
|
447
|
+
and isinstance(s.value, ast.Name)
|
|
448
|
+
and s.value.id == var
|
|
449
|
+
for s in ast.walk(operand)
|
|
450
|
+
)
|
|
451
|
+
is_none_test = any(
|
|
452
|
+
isinstance(s, ast.Compare)
|
|
453
|
+
and isinstance(s.left, ast.Name)
|
|
454
|
+
and s.left.id == var
|
|
455
|
+
and any(isinstance(o, (ast.Is, ast.IsNot)) for o in s.ops)
|
|
456
|
+
for s in ast.walk(operand)
|
|
457
|
+
)
|
|
458
|
+
if derefs_here:
|
|
459
|
+
saw_deref = True
|
|
460
|
+
elif is_none_test and saw_deref:
|
|
461
|
+
return True
|
|
462
|
+
return False
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _line_has_guard_for_key(cfg: CFG, key: str) -> str | None:
|
|
466
|
+
for b in cfg.iter_blocks():
|
|
467
|
+
for e in b.succ:
|
|
468
|
+
if not e.guard_src:
|
|
469
|
+
continue
|
|
470
|
+
if f"'{key}'" in e.guard_src or f'"{key}"' in e.guard_src:
|
|
471
|
+
if " in " in e.guard_src or ".get(" in e.guard_src:
|
|
472
|
+
return e.guard_src
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
_HANDLERS = {
|
|
477
|
+
"AttributeError": _explain_none,
|
|
478
|
+
"TypeError": _explain_none, # 'NoneType' object is not subscriptable/callable/iterable
|
|
479
|
+
"KeyError": _explain_key,
|
|
480
|
+
"IndexError": _explain_index,
|
|
481
|
+
"ZeroDivisionError": _explain_zerodiv,
|
|
482
|
+
"UnboundLocalError": _explain_unbound,
|
|
483
|
+
"NameError": _explain_unbound,
|
|
484
|
+
"ValueError": _explain_value,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# --------------------------------------------------------------------------
|
|
489
|
+
# call-chain formatting
|
|
490
|
+
# --------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
def _format_chain(
|
|
493
|
+
resolved: list[tuple[Frame, Module | None, FunctionDef | None]]
|
|
494
|
+
) -> list[str]:
|
|
495
|
+
out: list[str] = []
|
|
496
|
+
for frame, module, _fn in resolved:
|
|
497
|
+
loc = (
|
|
498
|
+
f"{module.dotted_name}"
|
|
499
|
+
if module is not None
|
|
500
|
+
else Path(frame.file).name
|
|
501
|
+
)
|
|
502
|
+
marker = "" if module is not None else " (not in project)"
|
|
503
|
+
out.append(f"{loc}:{frame.lineno} {frame.func}{marker}")
|
|
504
|
+
return out
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Render an :class:`Explanation` as terminal text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from deploy_guard.explain.explainer import Explanation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def render_explanation(exp: Explanation) -> str:
|
|
9
|
+
L: list[str] = []
|
|
10
|
+
exc = exp.exc_type + (f": {exp.exc_message}" if exp.exc_message else "")
|
|
11
|
+
L.append(f"Explaining: {exc}")
|
|
12
|
+
L.append("")
|
|
13
|
+
|
|
14
|
+
if exp.site is not None:
|
|
15
|
+
s = exp.site
|
|
16
|
+
where = s.fn.qualname if s.fn is not None else f"{s.module.dotted_name} (module scope)"
|
|
17
|
+
L.append(f"Crash site: {s.module.path}:{s.lineno}")
|
|
18
|
+
L.append(f" in {where}")
|
|
19
|
+
if s.source:
|
|
20
|
+
L.append(f" {s.lineno} | {s.source}")
|
|
21
|
+
L.append("")
|
|
22
|
+
|
|
23
|
+
if exp.why:
|
|
24
|
+
L.append("Why:")
|
|
25
|
+
for line in exp.why:
|
|
26
|
+
L.append(f" - {line}")
|
|
27
|
+
L.append("")
|
|
28
|
+
|
|
29
|
+
if exp.witness:
|
|
30
|
+
L.append("Reached when:")
|
|
31
|
+
L.append(" " + " and ".join(exp.witness))
|
|
32
|
+
L.append("")
|
|
33
|
+
|
|
34
|
+
if exp.call_chain:
|
|
35
|
+
L.append("Call chain (from the traceback):")
|
|
36
|
+
for i, frame in enumerate(exp.call_chain):
|
|
37
|
+
prefix = " " + (" " * i) + ("└─ " if i else "")
|
|
38
|
+
tail = " <- raised here" if i == len(exp.call_chain) - 1 else ""
|
|
39
|
+
L.append(f"{prefix}{frame}{tail}")
|
|
40
|
+
L.append("")
|
|
41
|
+
|
|
42
|
+
if exp.linked_finding:
|
|
43
|
+
f = exp.linked_finding
|
|
44
|
+
L.append(
|
|
45
|
+
f"Matches scan finding: {f.rule} ({f.severity}) at "
|
|
46
|
+
f"{f.file}:{f.lineno}"
|
|
47
|
+
)
|
|
48
|
+
L.append("")
|
|
49
|
+
|
|
50
|
+
if exp.fix_hint:
|
|
51
|
+
L.append(f"Fix: {exp.fix_hint}")
|
|
52
|
+
L.append("")
|
|
53
|
+
|
|
54
|
+
if exp.notes:
|
|
55
|
+
L.append("Notes:")
|
|
56
|
+
for n in exp.notes:
|
|
57
|
+
L.append(f" - {n}")
|
|
58
|
+
L.append("")
|
|
59
|
+
|
|
60
|
+
return "\n".join(L).rstrip() + "\n"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Parse a pasted CPython traceback into frames + the exception that escaped."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
_FRAME_RE = re.compile(
|
|
9
|
+
r'^\s*File "(?P<file>.+?)", line (?P<line>\d+), in (?P<func>.+?)\s*$'
|
|
10
|
+
)
|
|
11
|
+
# ``ExceptionType`` or ``pkg.mod.ExceptionType: message``
|
|
12
|
+
_EXC_RE = re.compile(r"^(?P<type>[A-Za-z_][\w.]*)(?::[ ]?(?P<msg>.*))?$")
|
|
13
|
+
|
|
14
|
+
_CHAIN_SEPARATORS = (
|
|
15
|
+
"During handling of the above exception, another exception occurred:",
|
|
16
|
+
"The above exception was the direct cause of the following exception:",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Frame:
|
|
22
|
+
file: str
|
|
23
|
+
lineno: int
|
|
24
|
+
func: str
|
|
25
|
+
source: str = "" # the source line CPython printed under the frame, if any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ParsedTraceback:
|
|
30
|
+
frames: list[Frame] = field(default_factory=list)
|
|
31
|
+
exc_type: str = "" # possibly dotted, as printed
|
|
32
|
+
exc_message: str = ""
|
|
33
|
+
raw: str = ""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def exc_short(self) -> str:
|
|
37
|
+
return self.exc_type.rsplit(".", 1)[-1]
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def ok(self) -> bool:
|
|
41
|
+
return bool(self.exc_type)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_traceback(text: str) -> ParsedTraceback:
|
|
45
|
+
# A chained traceback prints several blocks; the last one is what escaped.
|
|
46
|
+
for sep in _CHAIN_SEPARATORS:
|
|
47
|
+
if sep in text:
|
|
48
|
+
text = text.split(sep)[-1]
|
|
49
|
+
|
|
50
|
+
lines = text.splitlines()
|
|
51
|
+
frames: list[Frame] = []
|
|
52
|
+
|
|
53
|
+
i, n = 0, len(lines)
|
|
54
|
+
while i < n:
|
|
55
|
+
m = _FRAME_RE.match(lines[i])
|
|
56
|
+
if m:
|
|
57
|
+
source = ""
|
|
58
|
+
nxt = lines[i + 1] if i + 1 < n else ""
|
|
59
|
+
if (
|
|
60
|
+
nxt.strip()
|
|
61
|
+
and not _FRAME_RE.match(nxt)
|
|
62
|
+
and (nxt.startswith(" ") or nxt.startswith("\t"))
|
|
63
|
+
):
|
|
64
|
+
source = nxt.strip()
|
|
65
|
+
i += 1
|
|
66
|
+
frames.append(
|
|
67
|
+
Frame(
|
|
68
|
+
file=m.group("file"),
|
|
69
|
+
lineno=int(m.group("line")),
|
|
70
|
+
func=m.group("func"),
|
|
71
|
+
source=source,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
i += 1
|
|
75
|
+
|
|
76
|
+
exc_type, exc_message = "", ""
|
|
77
|
+
for ln in reversed(lines):
|
|
78
|
+
s = ln.rstrip()
|
|
79
|
+
if not s or s[0].isspace() or s.startswith("Traceback"):
|
|
80
|
+
continue
|
|
81
|
+
m = _EXC_RE.match(s)
|
|
82
|
+
if m:
|
|
83
|
+
exc_type = m.group("type")
|
|
84
|
+
exc_message = (m.group("msg") or "").strip()
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
return ParsedTraceback(
|
|
88
|
+
frames=frames, exc_type=exc_type, exc_message=exc_message, raw=text
|
|
89
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Language frontends: source -> :mod:`deploy_guard.ir`.
|
|
2
|
+
|
|
3
|
+
M1 ships the Python frontend. Java (via a bundled JavaParser fat-jar) lands
|
|
4
|
+
in M4 and reuses every analysis pass unchanged.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from deploy_guard.frontend.python_cfg import build_cfg
|
|
8
|
+
from deploy_guard.frontend.python_frontend import lower_python
|
|
9
|
+
|
|
10
|
+
__all__ = ["lower_python", "build_cfg"]
|