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.
Files changed (75) hide show
  1. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/PKG-INFO +8 -3
  2. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/README.md +7 -2
  3. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/cli.py +8 -1
  4. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/find_injection.py +91 -32
  5. bmsdna_devtools-0.28.0/bmsdna/devtools/injection_python.py +373 -0
  6. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_sql.py +11 -10
  7. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/pyproject.toml +1 -1
  8. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_find_injection.py +156 -0
  9. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/uv.lock +1 -1
  10. bmsdna_devtools-0.26.0/bmsdna/devtools/injection_python.py +0 -189
  11. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/.github/workflows/auto-release.yml +0 -0
  12. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/.github/workflows/python-publish.yml +0 -0
  13. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/.github/workflows/python-test.yml +0 -0
  14. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/.gitignore +0 -0
  15. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/.python-version +0 -0
  16. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/__init__.py +0 -0
  17. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/ado_auth.py +0 -0
  18. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/ado_issue.py +0 -0
  19. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/app_service_logs.py +0 -0
  20. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/bdt_config.py +0 -0
  21. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/cli_tools.py +0 -0
  22. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/commit.py +0 -0
  23. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/env_config.py +0 -0
  24. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/find_repo.py +0 -0
  25. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gh_issue.py +0 -0
  26. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gh_pr.py +0 -0
  27. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gitrepo.py +0 -0
  28. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/injection_frontend.py +0 -0
  29. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint.py +0 -0
  30. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_findings.py +0 -0
  31. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_models.py +0 -0
  32. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_tooling.py +0 -0
  33. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_typescript.py +0 -0
  34. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/logs.py +0 -0
  35. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_build.py +0 -0
  36. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  37. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_labels.py +0 -0
  38. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_markdown.py +0 -0
  39. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pull.py +0 -0
  40. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/testdb.py +0 -0
  41. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/worktree.py +0 -0
  42. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/justfile +0 -0
  43. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  44. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/conftest.py +0 -0
  45. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_ado_auth.py +0 -0
  46. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_ado_issue.py +0 -0
  47. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_ado_issue_flow.py +0 -0
  48. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_app_service_logs.py +0 -0
  49. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_bdt_config.py +0 -0
  50. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_cleanup.py +0 -0
  51. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_commit.py +0 -0
  52. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_issue.py +0 -0
  53. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pr_create.py +0 -0
  54. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pr_id.py +0 -0
  55. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pull.py +0 -0
  56. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_cli_tools.py +0 -0
  57. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_commit.py +0 -0
  58. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_env_config.py +0 -0
  59. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_find_repo.py +0 -0
  60. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_gh_issue.py +0 -0
  61. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_gh_pr.py +0 -0
  62. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_gitrepo.py +0 -0
  63. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_lint.py +0 -0
  64. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_lint_models.py +0 -0
  65. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_lint_sql.py +0 -0
  66. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_lint_tooling.py +0 -0
  67. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_lint_typescript.py +0 -0
  68. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pr_build.py +0 -0
  69. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pr_build_flow.py +0 -0
  70. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pr_issue_link.py +0 -0
  71. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pr_labels.py +0 -0
  72. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pr_markdown.py +0 -0
  73. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_pull.py +0 -0
  74. {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.28.0}/tests/test_testdb.py +0 -0
  75. {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.26.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
- Test files and generated code are skipped. Silence a confirmed-safe finding with
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
- Test files and generated code are skipped. Silence a confirmed-safe finding with
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
- def _walk_repo_text_files(root: Path, exclude_dirs: frozenset[str]):
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 exclude_dirs]
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], exclude_dirs: frozenset[str], only: set[Path] | None = None) -> list[Finding]:
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, exclude_dirs):
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
- weakened.extend(find_csp_weakening(candidate, text))
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
- exclude_dirs = _DEFAULT_EXCLUDE_DIR_NAMES | {
233
- ".next",
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 not _in_excluded_dir(p, root, exclude_dirs)]
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, exclude_dirs, (".py",))
247
- fe_files, _ = _iter_files(targets, exclude_dirs, FRONTEND_SUFFIXES)
248
- findings.extend(
249
- Finding(
250
- p,
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, exclude_dirs, {p.resolve() for p in changed} if diff else None))
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 _in_excluded_dir(path: Path, root: Path, exclude_dirs: frozenset[str]) -> bool:
273
- try:
274
- parts = path.relative_to(root).parts[:-1]
275
- except ValueError:
276
- parts = path.parts[:-1]
277
- return any(part in exclude_dirs for part in parts)
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