bmsdna-devtools 0.26.0__tar.gz → 0.28.0__tar.gz
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.
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/PKG-INFO +8 -3
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/README.md +7 -2
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/cli.py +8 -1
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/find_injection.py +91 -32
- bmsdna_devtools-0.28.0/bmsdna/devtools/injection_python.py +373 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_sql.py +11 -10
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_find_injection.py +156 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/uv.lock +1 -1
- bmsdna_devtools-0.26.0/bmsdna/devtools/injection_python.py +0 -189
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/.gitignore +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/.python-version +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/justfile +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_lint_sql.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_worktree_clean.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.0
|
|
4
4
|
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
@@ -512,7 +512,8 @@ it can't see through):
|
|
|
512
512
|
- **`sql-fstring-injection`** / **`sql-concat-injection`** /
|
|
513
513
|
**`sql-percent-format-injection`** / **`sql-format-injection`** — SQL built
|
|
514
514
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
515
|
-
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params.
|
|
515
|
+
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
|
|
516
|
+
usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
|
|
516
517
|
- **`sql-positional-param`** — positional `%s` instead of named `%(name)s`.
|
|
517
518
|
- **`sql-forbidden-join`** — `RIGHT JOIN`/`LATERAL JOIN`/`CROSS APPLY` (same
|
|
518
519
|
patterns the `prek` skill's `check_files.py` forbids in `.sql` files).
|
|
@@ -607,7 +608,11 @@ code 0 unless `--strict`).
|
|
|
607
608
|
`staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
|
|
608
609
|
`'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.
|
|
609
610
|
|
|
610
|
-
|
|
611
|
+
Skipped automatically: test files, generated code, `*.min.js` files, the usual build/vendor directories (`node_modules`, `.venv`, `dist`, ...) and,
|
|
612
|
+
inside a git repo, anything ignored by `.gitignore` (pass `--no-gitignore` to scan it anyway).
|
|
613
|
+
Add more with `--exclude` (repeatable; a directory name at any depth, a root-relative path, or a glob
|
|
614
|
+
such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproject.toml`.
|
|
615
|
+
Identical findings on the same line are reported once. Silence a confirmed-safe finding with
|
|
611
616
|
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
|
|
612
617
|
|
|
613
618
|
## Releasing
|
|
@@ -499,7 +499,8 @@ it can't see through):
|
|
|
499
499
|
- **`sql-fstring-injection`** / **`sql-concat-injection`** /
|
|
500
500
|
**`sql-percent-format-injection`** / **`sql-format-injection`** — SQL built
|
|
501
501
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
502
|
-
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params.
|
|
502
|
+
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
|
|
503
|
+
usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
|
|
503
504
|
- **`sql-positional-param`** — positional `%s` instead of named `%(name)s`.
|
|
504
505
|
- **`sql-forbidden-join`** — `RIGHT JOIN`/`LATERAL JOIN`/`CROSS APPLY` (same
|
|
505
506
|
patterns the `prek` skill's `check_files.py` forbids in `.sql` files).
|
|
@@ -594,7 +595,11 @@ code 0 unless `--strict`).
|
|
|
594
595
|
`staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
|
|
595
596
|
`'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.
|
|
596
597
|
|
|
597
|
-
|
|
598
|
+
Skipped automatically: test files, generated code, `*.min.js` files, the usual build/vendor directories (`node_modules`, `.venv`, `dist`, ...) and,
|
|
599
|
+
inside a git repo, anything ignored by `.gitignore` (pass `--no-gitignore` to scan it anyway).
|
|
600
|
+
Add more with `--exclude` (repeatable; a directory name at any depth, a root-relative path, or a glob
|
|
601
|
+
such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproject.toml`.
|
|
602
|
+
Identical findings on the same line are reported once. Silence a confirmed-safe finding with
|
|
598
603
|
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
|
|
599
604
|
|
|
600
605
|
## Releasing
|
|
@@ -933,13 +933,20 @@ def find_injection_cmd(
|
|
|
933
933
|
diff: bool = typer.Option(False, "--diff", help="Scan only files changed on this branch vs --base, plus uncommitted and untracked files."),
|
|
934
934
|
base: str = typer.Option(None, "--base", help="Branch/ref to diff against with --diff (default: origin's default branch)."),
|
|
935
935
|
strict: bool = typer.Option(False, "--strict", help="Also exit 1 when there are items to review (default: only definite errors fail)."),
|
|
936
|
+
exclude: list[str] = typer.Option(
|
|
937
|
+
None,
|
|
938
|
+
"--exclude",
|
|
939
|
+
help="Directory name (any depth), root-relative path, or glob to skip (repeatable), e.g. --exclude vendor --exclude 'assets/**/*.js'. "
|
|
940
|
+
"Also configurable via [tool.bdt.lint] exclude_dirs in pyproject.toml.",
|
|
941
|
+
),
|
|
942
|
+
no_gitignore: bool = typer.Option(False, "--no-gitignore", help="Also scan files ignored by .gitignore (default: they are skipped)."),
|
|
936
943
|
) -> None:
|
|
937
944
|
"""Find injection risks (bmsuisse/devtools#54) in backend and frontend code: SQL built from f-strings/
|
|
938
945
|
concatenation or from an unverifiable function call, eval/exec/shell/unsafe deserialization in Python,
|
|
939
946
|
innerHTML/dangerouslySetInnerHTML/eval/new Function/un-sandboxed iframes in the frontend, and a missing
|
|
940
947
|
or weakened Content-Security-Policy. Definite problems are errors; the rest are listed for a human/AI to verify.
|
|
941
948
|
"""
|
|
942
|
-
result = find_injection_mod.run(paths or [], diff=diff, base=base)
|
|
949
|
+
result = find_injection_mod.run(paths or [], diff=diff, base=base, exclude=exclude or [], respect_gitignore=not no_gitignore)
|
|
943
950
|
raise typer.Exit(find_injection_mod.print_report(result, strict=strict))
|
|
944
951
|
|
|
945
952
|
|
|
@@ -14,11 +14,13 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import os
|
|
16
16
|
import re
|
|
17
|
+
from fnmatch import fnmatch
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
19
20
|
from dataclasses import dataclass
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
|
|
23
|
+
from .bdt_config import load_bdt_table
|
|
22
24
|
from .injection_frontend import (
|
|
23
25
|
FRONTEND_SUFFIXES,
|
|
24
26
|
check_frontend_file,
|
|
@@ -165,21 +167,79 @@ def _scan_python(path: Path) -> list[Finding]:
|
|
|
165
167
|
return sql + check_python_sinks(path)
|
|
166
168
|
|
|
167
169
|
|
|
168
|
-
|
|
170
|
+
_GLOB_CHARS = frozenset("*?[")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _ScanFilter:
|
|
174
|
+
"""Which files are in scope: not under an excluded directory name, not matching an `--exclude`
|
|
175
|
+
path/glob (relative to the repo root), and -- in a git repo, unless disabled -- not gitignored
|
|
176
|
+
(tracked files always count, like git itself)."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, root: Path, exclude: list[str], respect_gitignore: bool) -> None:
|
|
179
|
+
self.root = root
|
|
180
|
+
self.dir_names = _DEFAULT_EXCLUDE_DIR_NAMES | {".next", "coverage", "storybook-static"}
|
|
181
|
+
self._patterns: list[str] = []
|
|
182
|
+
for entry in exclude:
|
|
183
|
+
entry = entry.replace("\\", "/").strip().strip("/")
|
|
184
|
+
if entry.startswith("./"):
|
|
185
|
+
entry = entry[2:]
|
|
186
|
+
if not entry:
|
|
187
|
+
continue
|
|
188
|
+
if "/" not in entry and not _GLOB_CHARS & set(entry):
|
|
189
|
+
self.dir_names = self.dir_names | {entry}
|
|
190
|
+
else:
|
|
191
|
+
self._patterns.append(entry)
|
|
192
|
+
self._visible = _git_visible_files(root) if respect_gitignore else None
|
|
193
|
+
|
|
194
|
+
def allows(self, path: Path) -> bool:
|
|
195
|
+
resolved = path.resolve()
|
|
196
|
+
try:
|
|
197
|
+
rel = resolved.relative_to(self.root)
|
|
198
|
+
except ValueError:
|
|
199
|
+
return True
|
|
200
|
+
if any(part in self.dir_names for part in rel.parts[:-1]):
|
|
201
|
+
return False
|
|
202
|
+
posix = rel.as_posix()
|
|
203
|
+
if any(fnmatch(posix, pat) or fnmatch(posix, pat + "/*") for pat in self._patterns):
|
|
204
|
+
return False
|
|
205
|
+
return self._visible is None or resolved in self._visible
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _git_visible_files(root: Path) -> set[Path] | None:
|
|
209
|
+
"""Tracked plus untracked-but-not-ignored files under `root`, or None outside a git repo."""
|
|
210
|
+
try:
|
|
211
|
+
out = subprocess.run(
|
|
212
|
+
["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
213
|
+
cwd=root,
|
|
214
|
+
capture_output=True,
|
|
215
|
+
encoding="utf-8",
|
|
216
|
+
)
|
|
217
|
+
except FileNotFoundError:
|
|
218
|
+
return None
|
|
219
|
+
if out.returncode != 0:
|
|
220
|
+
return None
|
|
221
|
+
return {(root / name).resolve() for name in out.stdout.split("\0") if name}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _is_minified(path: Path) -> bool:
|
|
225
|
+
return path.name.endswith(".min.js")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _walk_repo_text_files(root: Path, scope: _ScanFilter):
|
|
169
229
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
170
|
-
dirnames[:] = [d for d in dirnames if d not in
|
|
230
|
+
dirnames[:] = [d for d in dirnames if d not in scope.dir_names]
|
|
171
231
|
for name in filenames:
|
|
172
232
|
if name in _SKIP_NAMES or name.endswith(".min.js") or not (name in _CONFIG_NAMES or name.endswith(_CONFIG_SUFFIXES)):
|
|
173
233
|
continue
|
|
174
234
|
candidate = Path(dirpath, name)
|
|
175
235
|
try:
|
|
176
|
-
if candidate.stat().st_size <= _MAX_CSP_FILE_BYTES:
|
|
236
|
+
if candidate.stat().st_size <= _MAX_CSP_FILE_BYTES and scope.allows(candidate):
|
|
177
237
|
yield candidate
|
|
178
238
|
except OSError:
|
|
179
239
|
continue
|
|
180
240
|
|
|
181
241
|
|
|
182
|
-
def check_csp(root: Path, scanned: list[Path],
|
|
242
|
+
def check_csp(root: Path, scanned: list[Path], scope: _ScanFilter, only: set[Path] | None = None) -> list[Finding]:
|
|
183
243
|
"""Warns when the project serves a web UI/API (frontend files scanned, or a Python web framework
|
|
184
244
|
imported) but no file anywhere under `root` mentions a Content-Security-Policy. Always searches the
|
|
185
245
|
whole repo -- a diff that doesn't touch the header config mustn't look like "no CSP configured".
|
|
@@ -198,14 +258,15 @@ def check_csp(root: Path, scanned: list[Path], exclude_dirs: frozenset[str], onl
|
|
|
198
258
|
return []
|
|
199
259
|
weakened: list[Finding] = []
|
|
200
260
|
found = False
|
|
201
|
-
for candidate in _walk_repo_text_files(root,
|
|
261
|
+
for candidate in _walk_repo_text_files(root, scope):
|
|
202
262
|
try:
|
|
203
263
|
text = candidate.read_text(encoding="utf-8")
|
|
204
264
|
except OSError, UnicodeDecodeError:
|
|
205
265
|
continue
|
|
206
266
|
if has_csp(text):
|
|
207
267
|
found = True
|
|
208
|
-
|
|
268
|
+
if not _is_test_file(candidate, root):
|
|
269
|
+
weakened.extend(find_csp_weakening(candidate, text))
|
|
209
270
|
if found:
|
|
210
271
|
return [f for f in weakened if only is None or f.path.resolve() in only]
|
|
211
272
|
return [
|
|
@@ -227,36 +288,31 @@ def run(
|
|
|
227
288
|
root: Path | None = None,
|
|
228
289
|
diff: bool = False,
|
|
229
290
|
base: str | None = None,
|
|
291
|
+
exclude: list[str] | None = None,
|
|
292
|
+
respect_gitignore: bool = True,
|
|
230
293
|
) -> InjectionResult:
|
|
294
|
+
"""`exclude`: directory names or root-relative paths/globs to skip, on top of `[tool.bdt.lint] exclude_dirs`
|
|
295
|
+
in pyproject.toml and the built-in list (node_modules, .venv, dist, ...)."""
|
|
231
296
|
root = (root or Path.cwd()).resolve()
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
"coverage",
|
|
235
|
-
"storybook-static",
|
|
236
|
-
}
|
|
297
|
+
configured = [str(d) for d in load_bdt_table("lint", root).get("exclude_dirs", []) or []]
|
|
298
|
+
scope = _ScanFilter(root, configured + list(exclude or []), respect_gitignore)
|
|
237
299
|
findings: list[Finding] = []
|
|
238
300
|
changed: list[Path] = []
|
|
239
301
|
|
|
240
302
|
if diff:
|
|
241
|
-
changed = [p for p in diff_files(root, base) if
|
|
303
|
+
changed = [p for p in diff_files(root, base) if scope.allows(p)]
|
|
242
304
|
py_files = [p for p in changed if p.suffix == ".py"]
|
|
243
305
|
fe_files = [p for p in changed if p.suffix.lower() in FRONTEND_SUFFIXES]
|
|
244
306
|
else:
|
|
245
307
|
targets = [Path(p).resolve() for p in paths] if paths else [root]
|
|
246
|
-
py_files, missing = _iter_files(targets,
|
|
247
|
-
fe_files, _ = _iter_files(targets,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
0,
|
|
252
|
-
"path-not-found",
|
|
253
|
-
f"'{p}' doesn't exist -- nothing was scanned for it.",
|
|
254
|
-
)
|
|
255
|
-
for p in missing
|
|
256
|
-
)
|
|
308
|
+
py_files, missing = _iter_files(targets, scope.dir_names, (".py",))
|
|
309
|
+
fe_files, _ = _iter_files(targets, scope.dir_names, FRONTEND_SUFFIXES)
|
|
310
|
+
py_files = [p for p in py_files if scope.allows(p)]
|
|
311
|
+
fe_files = [p for p in fe_files if scope.allows(p)]
|
|
312
|
+
findings.extend(Finding(p, 0, "path-not-found", f"'{p}' doesn't exist -- nothing was scanned for it.") for p in missing)
|
|
257
313
|
|
|
258
314
|
py_files = [p for p in py_files if not _is_test_file(p, root)]
|
|
259
|
-
fe_files = [p for p in fe_files if not _is_test_file(p, root)]
|
|
315
|
+
fe_files = [p for p in fe_files if not _is_test_file(p, root) and not _is_minified(p)]
|
|
260
316
|
|
|
261
317
|
if py_files:
|
|
262
318
|
require_sqlglot()
|
|
@@ -265,16 +321,19 @@ def run(
|
|
|
265
321
|
for path in fe_files:
|
|
266
322
|
findings.extend(check_frontend_file(path))
|
|
267
323
|
|
|
268
|
-
findings.extend(check_csp(root, py_files + fe_files,
|
|
269
|
-
return InjectionResult(findings=_filter_ignored(findings))
|
|
324
|
+
findings.extend(check_csp(root, py_files + fe_files, scope, {p.resolve() for p in changed} if diff else None))
|
|
325
|
+
return InjectionResult(findings=_dedupe(_filter_ignored(findings)))
|
|
270
326
|
|
|
271
327
|
|
|
272
|
-
def
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
328
|
+
def _dedupe(findings: list[Finding]) -> list[Finding]:
|
|
329
|
+
seen: set[tuple[Path, int, str]] = set()
|
|
330
|
+
unique: list[Finding] = []
|
|
331
|
+
for f in findings:
|
|
332
|
+
key = (f.path, f.line, f.rule)
|
|
333
|
+
if key not in seen:
|
|
334
|
+
seen.add(key)
|
|
335
|
+
unique.append(f)
|
|
336
|
+
return unique
|
|
278
337
|
|
|
279
338
|
|
|
280
339
|
def _is_test_file(path: Path, root: Path) -> bool:
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""`bdt find-injection`'s non-SQL Python sinks (bmsuisse/devtools#54): code/command execution
|
|
2
|
+
and unsafe-deserialization/markup calls that are only a problem when their input isn't a
|
|
3
|
+
literal. SQL is handled by lint_sql (`check_sql_file(review=True)`).
|
|
4
|
+
|
|
5
|
+
Definite patterns are "error"; anything that depends on where the value comes from is a
|
|
6
|
+
"review" item for a human/AI to verify.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .lint_findings import Finding
|
|
15
|
+
|
|
16
|
+
_SAFE_YAML_LOADERS = frozenset({"SafeLoader", "CSafeLoader", "BaseLoader"})
|
|
17
|
+
_MARKUP_FUNCS = frozenset({"Markup", "mark_safe", "format_html_join"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_FIRST_ARG_KEYWORDS = ("command", "cmd", "args", "source", "string", "template_source", "data", "s")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _import_aliases(tree: ast.AST) -> dict[str, str]:
|
|
24
|
+
"""Local name -> the dotted name it was imported as (`from os import system as sh` -> `sh: os.system`)."""
|
|
25
|
+
aliases: dict[str, str] = {}
|
|
26
|
+
for node in ast.walk(tree):
|
|
27
|
+
if isinstance(node, ast.Import):
|
|
28
|
+
for a in node.names:
|
|
29
|
+
if a.asname:
|
|
30
|
+
aliases[a.asname] = a.name
|
|
31
|
+
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
32
|
+
for a in node.names:
|
|
33
|
+
aliases[a.asname or a.name] = f"{node.module}.{a.name}"
|
|
34
|
+
return aliases
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _dotted(func: ast.expr, aliases: dict[str, str]) -> str:
|
|
38
|
+
parts: list[str] = []
|
|
39
|
+
while isinstance(func, ast.Attribute):
|
|
40
|
+
parts.append(func.attr)
|
|
41
|
+
func = func.value
|
|
42
|
+
if isinstance(func, ast.Name):
|
|
43
|
+
parts.append(aliases.get(func.id, func.id))
|
|
44
|
+
return ".".join(reversed(parts))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Scope:
|
|
48
|
+
"""Every binding of each name in one function/module scope. `None` marks a binding whose value
|
|
49
|
+
isn't statically known (parameter, loop over a variable, `+=`, import, ...)."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, parent: _Scope | None, is_class: bool = False) -> None:
|
|
52
|
+
self.parent = parent
|
|
53
|
+
self.is_class = is_class
|
|
54
|
+
self.bindings: dict[str, list[ast.expr | None]] = {}
|
|
55
|
+
self.global_names: set[str] = set()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _target_names(target: ast.AST) -> list[str]:
|
|
59
|
+
return [n.id for n in ast.walk(target) if isinstance(n, ast.Name)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _ScopeBuilder(ast.NodeVisitor):
|
|
63
|
+
def __init__(self, tree: ast.Module) -> None:
|
|
64
|
+
self.module = _Scope(None)
|
|
65
|
+
self.scope = self.module
|
|
66
|
+
self.calls: list[tuple[ast.Call, _Scope]] = []
|
|
67
|
+
self._body(tree)
|
|
68
|
+
|
|
69
|
+
def _body(self, node: ast.AST) -> None:
|
|
70
|
+
for child in ast.iter_child_nodes(node):
|
|
71
|
+
self.visit(child)
|
|
72
|
+
|
|
73
|
+
def _bind(self, name: str, value: ast.expr | None) -> None:
|
|
74
|
+
scope = self.module if name in self.scope.global_names else self.scope
|
|
75
|
+
scope.bindings.setdefault(name, []).append(value)
|
|
76
|
+
|
|
77
|
+
def _unknown(self, target: ast.AST) -> None:
|
|
78
|
+
for name in _target_names(target):
|
|
79
|
+
self._bind(name, None)
|
|
80
|
+
|
|
81
|
+
def _function(self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda) -> None:
|
|
82
|
+
if not isinstance(node, ast.Lambda):
|
|
83
|
+
self._bind(node.name, None)
|
|
84
|
+
for deco in node.decorator_list:
|
|
85
|
+
self.visit(deco)
|
|
86
|
+
for default in [*node.args.defaults, *(d for d in node.args.kw_defaults if d is not None)]:
|
|
87
|
+
self.visit(default)
|
|
88
|
+
outer = self.scope
|
|
89
|
+
parent = outer
|
|
90
|
+
while parent.is_class and parent.parent is not None:
|
|
91
|
+
parent = parent.parent
|
|
92
|
+
self.scope = _Scope(parent)
|
|
93
|
+
args = node.args
|
|
94
|
+
for a in [*args.posonlyargs, *args.args, *args.kwonlyargs, *(x for x in (args.vararg, args.kwarg) if x)]:
|
|
95
|
+
self._bind(a.arg, None)
|
|
96
|
+
for stmt in node.body if isinstance(node.body, list) else [node.body]:
|
|
97
|
+
self.visit(stmt)
|
|
98
|
+
self.scope = outer
|
|
99
|
+
|
|
100
|
+
visit_FunctionDef = visit_AsyncFunctionDef = visit_Lambda = _function
|
|
101
|
+
|
|
102
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
103
|
+
self._bind(node.name, None)
|
|
104
|
+
for expr in [*node.bases, *(k.value for k in node.keywords), *node.decorator_list]:
|
|
105
|
+
self.visit(expr)
|
|
106
|
+
outer = self.scope
|
|
107
|
+
self.scope = _Scope(outer, is_class=True)
|
|
108
|
+
for stmt in node.body:
|
|
109
|
+
self.visit(stmt)
|
|
110
|
+
self.scope = outer
|
|
111
|
+
|
|
112
|
+
def visit_Global(self, node: ast.Global) -> None:
|
|
113
|
+
self.scope.global_names.update(node.names)
|
|
114
|
+
for name in node.names:
|
|
115
|
+
self.module.bindings.setdefault(name, [])
|
|
116
|
+
|
|
117
|
+
def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
|
|
118
|
+
scope = self.scope.parent
|
|
119
|
+
while scope is not None and scope is not self.module:
|
|
120
|
+
for name in node.names:
|
|
121
|
+
scope.bindings.setdefault(name, []).append(None)
|
|
122
|
+
scope = scope.parent
|
|
123
|
+
|
|
124
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
125
|
+
self.visit(node.value)
|
|
126
|
+
for target in node.targets:
|
|
127
|
+
if isinstance(target, ast.Name):
|
|
128
|
+
self._bind(target.id, node.value)
|
|
129
|
+
else:
|
|
130
|
+
self._unknown(target)
|
|
131
|
+
|
|
132
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
133
|
+
if node.value is not None:
|
|
134
|
+
self.visit(node.value)
|
|
135
|
+
if isinstance(node.target, ast.Name):
|
|
136
|
+
self._bind(node.target.id, node.value)
|
|
137
|
+
else:
|
|
138
|
+
self._unknown(node.target)
|
|
139
|
+
|
|
140
|
+
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
|
141
|
+
self.visit(node.value)
|
|
142
|
+
self._unknown(node.target)
|
|
143
|
+
|
|
144
|
+
def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
|
|
145
|
+
self.visit(node.value)
|
|
146
|
+
self._bind(node.target.id, None)
|
|
147
|
+
|
|
148
|
+
def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
|
|
149
|
+
self.visit(node.iter)
|
|
150
|
+
if isinstance(node.target, ast.Name) and isinstance(node.iter, (ast.List, ast.Tuple, ast.Set)):
|
|
151
|
+
for element in node.iter.elts:
|
|
152
|
+
self._bind(node.target.id, element)
|
|
153
|
+
else:
|
|
154
|
+
self._unknown(node.target)
|
|
155
|
+
for stmt in [*node.body, *node.orelse]:
|
|
156
|
+
self.visit(stmt)
|
|
157
|
+
|
|
158
|
+
visit_AsyncFor = visit_For
|
|
159
|
+
|
|
160
|
+
def visit_comprehension(self, node: ast.comprehension) -> None:
|
|
161
|
+
self._unknown(node.target)
|
|
162
|
+
self._body(node)
|
|
163
|
+
|
|
164
|
+
def visit_withitem(self, node: ast.withitem) -> None:
|
|
165
|
+
if node.optional_vars is not None:
|
|
166
|
+
self._unknown(node.optional_vars)
|
|
167
|
+
self._body(node)
|
|
168
|
+
|
|
169
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
170
|
+
if node.name:
|
|
171
|
+
self._bind(node.name, None)
|
|
172
|
+
self._body(node)
|
|
173
|
+
|
|
174
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
175
|
+
for a in node.names:
|
|
176
|
+
self._bind((a.asname or a.name).split(".")[0], None)
|
|
177
|
+
|
|
178
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
179
|
+
for a in node.names:
|
|
180
|
+
self._bind(a.asname or a.name, None)
|
|
181
|
+
|
|
182
|
+
def visit_Delete(self, node: ast.Delete) -> None:
|
|
183
|
+
for target in node.targets:
|
|
184
|
+
self._unknown(target)
|
|
185
|
+
|
|
186
|
+
def visit_MatchAs(self, node: ast.MatchAs) -> None:
|
|
187
|
+
if node.name:
|
|
188
|
+
self._bind(node.name, None)
|
|
189
|
+
self._body(node)
|
|
190
|
+
|
|
191
|
+
def visit_MatchStar(self, node: ast.MatchStar) -> None:
|
|
192
|
+
if node.name:
|
|
193
|
+
self._bind(node.name, None)
|
|
194
|
+
|
|
195
|
+
def visit_MatchMapping(self, node: ast.MatchMapping) -> None:
|
|
196
|
+
if node.rest:
|
|
197
|
+
self._bind(node.rest, None)
|
|
198
|
+
self._body(node)
|
|
199
|
+
|
|
200
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
201
|
+
self.calls.append((node, self.scope))
|
|
202
|
+
self._body(node)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _lookup(scope: _Scope, name: str) -> list[ast.expr | None] | None:
|
|
206
|
+
current: _Scope | None = scope
|
|
207
|
+
while current is not None:
|
|
208
|
+
if name in current.bindings:
|
|
209
|
+
return current.bindings[name]
|
|
210
|
+
current = current.parent
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _is_const(node: ast.expr, scope: _Scope, seen: frozenset[str] = frozenset()) -> bool:
|
|
215
|
+
"""True when every value `node` can take is a string built only from literals -- including names
|
|
216
|
+
whose every assignment in scope is such a constant."""
|
|
217
|
+
if isinstance(node, ast.Constant):
|
|
218
|
+
return isinstance(node.value, str)
|
|
219
|
+
if isinstance(node, ast.BinOp):
|
|
220
|
+
return isinstance(node.op, ast.Add) and _is_const(node.left, scope, seen) and _is_const(node.right, scope, seen)
|
|
221
|
+
if isinstance(node, ast.JoinedStr):
|
|
222
|
+
return all(_is_const(v.value if isinstance(v, ast.FormattedValue) else v, scope, seen) for v in node.values)
|
|
223
|
+
if isinstance(node, ast.IfExp):
|
|
224
|
+
return _is_const(node.body, scope, seen) and _is_const(node.orelse, scope, seen)
|
|
225
|
+
if isinstance(node, ast.Name):
|
|
226
|
+
if node.id in seen:
|
|
227
|
+
return False
|
|
228
|
+
bound = _lookup(scope, node.id)
|
|
229
|
+
return bool(bound) and all(b is not None and _is_const(b, scope, seen | {node.id}) for b in bound)
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _is_arg_list(node: ast.expr | None, scope: _Scope) -> bool:
|
|
234
|
+
"""A list/tuple command whose program is a literal: extra (even dynamic) items are arguments, not shell code."""
|
|
235
|
+
return isinstance(node, (ast.List, ast.Tuple)) and bool(node.elts) and _is_const(node.elts[0], scope)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _shell_true(call: ast.Call) -> bool:
|
|
239
|
+
return any(kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True for kw in call.keywords)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _finding(path: Path, node: ast.Call, rule: str, message: str, severity: str) -> Finding:
|
|
243
|
+
return Finding(path, node.lineno, rule, message, severity=severity)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _check_call(path: Path, node: ast.Call, aliases: dict[str, str], scope: _Scope) -> list[Finding]:
|
|
247
|
+
name = _dotted(node.func, aliases)
|
|
248
|
+
last = name.rsplit(".", 1)[-1]
|
|
249
|
+
first_arg = node.args[0] if node.args else next((kw.value for kw in node.keywords if kw.arg in _FIRST_ARG_KEYWORDS), None)
|
|
250
|
+
dynamic = first_arg is not None and not _is_const(first_arg, scope)
|
|
251
|
+
|
|
252
|
+
if name in ("eval", "exec") and dynamic:
|
|
253
|
+
return [
|
|
254
|
+
_finding(
|
|
255
|
+
path,
|
|
256
|
+
node,
|
|
257
|
+
"py-eval-exec",
|
|
258
|
+
f"`{name}()` on a non-literal executes arbitrary code -- never pass request/user data.",
|
|
259
|
+
"error",
|
|
260
|
+
)
|
|
261
|
+
]
|
|
262
|
+
if name in ("os.system", "os.popen") and dynamic:
|
|
263
|
+
return [
|
|
264
|
+
_finding(
|
|
265
|
+
path,
|
|
266
|
+
node,
|
|
267
|
+
"py-shell-command",
|
|
268
|
+
f"`{name}()` with a non-literal command runs it through a shell -- use subprocess with an argument list.",
|
|
269
|
+
"error",
|
|
270
|
+
)
|
|
271
|
+
]
|
|
272
|
+
if name.startswith("subprocess.") and _shell_true(node) and not _is_arg_list(first_arg, scope):
|
|
273
|
+
if dynamic:
|
|
274
|
+
return [
|
|
275
|
+
_finding(
|
|
276
|
+
path,
|
|
277
|
+
node,
|
|
278
|
+
"py-shell-command",
|
|
279
|
+
"`subprocess` with `shell=True` and a non-literal command -- pass an argument list without shell=True.",
|
|
280
|
+
"error",
|
|
281
|
+
)
|
|
282
|
+
]
|
|
283
|
+
if first_arg is not None:
|
|
284
|
+
return [
|
|
285
|
+
_finding(
|
|
286
|
+
path,
|
|
287
|
+
node,
|
|
288
|
+
"py-shell-true",
|
|
289
|
+
"`shell=True` with a literal command is safe today, but verify no value is ever interpolated into it later.",
|
|
290
|
+
"review",
|
|
291
|
+
)
|
|
292
|
+
]
|
|
293
|
+
if name in (
|
|
294
|
+
"pickle.loads",
|
|
295
|
+
"pickle.load",
|
|
296
|
+
"cPickle.loads",
|
|
297
|
+
"marshal.loads",
|
|
298
|
+
"shelve.open",
|
|
299
|
+
"dill.loads",
|
|
300
|
+
):
|
|
301
|
+
return [
|
|
302
|
+
_finding(
|
|
303
|
+
path,
|
|
304
|
+
node,
|
|
305
|
+
"py-unsafe-deserialization",
|
|
306
|
+
f"`{name}()` can execute arbitrary code from untrusted bytes. AI reviewer: verify the data is never user-supplied.",
|
|
307
|
+
"review",
|
|
308
|
+
)
|
|
309
|
+
]
|
|
310
|
+
if name in ("yaml.load", "yaml.load_all", "yaml.unsafe_load"):
|
|
311
|
+
loader = next((kw.value for kw in node.keywords if kw.arg == "Loader"), None)
|
|
312
|
+
if loader is None and len(node.args) > 1:
|
|
313
|
+
loader = node.args[1]
|
|
314
|
+
if name == "yaml.unsafe_load" or loader is None or _dotted(loader, aliases).rsplit(".", 1)[-1] not in _SAFE_YAML_LOADERS:
|
|
315
|
+
return [
|
|
316
|
+
_finding(
|
|
317
|
+
path,
|
|
318
|
+
node,
|
|
319
|
+
"py-unsafe-yaml",
|
|
320
|
+
f"`{name}()` without a safe loader can construct arbitrary objects -- use `yaml.safe_load`.",
|
|
321
|
+
"error",
|
|
322
|
+
)
|
|
323
|
+
]
|
|
324
|
+
if last in _MARKUP_FUNCS and dynamic:
|
|
325
|
+
return [
|
|
326
|
+
_finding(
|
|
327
|
+
path,
|
|
328
|
+
node,
|
|
329
|
+
"py-unescaped-markup",
|
|
330
|
+
f"`{last}()` marks a non-literal string as safe HTML (XSS). AI reviewer: verify it is escaped or never user-controlled.",
|
|
331
|
+
"review",
|
|
332
|
+
)
|
|
333
|
+
]
|
|
334
|
+
if last in ("Environment", "Jinja2Templates") and any(
|
|
335
|
+
kw.arg == "autoescape" and isinstance(kw.value, ast.Constant) and kw.value.value is False for kw in node.keywords
|
|
336
|
+
):
|
|
337
|
+
return [
|
|
338
|
+
_finding(
|
|
339
|
+
path,
|
|
340
|
+
node,
|
|
341
|
+
"py-autoescape-off",
|
|
342
|
+
"Jinja2 with `autoescape=False` -- enable autoescaping to prevent XSS.",
|
|
343
|
+
"error",
|
|
344
|
+
)
|
|
345
|
+
]
|
|
346
|
+
if last in ("render_template_string", "from_string") and dynamic:
|
|
347
|
+
return [
|
|
348
|
+
_finding(
|
|
349
|
+
path,
|
|
350
|
+
node,
|
|
351
|
+
"py-template-injection",
|
|
352
|
+
f"`{last}()` on a non-literal template allows server-side template injection -- never build the template from user data.",
|
|
353
|
+
"error",
|
|
354
|
+
)
|
|
355
|
+
]
|
|
356
|
+
return []
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def check_python_sinks(path: Path, source: str | None = None) -> list[Finding]:
|
|
360
|
+
if source is None:
|
|
361
|
+
try:
|
|
362
|
+
source = path.read_text(encoding="utf-8")
|
|
363
|
+
except OSError, UnicodeDecodeError:
|
|
364
|
+
return []
|
|
365
|
+
try:
|
|
366
|
+
tree = ast.parse(source, filename=str(path))
|
|
367
|
+
except SyntaxError:
|
|
368
|
+
return []
|
|
369
|
+
aliases = _import_aliases(tree)
|
|
370
|
+
findings: list[Finding] = []
|
|
371
|
+
for node, scope in _ScopeBuilder(tree).calls:
|
|
372
|
+
findings.extend(_check_call(path, node, aliases, scope))
|
|
373
|
+
return findings
|