repomind-local 0.3.2__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.
- repomind/__init__.py +3 -0
- repomind/analysis/__init__.py +1 -0
- repomind/analysis/checks.py +482 -0
- repomind/analysis/detectors.py +234 -0
- repomind/analysis/fixes.py +309 -0
- repomind/analysis/index.py +124 -0
- repomind/analysis/proof.py +155 -0
- repomind/analysis/search.py +99 -0
- repomind/analysis/symptoms.py +114 -0
- repomind/cases.py +13 -0
- repomind/cli.py +157 -0
- repomind/fixtures/paystream/README.md +9 -0
- repomind/fixtures/paystream/ops/incident_notes.md +3 -0
- repomind/fixtures/paystream/paystream/__init__.py +1 -0
- repomind/fixtures/paystream/paystream/activity.py +11 -0
- repomind/fixtures/paystream/paystream/auth/__init__.py +1 -0
- repomind/fixtures/paystream/paystream/auth/tokens.py +19 -0
- repomind/fixtures/paystream/paystream/ledger.py +22 -0
- repomind/fixtures/paystream/paystream/routes.py +24 -0
- repomind/fixtures/paystream/pytest.ini +3 -0
- repomind/fixtures/paystream/tests/test_activity.py +24 -0
- repomind/fixtures/paystream/tests/test_tokens.py +13 -0
- repomind/model.py +93 -0
- repomind/render.py +77 -0
- repomind/workflow.py +555 -0
- repomind_local-0.3.2.dist-info/METADATA +119 -0
- repomind_local-0.3.2.dist-info/RECORD +30 -0
- repomind_local-0.3.2.dist-info/WHEEL +5 -0
- repomind_local-0.3.2.dist-info/entry_points.txt +2 -0
- repomind_local-0.3.2.dist-info/top_level.txt +1 -0
repomind/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Static analysis used by the specialist agents."""
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Focused checks for a Python backend. Every finding cites a line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
from repomind.analysis.detectors import _enclosing_function, _finding, _line
|
|
8
|
+
|
|
9
|
+
_HTTP = {"get", "post", "put", "patch", "delete", "head", "request", "urlopen"}
|
|
10
|
+
_ROUTE = {"get", "post", "put", "patch", "delete", "head", "options", "api_route"}
|
|
11
|
+
_BLOCKING = {
|
|
12
|
+
"time.sleep",
|
|
13
|
+
"requests.get",
|
|
14
|
+
"requests.post",
|
|
15
|
+
"requests.put",
|
|
16
|
+
"requests.patch",
|
|
17
|
+
"requests.delete",
|
|
18
|
+
"requests.request",
|
|
19
|
+
"urllib.request.urlopen",
|
|
20
|
+
"subprocess.run",
|
|
21
|
+
"subprocess.call",
|
|
22
|
+
"subprocess.check_call",
|
|
23
|
+
"subprocess.check_output",
|
|
24
|
+
}
|
|
25
|
+
_QUERY = {"execute", "executemany", "executescript", "query", "find_one"}
|
|
26
|
+
_SECRET_NAMES = {"password", "passwd", "secret", "api_key", "apikey", "access_token", "private_key"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _dotted(node: ast.AST) -> str:
|
|
30
|
+
if isinstance(node, ast.Name):
|
|
31
|
+
return node.id
|
|
32
|
+
if isinstance(node, ast.Attribute):
|
|
33
|
+
parent = _dotted(node.value)
|
|
34
|
+
return f"{parent}.{node.attr}" if parent else node.attr
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _has_keyword(node: ast.Call, name: str) -> bool:
|
|
39
|
+
return any(keyword.arg == name for keyword in node.keywords)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _route_decorator(node: ast.AST) -> ast.Call | None:
|
|
43
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
44
|
+
return None
|
|
45
|
+
for decorator in node.decorator_list:
|
|
46
|
+
call = decorator if isinstance(decorator, ast.Call) else None
|
|
47
|
+
if call is None:
|
|
48
|
+
continue
|
|
49
|
+
name = _dotted(call.func)
|
|
50
|
+
if name.split(".")[-1] in _ROUTE:
|
|
51
|
+
return call
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _in_loop(tree: ast.AST, target: ast.AST) -> bool:
|
|
56
|
+
for node in ast.walk(tree):
|
|
57
|
+
if isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
|
|
58
|
+
for child in ast.walk(node):
|
|
59
|
+
if child is target:
|
|
60
|
+
return True
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _retry_decorator(node: ast.AST) -> bool:
|
|
65
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
66
|
+
return False
|
|
67
|
+
for decorator in node.decorator_list:
|
|
68
|
+
name = _dotted(decorator.func if isinstance(decorator, ast.Call) else decorator)
|
|
69
|
+
if "retry" in name.lower():
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extra_checks(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
75
|
+
found: list[dict] = []
|
|
76
|
+
found.extend(_http_calls(tree, source, rel))
|
|
77
|
+
found.extend(_blocking_and_routes(tree, source, rel))
|
|
78
|
+
found.extend(_dependencies(tree, source, rel))
|
|
79
|
+
found.extend(_exceptions(tree, source, rel))
|
|
80
|
+
found.extend(_weak_auth(tree, source, rel))
|
|
81
|
+
found.extend(_sql(tree, source, rel))
|
|
82
|
+
found.extend(_secrets(tree, source, rel))
|
|
83
|
+
found.extend(_shell(tree, source, rel))
|
|
84
|
+
found.extend(_resources(tree, source, rel))
|
|
85
|
+
found.extend(_query_loops(tree, source, rel))
|
|
86
|
+
return found
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _http_calls(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
90
|
+
found: list[dict] = []
|
|
91
|
+
retries = 0
|
|
92
|
+
for node in ast.walk(tree):
|
|
93
|
+
if not isinstance(node, ast.Call):
|
|
94
|
+
continue
|
|
95
|
+
name = _dotted(node.func)
|
|
96
|
+
short = name.split(".")[-1]
|
|
97
|
+
if short not in _HTTP or not any(prefix in name for prefix in ("requests.", "httpx.", "urllib.")):
|
|
98
|
+
continue
|
|
99
|
+
function = _enclosing_function(tree, node)
|
|
100
|
+
owner = _owner_function(tree, node)
|
|
101
|
+
if not _has_keyword(node, "timeout"):
|
|
102
|
+
found.append(
|
|
103
|
+
_finding(
|
|
104
|
+
detector="missing_timeout",
|
|
105
|
+
category="latency",
|
|
106
|
+
confidence=0.74,
|
|
107
|
+
file=rel,
|
|
108
|
+
line=node.lineno,
|
|
109
|
+
title=f"`{name}` has no timeout",
|
|
110
|
+
detail=f"`{function or 'this call'}` can wait forever if the remote service stalls.",
|
|
111
|
+
evidence=_line(source, node.lineno),
|
|
112
|
+
function=function,
|
|
113
|
+
extra={"call": name},
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
if retries < 8 and owner is not None and not _retry_decorator(owner) and not _in_loop(tree, node):
|
|
117
|
+
retries += 1
|
|
118
|
+
found.append(
|
|
119
|
+
_finding(
|
|
120
|
+
detector="missing_retry",
|
|
121
|
+
category="defect",
|
|
122
|
+
confidence=0.46,
|
|
123
|
+
file=rel,
|
|
124
|
+
line=node.lineno,
|
|
125
|
+
title=f"`{name}` is not retried",
|
|
126
|
+
detail="The external call is not inside a retry loop or a retry decorator.",
|
|
127
|
+
evidence=_line(source, node.lineno),
|
|
128
|
+
function=function,
|
|
129
|
+
extra={"call": name},
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
return found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _owner_function(tree: ast.AST, target: ast.AST) -> ast.AST | None:
|
|
136
|
+
for node in ast.walk(tree):
|
|
137
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
138
|
+
for child in ast.walk(node):
|
|
139
|
+
if child is target:
|
|
140
|
+
return node
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_fastapi(source: str) -> bool:
|
|
145
|
+
lowered = source.lower()
|
|
146
|
+
return "fastapi" in lowered or "apirouter" in lowered
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _blocking_and_routes(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
150
|
+
found: list[dict] = []
|
|
151
|
+
missing_models = 0
|
|
152
|
+
fastapi = _is_fastapi(source)
|
|
153
|
+
for node in ast.walk(tree):
|
|
154
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
155
|
+
continue
|
|
156
|
+
route = _route_decorator(node)
|
|
157
|
+
if (
|
|
158
|
+
fastapi
|
|
159
|
+
and route is not None
|
|
160
|
+
and missing_models < 8
|
|
161
|
+
and not _has_keyword(route, "response_model")
|
|
162
|
+
):
|
|
163
|
+
missing_models += 1
|
|
164
|
+
found.append(
|
|
165
|
+
_finding(
|
|
166
|
+
detector="missing_response_model",
|
|
167
|
+
category="fastapi",
|
|
168
|
+
confidence=0.5,
|
|
169
|
+
file=rel,
|
|
170
|
+
line=route.lineno,
|
|
171
|
+
title=f"`{node.name}` has no response_model",
|
|
172
|
+
detail="The route does not declare the shape of its response.",
|
|
173
|
+
evidence=_line(source, route.lineno),
|
|
174
|
+
function=node.name,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
if not isinstance(node, ast.AsyncFunctionDef):
|
|
178
|
+
continue
|
|
179
|
+
for child in ast.walk(node):
|
|
180
|
+
if not isinstance(child, ast.Call) or child is route:
|
|
181
|
+
continue
|
|
182
|
+
called = _dotted(child.func)
|
|
183
|
+
if called not in _BLOCKING and not called.startswith("requests."):
|
|
184
|
+
continue
|
|
185
|
+
routed = fastapi and route is not None
|
|
186
|
+
titled = "fastapi_blocking_route" if routed else "blocking_in_async"
|
|
187
|
+
category = "fastapi" if routed else "latency"
|
|
188
|
+
found.append(
|
|
189
|
+
_finding(
|
|
190
|
+
detector=titled,
|
|
191
|
+
category=category,
|
|
192
|
+
confidence=0.8,
|
|
193
|
+
file=rel,
|
|
194
|
+
line=child.lineno,
|
|
195
|
+
title=f"Async `{node.name}` calls blocking `{called}`",
|
|
196
|
+
detail="This call blocks the event loop. An async route should await a non-blocking client, or use asyncio.to_thread for a deliberate blocking boundary.",
|
|
197
|
+
evidence=_line(source, child.lineno),
|
|
198
|
+
function=node.name,
|
|
199
|
+
extra={"call": called},
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return found
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _dependencies(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
206
|
+
found: list[dict] = []
|
|
207
|
+
for node in ast.walk(tree):
|
|
208
|
+
if not _is_fastapi(source):
|
|
209
|
+
return found
|
|
210
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or _route_decorator(node) is None:
|
|
211
|
+
continue
|
|
212
|
+
for default in node.args.defaults:
|
|
213
|
+
if not isinstance(default, ast.Call):
|
|
214
|
+
continue
|
|
215
|
+
name = _dotted(default.func)
|
|
216
|
+
if name.split(".")[-1] == "Depends":
|
|
217
|
+
if default.args and isinstance(default.args[0], ast.Call):
|
|
218
|
+
found.append(
|
|
219
|
+
_finding(
|
|
220
|
+
detector="called_dependency",
|
|
221
|
+
category="fastapi",
|
|
222
|
+
confidence=0.84,
|
|
223
|
+
file=rel,
|
|
224
|
+
line=default.lineno,
|
|
225
|
+
title=f"`{node.name}` calls the dependency before Depends",
|
|
226
|
+
detail="Depends should receive the callable, not the result of calling it.",
|
|
227
|
+
evidence=_line(source, default.lineno),
|
|
228
|
+
function=node.name,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
continue
|
|
232
|
+
if name.startswith("get_"):
|
|
233
|
+
found.append(
|
|
234
|
+
_finding(
|
|
235
|
+
detector="missing_depends",
|
|
236
|
+
category="fastapi",
|
|
237
|
+
confidence=0.8,
|
|
238
|
+
file=rel,
|
|
239
|
+
line=default.lineno,
|
|
240
|
+
title=f"`{node.name}` calls `{name}` without Depends",
|
|
241
|
+
detail="FastAPI only injects the value when the default is wrapped in Depends.",
|
|
242
|
+
evidence=_line(source, default.lineno),
|
|
243
|
+
function=node.name,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
return found
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _exceptions(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
250
|
+
found: list[dict] = []
|
|
251
|
+
for node in ast.walk(tree):
|
|
252
|
+
if not isinstance(node, ast.ExceptHandler):
|
|
253
|
+
continue
|
|
254
|
+
bare = node.type is None
|
|
255
|
+
broad = isinstance(node.type, ast.Name) and node.type.id in {"Exception", "BaseException"}
|
|
256
|
+
if not bare and not broad:
|
|
257
|
+
continue
|
|
258
|
+
swallowed = len(node.body) == 1 and isinstance(node.body[0], ast.Pass)
|
|
259
|
+
if not bare and not swallowed:
|
|
260
|
+
continue
|
|
261
|
+
function = _enclosing_function(tree, node)
|
|
262
|
+
title = "Exception is swallowed" if swallowed else "Bare except hides the failure"
|
|
263
|
+
found.append(
|
|
264
|
+
_finding(
|
|
265
|
+
detector="swallowed_except",
|
|
266
|
+
category="defect",
|
|
267
|
+
confidence=0.76,
|
|
268
|
+
file=rel,
|
|
269
|
+
line=node.lineno,
|
|
270
|
+
title=title,
|
|
271
|
+
detail=f"`{function or 'this handler'}` catches a broad failure and does not name it.",
|
|
272
|
+
evidence=_line(source, node.lineno),
|
|
273
|
+
function=function,
|
|
274
|
+
extra={"bare": bare},
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
return found
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _weak_auth(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
281
|
+
found: list[dict] = []
|
|
282
|
+
for node in ast.walk(tree):
|
|
283
|
+
if not isinstance(node, ast.Call):
|
|
284
|
+
continue
|
|
285
|
+
name = _dotted(node.func)
|
|
286
|
+
if "jwt" not in name.lower() and not name.endswith("decode"):
|
|
287
|
+
continue
|
|
288
|
+
function = _enclosing_function(tree, node)
|
|
289
|
+
for keyword in node.keywords:
|
|
290
|
+
if keyword.arg == "verify" and _is_false(keyword.value):
|
|
291
|
+
found.append(_auth_finding(rel, source, node, function, "JWT signature verification is disabled"))
|
|
292
|
+
if keyword.arg == "algorithms" and _contains_none(keyword.value):
|
|
293
|
+
found.append(_auth_finding(rel, source, node, function, "JWT allows the none algorithm"))
|
|
294
|
+
if keyword.arg == "options" and isinstance(keyword.value, ast.Dict):
|
|
295
|
+
for key, value in zip(keyword.value.keys, keyword.value.values):
|
|
296
|
+
if isinstance(key, ast.Constant) and key.value == "verify_signature" and _is_false(value):
|
|
297
|
+
found.append(_auth_finding(rel, source, node, function, "JWT signature verification is disabled"))
|
|
298
|
+
return found
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _auth_finding(rel: str, source: str, node: ast.Call, function: str | None, title: str) -> dict:
|
|
302
|
+
return _finding(
|
|
303
|
+
detector="weak_jwt",
|
|
304
|
+
category="auth",
|
|
305
|
+
confidence=0.86,
|
|
306
|
+
file=rel,
|
|
307
|
+
line=node.lineno,
|
|
308
|
+
title=title,
|
|
309
|
+
detail=f"`{function or 'this decode'}` accepts a token without checking its signature.",
|
|
310
|
+
evidence=_line(source, node.lineno),
|
|
311
|
+
function=function,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _is_false(node: ast.AST) -> bool:
|
|
316
|
+
return isinstance(node, ast.Constant) and node.value is False
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _contains_none(node: ast.AST) -> bool:
|
|
320
|
+
if not isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
321
|
+
return False
|
|
322
|
+
return any(isinstance(item, ast.Constant) and item.value == "none" for item in node.elts)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _sql(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
326
|
+
found: list[dict] = []
|
|
327
|
+
for node in ast.walk(tree):
|
|
328
|
+
if not isinstance(node, ast.Call) or not node.args:
|
|
329
|
+
continue
|
|
330
|
+
if _dotted(node.func).split(".")[-1] not in {"execute", "executemany", "executescript"}:
|
|
331
|
+
continue
|
|
332
|
+
query = node.args[0]
|
|
333
|
+
interpolated = isinstance(query, ast.JoinedStr) or (
|
|
334
|
+
isinstance(query, ast.BinOp) and isinstance(query.op, (ast.Mod, ast.Add))
|
|
335
|
+
)
|
|
336
|
+
formatted = isinstance(query, ast.Call) and _dotted(query.func).endswith(".format")
|
|
337
|
+
if not interpolated and not formatted:
|
|
338
|
+
continue
|
|
339
|
+
function = _enclosing_function(tree, node)
|
|
340
|
+
found.append(
|
|
341
|
+
_finding(
|
|
342
|
+
detector="sql_interpolation",
|
|
343
|
+
category="security",
|
|
344
|
+
confidence=0.82,
|
|
345
|
+
file=rel,
|
|
346
|
+
line=node.lineno,
|
|
347
|
+
title="SQL is built with string interpolation",
|
|
348
|
+
detail=f"`{function or 'this query'}` puts data into the SQL string instead of using parameters.",
|
|
349
|
+
evidence=_line(source, node.lineno),
|
|
350
|
+
function=function,
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
return found
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _secrets(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
357
|
+
found: list[dict] = []
|
|
358
|
+
for node in ast.walk(tree):
|
|
359
|
+
if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Constant):
|
|
360
|
+
continue
|
|
361
|
+
if not isinstance(node.value.value, str) or len(node.value.value) < 6:
|
|
362
|
+
continue
|
|
363
|
+
for target in node.targets:
|
|
364
|
+
if isinstance(target, ast.Name) and target.id.lower() in _SECRET_NAMES:
|
|
365
|
+
found.append(
|
|
366
|
+
_finding(
|
|
367
|
+
detector="hardcoded_secret",
|
|
368
|
+
category="security",
|
|
369
|
+
confidence=0.7,
|
|
370
|
+
file=rel,
|
|
371
|
+
line=node.lineno,
|
|
372
|
+
title=f"`{target.id}` is hardcoded",
|
|
373
|
+
detail="The secret is stored in source. Move it to an environment variable.",
|
|
374
|
+
evidence=_line(source, node.lineno),
|
|
375
|
+
function=_enclosing_function(tree, node),
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
return found
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _shell(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
382
|
+
found: list[dict] = []
|
|
383
|
+
for node in ast.walk(tree):
|
|
384
|
+
if not isinstance(node, ast.Call):
|
|
385
|
+
continue
|
|
386
|
+
if _dotted(node.func).split(".")[-1] not in {"run", "call", "check_call", "check_output", "Popen"}:
|
|
387
|
+
continue
|
|
388
|
+
if not any(keyword.arg == "shell" and _is_true(keyword.value) for keyword in node.keywords):
|
|
389
|
+
continue
|
|
390
|
+
found.append(
|
|
391
|
+
_finding(
|
|
392
|
+
detector="shell_true",
|
|
393
|
+
category="security",
|
|
394
|
+
confidence=0.8,
|
|
395
|
+
file=rel,
|
|
396
|
+
line=node.lineno,
|
|
397
|
+
title="Subprocess runs through the shell",
|
|
398
|
+
detail="shell=True lets metacharacters in the command string change what runs.",
|
|
399
|
+
evidence=_line(source, node.lineno),
|
|
400
|
+
function=_enclosing_function(tree, node),
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
return found
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _is_true(node: ast.AST) -> bool:
|
|
407
|
+
return isinstance(node, ast.Constant) and node.value is True
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _resources(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
411
|
+
managed: set[int] = set()
|
|
412
|
+
for node in ast.walk(tree):
|
|
413
|
+
if isinstance(node, (ast.With, ast.AsyncWith)):
|
|
414
|
+
for item in node.items:
|
|
415
|
+
managed.add(id(item.context_expr))
|
|
416
|
+
closed: set[str] = set()
|
|
417
|
+
for node in ast.walk(tree):
|
|
418
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "close":
|
|
419
|
+
if isinstance(node.func.value, ast.Name):
|
|
420
|
+
closed.add(node.func.value.id)
|
|
421
|
+
found: list[dict] = []
|
|
422
|
+
for node in ast.walk(tree):
|
|
423
|
+
if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call):
|
|
424
|
+
continue
|
|
425
|
+
if id(node.value) in managed or not _is_resource(node.value):
|
|
426
|
+
continue
|
|
427
|
+
if not node.targets or not isinstance(node.targets[0], ast.Name):
|
|
428
|
+
continue
|
|
429
|
+
name = node.targets[0].id
|
|
430
|
+
if name in closed:
|
|
431
|
+
continue
|
|
432
|
+
kind = _dotted(node.value.func) or "open"
|
|
433
|
+
found.append(
|
|
434
|
+
_finding(
|
|
435
|
+
detector="resource_leak",
|
|
436
|
+
category="defect",
|
|
437
|
+
confidence=0.73,
|
|
438
|
+
file=rel,
|
|
439
|
+
line=node.lineno,
|
|
440
|
+
title=f"`{name}` is opened and never closed",
|
|
441
|
+
detail=f"`{kind}` is not used as a context manager and `{name}.close()` is not called.",
|
|
442
|
+
evidence=_line(source, node.lineno),
|
|
443
|
+
function=_enclosing_function(tree, node),
|
|
444
|
+
extra={"call": kind},
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
return found
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _is_resource(node: ast.Call) -> bool:
|
|
451
|
+
name = _dotted(node.func)
|
|
452
|
+
return name == "open" or name.endswith(".open") or name.endswith("Session") or name.endswith(".Session")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _query_loops(tree: ast.AST, source: str, rel: str) -> list[dict]:
|
|
456
|
+
found: list[dict] = []
|
|
457
|
+
for node in ast.walk(tree):
|
|
458
|
+
if not isinstance(node, (ast.For, ast.AsyncFor)):
|
|
459
|
+
continue
|
|
460
|
+
for child in ast.walk(node):
|
|
461
|
+
if child is node or not isinstance(child, ast.Call):
|
|
462
|
+
continue
|
|
463
|
+
short = _dotted(child.func).split(".")[-1]
|
|
464
|
+
if short not in _QUERY:
|
|
465
|
+
continue
|
|
466
|
+
function = _enclosing_function(tree, node)
|
|
467
|
+
found.append(
|
|
468
|
+
_finding(
|
|
469
|
+
detector="query_in_loop",
|
|
470
|
+
category="latency",
|
|
471
|
+
confidence=0.64,
|
|
472
|
+
file=rel,
|
|
473
|
+
line=child.lineno,
|
|
474
|
+
title=f"`{short}` runs inside a loop",
|
|
475
|
+
detail=f"`{function or 'this loop'}` can issue one query per item.",
|
|
476
|
+
evidence=_line(source, child.lineno),
|
|
477
|
+
function=function,
|
|
478
|
+
extra={"call": short},
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
break
|
|
482
|
+
return found
|