codesnake 1.2.1__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.
- codesnake/__init__.py +77 -0
- codesnake/__main__.py +5 -0
- codesnake/_version.py +3 -0
- codesnake/banner.py +76 -0
- codesnake/checker.py +2755 -0
- codesnake/cli.py +168 -0
- codesnake-1.2.1.dist-info/METADATA +377 -0
- codesnake-1.2.1.dist-info/RECORD +12 -0
- codesnake-1.2.1.dist-info/WHEEL +5 -0
- codesnake-1.2.1.dist-info/entry_points.txt +2 -0
- codesnake-1.2.1.dist-info/licenses/LICENSE +21 -0
- codesnake-1.2.1.dist-info/top_level.txt +1 -0
codesnake/checker.py
ADDED
|
@@ -0,0 +1,2755 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CodeSnake - Semantic Code Checker for Python 3
|
|
4
|
+
A comprehensive tool to detect coding issues, anti-patterns, and potential bugs.
|
|
5
|
+
|
|
6
|
+
🐍 CodeSnake strikes at code problems before they bite!
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import builtins
|
|
13
|
+
import concurrent.futures
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess as _subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import asdict, dataclass, fields
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Set, TextIO, Tuple
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import tomllib # Python 3.11+
|
|
26
|
+
except ImportError: # pragma: no cover - Python 3.10
|
|
27
|
+
tomllib = None # type: ignore[assignment]
|
|
28
|
+
|
|
29
|
+
from ._version import __version__
|
|
30
|
+
from .banner import print_snake_banner
|
|
31
|
+
|
|
32
|
+
PROJECT_URL = 'https://github.com/bitWarrior/codesnake'
|
|
33
|
+
RULES_URL = PROJECT_URL + '#what-it-checks'
|
|
34
|
+
|
|
35
|
+
# Below this many files a process pool costs more than it saves.
|
|
36
|
+
PARALLEL_MIN_FILES = 8
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
SEVERITY_RANK = {'error': 3, 'warning': 2, 'info': 1}
|
|
40
|
+
|
|
41
|
+
CATEGORY_FLAGS = {
|
|
42
|
+
'security': 'check_security',
|
|
43
|
+
'bugs': 'check_bugs',
|
|
44
|
+
'exceptions': 'check_exceptions',
|
|
45
|
+
'complexity': 'check_complexity',
|
|
46
|
+
'performance': 'check_performance',
|
|
47
|
+
'imports': 'check_imports',
|
|
48
|
+
'style': 'check_style',
|
|
49
|
+
'unused': 'check_unused',
|
|
50
|
+
'reliability': 'check_reliability',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Always surface these even when report_errors is false (fail closed).
|
|
54
|
+
ALWAYS_SHOW_CODES = frozenset({'IO001', 'SYN001'})
|
|
55
|
+
|
|
56
|
+
EVAL_EXEC_NAMES = frozenset({
|
|
57
|
+
'eval',
|
|
58
|
+
'exec',
|
|
59
|
+
'builtins.eval',
|
|
60
|
+
'builtins.exec',
|
|
61
|
+
'__builtins__.eval',
|
|
62
|
+
'__builtins__.exec',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
PICKLE_LOAD_NAMES = frozenset({
|
|
66
|
+
'pickle.loads', 'pickle.load',
|
|
67
|
+
'_pickle.loads', '_pickle.load',
|
|
68
|
+
'dill.loads', 'dill.load',
|
|
69
|
+
'cloudpickle.loads', 'cloudpickle.load',
|
|
70
|
+
'jsonpickle.decode',
|
|
71
|
+
'marshal.loads', 'marshal.load',
|
|
72
|
+
'shelve.open',
|
|
73
|
+
'yaml.unsafe_load', 'yaml.unsafe_load_all',
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
UNPICKLER_NAMES = frozenset({'pickle.Unpickler', '_pickle.Unpickler', 'dill.Unpickler'})
|
|
77
|
+
|
|
78
|
+
# yaml.load needs an explicit safe Loader; these loaders build arbitrary objects.
|
|
79
|
+
YAML_LOAD_NAMES = frozenset({'yaml.load', 'yaml.load_all'})
|
|
80
|
+
YAML_UNSAFE_LOADERS = frozenset({'yaml.Loader', 'yaml.UnsafeLoader'})
|
|
81
|
+
|
|
82
|
+
SUBPROCESS_SHELL_NAMES = frozenset({
|
|
83
|
+
'subprocess.call',
|
|
84
|
+
'subprocess.run',
|
|
85
|
+
'subprocess.Popen',
|
|
86
|
+
'subprocess.check_call',
|
|
87
|
+
'subprocess.check_output',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
# These always run their argument through a shell.
|
|
91
|
+
ALWAYS_SHELL_NAMES = frozenset({
|
|
92
|
+
'os.system',
|
|
93
|
+
'os.popen',
|
|
94
|
+
'subprocess.getoutput',
|
|
95
|
+
'subprocess.getstatusoutput',
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
MUTABLE_CTOR_NAMES = frozenset({
|
|
99
|
+
'list',
|
|
100
|
+
'dict',
|
|
101
|
+
'set',
|
|
102
|
+
'builtins.list',
|
|
103
|
+
'builtins.dict',
|
|
104
|
+
'builtins.set',
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
OPEN_NAMES = frozenset({'open', 'builtins.open', 'io.open'})
|
|
108
|
+
|
|
109
|
+
TAINT_CALL_NAMES = frozenset({
|
|
110
|
+
'input',
|
|
111
|
+
'builtins.input',
|
|
112
|
+
'os.getenv',
|
|
113
|
+
'os.environ.get',
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
TAINT_ATTR_NAMES = frozenset({
|
|
117
|
+
'sys.argv',
|
|
118
|
+
'os.environ',
|
|
119
|
+
'sys.stdin',
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
REQUEST_TAINT_ATTRS = frozenset({
|
|
123
|
+
'args', 'GET', 'POST', 'json', 'data', 'form', 'cookies', 'headers',
|
|
124
|
+
'values', 'query_params', 'query', 'params', 'body', 'files',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
# REQUEST_TAINT_ATTRS only count when read from something that looks like an
|
|
128
|
+
# HTTP request object (``request.args``, ``req.json``, ``self.request.GET``).
|
|
129
|
+
REQUEST_RECEIVER_NAMES = frozenset({'request', 'req', 'flask_request', 'http_request'})
|
|
130
|
+
|
|
131
|
+
# Calls that neutralize their input for the sinks we check (shell / eval).
|
|
132
|
+
SANITIZER_NAMES = frozenset({
|
|
133
|
+
'int', 'float', 'bool', 'len',
|
|
134
|
+
'builtins.int', 'builtins.float', 'builtins.bool', 'builtins.len',
|
|
135
|
+
'shlex.quote', 'shlex.join',
|
|
136
|
+
're.escape', 'html.escape',
|
|
137
|
+
'urllib.parse.quote', 'urllib.parse.quote_plus',
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
# Wrappers that take ownership of a file handle's lifetime.
|
|
141
|
+
HANDLE_OWNER_NAMES = frozenset({'contextlib.closing'})
|
|
142
|
+
HANDLE_OWNER_METHODS = frozenset({'enter_context', 'enter_async_context'})
|
|
143
|
+
|
|
144
|
+
_TEST_FILE_DIRS = frozenset({'test', 'tests', 'testing'})
|
|
145
|
+
|
|
146
|
+
_BROAD_EXCEPTION_NAMES = frozenset({'Exception', 'builtins.Exception'})
|
|
147
|
+
|
|
148
|
+
ABSTRACT_DECORATORS = frozenset({
|
|
149
|
+
'abstractmethod',
|
|
150
|
+
'abc.abstractmethod',
|
|
151
|
+
'overload',
|
|
152
|
+
'typing.overload',
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
ISSUE_SUGGESTIONS = {
|
|
156
|
+
'SEC001': 'Do not evaluate untrusted strings; use ast.literal_eval or a real parser.',
|
|
157
|
+
'SEC002': 'Avoid pickle/marshal/unsafe YAML for untrusted data; use json, yaml.safe_load, '
|
|
158
|
+
'or a dedicated serializer.',
|
|
159
|
+
'SEC003': 'Pass a sequence of arguments with shell=False.',
|
|
160
|
+
'SEC004': 'Pass a fixed executable and argument list, not a user-built command string.',
|
|
161
|
+
'BUG001': 'Use None as the default and create the mutable object inside the function.',
|
|
162
|
+
'BUG002': 'Remove or rename the duplicate key.',
|
|
163
|
+
'EXC001': "Catch specific exceptions, or use 'except Exception:' if you must.",
|
|
164
|
+
'EXC003': 'Log, re-raise, or handle the error; do not use a bare pass.',
|
|
165
|
+
'EXC005': "Use 'raise NewError(...) from exc' to chain the original exception.",
|
|
166
|
+
'IMP001': 'Import only the names you need.',
|
|
167
|
+
'IMP002': 'Remove the unused import.',
|
|
168
|
+
'IMP003': 'Import a name that the sibling module actually defines, or add that name.',
|
|
169
|
+
'RES001': "Use 'with open(...) as handle:'.",
|
|
170
|
+
'ASY001': 'Await a coroutine, or make the function synchronous.',
|
|
171
|
+
'VAR001': 'Remove the unused name, or prefix it with _ if it is intentional.',
|
|
172
|
+
'VAR002': 'Remove the unused argument, or prefix it with _ if it is required by an API.',
|
|
173
|
+
'PERF001': 'Use enumerate() to get both index and value.',
|
|
174
|
+
'STYLE001': "Write 'if flag:' or 'if not flag:'.",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
NESTED_SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
|
|
178
|
+
_TRY_NODES: Tuple[type, ...] = tuple(
|
|
179
|
+
node_type for node_type in (ast.Try, getattr(ast, 'TryStar', None)) if node_type is not None
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
SKIP_UNUSED_NAMES = frozenset({'self', 'cls', 'mcs', 'mcls'})
|
|
183
|
+
_BUILTIN_NAMES = frozenset(name for name in dir(builtins) if not name.startswith('_'))
|
|
184
|
+
|
|
185
|
+
SKIP_DIR_NAMES = frozenset({
|
|
186
|
+
'.git', '.hg', '.svn', '.tox', '.nox',
|
|
187
|
+
'.mypy_cache', '.pytest_cache', '.ruff_cache', '.coverage',
|
|
188
|
+
'__pycache__', 'site-packages', 'dist', 'build', 'htmlcov',
|
|
189
|
+
'node_modules', '.venv', 'venv', 'codesnake-venv', 'env', '.env',
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
_NOQA_RE = re.compile(
|
|
193
|
+
r'#\s*(?:noqa|codesnake:\s*ignore)\b(?:\s*[:=]\s*([^\n#]+))?',
|
|
194
|
+
re.IGNORECASE,
|
|
195
|
+
)
|
|
196
|
+
_CODING_COOKIE_RE = re.compile(
|
|
197
|
+
br'^[ \t\f]*#.*?coding[:=][ \t]*([-\w.]+)',
|
|
198
|
+
re.IGNORECASE,
|
|
199
|
+
)
|
|
200
|
+
_TYPE_CHECKING_NAMES = frozenset({'TYPE_CHECKING', 'typing.TYPE_CHECKING'})
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Binding kinds that participate in VAR003 shadow detection.
|
|
204
|
+
_SHADOW_CHECKED_KINDS = frozenset({
|
|
205
|
+
'assign', 'arg', 'vararg', 'function', 'decorated_function',
|
|
206
|
+
'loop', 'unpack', 'annotation',
|
|
207
|
+
})
|
|
208
|
+
# Binding kinds reported as unused locals (VAR001). Loop targets, tuple
|
|
209
|
+
# unpacking, bare annotations, and decorated nested functions are exempt.
|
|
210
|
+
_REPORTED_LOCAL_KINDS = frozenset({'assign', 'function', 'class'})
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ConfigError(Exception):
|
|
214
|
+
"""Raised when a configuration file cannot be loaded."""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class _Binding:
|
|
219
|
+
name: str
|
|
220
|
+
line: int
|
|
221
|
+
col: int
|
|
222
|
+
kind: str # import, arg, assign, function, class
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class _Scope:
|
|
226
|
+
__slots__ = ('kind', 'name', 'bindings', 'used', 'global_names',
|
|
227
|
+
'nonlocal_names', 'constants', 'tainted')
|
|
228
|
+
|
|
229
|
+
def __init__(self, kind: str, name: str = ''):
|
|
230
|
+
self.kind = kind
|
|
231
|
+
self.name = name
|
|
232
|
+
self.bindings: Dict[str, _Binding] = {}
|
|
233
|
+
self.used: Set[str] = set()
|
|
234
|
+
self.global_names: Set[str] = set()
|
|
235
|
+
self.nonlocal_names: Set[str] = set()
|
|
236
|
+
self.constants: Dict[str, Any] = {}
|
|
237
|
+
self.tainted: Set[str] = set()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass
|
|
241
|
+
class _GitIgnorePattern:
|
|
242
|
+
negated: bool
|
|
243
|
+
dir_only: bool
|
|
244
|
+
regex: Pattern[str]
|
|
245
|
+
|
|
246
|
+
def matches(self, rel: str, is_dir: bool) -> bool:
|
|
247
|
+
if self.dir_only and not is_dir:
|
|
248
|
+
return False
|
|
249
|
+
return self.regex.search(rel) is not None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _gitignore_glob_to_regex(pattern: str, anchored: bool) -> Pattern[str]:
|
|
253
|
+
parts: List[str] = []
|
|
254
|
+
i = 0
|
|
255
|
+
while i < len(pattern):
|
|
256
|
+
if pattern.startswith('**/', i):
|
|
257
|
+
parts.append('(?:.*/)?')
|
|
258
|
+
i += 3
|
|
259
|
+
elif pattern.startswith('**', i):
|
|
260
|
+
parts.append('.*')
|
|
261
|
+
i += 2
|
|
262
|
+
elif pattern[i] == '*':
|
|
263
|
+
parts.append('[^/]*')
|
|
264
|
+
i += 1
|
|
265
|
+
elif pattern[i] == '?':
|
|
266
|
+
parts.append('[^/]')
|
|
267
|
+
i += 1
|
|
268
|
+
else:
|
|
269
|
+
parts.append(re.escape(pattern[i]))
|
|
270
|
+
i += 1
|
|
271
|
+
body = ''.join(parts)
|
|
272
|
+
if anchored:
|
|
273
|
+
regex = '^' + body + '(?:/.*)?$'
|
|
274
|
+
else:
|
|
275
|
+
regex = r'(?:^|/)' + body + r'(?:/.*)?$'
|
|
276
|
+
return re.compile(regex)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _parse_gitignore_text(text: str) -> List[_GitIgnorePattern]:
|
|
280
|
+
parsed: List[_GitIgnorePattern] = []
|
|
281
|
+
for raw in text.splitlines():
|
|
282
|
+
line = raw.rstrip()
|
|
283
|
+
if not line or line.startswith('#'):
|
|
284
|
+
continue
|
|
285
|
+
negated = line.startswith('!')
|
|
286
|
+
if negated:
|
|
287
|
+
line = line[1:]
|
|
288
|
+
dir_only = line.endswith('/')
|
|
289
|
+
if dir_only:
|
|
290
|
+
line = line[:-1]
|
|
291
|
+
if line.startswith('/'):
|
|
292
|
+
line = line[1:]
|
|
293
|
+
if not line:
|
|
294
|
+
continue
|
|
295
|
+
# git anchors a pattern to the .gitignore's own directory when it holds
|
|
296
|
+
# a separator anywhere but the end; only a bare name matches at any depth.
|
|
297
|
+
anchored = '/' in line
|
|
298
|
+
parsed.append(_GitIgnorePattern(
|
|
299
|
+
negated=negated,
|
|
300
|
+
dir_only=dir_only,
|
|
301
|
+
regex=_gitignore_glob_to_regex(line, anchored),
|
|
302
|
+
))
|
|
303
|
+
return parsed
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
_DirectoryView = List[Tuple[str, List[_GitIgnorePattern]]]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class _IgnoreStack:
|
|
310
|
+
"""Every .gitignore that applies to the walk, keyed by the directory it lives in.
|
|
311
|
+
|
|
312
|
+
Paths handed in must already be resolved (they come from ``os.walk`` over a
|
|
313
|
+
resolved root), so no per-file ``resolve()`` syscalls are needed.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
def __init__(self) -> None:
|
|
317
|
+
self._entries: List[Tuple[Path, List[_GitIgnorePattern]]] = []
|
|
318
|
+
self._loaded: Set[Path] = set()
|
|
319
|
+
|
|
320
|
+
def add_gitignore(self, gi_path: Path) -> None:
|
|
321
|
+
if gi_path in self._loaded:
|
|
322
|
+
return
|
|
323
|
+
self._loaded.add(gi_path)
|
|
324
|
+
try:
|
|
325
|
+
text = gi_path.read_text(encoding='utf-8')
|
|
326
|
+
except OSError:
|
|
327
|
+
return
|
|
328
|
+
self._entries.append((gi_path.parent, _parse_gitignore_text(text)))
|
|
329
|
+
|
|
330
|
+
def view(self, directory: Path) -> _DirectoryView:
|
|
331
|
+
"""Per-.gitignore relative prefixes for ``directory``, computed once per directory."""
|
|
332
|
+
views: _DirectoryView = []
|
|
333
|
+
for base, patterns in self._entries:
|
|
334
|
+
try:
|
|
335
|
+
rel = directory.relative_to(base).as_posix()
|
|
336
|
+
except ValueError:
|
|
337
|
+
continue
|
|
338
|
+
views.append(('' if rel == '.' else rel + '/', patterns))
|
|
339
|
+
return views
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def ignored(views: _DirectoryView, name: str, is_dir: bool) -> bool:
|
|
343
|
+
ignored = False
|
|
344
|
+
for prefix, patterns in views:
|
|
345
|
+
rel = prefix + name
|
|
346
|
+
for pattern in patterns:
|
|
347
|
+
if pattern.matches(rel, is_dir):
|
|
348
|
+
ignored = not pattern.negated
|
|
349
|
+
return ignored
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
_REPO_MARKERS = ('.git', '.hg', '.svn')
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def find_repo_root(start: Path) -> Optional[Path]:
|
|
356
|
+
"""Nearest directory at or above ``start`` that contains a VCS marker."""
|
|
357
|
+
for candidate in (start, *start.parents):
|
|
358
|
+
if any((candidate / marker).exists() for marker in _REPO_MARKERS):
|
|
359
|
+
return candidate
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def detect_source_encoding(data: bytes) -> str:
|
|
364
|
+
"""PEP 263 encoding cookie, else UTF-8 (with BOM)."""
|
|
365
|
+
if data.startswith(b'\xef\xbb\xbf'):
|
|
366
|
+
return 'utf-8-sig'
|
|
367
|
+
chunks = data.split(b'\n', 2)[:2]
|
|
368
|
+
for raw_line in chunks:
|
|
369
|
+
match = _CODING_COOKIE_RE.match(raw_line.replace(b'\r', b''))
|
|
370
|
+
if match:
|
|
371
|
+
encoding = match.group(1).decode('ascii', errors='replace')
|
|
372
|
+
if encoding.lower() in ('utf-8', 'utf8'):
|
|
373
|
+
return 'utf-8'
|
|
374
|
+
return encoding
|
|
375
|
+
return 'utf-8'
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def read_python_source(path: Path) -> str:
|
|
379
|
+
data = path.read_bytes()
|
|
380
|
+
encoding = detect_source_encoding(data)
|
|
381
|
+
return data.decode(encoding)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def ignored_codes_on_line(line: str) -> Optional[Set[str]]:
|
|
385
|
+
"""None = no pragma; empty set = ignore all codes; otherwise specific codes."""
|
|
386
|
+
match = _NOQA_RE.search(line)
|
|
387
|
+
if not match:
|
|
388
|
+
return None
|
|
389
|
+
spec = match.group(1)
|
|
390
|
+
if not spec:
|
|
391
|
+
return set()
|
|
392
|
+
return {part.strip().upper() for part in spec.replace(',', ' ').split() if part.strip()}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def issue_ignored_by_pragma(code: str, line: int, source_lines: Sequence[str]) -> bool:
|
|
396
|
+
"""True if a ``# noqa`` / ``# codesnake: ignore`` pragma on ``line`` covers ``code``."""
|
|
397
|
+
if line <= 0 or line > len(source_lines):
|
|
398
|
+
return False
|
|
399
|
+
codes = ignored_codes_on_line(source_lines[line - 1])
|
|
400
|
+
if codes is None:
|
|
401
|
+
return False
|
|
402
|
+
if not codes:
|
|
403
|
+
return True
|
|
404
|
+
return code.upper() in codes
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def iter_python_files(root: Path) -> Iterable[Path]:
|
|
408
|
+
"""Yield .py files under root, skipping venvs, caches, and .gitignore matches."""
|
|
409
|
+
try:
|
|
410
|
+
root_resolved = root.resolve()
|
|
411
|
+
except OSError:
|
|
412
|
+
root_resolved = root
|
|
413
|
+
ignore = _IgnoreStack()
|
|
414
|
+
# .gitignore files between the repository root and the target apply to
|
|
415
|
+
# everything below them, so load them first (top-most first).
|
|
416
|
+
chain = [root_resolved]
|
|
417
|
+
repo_root = find_repo_root(root_resolved)
|
|
418
|
+
if repo_root is not None and repo_root != root_resolved:
|
|
419
|
+
for parent in root_resolved.parents:
|
|
420
|
+
chain.append(parent)
|
|
421
|
+
if parent == repo_root:
|
|
422
|
+
break
|
|
423
|
+
for directory in reversed(chain):
|
|
424
|
+
gi_path = directory / '.gitignore'
|
|
425
|
+
if gi_path.is_file():
|
|
426
|
+
ignore.add_gitignore(gi_path)
|
|
427
|
+
|
|
428
|
+
for dirpath, dirnames, filenames in os.walk(root_resolved):
|
|
429
|
+
current = Path(dirpath)
|
|
430
|
+
nested_gi = current / '.gitignore'
|
|
431
|
+
if nested_gi.is_file():
|
|
432
|
+
ignore.add_gitignore(nested_gi)
|
|
433
|
+
views = ignore.view(current)
|
|
434
|
+
|
|
435
|
+
dirnames[:] = [
|
|
436
|
+
name for name in dirnames
|
|
437
|
+
if name not in SKIP_DIR_NAMES
|
|
438
|
+
and not name.endswith('.egg-info')
|
|
439
|
+
and not _IgnoreStack.ignored(views, name, is_dir=True)
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
for name in filenames:
|
|
443
|
+
if name.endswith('.py') and not _IgnoreStack.ignored(views, name, is_dir=False):
|
|
444
|
+
yield current / name
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@dataclass
|
|
448
|
+
class Issue:
|
|
449
|
+
"""Represents a code issue found during analysis."""
|
|
450
|
+
severity: str # 'error', 'warning', 'info'
|
|
451
|
+
category: str
|
|
452
|
+
message: str
|
|
453
|
+
line: int
|
|
454
|
+
col: int
|
|
455
|
+
code: str # Issue code like 'SEC001', 'PERF001', etc.
|
|
456
|
+
filename: str = ''
|
|
457
|
+
source: str = 'codesnake'
|
|
458
|
+
end_line: int = 0
|
|
459
|
+
end_col: int = 0
|
|
460
|
+
suggestion: str = ''
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@dataclass
|
|
464
|
+
class CheckerConfig:
|
|
465
|
+
"""Analysis thresholds and category toggles. JSON is the source of truth."""
|
|
466
|
+
max_function_length: int = 50
|
|
467
|
+
max_function_params: int = 7
|
|
468
|
+
max_complexity: int = 10
|
|
469
|
+
max_class_methods: int = 20
|
|
470
|
+
max_instance_vars: int = 10
|
|
471
|
+
check_security: bool = True
|
|
472
|
+
check_bugs: bool = True
|
|
473
|
+
check_exceptions: bool = True
|
|
474
|
+
check_complexity: bool = True
|
|
475
|
+
check_performance: bool = True
|
|
476
|
+
check_imports: bool = True
|
|
477
|
+
check_style: bool = True
|
|
478
|
+
check_unused: bool = True
|
|
479
|
+
check_reliability: bool = True
|
|
480
|
+
use_bandit: bool = False
|
|
481
|
+
report_errors: bool = True
|
|
482
|
+
report_warnings: bool = True
|
|
483
|
+
report_info: bool = True
|
|
484
|
+
|
|
485
|
+
@classmethod
|
|
486
|
+
def from_file(cls, path: str) -> 'CheckerConfig':
|
|
487
|
+
"""Load ``.codesnake.json`` or a ``pyproject.toml`` with a ``[tool.codesnake]`` table."""
|
|
488
|
+
config_path = Path(path)
|
|
489
|
+
if config_path.suffix.lower() == '.toml':
|
|
490
|
+
if not config_path.is_file():
|
|
491
|
+
raise ConfigError(f"Config file '{path}' not found")
|
|
492
|
+
table = _pyproject_config(config_path)
|
|
493
|
+
if table is None:
|
|
494
|
+
raise ConfigError(f"'{path}' has no [tool.codesnake] table")
|
|
495
|
+
return cls.from_mapping(table, path)
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
raw = config_path.read_text(encoding='utf-8')
|
|
499
|
+
except FileNotFoundError:
|
|
500
|
+
raise ConfigError(f"Config file '{path}' not found") from None
|
|
501
|
+
except OSError as exc:
|
|
502
|
+
raise ConfigError(f"Could not read config file '{path}': {exc}") from exc
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
data = json.loads(raw)
|
|
506
|
+
except json.JSONDecodeError as exc:
|
|
507
|
+
raise ConfigError(f"Invalid JSON in '{path}': {exc}") from exc
|
|
508
|
+
|
|
509
|
+
if not isinstance(data, dict):
|
|
510
|
+
raise ConfigError(f"Config file '{path}' must contain a JSON object")
|
|
511
|
+
return cls.from_mapping(data, path)
|
|
512
|
+
|
|
513
|
+
@classmethod
|
|
514
|
+
def from_mapping(cls, data: Dict[str, Any], source: str = '<config>') -> 'CheckerConfig':
|
|
515
|
+
"""Validate key names and value types; ``source`` names the origin in messages."""
|
|
516
|
+
path = source
|
|
517
|
+
known = {item.name: item for item in fields(cls)}
|
|
518
|
+
unknown = sorted(key for key in data if key not in known)
|
|
519
|
+
if unknown:
|
|
520
|
+
print(
|
|
521
|
+
f"Warning: unknown config key(s) in '{path}': {', '.join(unknown)}",
|
|
522
|
+
file=sys.stderr,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
kwargs: Dict[str, Any] = {}
|
|
526
|
+
problems: List[str] = []
|
|
527
|
+
for key, value in data.items():
|
|
528
|
+
field = known.get(key)
|
|
529
|
+
if field is None:
|
|
530
|
+
continue
|
|
531
|
+
expected = type(field.default)
|
|
532
|
+
if expected is bool:
|
|
533
|
+
valid = isinstance(value, bool)
|
|
534
|
+
else:
|
|
535
|
+
valid = isinstance(value, int) and not isinstance(value, bool)
|
|
536
|
+
if not valid:
|
|
537
|
+
problems.append(
|
|
538
|
+
f"'{key}' must be {expected.__name__}, got {type(value).__name__}"
|
|
539
|
+
)
|
|
540
|
+
continue
|
|
541
|
+
# A threshold below 1 flags every function in the tree, which reads
|
|
542
|
+
# as a broken tool rather than a broken config.
|
|
543
|
+
if expected is int and value < 1:
|
|
544
|
+
problems.append(f"'{key}' must be 1 or greater, got {value}")
|
|
545
|
+
continue
|
|
546
|
+
kwargs[key] = value
|
|
547
|
+
if problems:
|
|
548
|
+
raise ConfigError(f"Invalid config values in '{path}': " + '; '.join(problems))
|
|
549
|
+
return cls(**kwargs)
|
|
550
|
+
|
|
551
|
+
def to_file(self, path: str) -> None:
|
|
552
|
+
output = Path(path)
|
|
553
|
+
output.write_text(json.dumps(asdict(self), indent=2) + '\n', encoding='utf-8')
|
|
554
|
+
|
|
555
|
+
def allows_severity(self, severity: str) -> bool:
|
|
556
|
+
if severity == 'error':
|
|
557
|
+
return self.report_errors
|
|
558
|
+
if severity == 'warning':
|
|
559
|
+
return self.report_warnings
|
|
560
|
+
if severity == 'info':
|
|
561
|
+
return self.report_info
|
|
562
|
+
return True
|
|
563
|
+
|
|
564
|
+
def allows_category(self, category: str) -> bool:
|
|
565
|
+
flag = CATEGORY_FLAGS.get(category)
|
|
566
|
+
if flag is None:
|
|
567
|
+
return True
|
|
568
|
+
return bool(getattr(self, flag))
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
CONFIG_FILENAME = '.codesnake.json'
|
|
572
|
+
PYPROJECT_FILENAME = 'pyproject.toml'
|
|
573
|
+
_TOOL_TABLE_RE = re.compile(rb'^\s*\[tool\.codesnake\]', re.MULTILINE)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _pyproject_config(pyproject: Path) -> Optional[Dict[str, Any]]:
|
|
577
|
+
"""The ``[tool.codesnake]`` table of ``pyproject`` or None when absent."""
|
|
578
|
+
try:
|
|
579
|
+
raw = pyproject.read_bytes()
|
|
580
|
+
except OSError:
|
|
581
|
+
return None
|
|
582
|
+
if tomllib is None: # pragma: no cover - Python 3.10 only
|
|
583
|
+
if _TOOL_TABLE_RE.search(raw):
|
|
584
|
+
print(
|
|
585
|
+
f"Warning: '{pyproject}' has a [tool.codesnake] table but reading it "
|
|
586
|
+
"needs Python 3.11+; using defaults",
|
|
587
|
+
file=sys.stderr,
|
|
588
|
+
)
|
|
589
|
+
return None
|
|
590
|
+
try:
|
|
591
|
+
data = tomllib.loads(raw.decode('utf-8'))
|
|
592
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
593
|
+
raise ConfigError(f"Invalid TOML in '{pyproject}': {exc}") from exc
|
|
594
|
+
tool = data.get('tool')
|
|
595
|
+
if not isinstance(tool, dict) or 'codesnake' not in tool:
|
|
596
|
+
return None
|
|
597
|
+
table = tool['codesnake']
|
|
598
|
+
if not isinstance(table, dict):
|
|
599
|
+
raise ConfigError(f"[tool.codesnake] in '{pyproject}' must be a table")
|
|
600
|
+
return table
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def discover_config_file(start: Optional[Path] = None) -> Optional[Path]:
|
|
604
|
+
"""Find the config that applies to ``start`` (default: cwd).
|
|
605
|
+
|
|
606
|
+
Walks upward, stopping after the repository root. In each directory a
|
|
607
|
+
``.codesnake.json`` wins over a ``pyproject.toml`` ``[tool.codesnake]`` table.
|
|
608
|
+
"""
|
|
609
|
+
here = (start or Path.cwd()).resolve()
|
|
610
|
+
repo_root = find_repo_root(here)
|
|
611
|
+
for directory in (here, *here.parents):
|
|
612
|
+
json_path = directory / CONFIG_FILENAME
|
|
613
|
+
if json_path.is_file():
|
|
614
|
+
return json_path
|
|
615
|
+
pyproject = directory / PYPROJECT_FILENAME
|
|
616
|
+
if pyproject.is_file() and _pyproject_config(pyproject) is not None:
|
|
617
|
+
return pyproject
|
|
618
|
+
if repo_root is not None and directory == repo_root:
|
|
619
|
+
break
|
|
620
|
+
return None
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def load_config(path: Optional[str] = None, start: Optional[Path] = None) -> CheckerConfig:
|
|
624
|
+
"""Explicit ``path`` if given; else the nearest discovered config; else defaults."""
|
|
625
|
+
if path:
|
|
626
|
+
return CheckerConfig.from_file(path)
|
|
627
|
+
found = discover_config_file(start)
|
|
628
|
+
if found is None:
|
|
629
|
+
return CheckerConfig()
|
|
630
|
+
return CheckerConfig.from_file(str(found))
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
class SemanticChecker(ast.NodeVisitor):
|
|
634
|
+
"""Main semantic checker that analyzes Python AST for issues."""
|
|
635
|
+
|
|
636
|
+
def __init__(
|
|
637
|
+
self,
|
|
638
|
+
source_code: str,
|
|
639
|
+
filename: str = '<string>',
|
|
640
|
+
config: Optional[CheckerConfig] = None,
|
|
641
|
+
known_exports: Optional[Dict[str, Set[str]]] = None,
|
|
642
|
+
):
|
|
643
|
+
self.source_code = source_code
|
|
644
|
+
self.filename = filename
|
|
645
|
+
self.config = config or CheckerConfig()
|
|
646
|
+
self.issues: List[Issue] = []
|
|
647
|
+
self.source_lines = source_code.split('\n')
|
|
648
|
+
|
|
649
|
+
self.aliases: Dict[str, str] = {}
|
|
650
|
+
# Cyclomatic complexity per function name (nested scopes not charged).
|
|
651
|
+
self.function_complexity: Dict[str, int] = {}
|
|
652
|
+
self.scopes: List[_Scope] = []
|
|
653
|
+
self._in_type_checking = False
|
|
654
|
+
self._with_expr_ids: Set[int] = set()
|
|
655
|
+
# id(Name node) -> binding kind for stores that are not plain assignments
|
|
656
|
+
# (loop targets, tuple unpacking, bare annotations).
|
|
657
|
+
self._store_kinds: Dict[int, str] = {}
|
|
658
|
+
# Function bodies are analyzed after the enclosing scope is fully bound,
|
|
659
|
+
# so closures may reference names assigned later in that scope.
|
|
660
|
+
self._deferred: List[Tuple[ast.AST, List[_Scope], bool]] = []
|
|
661
|
+
self.known_exports = known_exports or {}
|
|
662
|
+
|
|
663
|
+
def add_issue(
|
|
664
|
+
self,
|
|
665
|
+
severity: str,
|
|
666
|
+
category: str,
|
|
667
|
+
message: str,
|
|
668
|
+
node: ast.AST,
|
|
669
|
+
code: str,
|
|
670
|
+
):
|
|
671
|
+
"""Add an issue at ``node``'s location if its category is enabled."""
|
|
672
|
+
self._record_issue(
|
|
673
|
+
severity,
|
|
674
|
+
category,
|
|
675
|
+
message,
|
|
676
|
+
getattr(node, 'lineno', 0) or 0,
|
|
677
|
+
getattr(node, 'col_offset', 0) or 0,
|
|
678
|
+
code,
|
|
679
|
+
end_line=getattr(node, 'end_lineno', None) or getattr(node, 'lineno', 0) or 0,
|
|
680
|
+
end_col=getattr(node, 'end_col_offset', None) or getattr(node, 'col_offset', 0) or 0,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
def _record_issue(
|
|
684
|
+
self,
|
|
685
|
+
severity: str,
|
|
686
|
+
category: str,
|
|
687
|
+
message: str,
|
|
688
|
+
line: int,
|
|
689
|
+
col: int,
|
|
690
|
+
code: str,
|
|
691
|
+
source: str = 'codesnake',
|
|
692
|
+
end_line: int = 0,
|
|
693
|
+
end_col: int = 0,
|
|
694
|
+
suggestion: str = '',
|
|
695
|
+
) -> None:
|
|
696
|
+
if not self.config.allows_category(category):
|
|
697
|
+
return
|
|
698
|
+
end_line = end_line or line
|
|
699
|
+
col = self._char_col(line, col)
|
|
700
|
+
end_col = self._char_col(end_line, end_col) if end_col else col
|
|
701
|
+
self.issues.append(Issue(
|
|
702
|
+
severity=severity,
|
|
703
|
+
category=category,
|
|
704
|
+
message=message,
|
|
705
|
+
line=line,
|
|
706
|
+
col=col,
|
|
707
|
+
code=code,
|
|
708
|
+
filename=self.filename,
|
|
709
|
+
source=source,
|
|
710
|
+
end_line=end_line,
|
|
711
|
+
end_col=end_col,
|
|
712
|
+
suggestion=suggestion or ISSUE_SUGGESTIONS.get(code, ''),
|
|
713
|
+
))
|
|
714
|
+
|
|
715
|
+
def _char_col(self, line: int, col: int) -> int:
|
|
716
|
+
"""Convert an AST UTF-8 byte offset into a 0-based character offset."""
|
|
717
|
+
if col <= 0 or line <= 0 or line > len(self.source_lines):
|
|
718
|
+
return max(col, 0)
|
|
719
|
+
text = self.source_lines[line - 1]
|
|
720
|
+
if text.isascii():
|
|
721
|
+
return col
|
|
722
|
+
return len(text.encode('utf-8')[:col].decode('utf-8', errors='ignore'))
|
|
723
|
+
|
|
724
|
+
def _current_scope(self) -> Optional[_Scope]:
|
|
725
|
+
return self.scopes[-1] if self.scopes else None
|
|
726
|
+
|
|
727
|
+
def _push_scope(self, kind: str, name: str = '') -> _Scope:
|
|
728
|
+
scope = _Scope(kind, name)
|
|
729
|
+
self.scopes.append(scope)
|
|
730
|
+
return scope
|
|
731
|
+
|
|
732
|
+
def _pop_scope(self) -> Optional[_Scope]:
|
|
733
|
+
if not self.scopes:
|
|
734
|
+
return None
|
|
735
|
+
scope = self.scopes.pop()
|
|
736
|
+
if scope.kind == 'function':
|
|
737
|
+
self._report_unused_locals(scope)
|
|
738
|
+
elif scope.kind == 'module':
|
|
739
|
+
self._report_unused_imports(scope)
|
|
740
|
+
return scope
|
|
741
|
+
|
|
742
|
+
def _bind(self, name: str, node: ast.AST, kind: str) -> None:
|
|
743
|
+
scope = self._current_scope()
|
|
744
|
+
if scope is None:
|
|
745
|
+
return
|
|
746
|
+
if name in scope.global_names:
|
|
747
|
+
if len(self.scopes) > 1:
|
|
748
|
+
self.scopes[0].bindings.setdefault(name, _Binding(
|
|
749
|
+
name,
|
|
750
|
+
getattr(node, 'lineno', 0) or 0,
|
|
751
|
+
getattr(node, 'col_offset', 0) or 0,
|
|
752
|
+
kind,
|
|
753
|
+
))
|
|
754
|
+
return
|
|
755
|
+
if name in scope.nonlocal_names:
|
|
756
|
+
return
|
|
757
|
+
if name not in scope.bindings:
|
|
758
|
+
if kind in _SHADOW_CHECKED_KINDS and scope.kind == 'function':
|
|
759
|
+
self._maybe_shadow(name, node)
|
|
760
|
+
scope.bindings[name] = _Binding(
|
|
761
|
+
name,
|
|
762
|
+
getattr(node, 'lineno', 0) or 0,
|
|
763
|
+
getattr(node, 'col_offset', 0) or 0,
|
|
764
|
+
kind,
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
def _maybe_shadow(self, name: str, node: ast.AST) -> None:
|
|
768
|
+
if name.startswith('_') or name in SKIP_UNUSED_NAMES or name in _BUILTIN_NAMES:
|
|
769
|
+
return
|
|
770
|
+
for scope in reversed(self.scopes[:-1]):
|
|
771
|
+
if scope.kind == 'class' or scope.kind == 'comprehension':
|
|
772
|
+
continue
|
|
773
|
+
if scope.kind == 'module':
|
|
774
|
+
return
|
|
775
|
+
if name in scope.bindings:
|
|
776
|
+
self.add_issue(
|
|
777
|
+
'info',
|
|
778
|
+
'unused',
|
|
779
|
+
f"Name '{name}' shadows a name from an enclosing function",
|
|
780
|
+
node,
|
|
781
|
+
'VAR003',
|
|
782
|
+
)
|
|
783
|
+
return
|
|
784
|
+
|
|
785
|
+
def _mark_used(self, name: str) -> None:
|
|
786
|
+
for scope in reversed(self.scopes):
|
|
787
|
+
if name in scope.nonlocal_names:
|
|
788
|
+
continue
|
|
789
|
+
if name in scope.bindings or name in scope.global_names:
|
|
790
|
+
scope.used.add(name)
|
|
791
|
+
if name in scope.global_names and self.scopes:
|
|
792
|
+
self.scopes[0].used.add(name)
|
|
793
|
+
return
|
|
794
|
+
if self.scopes and name in self.scopes[0].bindings:
|
|
795
|
+
self.scopes[0].used.add(name)
|
|
796
|
+
|
|
797
|
+
def _lookup_const(self, name: str) -> Any:
|
|
798
|
+
for scope in reversed(self.scopes):
|
|
799
|
+
if name in scope.constants:
|
|
800
|
+
return scope.constants[name]
|
|
801
|
+
return None
|
|
802
|
+
|
|
803
|
+
def _set_const(self, name: str, value: Any) -> None:
|
|
804
|
+
scope = self._current_scope()
|
|
805
|
+
if scope is not None:
|
|
806
|
+
scope.constants[name] = value
|
|
807
|
+
|
|
808
|
+
def _mark_name_tainted(self, name: str) -> None:
|
|
809
|
+
for scope in reversed(self.scopes):
|
|
810
|
+
if name in scope.global_names:
|
|
811
|
+
self.scopes[0].tainted.add(name)
|
|
812
|
+
return
|
|
813
|
+
if name in scope.bindings:
|
|
814
|
+
scope.tainted.add(name)
|
|
815
|
+
return
|
|
816
|
+
if self.scopes:
|
|
817
|
+
self.scopes[-1].tainted.add(name)
|
|
818
|
+
|
|
819
|
+
def _name_is_tainted(self, name: str) -> bool:
|
|
820
|
+
for scope in reversed(self.scopes):
|
|
821
|
+
if name in scope.tainted:
|
|
822
|
+
return True
|
|
823
|
+
if name in scope.bindings:
|
|
824
|
+
return False
|
|
825
|
+
return False
|
|
826
|
+
|
|
827
|
+
def _mark_tainted_target(self, target: ast.AST) -> None:
|
|
828
|
+
if isinstance(target, ast.Name):
|
|
829
|
+
self._mark_name_tainted(target.id)
|
|
830
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
831
|
+
for elt in target.elts:
|
|
832
|
+
self._mark_tainted_target(elt)
|
|
833
|
+
elif isinstance(target, ast.Starred):
|
|
834
|
+
self._mark_tainted_target(target.value)
|
|
835
|
+
|
|
836
|
+
def _is_tainted_expr(self, node: Optional[ast.AST]) -> bool:
|
|
837
|
+
if node is None:
|
|
838
|
+
return False
|
|
839
|
+
if isinstance(node, ast.Constant):
|
|
840
|
+
return False
|
|
841
|
+
if isinstance(node, ast.Name):
|
|
842
|
+
return self._name_is_tainted(node.id)
|
|
843
|
+
if isinstance(node, ast.JoinedStr):
|
|
844
|
+
return any(self._is_tainted_expr(value) for value in node.values)
|
|
845
|
+
if isinstance(node, ast.FormattedValue):
|
|
846
|
+
return self._is_tainted_expr(node.value)
|
|
847
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
|
|
848
|
+
return self._is_tainted_expr(node.left) or self._is_tainted_expr(node.right)
|
|
849
|
+
if isinstance(node, ast.Call):
|
|
850
|
+
resolved = self._resolve_name(node.func)
|
|
851
|
+
if resolved in TAINT_CALL_NAMES:
|
|
852
|
+
return True
|
|
853
|
+
if resolved in SANITIZER_NAMES:
|
|
854
|
+
return False
|
|
855
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ('get', 'format'):
|
|
856
|
+
if self._is_tainted_expr(node.func.value):
|
|
857
|
+
return True
|
|
858
|
+
if self._call_args_tainted(node):
|
|
859
|
+
return True
|
|
860
|
+
return self._call_args_tainted(node)
|
|
861
|
+
if isinstance(node, ast.Attribute):
|
|
862
|
+
resolved = self._resolve_name(node)
|
|
863
|
+
if resolved in TAINT_ATTR_NAMES:
|
|
864
|
+
return True
|
|
865
|
+
if node.attr in REQUEST_TAINT_ATTRS and self._is_request_like(node.value):
|
|
866
|
+
return True
|
|
867
|
+
return self._is_tainted_expr(node.value)
|
|
868
|
+
if isinstance(node, ast.Subscript):
|
|
869
|
+
return self._is_tainted_expr(node.value)
|
|
870
|
+
if isinstance(node, ast.Starred):
|
|
871
|
+
return self._is_tainted_expr(node.value)
|
|
872
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
873
|
+
return any(self._is_tainted_expr(elt) for elt in node.elts)
|
|
874
|
+
return False
|
|
875
|
+
|
|
876
|
+
@staticmethod
|
|
877
|
+
def _first_arg(node: ast.Call, keyword: Optional[str] = None) -> Optional[ast.AST]:
|
|
878
|
+
"""The call's first argument, positional or passed by ``keyword``."""
|
|
879
|
+
if node.args:
|
|
880
|
+
first = node.args[0]
|
|
881
|
+
# ``f(*seq)`` says nothing about what lands in position 0.
|
|
882
|
+
return None if isinstance(first, ast.Starred) else first
|
|
883
|
+
if keyword:
|
|
884
|
+
for kw in node.keywords:
|
|
885
|
+
if kw.arg == keyword:
|
|
886
|
+
return kw.value
|
|
887
|
+
return None
|
|
888
|
+
|
|
889
|
+
def _call_args_tainted(self, node: ast.Call) -> bool:
|
|
890
|
+
"""Taint reaches a call through keywords as readily as positionals."""
|
|
891
|
+
if any(self._is_tainted_expr(arg) for arg in node.args):
|
|
892
|
+
return True
|
|
893
|
+
return any(self._is_tainted_expr(kw.value) for kw in node.keywords)
|
|
894
|
+
|
|
895
|
+
@staticmethod
|
|
896
|
+
def _is_request_like(node: ast.AST) -> bool:
|
|
897
|
+
if isinstance(node, ast.Name):
|
|
898
|
+
return node.id in REQUEST_RECEIVER_NAMES
|
|
899
|
+
if isinstance(node, ast.Attribute):
|
|
900
|
+
return node.attr in REQUEST_RECEIVER_NAMES
|
|
901
|
+
return False
|
|
902
|
+
|
|
903
|
+
def _is_literal_expr(self, node: Optional[ast.AST]) -> bool:
|
|
904
|
+
if node is None:
|
|
905
|
+
return False
|
|
906
|
+
if isinstance(node, ast.Constant):
|
|
907
|
+
return True
|
|
908
|
+
if isinstance(node, ast.Name):
|
|
909
|
+
if self._name_is_tainted(node.id):
|
|
910
|
+
return False
|
|
911
|
+
for scope in reversed(self.scopes):
|
|
912
|
+
if node.id in scope.constants:
|
|
913
|
+
return True
|
|
914
|
+
return False
|
|
915
|
+
if isinstance(node, ast.JoinedStr):
|
|
916
|
+
return all(
|
|
917
|
+
isinstance(value, ast.Constant) or (
|
|
918
|
+
isinstance(value, ast.FormattedValue)
|
|
919
|
+
and self._is_literal_expr(value.value)
|
|
920
|
+
)
|
|
921
|
+
for value in node.values
|
|
922
|
+
)
|
|
923
|
+
return False
|
|
924
|
+
|
|
925
|
+
def _report_unused_imports(self, scope: _Scope) -> None:
|
|
926
|
+
for name, binding in scope.bindings.items():
|
|
927
|
+
if binding.kind != 'import':
|
|
928
|
+
continue
|
|
929
|
+
if name in scope.used:
|
|
930
|
+
continue
|
|
931
|
+
self._record_issue(
|
|
932
|
+
'warning',
|
|
933
|
+
'imports',
|
|
934
|
+
f"Imported name '{name}' is unused",
|
|
935
|
+
binding.line,
|
|
936
|
+
binding.col,
|
|
937
|
+
'IMP002',
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
def _report_unused_locals(self, scope: _Scope) -> None:
|
|
941
|
+
for name, binding in scope.bindings.items():
|
|
942
|
+
if name in scope.used:
|
|
943
|
+
continue
|
|
944
|
+
if name in SKIP_UNUSED_NAMES or name.startswith('_'):
|
|
945
|
+
continue
|
|
946
|
+
if binding.kind == 'import':
|
|
947
|
+
self._record_issue(
|
|
948
|
+
'warning',
|
|
949
|
+
'imports',
|
|
950
|
+
f"Imported name '{name}' is unused",
|
|
951
|
+
binding.line,
|
|
952
|
+
binding.col,
|
|
953
|
+
'IMP002',
|
|
954
|
+
)
|
|
955
|
+
elif binding.kind == 'arg':
|
|
956
|
+
self._record_issue(
|
|
957
|
+
'warning',
|
|
958
|
+
'unused',
|
|
959
|
+
f"Unused argument '{name}'",
|
|
960
|
+
binding.line,
|
|
961
|
+
binding.col,
|
|
962
|
+
'VAR002',
|
|
963
|
+
)
|
|
964
|
+
elif binding.kind in _REPORTED_LOCAL_KINDS:
|
|
965
|
+
label = 'nested function' if binding.kind == 'function' else (
|
|
966
|
+
'nested class' if binding.kind == 'class' else 'local variable'
|
|
967
|
+
)
|
|
968
|
+
self._record_issue(
|
|
969
|
+
'warning',
|
|
970
|
+
'unused',
|
|
971
|
+
f"Unused {label} '{name}'",
|
|
972
|
+
binding.line,
|
|
973
|
+
binding.col,
|
|
974
|
+
'VAR001',
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
def _mark_store_kind(self, target: ast.AST, kind: str) -> None:
|
|
978
|
+
"""Tag every Name inside ``target`` so visit_Name binds it as ``kind``."""
|
|
979
|
+
if isinstance(target, ast.Name):
|
|
980
|
+
self._store_kinds[id(target)] = kind
|
|
981
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
982
|
+
for elt in target.elts:
|
|
983
|
+
self._mark_store_kind(elt, kind)
|
|
984
|
+
elif isinstance(target, ast.Starred):
|
|
985
|
+
self._mark_store_kind(target.value, kind)
|
|
986
|
+
|
|
987
|
+
def _is_test_file(self) -> bool:
|
|
988
|
+
path = Path(self.filename)
|
|
989
|
+
name = path.name
|
|
990
|
+
if name.startswith('test_') or name.endswith('_test.py') or name == 'conftest.py':
|
|
991
|
+
return True
|
|
992
|
+
return any(part in _TEST_FILE_DIRS for part in path.parts[:-1])
|
|
993
|
+
|
|
994
|
+
def _is_ignored(self, issue: Issue) -> bool:
|
|
995
|
+
return issue_ignored_by_pragma(issue.code, issue.line, self.source_lines)
|
|
996
|
+
|
|
997
|
+
def _is_type_checking_test(self, test: ast.AST) -> bool:
|
|
998
|
+
resolved = self._resolve_name(test)
|
|
999
|
+
return resolved in _TYPE_CHECKING_NAMES
|
|
1000
|
+
|
|
1001
|
+
def _mark_exported(self, value: Optional[ast.AST]) -> None:
|
|
1002
|
+
"""Treat the string literals in an ``__all__`` value as module-level uses."""
|
|
1003
|
+
if not isinstance(value, (ast.List, ast.Tuple, ast.Set)):
|
|
1004
|
+
return
|
|
1005
|
+
for elt in value.elts:
|
|
1006
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
1007
|
+
self.scopes[0].used.add(elt.value)
|
|
1008
|
+
|
|
1009
|
+
def _at_module_scope(self) -> bool:
|
|
1010
|
+
return bool(self.scopes) and self.scopes[-1].kind == 'module'
|
|
1011
|
+
|
|
1012
|
+
def _record_dunder_all(self, node: ast.Assign) -> None:
|
|
1013
|
+
if not self._at_module_scope():
|
|
1014
|
+
return
|
|
1015
|
+
for target in node.targets:
|
|
1016
|
+
if isinstance(target, ast.Name) and target.id == '__all__':
|
|
1017
|
+
self._mark_exported(node.value)
|
|
1018
|
+
|
|
1019
|
+
def _record_dunder_all_augmented(self, node: ast.AugAssign) -> None:
|
|
1020
|
+
"""``__all__ += [...]``, the usual way a package re-exports submodules."""
|
|
1021
|
+
if not self._at_module_scope():
|
|
1022
|
+
return
|
|
1023
|
+
if isinstance(node.target, ast.Name) and node.target.id == '__all__':
|
|
1024
|
+
self._mark_exported(node.value)
|
|
1025
|
+
|
|
1026
|
+
def _record_dunder_all_call(self, node: ast.Call) -> None:
|
|
1027
|
+
"""``__all__.extend([...])`` / ``__all__.append('name')``."""
|
|
1028
|
+
if not self._at_module_scope():
|
|
1029
|
+
return
|
|
1030
|
+
func = node.func
|
|
1031
|
+
if not isinstance(func, ast.Attribute):
|
|
1032
|
+
return
|
|
1033
|
+
if not isinstance(func.value, ast.Name) or func.value.id != '__all__':
|
|
1034
|
+
return
|
|
1035
|
+
if func.attr == 'extend' and node.args:
|
|
1036
|
+
self._mark_exported(node.args[0])
|
|
1037
|
+
elif func.attr == 'append':
|
|
1038
|
+
for arg in node.args:
|
|
1039
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
1040
|
+
self.scopes[0].used.add(arg.value)
|
|
1041
|
+
|
|
1042
|
+
def _record_constant_assign(self, targets: Sequence[ast.AST], value: ast.AST) -> None:
|
|
1043
|
+
if not isinstance(value, ast.Constant):
|
|
1044
|
+
return
|
|
1045
|
+
for target in targets:
|
|
1046
|
+
if isinstance(target, ast.Name):
|
|
1047
|
+
self._set_const(target.id, value.value)
|
|
1048
|
+
|
|
1049
|
+
def _collect_imports(self, tree: ast.AST) -> None:
|
|
1050
|
+
"""Build a name -> qualified-name map from import statements."""
|
|
1051
|
+
for node in ast.walk(tree):
|
|
1052
|
+
if isinstance(node, ast.Import):
|
|
1053
|
+
for alias in node.names:
|
|
1054
|
+
if alias.asname:
|
|
1055
|
+
self.aliases[alias.asname] = alias.name
|
|
1056
|
+
else:
|
|
1057
|
+
root = alias.name.split('.')[0]
|
|
1058
|
+
self.aliases[root] = root
|
|
1059
|
+
elif isinstance(node, ast.ImportFrom):
|
|
1060
|
+
module = node.module or ''
|
|
1061
|
+
for alias in node.names:
|
|
1062
|
+
if alias.name == '*':
|
|
1063
|
+
continue
|
|
1064
|
+
local = alias.asname or alias.name
|
|
1065
|
+
if module:
|
|
1066
|
+
self.aliases[local] = f'{module}.{alias.name}'
|
|
1067
|
+
else:
|
|
1068
|
+
self.aliases[local] = alias.name
|
|
1069
|
+
|
|
1070
|
+
def _resolve_name(self, node: Optional[ast.AST]) -> Optional[str]:
|
|
1071
|
+
"""Return a dotted name for a Call target, using the import map."""
|
|
1072
|
+
if node is None:
|
|
1073
|
+
return None
|
|
1074
|
+
if isinstance(node, ast.Name):
|
|
1075
|
+
return self.aliases.get(node.id, node.id)
|
|
1076
|
+
if isinstance(node, ast.Attribute):
|
|
1077
|
+
base = self._resolve_name(node.value)
|
|
1078
|
+
if base:
|
|
1079
|
+
return f'{base}.{node.attr}'
|
|
1080
|
+
return node.attr
|
|
1081
|
+
return None
|
|
1082
|
+
|
|
1083
|
+
def _is_abstract(self, node: ast.AST) -> bool:
|
|
1084
|
+
for decorator in getattr(node, 'decorator_list', []):
|
|
1085
|
+
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
1086
|
+
resolved = self._resolve_name(target) or ''
|
|
1087
|
+
if resolved.rsplit('.', 1)[-1] in {'abstractmethod', 'overload'}:
|
|
1088
|
+
return True
|
|
1089
|
+
if resolved in ABSTRACT_DECORATORS:
|
|
1090
|
+
return True
|
|
1091
|
+
return False
|
|
1092
|
+
|
|
1093
|
+
def _is_stub_body(self, node: ast.AST) -> bool:
|
|
1094
|
+
body = list(getattr(node, 'body', []))
|
|
1095
|
+
if (
|
|
1096
|
+
body
|
|
1097
|
+
and isinstance(body[0], ast.Expr)
|
|
1098
|
+
and isinstance(body[0].value, ast.Constant)
|
|
1099
|
+
and isinstance(body[0].value.value, str)
|
|
1100
|
+
):
|
|
1101
|
+
body = body[1:]
|
|
1102
|
+
if not body:
|
|
1103
|
+
return True
|
|
1104
|
+
if len(body) == 1 and isinstance(body[0], ast.Pass):
|
|
1105
|
+
return True
|
|
1106
|
+
if (
|
|
1107
|
+
len(body) == 1
|
|
1108
|
+
and isinstance(body[0], ast.Expr)
|
|
1109
|
+
and isinstance(body[0].value, ast.Constant)
|
|
1110
|
+
and body[0].value.value is ...
|
|
1111
|
+
):
|
|
1112
|
+
return True
|
|
1113
|
+
return False
|
|
1114
|
+
|
|
1115
|
+
def _async_body_has_await(self, node: ast.AST) -> bool:
|
|
1116
|
+
for child in ast.iter_child_nodes(node):
|
|
1117
|
+
if self._subtree_has_await(child):
|
|
1118
|
+
return True
|
|
1119
|
+
return False
|
|
1120
|
+
|
|
1121
|
+
def _subtree_has_await(self, node: ast.AST) -> bool:
|
|
1122
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
|
|
1123
|
+
return False
|
|
1124
|
+
if isinstance(node, (ast.Await, ast.AsyncFor, ast.AsyncWith)):
|
|
1125
|
+
return True
|
|
1126
|
+
return any(self._subtree_has_await(child) for child in ast.iter_child_nodes(node))
|
|
1127
|
+
|
|
1128
|
+
def _iter_raises(self, stmts: Sequence[ast.AST]) -> Iterable[ast.Raise]:
|
|
1129
|
+
for stmt in stmts:
|
|
1130
|
+
if isinstance(stmt, ast.Raise):
|
|
1131
|
+
yield stmt
|
|
1132
|
+
elif isinstance(stmt, ast.If):
|
|
1133
|
+
yield from self._iter_raises(stmt.body)
|
|
1134
|
+
yield from self._iter_raises(stmt.orelse)
|
|
1135
|
+
elif isinstance(stmt, (ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith)):
|
|
1136
|
+
yield from self._iter_raises(stmt.body)
|
|
1137
|
+
yield from self._iter_raises(getattr(stmt, 'orelse', []))
|
|
1138
|
+
elif isinstance(stmt, _TRY_NODES):
|
|
1139
|
+
# A nested handler's own body is checked when visit_Try reaches
|
|
1140
|
+
# that Try, so descending into it here would report twice.
|
|
1141
|
+
yield from self._iter_raises(stmt.body)
|
|
1142
|
+
yield from self._iter_raises(stmt.orelse)
|
|
1143
|
+
yield from self._iter_raises(stmt.finalbody)
|
|
1144
|
+
elif isinstance(stmt, ast.Match):
|
|
1145
|
+
for case in stmt.cases:
|
|
1146
|
+
yield from self._iter_raises(case.body)
|
|
1147
|
+
|
|
1148
|
+
def _check_lossy_raises(self, handler: ast.ExceptHandler) -> None:
|
|
1149
|
+
for raise_node in self._iter_raises(handler.body):
|
|
1150
|
+
if raise_node.exc is None:
|
|
1151
|
+
continue
|
|
1152
|
+
if raise_node.cause is not None:
|
|
1153
|
+
continue
|
|
1154
|
+
if (
|
|
1155
|
+
isinstance(raise_node.exc, ast.Name)
|
|
1156
|
+
and handler.name
|
|
1157
|
+
and raise_node.exc.id == handler.name
|
|
1158
|
+
):
|
|
1159
|
+
continue
|
|
1160
|
+
if isinstance(raise_node.exc, (ast.Call, ast.Name)):
|
|
1161
|
+
self.add_issue(
|
|
1162
|
+
'warning',
|
|
1163
|
+
'exceptions',
|
|
1164
|
+
"Raising a new exception in 'except' hides the original - use 'raise ... from'",
|
|
1165
|
+
raise_node,
|
|
1166
|
+
'EXC005',
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
def _keyword_true(self, node: ast.Call, name: str) -> bool:
|
|
1170
|
+
for keyword in node.keywords:
|
|
1171
|
+
if keyword.arg != name:
|
|
1172
|
+
continue
|
|
1173
|
+
value = keyword.value
|
|
1174
|
+
# Identity on purpose: 1 == True but ``shell=1`` is not what we match.
|
|
1175
|
+
if isinstance(value, ast.Constant) and value.value is True: # noqa: STYLE001
|
|
1176
|
+
return True
|
|
1177
|
+
if isinstance(value, ast.Name) and self._lookup_const(value.id) is True: # noqa: STYLE001
|
|
1178
|
+
return True
|
|
1179
|
+
return False
|
|
1180
|
+
|
|
1181
|
+
# Security Checks
|
|
1182
|
+
|
|
1183
|
+
def visit_Call(self, node: ast.Call):
|
|
1184
|
+
"""Check for security issues in function calls."""
|
|
1185
|
+
self._record_dunder_all_call(node)
|
|
1186
|
+
resolved = self._resolve_name(node.func)
|
|
1187
|
+
|
|
1188
|
+
if resolved in EVAL_EXEC_NAMES:
|
|
1189
|
+
called = resolved.rsplit('.', 1)[-1]
|
|
1190
|
+
arg0 = self._first_arg(node)
|
|
1191
|
+
if arg0 is not None and self._is_tainted_expr(arg0):
|
|
1192
|
+
self.add_issue(
|
|
1193
|
+
'error',
|
|
1194
|
+
'security',
|
|
1195
|
+
f"Dangerous use of '{called}()' on untrusted input",
|
|
1196
|
+
node,
|
|
1197
|
+
'SEC001',
|
|
1198
|
+
)
|
|
1199
|
+
elif arg0 is not None and self._is_literal_expr(arg0):
|
|
1200
|
+
self.add_issue(
|
|
1201
|
+
'info',
|
|
1202
|
+
'security',
|
|
1203
|
+
f"Use of '{called}()' on a constant - avoid eval/exec",
|
|
1204
|
+
node,
|
|
1205
|
+
'SEC001',
|
|
1206
|
+
)
|
|
1207
|
+
else:
|
|
1208
|
+
self.add_issue(
|
|
1209
|
+
'error',
|
|
1210
|
+
'security',
|
|
1211
|
+
f"Dangerous use of '{called}()' - can execute arbitrary code",
|
|
1212
|
+
node,
|
|
1213
|
+
'SEC001',
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
if resolved in PICKLE_LOAD_NAMES:
|
|
1217
|
+
self.add_issue(
|
|
1218
|
+
'warning',
|
|
1219
|
+
'security',
|
|
1220
|
+
f"{resolved}() can execute arbitrary code - use with caution",
|
|
1221
|
+
node,
|
|
1222
|
+
'SEC002',
|
|
1223
|
+
)
|
|
1224
|
+
elif (
|
|
1225
|
+
isinstance(node.func, ast.Attribute)
|
|
1226
|
+
and node.func.attr == 'load'
|
|
1227
|
+
and isinstance(node.func.value, ast.Call)
|
|
1228
|
+
and self._resolve_name(node.func.value.func) in UNPICKLER_NAMES
|
|
1229
|
+
):
|
|
1230
|
+
unpickler = self._resolve_name(node.func.value.func)
|
|
1231
|
+
self.add_issue(
|
|
1232
|
+
'warning',
|
|
1233
|
+
'security',
|
|
1234
|
+
f"{unpickler}(...).load() can execute arbitrary code - use with caution",
|
|
1235
|
+
node,
|
|
1236
|
+
'SEC002',
|
|
1237
|
+
)
|
|
1238
|
+
elif resolved in YAML_LOAD_NAMES:
|
|
1239
|
+
loader = next((kw.value for kw in node.keywords if kw.arg == 'Loader'), None)
|
|
1240
|
+
if loader is None and len(node.args) >= 2:
|
|
1241
|
+
loader = node.args[1]
|
|
1242
|
+
loader_name = self._resolve_name(loader) if loader is not None else None
|
|
1243
|
+
if loader is None or loader_name in YAML_UNSAFE_LOADERS:
|
|
1244
|
+
self.add_issue(
|
|
1245
|
+
'warning',
|
|
1246
|
+
'security',
|
|
1247
|
+
f"{resolved}() without a safe Loader can execute arbitrary code - "
|
|
1248
|
+
"use yaml.safe_load()",
|
|
1249
|
+
node,
|
|
1250
|
+
'SEC002',
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
if resolved in SUBPROCESS_SHELL_NAMES:
|
|
1254
|
+
cmd = self._first_arg(node, 'args')
|
|
1255
|
+
tainted_cmd = cmd is not None and self._is_tainted_expr(cmd)
|
|
1256
|
+
if self._keyword_true(node, 'shell'):
|
|
1257
|
+
if tainted_cmd:
|
|
1258
|
+
self.add_issue(
|
|
1259
|
+
'error',
|
|
1260
|
+
'security',
|
|
1261
|
+
"subprocess with shell=True and untrusted input is command injection",
|
|
1262
|
+
node,
|
|
1263
|
+
'SEC003',
|
|
1264
|
+
)
|
|
1265
|
+
else:
|
|
1266
|
+
self.add_issue(
|
|
1267
|
+
'warning',
|
|
1268
|
+
'security',
|
|
1269
|
+
"subprocess with shell=True is a security risk - use shell=False",
|
|
1270
|
+
node,
|
|
1271
|
+
'SEC003',
|
|
1272
|
+
)
|
|
1273
|
+
elif tainted_cmd:
|
|
1274
|
+
self.add_issue(
|
|
1275
|
+
'warning',
|
|
1276
|
+
'security',
|
|
1277
|
+
"subprocess command built from untrusted input",
|
|
1278
|
+
node,
|
|
1279
|
+
'SEC004',
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1282
|
+
if resolved in ALWAYS_SHELL_NAMES:
|
|
1283
|
+
cmd = self._first_arg(node, 'cmd')
|
|
1284
|
+
if cmd is not None and self._is_tainted_expr(cmd):
|
|
1285
|
+
self.add_issue(
|
|
1286
|
+
'error',
|
|
1287
|
+
'security',
|
|
1288
|
+
f"{resolved}() with untrusted input is command injection",
|
|
1289
|
+
node,
|
|
1290
|
+
'SEC003',
|
|
1291
|
+
)
|
|
1292
|
+
else:
|
|
1293
|
+
self.add_issue(
|
|
1294
|
+
'warning',
|
|
1295
|
+
'security',
|
|
1296
|
+
f"{resolved}() runs its argument through a shell - "
|
|
1297
|
+
"use subprocess with shell=False and an argument list",
|
|
1298
|
+
node,
|
|
1299
|
+
'SEC003',
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
if resolved in HANDLE_OWNER_NAMES or (
|
|
1303
|
+
isinstance(node.func, ast.Attribute) and node.func.attr in HANDLE_OWNER_METHODS
|
|
1304
|
+
):
|
|
1305
|
+
for arg in node.args:
|
|
1306
|
+
self._with_expr_ids.add(id(arg))
|
|
1307
|
+
|
|
1308
|
+
if resolved in OPEN_NAMES and id(node) not in self._with_expr_ids:
|
|
1309
|
+
self.add_issue(
|
|
1310
|
+
'warning',
|
|
1311
|
+
'bugs',
|
|
1312
|
+
"open() should be used as a context manager (with open(...) as ...)",
|
|
1313
|
+
node,
|
|
1314
|
+
'RES001',
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
self.generic_visit(node)
|
|
1318
|
+
|
|
1319
|
+
def _enter_with(self, node: ast.AST) -> None:
|
|
1320
|
+
for item in node.items: # type: ignore[attr-defined]
|
|
1321
|
+
# Any open() anywhere in the context expression (including inside
|
|
1322
|
+
# closing(...) or a helper) is owned by the with statement.
|
|
1323
|
+
for sub in ast.walk(item.context_expr):
|
|
1324
|
+
if isinstance(sub, ast.Call):
|
|
1325
|
+
self._with_expr_ids.add(id(sub))
|
|
1326
|
+
if item.optional_vars is not None:
|
|
1327
|
+
self._mark_store_kind(item.optional_vars, 'unpack')
|
|
1328
|
+
self.generic_visit(node)
|
|
1329
|
+
|
|
1330
|
+
def visit_With(self, node: ast.With):
|
|
1331
|
+
self._enter_with(node)
|
|
1332
|
+
|
|
1333
|
+
def visit_AsyncWith(self, node: ast.AsyncWith):
|
|
1334
|
+
self._enter_with(node)
|
|
1335
|
+
|
|
1336
|
+
_MISSING = object()
|
|
1337
|
+
|
|
1338
|
+
@classmethod
|
|
1339
|
+
def _literal_key(cls, node: Optional[ast.AST]) -> Any:
|
|
1340
|
+
"""The hashable value of a literal key, or ``_MISSING`` if it is not one.
|
|
1341
|
+
|
|
1342
|
+
NaN is treated as non-literal: it never equals itself, so two NaN keys
|
|
1343
|
+
are not a duplicate.
|
|
1344
|
+
"""
|
|
1345
|
+
if isinstance(node, ast.Constant):
|
|
1346
|
+
value = node.value
|
|
1347
|
+
if isinstance(value, float) and value != value:
|
|
1348
|
+
return cls._MISSING
|
|
1349
|
+
if isinstance(value, (str, int, float, bool, bytes, type(None))):
|
|
1350
|
+
return value
|
|
1351
|
+
return cls._MISSING
|
|
1352
|
+
if isinstance(node, ast.Tuple):
|
|
1353
|
+
items = []
|
|
1354
|
+
for elt in node.elts:
|
|
1355
|
+
item = cls._literal_key(elt)
|
|
1356
|
+
if item is cls._MISSING:
|
|
1357
|
+
return cls._MISSING
|
|
1358
|
+
items.append(item)
|
|
1359
|
+
return tuple(items)
|
|
1360
|
+
return cls._MISSING
|
|
1361
|
+
|
|
1362
|
+
def visit_Dict(self, node: ast.Dict):
|
|
1363
|
+
seen: Dict[Any, ast.AST] = {}
|
|
1364
|
+
for key in node.keys:
|
|
1365
|
+
if key is None: # ``**spread``
|
|
1366
|
+
continue
|
|
1367
|
+
value = self._literal_key(key)
|
|
1368
|
+
if value is self._MISSING:
|
|
1369
|
+
continue
|
|
1370
|
+
if value in seen:
|
|
1371
|
+
self.add_issue(
|
|
1372
|
+
'warning',
|
|
1373
|
+
'bugs',
|
|
1374
|
+
f"Duplicate dictionary key {value!r}",
|
|
1375
|
+
key,
|
|
1376
|
+
'BUG002',
|
|
1377
|
+
)
|
|
1378
|
+
else:
|
|
1379
|
+
seen[value] = key
|
|
1380
|
+
self.generic_visit(node)
|
|
1381
|
+
|
|
1382
|
+
def visit_Assert(self, node: ast.Assert):
|
|
1383
|
+
"""Check assert statements (skipped in test files, where assert is the API)."""
|
|
1384
|
+
if self._is_test_file():
|
|
1385
|
+
self.generic_visit(node)
|
|
1386
|
+
return
|
|
1387
|
+
self.add_issue(
|
|
1388
|
+
'info',
|
|
1389
|
+
'reliability',
|
|
1390
|
+
"Assert statements are removed when optimization is enabled (-O flag)",
|
|
1391
|
+
node,
|
|
1392
|
+
'REL002',
|
|
1393
|
+
)
|
|
1394
|
+
self.generic_visit(node)
|
|
1395
|
+
|
|
1396
|
+
# Function / lambda analysis (shared)
|
|
1397
|
+
|
|
1398
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
1399
|
+
self._bind(node.name, node, self._function_kind(node))
|
|
1400
|
+
self._check_function(node)
|
|
1401
|
+
|
|
1402
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
1403
|
+
self._bind(node.name, node, self._function_kind(node))
|
|
1404
|
+
if not self._is_abstract(node) and not self._is_stub_body(node):
|
|
1405
|
+
if not self._async_body_has_await(node):
|
|
1406
|
+
self.add_issue(
|
|
1407
|
+
'warning',
|
|
1408
|
+
'reliability',
|
|
1409
|
+
f"Async function '{node.name}' never awaits - it will not yield to the event loop",
|
|
1410
|
+
node,
|
|
1411
|
+
'ASY001',
|
|
1412
|
+
)
|
|
1413
|
+
self._check_function(node)
|
|
1414
|
+
|
|
1415
|
+
@staticmethod
|
|
1416
|
+
def _function_kind(node: ast.AST) -> str:
|
|
1417
|
+
# A decorator (route registration, signal hook, ...) is a use.
|
|
1418
|
+
return 'decorated_function' if getattr(node, 'decorator_list', None) else 'function'
|
|
1419
|
+
|
|
1420
|
+
def visit_Lambda(self, node: ast.Lambda):
|
|
1421
|
+
self._check_function(node, is_lambda=True)
|
|
1422
|
+
|
|
1423
|
+
def _count_params(self, args: ast.arguments) -> int:
|
|
1424
|
+
return (
|
|
1425
|
+
len(args.posonlyargs)
|
|
1426
|
+
+ len(args.args)
|
|
1427
|
+
+ len(args.kwonlyargs)
|
|
1428
|
+
+ (1 if args.vararg else 0)
|
|
1429
|
+
+ (1 if args.kwarg else 0)
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1432
|
+
def _is_mutable_default(self, node: ast.AST) -> bool:
|
|
1433
|
+
if isinstance(node, (ast.List, ast.Dict, ast.Set)):
|
|
1434
|
+
return True
|
|
1435
|
+
if isinstance(node, (ast.ListComp, ast.DictComp, ast.SetComp)):
|
|
1436
|
+
return True
|
|
1437
|
+
if isinstance(node, ast.Call):
|
|
1438
|
+
resolved = self._resolve_name(node.func)
|
|
1439
|
+
return resolved in MUTABLE_CTOR_NAMES
|
|
1440
|
+
return False
|
|
1441
|
+
|
|
1442
|
+
def _check_mutable_defaults(self, args: ast.arguments) -> None:
|
|
1443
|
+
for default in args.defaults:
|
|
1444
|
+
if default is not None and self._is_mutable_default(default):
|
|
1445
|
+
self.add_issue(
|
|
1446
|
+
'error',
|
|
1447
|
+
'bugs',
|
|
1448
|
+
"Mutable default argument - use None and initialize in function body",
|
|
1449
|
+
default,
|
|
1450
|
+
'BUG001',
|
|
1451
|
+
)
|
|
1452
|
+
for default in args.kw_defaults:
|
|
1453
|
+
if default is not None and self._is_mutable_default(default):
|
|
1454
|
+
self.add_issue(
|
|
1455
|
+
'error',
|
|
1456
|
+
'bugs',
|
|
1457
|
+
"Mutable default argument - use None and initialize in function body",
|
|
1458
|
+
default,
|
|
1459
|
+
'BUG001',
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
def _check_function(self, node: ast.AST, is_lambda: bool = False) -> None:
|
|
1463
|
+
name = getattr(node, 'name', '<lambda>')
|
|
1464
|
+
args = node.args # type: ignore[attr-defined]
|
|
1465
|
+
total_args = self._count_params(args)
|
|
1466
|
+
if total_args > self.config.max_function_params:
|
|
1467
|
+
self.add_issue(
|
|
1468
|
+
'warning',
|
|
1469
|
+
'complexity',
|
|
1470
|
+
f"Function has {total_args} parameters "
|
|
1471
|
+
f"(max recommended: {self.config.max_function_params})",
|
|
1472
|
+
node,
|
|
1473
|
+
'COMP001',
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
self._check_mutable_defaults(args)
|
|
1477
|
+
|
|
1478
|
+
if not is_lambda:
|
|
1479
|
+
complexity = self._calculate_complexity(node)
|
|
1480
|
+
self.function_complexity[name] = complexity
|
|
1481
|
+
if complexity > self.config.max_complexity:
|
|
1482
|
+
self.add_issue(
|
|
1483
|
+
'warning',
|
|
1484
|
+
'complexity',
|
|
1485
|
+
f"Function has cyclomatic complexity of {complexity} "
|
|
1486
|
+
f"(max recommended: {self.config.max_complexity})",
|
|
1487
|
+
node,
|
|
1488
|
+
'COMP002',
|
|
1489
|
+
)
|
|
1490
|
+
|
|
1491
|
+
end_lineno = getattr(node, 'end_lineno', None)
|
|
1492
|
+
if end_lineno is not None:
|
|
1493
|
+
func_length = end_lineno - node.lineno + 1
|
|
1494
|
+
if func_length > self.config.max_function_length:
|
|
1495
|
+
self.add_issue(
|
|
1496
|
+
'warning',
|
|
1497
|
+
'complexity',
|
|
1498
|
+
f"Function is {func_length} lines long "
|
|
1499
|
+
f"(max recommended: {self.config.max_function_length})",
|
|
1500
|
+
node,
|
|
1501
|
+
'COMP003',
|
|
1502
|
+
)
|
|
1503
|
+
|
|
1504
|
+
# Decorators, defaults, annotations, and type parameters are evaluated in
|
|
1505
|
+
# the enclosing scope when the def statement runs, so visit them now.
|
|
1506
|
+
for field_name in ('decorator_list', 'type_params'):
|
|
1507
|
+
for child in getattr(node, field_name, None) or []:
|
|
1508
|
+
self.visit(child)
|
|
1509
|
+
returns = getattr(node, 'returns', None)
|
|
1510
|
+
if returns is not None:
|
|
1511
|
+
self.visit(returns)
|
|
1512
|
+
self.visit(args)
|
|
1513
|
+
|
|
1514
|
+
# The body runs later; defer it so names bound after this def are visible.
|
|
1515
|
+
self._deferred.append((node, list(self.scopes), self._in_type_checking))
|
|
1516
|
+
|
|
1517
|
+
def _visit_function_body(self, node: ast.AST) -> None:
|
|
1518
|
+
name = getattr(node, 'name', '<lambda>')
|
|
1519
|
+
args = node.args # type: ignore[attr-defined]
|
|
1520
|
+
|
|
1521
|
+
scope = self._push_scope('function', name)
|
|
1522
|
+
for arg in list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs):
|
|
1523
|
+
self._bind(arg.arg, arg, 'arg')
|
|
1524
|
+
# *args / **kwargs usually exist for signature compatibility.
|
|
1525
|
+
if args.vararg is not None:
|
|
1526
|
+
self._bind(args.vararg.arg, args.vararg, 'vararg')
|
|
1527
|
+
if args.kwarg is not None:
|
|
1528
|
+
self._bind(args.kwarg.arg, args.kwarg, 'vararg')
|
|
1529
|
+
|
|
1530
|
+
is_lambda = isinstance(node, ast.Lambda)
|
|
1531
|
+
if (
|
|
1532
|
+
is_lambda # arity is dictated by the caller
|
|
1533
|
+
or (name.startswith('__') and name.endswith('__')) # protocol methods
|
|
1534
|
+
or self._is_abstract(node)
|
|
1535
|
+
or (not is_lambda and self._is_stub_body(node))
|
|
1536
|
+
):
|
|
1537
|
+
scope.used.update(scope.bindings)
|
|
1538
|
+
|
|
1539
|
+
body = node.body # type: ignore[attr-defined]
|
|
1540
|
+
queued = len(self._deferred)
|
|
1541
|
+
if isinstance(body, list):
|
|
1542
|
+
for stmt in body:
|
|
1543
|
+
self.visit(stmt)
|
|
1544
|
+
else:
|
|
1545
|
+
self.visit(body)
|
|
1546
|
+
# Nested functions queued by this body must run before this scope is
|
|
1547
|
+
# popped, so their uses of our locals count and we report accurately.
|
|
1548
|
+
self._run_deferred(queued)
|
|
1549
|
+
self._pop_scope()
|
|
1550
|
+
|
|
1551
|
+
def _run_deferred(self, start: int) -> None:
|
|
1552
|
+
"""Analyze function bodies deferred since ``start`` (they may defer more)."""
|
|
1553
|
+
saved_scopes = self.scopes
|
|
1554
|
+
saved_flag = self._in_type_checking
|
|
1555
|
+
while len(self._deferred) > start:
|
|
1556
|
+
node, scopes, in_type_checking = self._deferred.pop()
|
|
1557
|
+
self.scopes = scopes
|
|
1558
|
+
self._in_type_checking = in_type_checking
|
|
1559
|
+
self._visit_function_body(node)
|
|
1560
|
+
self.scopes = saved_scopes
|
|
1561
|
+
self._in_type_checking = saved_flag
|
|
1562
|
+
|
|
1563
|
+
def visit_ClassDef(self, node: ast.ClassDef):
|
|
1564
|
+
"""Check class definitions."""
|
|
1565
|
+
self._bind(node.name, node, 'class')
|
|
1566
|
+
self._push_scope('class', node.name)
|
|
1567
|
+
|
|
1568
|
+
methods = [
|
|
1569
|
+
n for n in node.body
|
|
1570
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
1571
|
+
]
|
|
1572
|
+
if len(methods) > self.config.max_class_methods:
|
|
1573
|
+
self.add_issue(
|
|
1574
|
+
'warning',
|
|
1575
|
+
'complexity',
|
|
1576
|
+
f"Class has {len(methods)} methods "
|
|
1577
|
+
f"(max recommended: {self.config.max_class_methods})",
|
|
1578
|
+
node,
|
|
1579
|
+
'COMP004',
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
init_method = next((m for m in methods if m.name == '__init__'), None)
|
|
1583
|
+
if init_method:
|
|
1584
|
+
instance_vars = set()
|
|
1585
|
+
for stmt in ast.walk(init_method):
|
|
1586
|
+
if isinstance(stmt, ast.Attribute) and isinstance(stmt.ctx, ast.Store):
|
|
1587
|
+
if isinstance(stmt.value, ast.Name) and stmt.value.id == 'self':
|
|
1588
|
+
instance_vars.add(stmt.attr)
|
|
1589
|
+
|
|
1590
|
+
if len(instance_vars) > self.config.max_instance_vars:
|
|
1591
|
+
self.add_issue(
|
|
1592
|
+
'warning',
|
|
1593
|
+
'complexity',
|
|
1594
|
+
f"Class has {len(instance_vars)} instance variables "
|
|
1595
|
+
f"(max recommended: {self.config.max_instance_vars})",
|
|
1596
|
+
node,
|
|
1597
|
+
'COMP005',
|
|
1598
|
+
)
|
|
1599
|
+
|
|
1600
|
+
self.generic_visit(node)
|
|
1601
|
+
self._pop_scope()
|
|
1602
|
+
|
|
1603
|
+
# Exception Handling Checks
|
|
1604
|
+
|
|
1605
|
+
def visit_Try(self, node: ast.Try):
|
|
1606
|
+
"""Check exception handling."""
|
|
1607
|
+
for handler in node.handlers:
|
|
1608
|
+
if handler.type is None:
|
|
1609
|
+
self.add_issue(
|
|
1610
|
+
'warning',
|
|
1611
|
+
'exceptions',
|
|
1612
|
+
"Bare 'except:' catches all exceptions including SystemExit and KeyboardInterrupt",
|
|
1613
|
+
handler,
|
|
1614
|
+
'EXC001',
|
|
1615
|
+
)
|
|
1616
|
+
elif self._catches_bare_exception(handler.type):
|
|
1617
|
+
self.add_issue(
|
|
1618
|
+
'info',
|
|
1619
|
+
'exceptions',
|
|
1620
|
+
"Catching 'Exception' is very broad - consider catching specific exceptions",
|
|
1621
|
+
handler,
|
|
1622
|
+
'EXC002',
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
if len(handler.body) == 1 and isinstance(handler.body[0], ast.Pass):
|
|
1626
|
+
self.add_issue(
|
|
1627
|
+
'warning',
|
|
1628
|
+
'exceptions',
|
|
1629
|
+
"Empty except block silently ignores errors",
|
|
1630
|
+
handler,
|
|
1631
|
+
'EXC003',
|
|
1632
|
+
)
|
|
1633
|
+
|
|
1634
|
+
self._check_lossy_raises(handler)
|
|
1635
|
+
|
|
1636
|
+
self.generic_visit(node)
|
|
1637
|
+
|
|
1638
|
+
visit_TryStar = visit_Try
|
|
1639
|
+
|
|
1640
|
+
def _catches_bare_exception(self, node: Optional[ast.AST]) -> bool:
|
|
1641
|
+
"""True for ``Exception``, ``builtins.Exception``, or a tuple holding one."""
|
|
1642
|
+
if node is None:
|
|
1643
|
+
return False
|
|
1644
|
+
if isinstance(node, ast.Tuple):
|
|
1645
|
+
return any(self._catches_bare_exception(elt) for elt in node.elts)
|
|
1646
|
+
return self._resolve_name(node) in _BROAD_EXCEPTION_NAMES
|
|
1647
|
+
|
|
1648
|
+
def visit_Raise(self, node: ast.Raise):
|
|
1649
|
+
"""Check raise statements."""
|
|
1650
|
+
if node.exc and isinstance(node.exc, ast.Call):
|
|
1651
|
+
if isinstance(node.exc.func, ast.Name) and node.exc.func.id == 'Exception':
|
|
1652
|
+
if not node.exc.args:
|
|
1653
|
+
self.add_issue(
|
|
1654
|
+
'warning',
|
|
1655
|
+
'exceptions',
|
|
1656
|
+
"Raising Exception without a message - provide descriptive error message",
|
|
1657
|
+
node,
|
|
1658
|
+
'EXC004',
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1661
|
+
self.generic_visit(node)
|
|
1662
|
+
|
|
1663
|
+
# Performance Checks
|
|
1664
|
+
|
|
1665
|
+
def visit_AsyncFor(self, node: ast.AsyncFor):
|
|
1666
|
+
self._mark_store_kind(node.target, 'loop')
|
|
1667
|
+
self.generic_visit(node)
|
|
1668
|
+
|
|
1669
|
+
def visit_For(self, node: ast.For):
|
|
1670
|
+
"""Check for loops for performance issues."""
|
|
1671
|
+
self._mark_store_kind(node.target, 'loop')
|
|
1672
|
+
if isinstance(node.iter, ast.Call):
|
|
1673
|
+
if isinstance(node.iter.func, ast.Name) and node.iter.func.id == 'range':
|
|
1674
|
+
if node.iter.args and isinstance(node.iter.args[0], ast.Call):
|
|
1675
|
+
if isinstance(node.iter.args[0].func, ast.Name):
|
|
1676
|
+
if node.iter.args[0].func.id == 'len':
|
|
1677
|
+
self.add_issue(
|
|
1678
|
+
'info',
|
|
1679
|
+
'performance',
|
|
1680
|
+
"Use 'enumerate()' instead of 'range(len())' for better readability",
|
|
1681
|
+
node,
|
|
1682
|
+
'PERF001',
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
self.generic_visit(node)
|
|
1686
|
+
|
|
1687
|
+
def visit_Compare(self, node: ast.Compare):
|
|
1688
|
+
"""Check comparison operations."""
|
|
1689
|
+
if isinstance(node.ops[0], (ast.Is, ast.IsNot)):
|
|
1690
|
+
if node.comparators and isinstance(node.comparators[0], ast.Constant):
|
|
1691
|
+
if isinstance(node.comparators[0].value, bool):
|
|
1692
|
+
self.add_issue(
|
|
1693
|
+
'info',
|
|
1694
|
+
'style',
|
|
1695
|
+
"Don't use 'is True/False' - use the boolean value directly",
|
|
1696
|
+
node,
|
|
1697
|
+
'STYLE001',
|
|
1698
|
+
)
|
|
1699
|
+
|
|
1700
|
+
self.generic_visit(node)
|
|
1701
|
+
|
|
1702
|
+
# Import Checks
|
|
1703
|
+
|
|
1704
|
+
def visit_Import(self, node: ast.Import):
|
|
1705
|
+
"""Check import statements."""
|
|
1706
|
+
for alias in node.names:
|
|
1707
|
+
bound = alias.asname if alias.asname else alias.name.split('.')[0]
|
|
1708
|
+
self._bind(bound, node, 'import')
|
|
1709
|
+
if self._in_type_checking:
|
|
1710
|
+
scope = self._current_scope()
|
|
1711
|
+
if scope is not None:
|
|
1712
|
+
scope.used.add(bound)
|
|
1713
|
+
self.generic_visit(node)
|
|
1714
|
+
|
|
1715
|
+
def visit_ImportFrom(self, node: ast.ImportFrom):
|
|
1716
|
+
"""Check from...import statements."""
|
|
1717
|
+
if node.module == '__future__':
|
|
1718
|
+
self.generic_visit(node)
|
|
1719
|
+
return
|
|
1720
|
+
for alias in node.names:
|
|
1721
|
+
if alias.name == '*':
|
|
1722
|
+
module = node.module if node.module is not None else '.'
|
|
1723
|
+
self.add_issue(
|
|
1724
|
+
'warning',
|
|
1725
|
+
'imports',
|
|
1726
|
+
f"Wildcard import from '{module}' pollutes namespace",
|
|
1727
|
+
node,
|
|
1728
|
+
'IMP001',
|
|
1729
|
+
)
|
|
1730
|
+
else:
|
|
1731
|
+
bound = alias.asname if alias.asname else alias.name
|
|
1732
|
+
self._bind(bound, node, 'import')
|
|
1733
|
+
if self._in_type_checking:
|
|
1734
|
+
scope = self._current_scope()
|
|
1735
|
+
if scope is not None:
|
|
1736
|
+
scope.used.add(bound)
|
|
1737
|
+
|
|
1738
|
+
if node.level and node.level >= 1 and self.known_exports:
|
|
1739
|
+
self._check_relative_import(node)
|
|
1740
|
+
|
|
1741
|
+
self.generic_visit(node)
|
|
1742
|
+
|
|
1743
|
+
def _check_relative_import(self, node: ast.ImportFrom) -> None:
|
|
1744
|
+
target = resolve_relative_module(self.filename, node.level, node.module)
|
|
1745
|
+
if target is None:
|
|
1746
|
+
return
|
|
1747
|
+
defined = self.known_exports.get(str(target))
|
|
1748
|
+
if defined is None:
|
|
1749
|
+
return
|
|
1750
|
+
module_label = node.module or '.'
|
|
1751
|
+
for alias in node.names:
|
|
1752
|
+
if alias.name == '*':
|
|
1753
|
+
continue
|
|
1754
|
+
if alias.name not in defined:
|
|
1755
|
+
self.add_issue(
|
|
1756
|
+
'error',
|
|
1757
|
+
'imports',
|
|
1758
|
+
f"Imported name '{alias.name}' is not defined in relative module '{module_label}'",
|
|
1759
|
+
node,
|
|
1760
|
+
'IMP003',
|
|
1761
|
+
)
|
|
1762
|
+
|
|
1763
|
+
def visit_Name(self, node: ast.Name):
|
|
1764
|
+
"""Track variable usage."""
|
|
1765
|
+
if isinstance(node.ctx, ast.Load):
|
|
1766
|
+
self._mark_used(node.id)
|
|
1767
|
+
elif isinstance(node.ctx, ast.Store):
|
|
1768
|
+
scope = self._current_scope()
|
|
1769
|
+
if scope is not None:
|
|
1770
|
+
# Any rebinding invalidates a previously recorded constant.
|
|
1771
|
+
scope.constants.pop(node.id, None)
|
|
1772
|
+
self._bind(node.id, node, self._store_kinds.pop(id(node), 'assign'))
|
|
1773
|
+
elif isinstance(node.ctx, ast.Del):
|
|
1774
|
+
self._mark_used(node.id)
|
|
1775
|
+
|
|
1776
|
+
self.generic_visit(node)
|
|
1777
|
+
|
|
1778
|
+
def visit_Assign(self, node: ast.Assign):
|
|
1779
|
+
self._record_dunder_all(node)
|
|
1780
|
+
for target in node.targets:
|
|
1781
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
1782
|
+
self._mark_store_kind(target, 'unpack')
|
|
1783
|
+
self.generic_visit(node)
|
|
1784
|
+
# Record after visiting targets, which clears any stale constant.
|
|
1785
|
+
self._record_constant_assign(node.targets, node.value)
|
|
1786
|
+
if self._is_tainted_expr(node.value):
|
|
1787
|
+
for target in node.targets:
|
|
1788
|
+
self._mark_tainted_target(target)
|
|
1789
|
+
|
|
1790
|
+
def visit_AnnAssign(self, node: ast.AnnAssign):
|
|
1791
|
+
if node.value is None:
|
|
1792
|
+
self._mark_store_kind(node.target, 'annotation')
|
|
1793
|
+
self.generic_visit(node)
|
|
1794
|
+
if node.value is not None:
|
|
1795
|
+
self._record_constant_assign([node.target], node.value)
|
|
1796
|
+
if self._is_tainted_expr(node.value):
|
|
1797
|
+
self._mark_tainted_target(node.target)
|
|
1798
|
+
|
|
1799
|
+
def visit_AugAssign(self, node: ast.AugAssign):
|
|
1800
|
+
self._record_dunder_all_augmented(node)
|
|
1801
|
+
self.generic_visit(node)
|
|
1802
|
+
if isinstance(node.target, ast.Name):
|
|
1803
|
+
self._mark_used(node.target.id) # ``x += 1`` reads x
|
|
1804
|
+
if self._is_tainted_expr(node.value):
|
|
1805
|
+
self._mark_tainted_target(node.target)
|
|
1806
|
+
|
|
1807
|
+
def visit_If(self, node: ast.If):
|
|
1808
|
+
type_checking = self._is_type_checking_test(node.test)
|
|
1809
|
+
self.visit(node.test)
|
|
1810
|
+
previous = self._in_type_checking
|
|
1811
|
+
if type_checking:
|
|
1812
|
+
self._in_type_checking = True
|
|
1813
|
+
for stmt in node.body:
|
|
1814
|
+
self.visit(stmt)
|
|
1815
|
+
self._in_type_checking = previous
|
|
1816
|
+
for stmt in node.orelse:
|
|
1817
|
+
self.visit(stmt)
|
|
1818
|
+
|
|
1819
|
+
def visit_Global(self, node: ast.Global):
|
|
1820
|
+
scope = self._current_scope()
|
|
1821
|
+
if scope is not None:
|
|
1822
|
+
for name in node.names:
|
|
1823
|
+
scope.global_names.add(name)
|
|
1824
|
+
self.generic_visit(node)
|
|
1825
|
+
|
|
1826
|
+
def visit_Nonlocal(self, node: ast.Nonlocal):
|
|
1827
|
+
scope = self._current_scope()
|
|
1828
|
+
if scope is not None:
|
|
1829
|
+
for name in node.names:
|
|
1830
|
+
scope.nonlocal_names.add(name)
|
|
1831
|
+
self._mark_used(name)
|
|
1832
|
+
self.generic_visit(node)
|
|
1833
|
+
|
|
1834
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler):
|
|
1835
|
+
if node.name:
|
|
1836
|
+
scope = self._current_scope()
|
|
1837
|
+
if scope is not None:
|
|
1838
|
+
scope.used.add(node.name)
|
|
1839
|
+
self.generic_visit(node)
|
|
1840
|
+
|
|
1841
|
+
def _visit_comprehension(self, node: ast.AST) -> None:
|
|
1842
|
+
self._push_scope('comprehension')
|
|
1843
|
+
self.generic_visit(node)
|
|
1844
|
+
self._pop_scope()
|
|
1845
|
+
|
|
1846
|
+
def visit_ListComp(self, node: ast.ListComp):
|
|
1847
|
+
self._visit_comprehension(node)
|
|
1848
|
+
|
|
1849
|
+
def visit_SetComp(self, node: ast.SetComp):
|
|
1850
|
+
self._visit_comprehension(node)
|
|
1851
|
+
|
|
1852
|
+
def visit_DictComp(self, node: ast.DictComp):
|
|
1853
|
+
self._visit_comprehension(node)
|
|
1854
|
+
|
|
1855
|
+
def visit_GeneratorExp(self, node: ast.GeneratorExp):
|
|
1856
|
+
self._visit_comprehension(node)
|
|
1857
|
+
|
|
1858
|
+
def _complexity_contrib(self, node: ast.AST) -> int:
|
|
1859
|
+
"""Contribution of this node and its descendants, skipping nested scopes."""
|
|
1860
|
+
if isinstance(node, NESTED_SCOPE_NODES):
|
|
1861
|
+
return 0
|
|
1862
|
+
|
|
1863
|
+
contrib = 0
|
|
1864
|
+
if isinstance(node, (ast.If, ast.While, ast.For, ast.AsyncFor, ast.ExceptHandler)):
|
|
1865
|
+
contrib = 1
|
|
1866
|
+
elif isinstance(node, ast.IfExp):
|
|
1867
|
+
contrib = 1
|
|
1868
|
+
elif isinstance(node, ast.BoolOp):
|
|
1869
|
+
contrib = max(len(node.values) - 1, 0)
|
|
1870
|
+
elif hasattr(ast, 'Match') and isinstance(node, ast.Match):
|
|
1871
|
+
contrib = len(node.cases)
|
|
1872
|
+
|
|
1873
|
+
for child in ast.iter_child_nodes(node):
|
|
1874
|
+
contrib += self._complexity_contrib(child)
|
|
1875
|
+
return contrib
|
|
1876
|
+
|
|
1877
|
+
def _calculate_complexity(self, node: ast.AST) -> int:
|
|
1878
|
+
"""Cyclomatic complexity of a function, excluding nested functions/classes."""
|
|
1879
|
+
complexity = 1
|
|
1880
|
+
for child in ast.iter_child_nodes(node):
|
|
1881
|
+
complexity += self._complexity_contrib(child)
|
|
1882
|
+
return complexity
|
|
1883
|
+
|
|
1884
|
+
def analyze(self) -> List[Issue]:
|
|
1885
|
+
"""Perform the complete analysis."""
|
|
1886
|
+
self.issues = []
|
|
1887
|
+
self.aliases = {}
|
|
1888
|
+
self.function_complexity = {}
|
|
1889
|
+
self._deferred = []
|
|
1890
|
+
try:
|
|
1891
|
+
tree = ast.parse(self.source_code, filename=self.filename)
|
|
1892
|
+
self._collect_imports(tree)
|
|
1893
|
+
self.scopes = []
|
|
1894
|
+
self._in_type_checking = False
|
|
1895
|
+
self._with_expr_ids = set()
|
|
1896
|
+
self._store_kinds = {}
|
|
1897
|
+
self._push_scope('module')
|
|
1898
|
+
self.visit(tree)
|
|
1899
|
+
self._run_deferred(0)
|
|
1900
|
+
self._pop_scope()
|
|
1901
|
+
self.issues = [issue for issue in self.issues if not self._is_ignored(issue)]
|
|
1902
|
+
return sorted(self.issues, key=lambda x: (x.line, x.col, x.code))
|
|
1903
|
+
except SyntaxError as exc:
|
|
1904
|
+
return [Issue(
|
|
1905
|
+
severity='error',
|
|
1906
|
+
category='syntax',
|
|
1907
|
+
message=f"Syntax error: {exc.msg}",
|
|
1908
|
+
line=exc.lineno or 0,
|
|
1909
|
+
col=exc.offset or 0,
|
|
1910
|
+
code='SYN001',
|
|
1911
|
+
filename=self.filename,
|
|
1912
|
+
)]
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
class EnhancedSemanticChecker(SemanticChecker):
|
|
1916
|
+
"""Backward-compatible alias; configuration lives on SemanticChecker."""
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
def _io_issue(filepath: str, message: str) -> Issue:
|
|
1920
|
+
return Issue(
|
|
1921
|
+
severity='error',
|
|
1922
|
+
category='io',
|
|
1923
|
+
message=message,
|
|
1924
|
+
line=0,
|
|
1925
|
+
col=0,
|
|
1926
|
+
code='IO001',
|
|
1927
|
+
filename=filepath,
|
|
1928
|
+
)
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def collect_module_exports(source: str) -> Set[str]:
|
|
1932
|
+
"""Module-level names defined or re-exported in a file."""
|
|
1933
|
+
try:
|
|
1934
|
+
tree = ast.parse(source)
|
|
1935
|
+
except (SyntaxError, RecursionError):
|
|
1936
|
+
return set()
|
|
1937
|
+
names: Set[str] = set()
|
|
1938
|
+
for node in tree.body:
|
|
1939
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
1940
|
+
names.add(node.name)
|
|
1941
|
+
elif isinstance(node, ast.Assign):
|
|
1942
|
+
for target in node.targets:
|
|
1943
|
+
if isinstance(target, ast.Name):
|
|
1944
|
+
names.add(target.id)
|
|
1945
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
1946
|
+
names.add(node.target.id)
|
|
1947
|
+
elif isinstance(node, ast.ImportFrom):
|
|
1948
|
+
for alias in node.names:
|
|
1949
|
+
if alias.name != '*':
|
|
1950
|
+
names.add(alias.asname or alias.name)
|
|
1951
|
+
elif isinstance(node, ast.Import):
|
|
1952
|
+
for alias in node.names:
|
|
1953
|
+
names.add(alias.asname or alias.name.split('.')[0])
|
|
1954
|
+
return names
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
def resolve_relative_module(
|
|
1958
|
+
importer: str,
|
|
1959
|
+
level: int,
|
|
1960
|
+
module: Optional[str],
|
|
1961
|
+
) -> Optional[Path]:
|
|
1962
|
+
"""Resolve a relative import to a .py file or package __init__.py."""
|
|
1963
|
+
if level < 1 or not importer or importer == '<string>':
|
|
1964
|
+
return None
|
|
1965
|
+
try:
|
|
1966
|
+
base = Path(importer).resolve().parent
|
|
1967
|
+
except OSError:
|
|
1968
|
+
return None
|
|
1969
|
+
for _ in range(level - 1):
|
|
1970
|
+
parent = base.parent
|
|
1971
|
+
if parent == base:
|
|
1972
|
+
return None
|
|
1973
|
+
base = parent
|
|
1974
|
+
candidate = base.joinpath(*module.split('.')) if module else base
|
|
1975
|
+
pyfile = Path(str(candidate) + '.py')
|
|
1976
|
+
init = candidate / '__init__.py'
|
|
1977
|
+
if pyfile.is_file():
|
|
1978
|
+
return pyfile.resolve()
|
|
1979
|
+
if init.is_file():
|
|
1980
|
+
return init.resolve()
|
|
1981
|
+
return None
|
|
1982
|
+
|
|
1983
|
+
|
|
1984
|
+
def _normalize_issue_path(filename: str) -> str:
|
|
1985
|
+
if not filename:
|
|
1986
|
+
return ''
|
|
1987
|
+
path = Path(filename)
|
|
1988
|
+
try:
|
|
1989
|
+
return path.resolve().relative_to(Path.cwd().resolve()).as_posix()
|
|
1990
|
+
except ValueError:
|
|
1991
|
+
return path.as_posix()
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
_DIGITS_RE = re.compile(r'\d+')
|
|
1995
|
+
BASELINE_VERSION = 2
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
def normalize_issue_message(message: str) -> str:
|
|
1999
|
+
"""Replace numbers so 'Function is 52 lines long' and '... 53 lines long' match."""
|
|
2000
|
+
return _DIGITS_RE.sub('#', message)
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def _fingerprint_key(filename: str, code: str, message: str) -> str:
|
|
2004
|
+
return f"{_normalize_issue_path(filename)}|{code}|{normalize_issue_message(message)}"
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
def issue_fingerprint(issue: Issue, occurrence: int = 0) -> str:
|
|
2008
|
+
"""``path|code|normalized message|occurrence``.
|
|
2009
|
+
|
|
2010
|
+
``occurrence`` numbers repeated identical findings within one file, so a
|
|
2011
|
+
baseline holding one ``eval(input())`` does not also hide a second one.
|
|
2012
|
+
"""
|
|
2013
|
+
return f"{_fingerprint_key(issue.filename, issue.code, issue.message)}|{occurrence}"
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
def issue_fingerprints(issues: Sequence[Issue]) -> List[str]:
|
|
2017
|
+
"""Fingerprints for a batch, numbering repeats in (file, line, col) order."""
|
|
2018
|
+
order = sorted(
|
|
2019
|
+
range(len(issues)),
|
|
2020
|
+
key=lambda i: (_normalize_issue_path(issues[i].filename), issues[i].line, issues[i].col),
|
|
2021
|
+
)
|
|
2022
|
+
counts: Dict[str, int] = {}
|
|
2023
|
+
result = [''] * len(issues)
|
|
2024
|
+
for index in order:
|
|
2025
|
+
issue = issues[index]
|
|
2026
|
+
key = _fingerprint_key(issue.filename, issue.code, issue.message)
|
|
2027
|
+
occurrence = counts.get(key, 0)
|
|
2028
|
+
counts[key] = occurrence + 1
|
|
2029
|
+
result[index] = f"{key}|{occurrence}"
|
|
2030
|
+
return result
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
def load_baseline(path: str) -> Set[str]:
|
|
2034
|
+
baseline_path = Path(path)
|
|
2035
|
+
try:
|
|
2036
|
+
raw = baseline_path.read_text(encoding='utf-8')
|
|
2037
|
+
data = json.loads(raw)
|
|
2038
|
+
except FileNotFoundError:
|
|
2039
|
+
raise ConfigError(f"Baseline file '{path}' not found") from None
|
|
2040
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
2041
|
+
raise ConfigError(f"Could not read baseline '{path}': {exc}") from exc
|
|
2042
|
+
if not isinstance(data, dict):
|
|
2043
|
+
raise ConfigError(f"Baseline '{path}' must contain a JSON object")
|
|
2044
|
+
items = [item for item in data.get('issues') or [] if isinstance(item, dict)]
|
|
2045
|
+
version = data.get('version') or 1
|
|
2046
|
+
|
|
2047
|
+
fingerprints: Set[str] = set()
|
|
2048
|
+
if isinstance(version, int) and version >= 2:
|
|
2049
|
+
for item in items:
|
|
2050
|
+
stored = item.get('fingerprint')
|
|
2051
|
+
if stored:
|
|
2052
|
+
fingerprints.add(str(stored))
|
|
2053
|
+
return fingerprints
|
|
2054
|
+
|
|
2055
|
+
# Version 1 stored ``path|code|raw message`` with no occurrence index.
|
|
2056
|
+
# Rebuild version-2 fingerprints; entries were written in (file, line) order.
|
|
2057
|
+
counts: Dict[str, int] = {}
|
|
2058
|
+
for item in items:
|
|
2059
|
+
code = item.get('code')
|
|
2060
|
+
message = item.get('message')
|
|
2061
|
+
if code is not None and message is not None:
|
|
2062
|
+
key = _fingerprint_key(str(item.get('filename') or ''), str(code), str(message))
|
|
2063
|
+
else:
|
|
2064
|
+
stored = str(item.get('fingerprint') or '')
|
|
2065
|
+
if stored.count('|') < 2:
|
|
2066
|
+
continue
|
|
2067
|
+
stored_path, stored_code, stored_message = stored.split('|', 2)
|
|
2068
|
+
key = f"{stored_path}|{stored_code}|{normalize_issue_message(stored_message)}"
|
|
2069
|
+
occurrence = counts.get(key, 0)
|
|
2070
|
+
counts[key] = occurrence + 1
|
|
2071
|
+
fingerprints.add(f"{key}|{occurrence}")
|
|
2072
|
+
return fingerprints
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def write_baseline(issues: Sequence[Issue], path: str) -> None:
|
|
2076
|
+
payload = {
|
|
2077
|
+
'version': BASELINE_VERSION,
|
|
2078
|
+
'issues': [
|
|
2079
|
+
{
|
|
2080
|
+
'filename': _normalize_issue_path(issue.filename),
|
|
2081
|
+
'code': issue.code,
|
|
2082
|
+
'message': issue.message,
|
|
2083
|
+
'line': issue.line,
|
|
2084
|
+
'fingerprint': fingerprint,
|
|
2085
|
+
}
|
|
2086
|
+
for issue, fingerprint in zip(issues, issue_fingerprints(issues))
|
|
2087
|
+
],
|
|
2088
|
+
}
|
|
2089
|
+
Path(path).write_text(json.dumps(payload, indent=2) + '\n', encoding='utf-8')
|
|
2090
|
+
|
|
2091
|
+
|
|
2092
|
+
def _display_path(path: Path) -> str:
|
|
2093
|
+
"""Path relative to cwd when it is underneath it, else absolute."""
|
|
2094
|
+
try:
|
|
2095
|
+
rel = os.path.relpath(path, Path.cwd())
|
|
2096
|
+
except ValueError:
|
|
2097
|
+
return str(path)
|
|
2098
|
+
if rel.startswith('..'):
|
|
2099
|
+
return str(path)
|
|
2100
|
+
return rel
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
def git_staged_python_files() -> Tuple[List[str], Optional[str]]:
|
|
2104
|
+
"""Return staged *.py paths (relative to cwd), or an error message.
|
|
2105
|
+
|
|
2106
|
+
``git diff --name-only`` prints paths relative to the repository root, not
|
|
2107
|
+
the current directory, so the root is resolved first.
|
|
2108
|
+
"""
|
|
2109
|
+
try:
|
|
2110
|
+
toplevel = _subprocess.run(
|
|
2111
|
+
['git', 'rev-parse', '--show-toplevel'],
|
|
2112
|
+
capture_output=True,
|
|
2113
|
+
text=True,
|
|
2114
|
+
timeout=30,
|
|
2115
|
+
)
|
|
2116
|
+
if toplevel.returncode != 0:
|
|
2117
|
+
detail = (toplevel.stderr or toplevel.stdout or 'not a git repository').strip()
|
|
2118
|
+
return [], detail
|
|
2119
|
+
completed = _subprocess.run(
|
|
2120
|
+
['git', 'diff', '--cached', '--name-only', '--diff-filter=ACMR'],
|
|
2121
|
+
capture_output=True,
|
|
2122
|
+
text=True,
|
|
2123
|
+
timeout=30,
|
|
2124
|
+
)
|
|
2125
|
+
except (OSError, _subprocess.TimeoutExpired) as exc:
|
|
2126
|
+
return [], f"Could not run git: {exc}"
|
|
2127
|
+
if completed.returncode != 0:
|
|
2128
|
+
detail = (completed.stderr or completed.stdout or 'git diff failed').strip()
|
|
2129
|
+
return [], detail
|
|
2130
|
+
root = Path(toplevel.stdout.strip() or '.')
|
|
2131
|
+
files: List[str] = []
|
|
2132
|
+
for line in completed.stdout.splitlines():
|
|
2133
|
+
name = line.strip()
|
|
2134
|
+
if not name.endswith('.py'):
|
|
2135
|
+
continue
|
|
2136
|
+
full = root / name
|
|
2137
|
+
if full.is_file():
|
|
2138
|
+
files.append(_display_path(full))
|
|
2139
|
+
return files, None
|
|
2140
|
+
|
|
2141
|
+
|
|
2142
|
+
def expand_python_targets(paths: Sequence[str]) -> Tuple[List[str], List[Issue]]:
|
|
2143
|
+
"""Expand directories to .py files. Explicit files are kept as given."""
|
|
2144
|
+
targets: List[str] = []
|
|
2145
|
+
extras: List[Issue] = []
|
|
2146
|
+
seen: Set[str] = set()
|
|
2147
|
+
|
|
2148
|
+
def _add(item: str) -> None:
|
|
2149
|
+
# Explicit files keep the spelling that was passed, while directory
|
|
2150
|
+
# expansion yields resolved paths, so dedupe on identity rather than
|
|
2151
|
+
# on the string: 'pkg/a.py pkg/' is one file, not two.
|
|
2152
|
+
try:
|
|
2153
|
+
key = str(Path(item).resolve())
|
|
2154
|
+
except OSError:
|
|
2155
|
+
key = item
|
|
2156
|
+
if key not in seen:
|
|
2157
|
+
seen.add(key)
|
|
2158
|
+
targets.append(item)
|
|
2159
|
+
|
|
2160
|
+
for raw in paths:
|
|
2161
|
+
path = Path(raw)
|
|
2162
|
+
if path.is_dir():
|
|
2163
|
+
found = [str(candidate) for candidate in iter_python_files(path)]
|
|
2164
|
+
if not found:
|
|
2165
|
+
extras.append(_io_issue(raw, f"No Python files found in '{raw}'"))
|
|
2166
|
+
continue
|
|
2167
|
+
for filepath in found:
|
|
2168
|
+
_add(filepath)
|
|
2169
|
+
else:
|
|
2170
|
+
_add(raw)
|
|
2171
|
+
return targets, extras
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
def check_file(
|
|
2175
|
+
filepath: str,
|
|
2176
|
+
config: Optional[CheckerConfig] = None,
|
|
2177
|
+
known_exports: Optional[Dict[str, Set[str]]] = None,
|
|
2178
|
+
source: Optional[str] = None,
|
|
2179
|
+
) -> List[Issue]:
|
|
2180
|
+
"""Check a Python file for semantic issues. I/O failures become IO001 errors.
|
|
2181
|
+
|
|
2182
|
+
Pass ``source`` to analyze already-read text without touching the disk again.
|
|
2183
|
+
"""
|
|
2184
|
+
path = Path(filepath)
|
|
2185
|
+
if not path.exists():
|
|
2186
|
+
return [_io_issue(filepath, f"File '{filepath}' not found")]
|
|
2187
|
+
if not path.is_file():
|
|
2188
|
+
return [_io_issue(filepath, f"'{filepath}' is not a file")]
|
|
2189
|
+
|
|
2190
|
+
try:
|
|
2191
|
+
source_code = source if source is not None else read_python_source(path)
|
|
2192
|
+
except LookupError as exc:
|
|
2193
|
+
return [_io_issue(filepath, f"Unknown encoding in '{filepath}': {exc}")]
|
|
2194
|
+
except UnicodeDecodeError as exc:
|
|
2195
|
+
return [_io_issue(filepath, f"Could not decode '{filepath}': {exc}")]
|
|
2196
|
+
except OSError as exc:
|
|
2197
|
+
return [_io_issue(filepath, f"Could not read '{filepath}': {exc}")]
|
|
2198
|
+
|
|
2199
|
+
checker = SemanticChecker(
|
|
2200
|
+
source_code,
|
|
2201
|
+
str(path),
|
|
2202
|
+
config=config,
|
|
2203
|
+
known_exports=known_exports,
|
|
2204
|
+
)
|
|
2205
|
+
try:
|
|
2206
|
+
return checker.analyze()
|
|
2207
|
+
except RecursionError:
|
|
2208
|
+
# Analysis recurses per AST node, so source CPython parses happily can
|
|
2209
|
+
# still exhaust the stack (long chained expressions, generated tables).
|
|
2210
|
+
# Contain it here: one unanalyzable file must not sink the whole run.
|
|
2211
|
+
return [_io_issue(
|
|
2212
|
+
filepath,
|
|
2213
|
+
f"'{filepath}' is nested too deeply to analyze - skipped",
|
|
2214
|
+
)]
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
# Per-worker state for the process pool (set once by the initializer so the
|
|
2218
|
+
# config and export map are not re-pickled for every file).
|
|
2219
|
+
_WORKER_CONFIG: Optional[CheckerConfig] = None
|
|
2220
|
+
_WORKER_EXPORTS: Dict[str, Set[str]] = {}
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
def _init_worker(config: CheckerConfig, known_exports: Dict[str, Set[str]]) -> None:
|
|
2224
|
+
global _WORKER_CONFIG, _WORKER_EXPORTS
|
|
2225
|
+
_WORKER_CONFIG = config
|
|
2226
|
+
_WORKER_EXPORTS = known_exports
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
def _check_in_worker(item: Tuple[str, Optional[str]]) -> List[Issue]:
|
|
2230
|
+
filepath, source = item
|
|
2231
|
+
return check_file(filepath, config=_WORKER_CONFIG, known_exports=_WORKER_EXPORTS, source=source)
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
def resolve_jobs(jobs: Optional[int], file_count: int) -> int:
|
|
2235
|
+
"""Worker count. ``None``/``0`` = auto: one per CPU once there are enough files."""
|
|
2236
|
+
if file_count <= 1:
|
|
2237
|
+
return 1
|
|
2238
|
+
if jobs is None or jobs <= 0:
|
|
2239
|
+
if file_count < PARALLEL_MIN_FILES:
|
|
2240
|
+
return 1
|
|
2241
|
+
return max(1, min(os.cpu_count() or 1, file_count))
|
|
2242
|
+
return min(jobs, file_count)
|
|
2243
|
+
|
|
2244
|
+
|
|
2245
|
+
def analyze_files(
|
|
2246
|
+
targets: Sequence[str],
|
|
2247
|
+
config: CheckerConfig,
|
|
2248
|
+
known_exports: Dict[str, Set[str]],
|
|
2249
|
+
sources: Dict[str, str],
|
|
2250
|
+
jobs: Optional[int] = None,
|
|
2251
|
+
) -> List[List[Issue]]:
|
|
2252
|
+
"""``check_file`` for every target, in order, using a process pool when worthwhile."""
|
|
2253
|
+
items = [(filepath, sources.get(filepath)) for filepath in targets]
|
|
2254
|
+
workers = resolve_jobs(jobs, len(items))
|
|
2255
|
+
if workers > 1:
|
|
2256
|
+
try:
|
|
2257
|
+
with concurrent.futures.ProcessPoolExecutor(
|
|
2258
|
+
max_workers=workers,
|
|
2259
|
+
initializer=_init_worker,
|
|
2260
|
+
initargs=(config, known_exports),
|
|
2261
|
+
) as pool:
|
|
2262
|
+
chunksize = max(1, len(items) // (workers * 4))
|
|
2263
|
+
return list(pool.map(_check_in_worker, items, chunksize=chunksize))
|
|
2264
|
+
except Exception: # noqa: EXC002 - any pool failure: redo sequentially, which surfaces real bugs
|
|
2265
|
+
pass
|
|
2266
|
+
return [
|
|
2267
|
+
check_file(filepath, config=config, known_exports=known_exports, source=source)
|
|
2268
|
+
for filepath, source in items
|
|
2269
|
+
]
|
|
2270
|
+
|
|
2271
|
+
|
|
2272
|
+
_BANDIT_SEVERITY = {
|
|
2273
|
+
'HIGH': 'error',
|
|
2274
|
+
'MEDIUM': 'warning',
|
|
2275
|
+
'LOW': 'info',
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
def collect_bandit_issues(filepaths: Sequence[str]) -> List[Issue]:
|
|
2280
|
+
"""Run bandit if installed and convert findings to Issue objects."""
|
|
2281
|
+
bandit_bin = shutil.which('bandit')
|
|
2282
|
+
if not bandit_bin or not filepaths:
|
|
2283
|
+
return []
|
|
2284
|
+
try:
|
|
2285
|
+
completed = _subprocess.run(
|
|
2286
|
+
[bandit_bin, '-f', 'json', *filepaths],
|
|
2287
|
+
capture_output=True,
|
|
2288
|
+
text=True,
|
|
2289
|
+
timeout=120,
|
|
2290
|
+
)
|
|
2291
|
+
except (OSError, _subprocess.TimeoutExpired):
|
|
2292
|
+
return []
|
|
2293
|
+
payload = completed.stdout.strip()
|
|
2294
|
+
if not payload:
|
|
2295
|
+
return []
|
|
2296
|
+
try:
|
|
2297
|
+
data = json.loads(payload)
|
|
2298
|
+
except json.JSONDecodeError:
|
|
2299
|
+
return []
|
|
2300
|
+
issues: List[Issue] = []
|
|
2301
|
+
for result in data.get('results') or []:
|
|
2302
|
+
severity = _BANDIT_SEVERITY.get(
|
|
2303
|
+
str(result.get('issue_severity', '')).upper(),
|
|
2304
|
+
'warning',
|
|
2305
|
+
)
|
|
2306
|
+
test_id = str(result.get('test_id') or 'BANDIT')
|
|
2307
|
+
issues.append(Issue(
|
|
2308
|
+
severity=severity,
|
|
2309
|
+
category='security',
|
|
2310
|
+
message=str(result.get('issue_text') or test_id),
|
|
2311
|
+
line=int(result.get('line_number') or 0),
|
|
2312
|
+
col=int(result.get('col_offset') or 0),
|
|
2313
|
+
code=test_id,
|
|
2314
|
+
filename=str(result.get('filename') or ''),
|
|
2315
|
+
source='bandit',
|
|
2316
|
+
))
|
|
2317
|
+
return issues
|
|
2318
|
+
|
|
2319
|
+
|
|
2320
|
+
def _read_source_line(source: Optional[str], line: int) -> str:
|
|
2321
|
+
if not source or line <= 0:
|
|
2322
|
+
return ''
|
|
2323
|
+
return _source_line_from(source.splitlines(), line)
|
|
2324
|
+
|
|
2325
|
+
|
|
2326
|
+
def _source_line_from(lines: Sequence[str], line: int) -> str:
|
|
2327
|
+
"""``line`` (1-based) out of an already-split source, or '' when out of range."""
|
|
2328
|
+
if line <= 0 or line > len(lines):
|
|
2329
|
+
return ''
|
|
2330
|
+
return lines[line - 1]
|
|
2331
|
+
|
|
2332
|
+
|
|
2333
|
+
def format_issue(issue: Issue, source_line: str = '', use_color: bool = True) -> str:
|
|
2334
|
+
"""Format an issue for display."""
|
|
2335
|
+
severity_colors = {
|
|
2336
|
+
'error': '\033[91m',
|
|
2337
|
+
'warning': '\033[93m',
|
|
2338
|
+
'info': '\033[94m',
|
|
2339
|
+
}
|
|
2340
|
+
reset = '\033[0m'
|
|
2341
|
+
color = severity_colors.get(issue.severity, '') if use_color else ''
|
|
2342
|
+
color_reset = reset if use_color else ''
|
|
2343
|
+
severity_str = issue.severity.upper()
|
|
2344
|
+
|
|
2345
|
+
origin = f" ({issue.source})" if issue.source and issue.source != 'codesnake' else ''
|
|
2346
|
+
output = (
|
|
2347
|
+
f"{color}{severity_str}{color_reset} [{issue.code}] "
|
|
2348
|
+
f"{issue.category}{origin}: {issue.message}\n"
|
|
2349
|
+
)
|
|
2350
|
+
# Issue.col is a 0-based character offset; humans expect 1-based columns.
|
|
2351
|
+
location = f"Line {issue.line}, Column {issue.col + 1}" if issue.line > 0 else ''
|
|
2352
|
+
if issue.filename and location:
|
|
2353
|
+
output += f" {issue.filename}: {location}\n"
|
|
2354
|
+
elif issue.filename:
|
|
2355
|
+
output += f" {issue.filename}\n"
|
|
2356
|
+
elif location:
|
|
2357
|
+
output += f" {location}\n"
|
|
2358
|
+
|
|
2359
|
+
if source_line:
|
|
2360
|
+
display = source_line.rstrip('\n')
|
|
2361
|
+
output += f" {display}\n"
|
|
2362
|
+
if issue.col >= 0:
|
|
2363
|
+
output += f" {' ' * issue.col}^\n"
|
|
2364
|
+
if issue.suggestion:
|
|
2365
|
+
output += f" Suggestion: {issue.suggestion}\n"
|
|
2366
|
+
|
|
2367
|
+
return output
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
def filter_issues(
|
|
2371
|
+
issues: Sequence[Issue],
|
|
2372
|
+
config: CheckerConfig,
|
|
2373
|
+
min_severity: Optional[str] = None,
|
|
2374
|
+
) -> List[Issue]:
|
|
2375
|
+
"""Apply config report_* flags and optional --severity minimum."""
|
|
2376
|
+
min_rank = SEVERITY_RANK.get(min_severity or 'info', 1)
|
|
2377
|
+
filtered: List[Issue] = []
|
|
2378
|
+
for issue in issues:
|
|
2379
|
+
if issue.code in ALWAYS_SHOW_CODES:
|
|
2380
|
+
filtered.append(issue)
|
|
2381
|
+
continue
|
|
2382
|
+
if SEVERITY_RANK.get(issue.severity, 0) < min_rank:
|
|
2383
|
+
continue
|
|
2384
|
+
if not config.allows_severity(issue.severity):
|
|
2385
|
+
continue
|
|
2386
|
+
filtered.append(issue)
|
|
2387
|
+
return filtered
|
|
2388
|
+
|
|
2389
|
+
|
|
2390
|
+
def _count_by_severity(issues: Iterable[Issue]) -> Tuple[int, int, int]:
|
|
2391
|
+
errors = warnings = infos = 0
|
|
2392
|
+
for issue in issues:
|
|
2393
|
+
if issue.severity == 'error':
|
|
2394
|
+
errors += 1
|
|
2395
|
+
elif issue.severity == 'warning':
|
|
2396
|
+
warnings += 1
|
|
2397
|
+
elif issue.severity == 'info':
|
|
2398
|
+
infos += 1
|
|
2399
|
+
return errors, warnings, infos
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
def _issue_to_dict(issue: Issue) -> dict:
|
|
2403
|
+
return {
|
|
2404
|
+
'severity': issue.severity,
|
|
2405
|
+
'category': issue.category,
|
|
2406
|
+
'code': issue.code,
|
|
2407
|
+
'message': issue.message,
|
|
2408
|
+
'line': issue.line,
|
|
2409
|
+
'col': issue.col,
|
|
2410
|
+
'filename': issue.filename,
|
|
2411
|
+
'source': issue.source,
|
|
2412
|
+
'end_line': issue.end_line,
|
|
2413
|
+
'end_col': issue.end_col,
|
|
2414
|
+
'suggestion': issue.suggestion,
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
|
|
2418
|
+
def format_json_report(
|
|
2419
|
+
file_issues: Sequence[Tuple[str, List[Issue]]],
|
|
2420
|
+
) -> str:
|
|
2421
|
+
files_out = []
|
|
2422
|
+
all_issues: List[Issue] = []
|
|
2423
|
+
for path, issues in file_issues:
|
|
2424
|
+
all_issues.extend(issues)
|
|
2425
|
+
files_out.append({
|
|
2426
|
+
'path': path,
|
|
2427
|
+
'issues': [_issue_to_dict(issue) for issue in issues],
|
|
2428
|
+
})
|
|
2429
|
+
errors, warnings, infos = _count_by_severity(all_issues)
|
|
2430
|
+
payload = {
|
|
2431
|
+
'files': files_out,
|
|
2432
|
+
'summary': {
|
|
2433
|
+
'files': len(file_issues),
|
|
2434
|
+
'errors': errors,
|
|
2435
|
+
'warnings': warnings,
|
|
2436
|
+
'info': infos,
|
|
2437
|
+
},
|
|
2438
|
+
}
|
|
2439
|
+
return json.dumps(payload, indent=2) + '\n'
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
def _gh_escape_data(value: str) -> str:
|
|
2443
|
+
"""Escape a workflow-command message (GitHub's documented rules)."""
|
|
2444
|
+
return value.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A')
|
|
2445
|
+
|
|
2446
|
+
|
|
2447
|
+
def _gh_escape_property(value: str) -> str:
|
|
2448
|
+
"""Escape a workflow-command property value (``file=``, ``title=`` ...)."""
|
|
2449
|
+
return _gh_escape_data(value).replace(':', '%3A').replace(',', '%2C')
|
|
2450
|
+
|
|
2451
|
+
|
|
2452
|
+
def format_github_report(issues: Sequence[Issue]) -> str:
|
|
2453
|
+
lines = []
|
|
2454
|
+
level_map = {'error': 'error', 'warning': 'warning', 'info': 'notice'}
|
|
2455
|
+
for issue in issues:
|
|
2456
|
+
level = level_map.get(issue.severity, 'warning')
|
|
2457
|
+
props = [f"file={_gh_escape_property(_gh_path(issue.filename))}", f"line={issue.line}"]
|
|
2458
|
+
col = issue.col + 1 if issue.col >= 0 else 1
|
|
2459
|
+
props.append(f"col={col}")
|
|
2460
|
+
if issue.end_line and issue.end_line != issue.line:
|
|
2461
|
+
props.append(f"endLine={issue.end_line}")
|
|
2462
|
+
elif issue.end_col > issue.col:
|
|
2463
|
+
props.append(f"endColumn={issue.end_col + 1}")
|
|
2464
|
+
props.append(f"title={_gh_escape_property(f'{issue.code} {issue.category}')}")
|
|
2465
|
+
message = f"[{issue.code}] {issue.message}"
|
|
2466
|
+
if issue.suggestion:
|
|
2467
|
+
message += f" {issue.suggestion}"
|
|
2468
|
+
lines.append(f"::{level} {','.join(props)}::{_gh_escape_data(message)}")
|
|
2469
|
+
return '\n'.join(lines) + ('\n' if lines else '')
|
|
2470
|
+
|
|
2471
|
+
|
|
2472
|
+
def _relative_to_cwd(filename: str) -> Optional[str]:
|
|
2473
|
+
"""POSIX path relative to the current directory, or ``None`` if outside it.
|
|
2474
|
+
|
|
2475
|
+
Directory arguments are walked resolved (see :func:`iter_python_files`), so
|
|
2476
|
+
issue filenames are absolute even when the user passed a relative path.
|
|
2477
|
+
Report formats that are consumed relative to a workspace root have to undo
|
|
2478
|
+
that before emitting a location.
|
|
2479
|
+
"""
|
|
2480
|
+
if not filename:
|
|
2481
|
+
return None
|
|
2482
|
+
try:
|
|
2483
|
+
resolved = Path(filename).resolve()
|
|
2484
|
+
except OSError:
|
|
2485
|
+
return None
|
|
2486
|
+
try:
|
|
2487
|
+
return resolved.relative_to(Path.cwd().resolve()).as_posix()
|
|
2488
|
+
except ValueError:
|
|
2489
|
+
return None
|
|
2490
|
+
|
|
2491
|
+
|
|
2492
|
+
def _gh_path(filename: str) -> str:
|
|
2493
|
+
"""Workspace-relative POSIX path for a ``file=`` annotation property.
|
|
2494
|
+
|
|
2495
|
+
GitHub resolves ``file=`` against the workspace root and silently drops
|
|
2496
|
+
annotations whose path does not match a file there, so an absolute path
|
|
2497
|
+
would never attach to the diff.
|
|
2498
|
+
"""
|
|
2499
|
+
if not filename:
|
|
2500
|
+
return ''
|
|
2501
|
+
relative = _relative_to_cwd(filename)
|
|
2502
|
+
if relative is not None:
|
|
2503
|
+
return relative
|
|
2504
|
+
return Path(filename).as_posix()
|
|
2505
|
+
|
|
2506
|
+
|
|
2507
|
+
def _sarif_uri(filename: str) -> str:
|
|
2508
|
+
"""Relative POSIX path under cwd, otherwise an absolute ``file://`` URI."""
|
|
2509
|
+
if not filename:
|
|
2510
|
+
return ''
|
|
2511
|
+
path = Path(filename)
|
|
2512
|
+
relative = _relative_to_cwd(filename)
|
|
2513
|
+
if relative is not None:
|
|
2514
|
+
return relative
|
|
2515
|
+
try:
|
|
2516
|
+
return path.resolve().as_uri()
|
|
2517
|
+
except (OSError, ValueError):
|
|
2518
|
+
return path.as_posix()
|
|
2519
|
+
|
|
2520
|
+
|
|
2521
|
+
def format_sarif_report(issues: Sequence[Issue], tool_version: str) -> str:
|
|
2522
|
+
level_map = {'error': 'error', 'warning': 'warning', 'info': 'note'}
|
|
2523
|
+
rules = []
|
|
2524
|
+
seen_codes = set()
|
|
2525
|
+
for issue in issues:
|
|
2526
|
+
if issue.code in seen_codes:
|
|
2527
|
+
continue
|
|
2528
|
+
seen_codes.add(issue.code)
|
|
2529
|
+
rule: Dict[str, Any] = {
|
|
2530
|
+
'id': issue.code,
|
|
2531
|
+
'name': issue.code,
|
|
2532
|
+
'shortDescription': {'text': f"{issue.category}: {issue.code}"},
|
|
2533
|
+
'helpUri': RULES_URL,
|
|
2534
|
+
'defaultConfiguration': {'level': level_map.get(issue.severity, 'warning')},
|
|
2535
|
+
'properties': {'category': issue.category},
|
|
2536
|
+
}
|
|
2537
|
+
suggestion = issue.suggestion or ISSUE_SUGGESTIONS.get(issue.code)
|
|
2538
|
+
if suggestion:
|
|
2539
|
+
rule['fullDescription'] = {'text': suggestion}
|
|
2540
|
+
rules.append(rule)
|
|
2541
|
+
|
|
2542
|
+
results = []
|
|
2543
|
+
for issue in issues:
|
|
2544
|
+
# SARIF columns are 1-based; Issue.col/end_col are 0-based offsets.
|
|
2545
|
+
start_line = issue.line if issue.line > 0 else 1
|
|
2546
|
+
start_col = issue.col + 1 if issue.col >= 0 else 1
|
|
2547
|
+
end_line = issue.end_line if issue.end_line > 0 else start_line
|
|
2548
|
+
end_col = issue.end_col + 1 if issue.end_col > 0 else start_col
|
|
2549
|
+
message_text = issue.message
|
|
2550
|
+
if issue.suggestion:
|
|
2551
|
+
message_text = f"{issue.message} Suggestion: {issue.suggestion}"
|
|
2552
|
+
results.append({
|
|
2553
|
+
'ruleId': issue.code,
|
|
2554
|
+
'level': level_map.get(issue.severity, 'warning'),
|
|
2555
|
+
'message': {'text': message_text},
|
|
2556
|
+
'locations': [{
|
|
2557
|
+
'physicalLocation': {
|
|
2558
|
+
'artifactLocation': {'uri': _sarif_uri(issue.filename)},
|
|
2559
|
+
'region': {
|
|
2560
|
+
'startLine': start_line,
|
|
2561
|
+
'startColumn': start_col,
|
|
2562
|
+
'endLine': end_line,
|
|
2563
|
+
'endColumn': max(end_col, 1),
|
|
2564
|
+
},
|
|
2565
|
+
},
|
|
2566
|
+
}],
|
|
2567
|
+
})
|
|
2568
|
+
|
|
2569
|
+
payload = {
|
|
2570
|
+
'$schema': 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
2571
|
+
'version': '2.1.0',
|
|
2572
|
+
'runs': [{
|
|
2573
|
+
'tool': {
|
|
2574
|
+
'driver': {
|
|
2575
|
+
'name': 'CodeSnake',
|
|
2576
|
+
'version': tool_version,
|
|
2577
|
+
'informationUri': PROJECT_URL,
|
|
2578
|
+
'rules': rules,
|
|
2579
|
+
},
|
|
2580
|
+
},
|
|
2581
|
+
'results': results,
|
|
2582
|
+
}],
|
|
2583
|
+
}
|
|
2584
|
+
return json.dumps(payload, indent=2) + '\n'
|
|
2585
|
+
|
|
2586
|
+
|
|
2587
|
+
def _should_use_color(color: Optional[bool], stream: TextIO) -> bool:
|
|
2588
|
+
if color is False: # noqa: STYLE001 - tri-state Optional[bool]
|
|
2589
|
+
return False
|
|
2590
|
+
if color is True: # noqa: STYLE001
|
|
2591
|
+
return True
|
|
2592
|
+
if os.environ.get('NO_COLOR'):
|
|
2593
|
+
return False
|
|
2594
|
+
return hasattr(stream, 'isatty') and stream.isatty()
|
|
2595
|
+
|
|
2596
|
+
|
|
2597
|
+
def run_check(
|
|
2598
|
+
files: Sequence[str],
|
|
2599
|
+
*,
|
|
2600
|
+
config: Optional[CheckerConfig] = None,
|
|
2601
|
+
config_path: Optional[str] = None,
|
|
2602
|
+
output_format: str = 'text',
|
|
2603
|
+
min_severity: Optional[str] = None,
|
|
2604
|
+
show_banner: bool = False,
|
|
2605
|
+
color: Optional[bool] = None,
|
|
2606
|
+
stream: Optional[TextIO] = None,
|
|
2607
|
+
use_bandit: Optional[bool] = None,
|
|
2608
|
+
staged: bool = False,
|
|
2609
|
+
baseline_path: Optional[str] = None,
|
|
2610
|
+
update_baseline: Optional[str] = None,
|
|
2611
|
+
jobs: Optional[int] = None,
|
|
2612
|
+
) -> int:
|
|
2613
|
+
"""Analyze one or more files. Returns 1 if any error-severity issue exists.
|
|
2614
|
+
|
|
2615
|
+
``jobs``: worker processes (``None``/``0`` = auto, ``1`` = sequential).
|
|
2616
|
+
"""
|
|
2617
|
+
out = stream if stream is not None else sys.stdout
|
|
2618
|
+
|
|
2619
|
+
try:
|
|
2620
|
+
if config is None:
|
|
2621
|
+
config = load_config(config_path)
|
|
2622
|
+
except ConfigError as exc:
|
|
2623
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2624
|
+
return 1
|
|
2625
|
+
|
|
2626
|
+
file_list = list(files)
|
|
2627
|
+
if staged:
|
|
2628
|
+
staged_files, git_error = git_staged_python_files()
|
|
2629
|
+
if git_error:
|
|
2630
|
+
print(f"Error: {git_error}", file=sys.stderr)
|
|
2631
|
+
return 1
|
|
2632
|
+
if not staged_files:
|
|
2633
|
+
print("No staged Python files.", file=sys.stderr)
|
|
2634
|
+
return 0
|
|
2635
|
+
file_list = staged_files
|
|
2636
|
+
|
|
2637
|
+
if not file_list:
|
|
2638
|
+
print("Error: no files to check", file=sys.stderr)
|
|
2639
|
+
return 1
|
|
2640
|
+
|
|
2641
|
+
use_color = _should_use_color(color, out) and output_format == 'text'
|
|
2642
|
+
|
|
2643
|
+
if show_banner and output_format == 'text':
|
|
2644
|
+
print_snake_banner(use_color=use_color)
|
|
2645
|
+
|
|
2646
|
+
targets, extra_issues = expand_python_targets(file_list)
|
|
2647
|
+
|
|
2648
|
+
file_reports: List[Tuple[str, List[Issue], Optional[str]]] = []
|
|
2649
|
+
for extra in extra_issues:
|
|
2650
|
+
filtered = filter_issues([extra], config, min_severity=min_severity)
|
|
2651
|
+
file_reports.append((extra.filename or '', filtered, None))
|
|
2652
|
+
|
|
2653
|
+
sources_by_file: Dict[str, str] = {}
|
|
2654
|
+
known_exports: Dict[str, Set[str]] = {}
|
|
2655
|
+
for filepath in targets:
|
|
2656
|
+
path = Path(filepath)
|
|
2657
|
+
if not path.is_file():
|
|
2658
|
+
continue
|
|
2659
|
+
try:
|
|
2660
|
+
source = read_python_source(path)
|
|
2661
|
+
except (OSError, UnicodeDecodeError, LookupError):
|
|
2662
|
+
continue
|
|
2663
|
+
sources_by_file[filepath] = source
|
|
2664
|
+
try:
|
|
2665
|
+
known_exports[str(path.resolve())] = collect_module_exports(source)
|
|
2666
|
+
except OSError:
|
|
2667
|
+
known_exports[filepath] = collect_module_exports(source)
|
|
2668
|
+
|
|
2669
|
+
results = analyze_files(targets, config, known_exports, sources_by_file, jobs=jobs)
|
|
2670
|
+
for filepath, issues in zip(targets, results):
|
|
2671
|
+
issues = filter_issues(issues, config, min_severity=min_severity)
|
|
2672
|
+
file_reports.append((filepath, issues, sources_by_file.get(filepath)))
|
|
2673
|
+
|
|
2674
|
+
run_bandit = config.use_bandit if use_bandit is None else use_bandit
|
|
2675
|
+
if run_bandit and targets:
|
|
2676
|
+
if shutil.which('bandit') is None:
|
|
2677
|
+
print(
|
|
2678
|
+
"Warning: --bandit requested but the 'bandit' executable was not found",
|
|
2679
|
+
file=sys.stderr,
|
|
2680
|
+
)
|
|
2681
|
+
else:
|
|
2682
|
+
bandit_issues = collect_bandit_issues(targets)
|
|
2683
|
+
by_file: Dict[str, List[Issue]] = {}
|
|
2684
|
+
for issue in bandit_issues:
|
|
2685
|
+
by_file.setdefault(os.path.abspath(issue.filename), []).append(issue)
|
|
2686
|
+
for index, (filepath, issues, source) in enumerate(file_reports):
|
|
2687
|
+
extra = by_file.get(os.path.abspath(filepath), [])
|
|
2688
|
+
if source:
|
|
2689
|
+
lines = source.split('\n')
|
|
2690
|
+
extra = [
|
|
2691
|
+
issue for issue in extra
|
|
2692
|
+
if not issue_ignored_by_pragma(issue.code, issue.line, lines)
|
|
2693
|
+
]
|
|
2694
|
+
extra = filter_issues(extra, config, min_severity=min_severity)
|
|
2695
|
+
if extra:
|
|
2696
|
+
file_reports[index] = (filepath, issues + extra, source)
|
|
2697
|
+
|
|
2698
|
+
if update_baseline:
|
|
2699
|
+
snapshot = [issue for _, issues, _ in file_reports for issue in issues]
|
|
2700
|
+
try:
|
|
2701
|
+
write_baseline(snapshot, update_baseline)
|
|
2702
|
+
except OSError as exc:
|
|
2703
|
+
print(f"Error: could not write baseline '{update_baseline}': {exc}", file=sys.stderr)
|
|
2704
|
+
return 1
|
|
2705
|
+
|
|
2706
|
+
if baseline_path:
|
|
2707
|
+
try:
|
|
2708
|
+
fingerprints = load_baseline(baseline_path)
|
|
2709
|
+
except ConfigError as exc:
|
|
2710
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2711
|
+
return 1
|
|
2712
|
+
filtered_reports: List[Tuple[str, List[Issue], Optional[str]]] = []
|
|
2713
|
+
for filepath, issues, source in file_reports:
|
|
2714
|
+
keep = [
|
|
2715
|
+
issue for issue, fingerprint in zip(issues, issue_fingerprints(issues))
|
|
2716
|
+
if fingerprint not in fingerprints
|
|
2717
|
+
]
|
|
2718
|
+
filtered_reports.append((filepath, keep, source))
|
|
2719
|
+
file_reports = filtered_reports
|
|
2720
|
+
|
|
2721
|
+
displayed: List[Tuple[str, List[Issue]]] = [
|
|
2722
|
+
(path, issues) for path, issues, _ in file_reports
|
|
2723
|
+
]
|
|
2724
|
+
all_displayed = [issue for _, issues in displayed for issue in issues]
|
|
2725
|
+
errors, warnings, infos = _count_by_severity(all_displayed)
|
|
2726
|
+
if output_format == 'json':
|
|
2727
|
+
out.write(format_json_report(displayed))
|
|
2728
|
+
elif output_format == 'github':
|
|
2729
|
+
out.write(format_github_report(all_displayed))
|
|
2730
|
+
elif output_format == 'sarif':
|
|
2731
|
+
out.write(format_sarif_report(all_displayed, __version__))
|
|
2732
|
+
else:
|
|
2733
|
+
any_issues = False
|
|
2734
|
+
for filepath, issues, source in file_reports:
|
|
2735
|
+
if not issues:
|
|
2736
|
+
out.write(f"✓ No issues found in {filepath}\n")
|
|
2737
|
+
continue
|
|
2738
|
+
any_issues = True
|
|
2739
|
+
out.write(f"\nAnalysis of {filepath}:\n")
|
|
2740
|
+
out.write(f"{'=' * 60}\n\n")
|
|
2741
|
+
# Split once per file, not once per issue.
|
|
2742
|
+
source_lines = source.splitlines() if source else []
|
|
2743
|
+
for issue in issues:
|
|
2744
|
+
source_line = _source_line_from(source_lines, issue.line)
|
|
2745
|
+
rendered = format_issue(issue, source_line, use_color=use_color)
|
|
2746
|
+
out.write(rendered)
|
|
2747
|
+
if not rendered.endswith('\n'):
|
|
2748
|
+
out.write('\n')
|
|
2749
|
+
out.write('\n')
|
|
2750
|
+
if any_issues or len(file_reports) > 1:
|
|
2751
|
+
out.write(
|
|
2752
|
+
f"\nSummary: {errors} errors, {warnings} warnings, {infos} info\n"
|
|
2753
|
+
)
|
|
2754
|
+
|
|
2755
|
+
return 1 if errors else 0
|