bmsdna-devtools 0.27.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.27.0 → bmsdna_devtools-0.28.0}/PKG-INFO +3 -2
  2. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/README.md +2 -1
  3. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/find_injection.py +2 -1
  4. bmsdna_devtools-0.28.0/bmsdna/devtools/injection_python.py +373 -0
  5. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_sql.py +11 -10
  6. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/pyproject.toml +1 -1
  7. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_find_injection.py +94 -0
  8. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/uv.lock +1 -1
  9. bmsdna_devtools-0.27.0/bmsdna/devtools/injection_python.py +0 -189
  10. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/.github/workflows/auto-release.yml +0 -0
  11. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/.github/workflows/python-publish.yml +0 -0
  12. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/.github/workflows/python-test.yml +0 -0
  13. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/.gitignore +0 -0
  14. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/.python-version +0 -0
  15. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/__init__.py +0 -0
  16. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/ado_auth.py +0 -0
  17. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/ado_issue.py +0 -0
  18. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/app_service_logs.py +0 -0
  19. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/bdt_config.py +0 -0
  20. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/cli.py +0 -0
  21. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/cli_tools.py +0 -0
  22. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/commit.py +0 -0
  23. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/env_config.py +0 -0
  24. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/find_repo.py +0 -0
  25. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gh_issue.py +0 -0
  26. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gh_pr.py +0 -0
  27. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gitrepo.py +0 -0
  28. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/injection_frontend.py +0 -0
  29. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint.py +0 -0
  30. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_findings.py +0 -0
  31. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_models.py +0 -0
  32. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_tooling.py +0 -0
  33. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_typescript.py +0 -0
  34. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/logs.py +0 -0
  35. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_build.py +0 -0
  36. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  37. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_labels.py +0 -0
  38. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_markdown.py +0 -0
  39. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pull.py +0 -0
  40. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/testdb.py +0 -0
  41. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/worktree.py +0 -0
  42. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/justfile +0 -0
  43. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  44. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/conftest.py +0 -0
  45. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_ado_auth.py +0 -0
  46. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_ado_issue.py +0 -0
  47. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_ado_issue_flow.py +0 -0
  48. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_app_service_logs.py +0 -0
  49. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_bdt_config.py +0 -0
  50. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_cleanup.py +0 -0
  51. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_commit.py +0 -0
  52. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_issue.py +0 -0
  53. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pr_create.py +0 -0
  54. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pr_id.py +0 -0
  55. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pull.py +0 -0
  56. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_tools.py +0 -0
  57. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_commit.py +0 -0
  58. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_env_config.py +0 -0
  59. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_find_repo.py +0 -0
  60. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_gh_issue.py +0 -0
  61. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_gh_pr.py +0 -0
  62. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_gitrepo.py +0 -0
  63. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_lint.py +0 -0
  64. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_lint_models.py +0 -0
  65. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_lint_sql.py +0 -0
  66. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_lint_tooling.py +0 -0
  67. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_lint_typescript.py +0 -0
  68. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pr_build.py +0 -0
  69. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pr_build_flow.py +0 -0
  70. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pr_issue_link.py +0 -0
  71. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pr_labels.py +0 -0
  72. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pr_markdown.py +0 -0
  73. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pull.py +0 -0
  74. {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_testdb.py +0 -0
  75. {bmsdna_devtools-0.27.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.27.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).
@@ -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).
@@ -265,7 +265,8 @@ def check_csp(root: Path, scanned: list[Path], scope: _ScanFilter, only: set[Pat
265
265
  continue
266
266
  if has_csp(text):
267
267
  found = True
268
- weakened.extend(find_csp_weakening(candidate, text))
268
+ if not _is_test_file(candidate, root):
269
+ weakened.extend(find_csp_weakening(candidate, text))
269
270
  if found:
270
271
  return [f for f in weakened if only is None or f.path.resolve() in only]
271
272
  return [
@@ -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
@@ -109,7 +109,8 @@ def _is_sql_composed_call(node: ast.AST) -> bool:
109
109
  if not isinstance(node, ast.Call):
110
110
  return False
111
111
  func = node.func
112
- return isinstance(func, ast.Attribute) and func.attr in ("SQL", "Identifier", "Composed")
112
+ name = func.attr if isinstance(func, ast.Attribute) else func.id if isinstance(func, ast.Name) else ""
113
+ return name in ("SQL", "Identifier", "Composed")
113
114
 
114
115
 
115
116
  _UNWRAP_METHODS = frozenset({"strip", "lstrip", "rstrip"})
@@ -265,18 +266,18 @@ def _concat_probe_text(node: ast.BinOp, lookup) -> str | None:
265
266
  return "".join(parts) if saw_literal else None
266
267
 
267
268
 
269
+ _FSTRING_FIX = (
270
+ "Change the `f` prefix to `t` (psycopg t-string, Python 3.14+): `{value}` becomes a bound parameter and "
271
+ "`{name:i}` quotes a table/column identifier. Or use load_sql()/a .sql file for static SQL."
272
+ )
273
+ _GENERIC_FIX = "Use a psycopg t-string (Python 3.14+), psycopg.sql for dynamic SQL, or load_sql()/a .sql file for static SQL."
274
+
275
+
268
276
  def _injection_finding(text: str, path: Path, lineno: int, rule: str, how: str) -> list[Finding]:
269
277
  if parse_sql_text(text) is None:
270
278
  return []
271
- return [
272
- Finding(
273
- path,
274
- lineno,
275
- rule,
276
- f"SQL built with {how} -- injection risk. Use a psycopg t-string (Python 3.14+), "
277
- "psycopg.sql for dynamic SQL, or load_sql()/a .sql file for static SQL.",
278
- )
279
- ]
279
+ fix = _FSTRING_FIX if rule == "sql-fstring-injection" else _GENERIC_FIX
280
+ return [Finding(path, lineno, rule, f"SQL built with {how} -- injection risk. {fix}")]
280
281
 
281
282
 
282
283
  def _literal_findings(text: str, path: Path, lineno: int) -> list[Finding]:
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.27.0"
13
+ version = "0.28.0"
14
14
  description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency
@@ -298,3 +298,97 @@ def run(conn, t):
298
298
  """
299
299
  )
300
300
  assert find_injection.run([], root=tmp_path).findings == []
301
+
302
+
303
+ def test_subprocess_arg_list_with_shell_true_and_bare_sql(tmp_path: Path) -> None:
304
+ (tmp_path / "ok.py").write_text(
305
+ """import subprocess
306
+ from psycopg.sql import SQL
307
+
308
+
309
+ def run(conn, target):
310
+ subprocess.check_call(["bun", "run", "build"], shell=True)
311
+ subprocess.check_call(["bun", "run", target], cwd=target, shell=True)
312
+ conn.execute(SQL("UPDATE t SET a = 1 WHERE id = %s"), (1,))
313
+ """
314
+ )
315
+ (tmp_path / "bad.py").write_text(
316
+ """import subprocess
317
+
318
+
319
+ def run(cmd, target):
320
+ subprocess.check_call([cmd, "x"], shell=True)
321
+ subprocess.check_call(f"bun run {target}", shell=True)
322
+ """
323
+ )
324
+ result = find_injection.run([], root=tmp_path)
325
+ assert [(f.path.name, f.line, f.rule) for f in result.findings] == [("bad.py", 5, "py-shell-command"), ("bad.py", 6, "py-shell-command")]
326
+
327
+
328
+ def test_csp_weakening_in_test_files_is_ignored(tmp_path: Path) -> None:
329
+ (tmp_path / "app.js").write_text("run();\n")
330
+ (tmp_path / "web.config").write_text("Content-Security-Policy: default-src 'self'")
331
+ (tmp_path / "test_headers.py").write_text("H = \"Content-Security-Policy: script-src 'unsafe-eval'\"\n")
332
+ assert find_injection.run([], root=tmp_path).findings == []
333
+
334
+
335
+ def _lines(tmp_path: Path, src: str) -> list[int]:
336
+ path = tmp_path / "m.py"
337
+ path.write_text(src)
338
+ return [f.line for f in check_python_sinks(path)]
339
+
340
+
341
+ def test_names_assigned_only_constants_are_constant(tmp_path: Path) -> None:
342
+ src = """import os, subprocess
343
+ GREETING = "echo hi"
344
+ CMD = GREETING + " there"
345
+
346
+
347
+ def ok(flag):
348
+ os.system(CMD)
349
+ cmd = "ls" if flag else "pwd"
350
+ os.system(cmd)
351
+ for c in ("a", "b"):
352
+ os.system(c)
353
+ os.system(f"{GREETING} now")
354
+ prog = "bun"
355
+ subprocess.check_call([prog, "x"], shell=True)
356
+
357
+
358
+ def bad(arg, flag):
359
+ cmd = "ls"
360
+ cmd += arg
361
+ os.system(cmd)
362
+ other = "ls"
363
+ if flag:
364
+ other = arg
365
+ os.system(other)
366
+ os.system(arg)
367
+ for c in arg:
368
+ os.system(c)
369
+ os.system(undefined_name)
370
+ """
371
+ assert _lines(tmp_path, src) == [20, 24, 25, 27, 28]
372
+
373
+
374
+ def test_global_rebinding_and_class_scope_defeat_constness(tmp_path: Path) -> None:
375
+ src = """import os
376
+ GREETING = "echo hi"
377
+
378
+
379
+ def rebind():
380
+ global GREETING
381
+ GREETING = input()
382
+
383
+
384
+ def use():
385
+ os.system(GREETING)
386
+
387
+
388
+ class K:
389
+ x = "ls"
390
+
391
+ def m(self):
392
+ os.system(x)
393
+ """
394
+ assert _lines(tmp_path, src) == [11, 18]
@@ -22,7 +22,7 @@ wheels = [
22
22
 
23
23
  [[package]]
24
24
  name = "bmsdna-devtools"
25
- version = "0.27.0"
25
+ version = "0.28.0"
26
26
  source = { editable = "." }
27
27
  dependencies = [
28
28
  { name = "pgdevkit", extra = ["db"] },
@@ -1,189 +0,0 @@
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
- def _is_literal(node: ast.expr) -> bool:
48
- if isinstance(node, ast.Constant):
49
- return isinstance(node.value, str)
50
- return isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add) and _is_literal(node.left) and _is_literal(node.right)
51
-
52
-
53
- def _shell_true(call: ast.Call) -> bool:
54
- return any(kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True for kw in call.keywords)
55
-
56
-
57
- def _finding(path: Path, node: ast.Call, rule: str, message: str, severity: str) -> Finding:
58
- return Finding(path, node.lineno, rule, message, severity=severity)
59
-
60
-
61
- def _check_call(path: Path, node: ast.Call, aliases: dict[str, str]) -> list[Finding]:
62
- name = _dotted(node.func, aliases)
63
- last = name.rsplit(".", 1)[-1]
64
- 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)
65
- dynamic = first_arg is not None and not _is_literal(first_arg)
66
-
67
- if name in ("eval", "exec") and dynamic:
68
- return [
69
- _finding(
70
- path,
71
- node,
72
- "py-eval-exec",
73
- f"`{name}()` on a non-literal executes arbitrary code -- never pass request/user data.",
74
- "error",
75
- )
76
- ]
77
- if name in ("os.system", "os.popen") and dynamic:
78
- return [
79
- _finding(
80
- path,
81
- node,
82
- "py-shell-command",
83
- f"`{name}()` with a non-literal command runs it through a shell -- use subprocess with an argument list.",
84
- "error",
85
- )
86
- ]
87
- if name.startswith("subprocess.") and _shell_true(node):
88
- if dynamic or isinstance(first_arg, (ast.JoinedStr, ast.BinOp)):
89
- return [
90
- _finding(
91
- path,
92
- node,
93
- "py-shell-command",
94
- "`subprocess` with `shell=True` and a non-literal command -- pass an argument list without shell=True.",
95
- "error",
96
- )
97
- ]
98
- if first_arg is not None:
99
- return [
100
- _finding(
101
- path,
102
- node,
103
- "py-shell-true",
104
- "`shell=True` with a literal command is safe today, but verify no value is ever interpolated into it later.",
105
- "review",
106
- )
107
- ]
108
- if name in (
109
- "pickle.loads",
110
- "pickle.load",
111
- "cPickle.loads",
112
- "marshal.loads",
113
- "shelve.open",
114
- "dill.loads",
115
- ):
116
- return [
117
- _finding(
118
- path,
119
- node,
120
- "py-unsafe-deserialization",
121
- f"`{name}()` can execute arbitrary code from untrusted bytes. AI reviewer: verify the data is never user-supplied.",
122
- "review",
123
- )
124
- ]
125
- if name in ("yaml.load", "yaml.load_all", "yaml.unsafe_load"):
126
- loader = next((kw.value for kw in node.keywords if kw.arg == "Loader"), None)
127
- if loader is None and len(node.args) > 1:
128
- loader = node.args[1]
129
- if name == "yaml.unsafe_load" or loader is None or _dotted(loader, aliases).rsplit(".", 1)[-1] not in _SAFE_YAML_LOADERS:
130
- return [
131
- _finding(
132
- path,
133
- node,
134
- "py-unsafe-yaml",
135
- f"`{name}()` without a safe loader can construct arbitrary objects -- use `yaml.safe_load`.",
136
- "error",
137
- )
138
- ]
139
- if last in _MARKUP_FUNCS and dynamic:
140
- return [
141
- _finding(
142
- path,
143
- node,
144
- "py-unescaped-markup",
145
- f"`{last}()` marks a non-literal string as safe HTML (XSS). AI reviewer: verify it is escaped or never user-controlled.",
146
- "review",
147
- )
148
- ]
149
- if last in ("Environment", "Jinja2Templates") and any(
150
- kw.arg == "autoescape" and isinstance(kw.value, ast.Constant) and kw.value.value is False for kw in node.keywords
151
- ):
152
- return [
153
- _finding(
154
- path,
155
- node,
156
- "py-autoescape-off",
157
- "Jinja2 with `autoescape=False` -- enable autoescaping to prevent XSS.",
158
- "error",
159
- )
160
- ]
161
- if last in ("render_template_string", "from_string") and dynamic:
162
- return [
163
- _finding(
164
- path,
165
- node,
166
- "py-template-injection",
167
- f"`{last}()` on a non-literal template allows server-side template injection -- never build the template from user data.",
168
- "error",
169
- )
170
- ]
171
- return []
172
-
173
-
174
- def check_python_sinks(path: Path, source: str | None = None) -> list[Finding]:
175
- if source is None:
176
- try:
177
- source = path.read_text(encoding="utf-8")
178
- except OSError, UnicodeDecodeError:
179
- return []
180
- try:
181
- tree = ast.parse(source, filename=str(path))
182
- except SyntaxError:
183
- return []
184
- aliases = _import_aliases(tree)
185
- findings: list[Finding] = []
186
- for node in ast.walk(tree):
187
- if isinstance(node, ast.Call):
188
- findings.extend(_check_call(path, node, aliases))
189
- return findings