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.
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/PKG-INFO +3 -2
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/README.md +2 -1
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/find_injection.py +2 -1
- bmsdna_devtools-0.28.0/bmsdna/devtools/injection_python.py +373 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_sql.py +11 -10
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_find_injection.py +94 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/uv.lock +1 -1
- bmsdna_devtools-0.27.0/bmsdna/devtools/injection_python.py +0 -189
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/.gitignore +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/.python-version +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/cli.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/justfile +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_lint_sql.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.27.0 → bmsdna_devtools-0.28.0}/tests/test_testdb.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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.
|
|
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]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|