bmsdna-devtools 0.34.0__tar.gz → 0.35.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.34.0/README.md → bmsdna_devtools-0.35.0/PKG-INFO +28 -3
- bmsdna_devtools-0.34.0/PKG-INFO → bmsdna_devtools-0.35.0/README.md +14 -17
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/cli.py +1 -1
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_sql.py +81 -15
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/skills/bmsdna-devtools/SKILL.md +1 -1
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_find_injection.py +12 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint_sql.py +255 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/uv.lock +1 -1
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/.gitignore +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/.python-version +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pr_info.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/justfile +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_info.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_translate.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.35.0}/tests/test_worktree_clean.py +0 -0
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bmsdna-devtools
|
|
3
|
+
Version: 0.35.0
|
|
4
|
+
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
7
|
+
Requires-Dist: requests>=2.32.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: tomli-w>=1.0.0
|
|
10
|
+
Requires-Dist: typer>=0.26.7
|
|
11
|
+
Provides-Extra: lint
|
|
12
|
+
Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
1
15
|
# bmsdna-devtools
|
|
2
16
|
|
|
3
17
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
@@ -616,12 +630,21 @@ bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-c
|
|
|
616
630
|
bdt lint --no-tooling-check # skip the tooling-config check for this run
|
|
617
631
|
```
|
|
618
632
|
|
|
619
|
-
Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
|
|
633
|
+
Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
|
|
620
634
|
`PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
|
|
621
635
|
literal or f-string/concatenation/`%`-format expression is checked (an
|
|
622
636
|
unresolvable argument, e.g. a plain function parameter, is silently skipped --
|
|
623
637
|
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
|
|
624
|
-
it can't see through)
|
|
638
|
+
it can't see through).
|
|
639
|
+
|
|
640
|
+
pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
|
|
641
|
+
exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
|
|
642
|
+
`pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
|
|
643
|
+
recognised file-wide), so an unrelated `execute()` from another library, or one defined locally in a file without that
|
|
644
|
+
import, is never flagged. Aliased imports of the other helpers (`fetch_all as fa`) are recognised too. Not covered: a re-export through the
|
|
645
|
+
project's own module (`from app.db import execute`; call it as `db.execute(...)` or import it from `pgdevkit.db`
|
|
646
|
+
instead), a subclass of `PostgresJsonResponse` under another name, and CCMT2's pre-pgdevkit
|
|
647
|
+
`PostgresJsonResponse("postgres", sql)`, whose first argument is a connection source, not SQL.
|
|
625
648
|
|
|
626
649
|
- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
|
|
627
650
|
JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
|
|
@@ -634,6 +657,8 @@ it can't see through):
|
|
|
634
657
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
635
658
|
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
|
|
636
659
|
usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
|
|
660
|
+
A dynamic fragment may be an identifier/expression, an optional clause, or a (possibly empty) list of
|
|
661
|
+
assignments -- the statement is flagged if it parses as DML with any of these readings of its fragments.
|
|
637
662
|
- **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
|
|
638
663
|
sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
|
|
639
664
|
The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
|
|
@@ -821,7 +846,7 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
|
|
|
821
846
|
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
|
|
822
847
|
code 0 unless `--strict`).
|
|
823
848
|
|
|
824
|
-
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
849
|
+
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
825
850
|
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
|
|
826
851
|
a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
|
|
827
852
|
call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.34.0
|
|
4
|
-
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
|
-
Requires-Python: >=3.14
|
|
6
|
-
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
7
|
-
Requires-Dist: requests>=2.32.0
|
|
8
|
-
Requires-Dist: rich>=13.0.0
|
|
9
|
-
Requires-Dist: tomli-w>=1.0.0
|
|
10
|
-
Requires-Dist: typer>=0.26.7
|
|
11
|
-
Provides-Extra: lint
|
|
12
|
-
Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
|
|
13
|
-
Description-Content-Type: text/markdown
|
|
14
|
-
|
|
15
1
|
# bmsdna-devtools
|
|
16
2
|
|
|
17
3
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
@@ -630,12 +616,21 @@ bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-c
|
|
|
630
616
|
bdt lint --no-tooling-check # skip the tooling-config check for this run
|
|
631
617
|
```
|
|
632
618
|
|
|
633
|
-
Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
|
|
619
|
+
Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
|
|
634
620
|
`PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
|
|
635
621
|
literal or f-string/concatenation/`%`-format expression is checked (an
|
|
636
622
|
unresolvable argument, e.g. a plain function parameter, is silently skipped --
|
|
637
623
|
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
|
|
638
|
-
it can't see through)
|
|
624
|
+
it can't see through).
|
|
625
|
+
|
|
626
|
+
pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
|
|
627
|
+
exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
|
|
628
|
+
`pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
|
|
629
|
+
recognised file-wide), so an unrelated `execute()` from another library, or one defined locally in a file without that
|
|
630
|
+
import, is never flagged. Aliased imports of the other helpers (`fetch_all as fa`) are recognised too. Not covered: a re-export through the
|
|
631
|
+
project's own module (`from app.db import execute`; call it as `db.execute(...)` or import it from `pgdevkit.db`
|
|
632
|
+
instead), a subclass of `PostgresJsonResponse` under another name, and CCMT2's pre-pgdevkit
|
|
633
|
+
`PostgresJsonResponse("postgres", sql)`, whose first argument is a connection source, not SQL.
|
|
639
634
|
|
|
640
635
|
- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
|
|
641
636
|
JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
|
|
@@ -648,6 +643,8 @@ it can't see through):
|
|
|
648
643
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
649
644
|
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
|
|
650
645
|
usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
|
|
646
|
+
A dynamic fragment may be an identifier/expression, an optional clause, or a (possibly empty) list of
|
|
647
|
+
assignments -- the statement is flagged if it parses as DML with any of these readings of its fragments.
|
|
651
648
|
- **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
|
|
652
649
|
sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
|
|
653
650
|
The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
|
|
@@ -835,7 +832,7 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
|
|
|
835
832
|
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
|
|
836
833
|
code 0 unless `--strict`).
|
|
837
834
|
|
|
838
|
-
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
835
|
+
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
839
836
|
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
|
|
840
837
|
a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
|
|
841
838
|
call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
|
|
@@ -1093,7 +1093,7 @@ def lint(
|
|
|
1093
1093
|
),
|
|
1094
1094
|
) -> None:
|
|
1095
1095
|
"""Static checks (bmsuisse/skills#52): postgres/psycopg SQL rules on every `.execute()` call and pgdevkit
|
|
1096
|
-
`fetch_all`/`fetch_one`/`fetch_scalar`/`PostgresJsonResponse` call
|
|
1096
|
+
`fetch_all`/`fetch_one`/`fetch_scalar`/`execute`/`PostgresJsonResponse` call
|
|
1097
1097
|
(must use load_sql()/a .sql file, a t-string, or psycopg.sql for anything beyond a trivial
|
|
1098
1098
|
query; never an f-string/concatenation/`%`-formatting), pydantic-model placement under api/
|
|
1099
1099
|
directories, hand-wired HTTP in TypeScript, and that the repo declares/configures ty, ruff, pytest and prek.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""AST rule engine for `bdt lint`'s postgres/psycopg checks (bmsuisse/skills#52):
|
|
2
|
-
every `.execute()`/`.executemany()` call (and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
|
|
2
|
+
every `.execute()`/`.executemany()` call (and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
|
|
3
3
|
`PostgresJsonResponse`) is classified by how its SQL argument was
|
|
4
4
|
built, then checked against the `postgres-best-practices` skill's rules
|
|
5
5
|
(../../skills/postgres-best-practices in a checkout of bmsuisse/skills).
|
|
@@ -24,6 +24,7 @@ than guessed at -- a linter that can't be sure must stay quiet, not noisy.
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
26
|
import ast
|
|
27
|
+
import itertools
|
|
27
28
|
import re
|
|
28
29
|
import sys
|
|
29
30
|
from collections.abc import Mapping
|
|
@@ -63,10 +64,18 @@ _POSITIONAL_PARAM_RE = re.compile(r"(?<!%)%s\b")
|
|
|
63
64
|
# Placeholder substituted for a non-literal fragment (an f-string {expr}, a concatenated
|
|
64
65
|
# non-constant operand, a str.format() {field}) before attempting an sqlglot parse -- padded
|
|
65
66
|
# with spaces so it never merges into a neighboring identifier/keyword, and looks like an
|
|
66
|
-
# ordinary identifier so the parse doesn't fail on the substitution itself
|
|
67
|
+
# ordinary identifier so the parse doesn't fail on the substitution itself (see
|
|
68
|
+
# `_PROBE_SLOT_VARIANTS`/`_parse_probe_text` for the fallbacks when an identifier doesn't fit).
|
|
67
69
|
_PROBE_PLACEHOLDER = " __X__ "
|
|
68
70
|
_FORMAT_FIELD_RE = re.compile(r"\{[^{}]*\}")
|
|
69
71
|
|
|
72
|
+
# A dynamic fragment isn't always an identifier/expression: `SET {assignments}, {extra}` where `extra` is
|
|
73
|
+
# empty or `"is_approved = false,"`, or an optional `{where}` clause. When the probe with every fragment as an
|
|
74
|
+
# identifier doesn't parse, each fragment is retried as "nothing" and as an assignment, all combinations
|
|
75
|
+
# (only up to _MAX_PROBE_SLOTS fragments, so the number of parses stays small).
|
|
76
|
+
_PROBE_SLOT_VARIANTS = (_PROBE_PLACEHOLDER, " ", " __X__ = 1 ")
|
|
77
|
+
_MAX_PROBE_SLOTS = 4
|
|
78
|
+
|
|
70
79
|
_MAX_RESOLVE_HOPS = 5
|
|
71
80
|
|
|
72
81
|
|
|
@@ -127,6 +136,7 @@ class _Trust:
|
|
|
127
136
|
sqlglot_names: frozenset[str] = frozenset()
|
|
128
137
|
literal_funcs: frozenset[str] = frozenset()
|
|
129
138
|
sqlglot_aliases: Mapping[str, str] = field(default_factory=dict) # local name -> name it was imported as
|
|
139
|
+
pgdevkit_sinks: frozenset[str] = frozenset() # local names bound by `from pgdevkit[.x] import <sql-taking helper>`
|
|
130
140
|
|
|
131
141
|
|
|
132
142
|
_NO_TRUST = _Trust()
|
|
@@ -185,10 +195,15 @@ def _unwrap_str_call(node: ast.Call) -> ast.expr | None:
|
|
|
185
195
|
return None
|
|
186
196
|
|
|
187
197
|
|
|
198
|
+
def _is_pgdevkit_module(module: str | None) -> bool:
|
|
199
|
+
return module is not None and (module == "pgdevkit" or module.startswith("pgdevkit."))
|
|
200
|
+
|
|
201
|
+
|
|
188
202
|
def _file_trust(tree: ast.AST) -> _Trust:
|
|
189
203
|
literal_funcs: set[str] = set()
|
|
190
204
|
sqlglot_names: set[str] = set()
|
|
191
205
|
sqlglot_aliases: dict[str, str] = {}
|
|
206
|
+
pgdevkit_sinks: set[str] = set()
|
|
192
207
|
for node in ast.walk(tree):
|
|
193
208
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None and _is_literal_string_ref(node.returns):
|
|
194
209
|
literal_funcs.add(node.name)
|
|
@@ -197,7 +212,9 @@ def _file_trust(tree: ast.AST) -> _Trust:
|
|
|
197
212
|
elif isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] == "sqlglot":
|
|
198
213
|
sqlglot_names.update(a.asname or a.name for a in node.names)
|
|
199
214
|
sqlglot_aliases.update({a.asname or a.name: a.name for a in node.names})
|
|
200
|
-
|
|
215
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and _is_pgdevkit_module(node.module):
|
|
216
|
+
pgdevkit_sinks.update(a.asname or a.name for a in node.names if a.name in _PGDEVKIT_SQL_CALLEES)
|
|
217
|
+
return _Trust(frozenset(sqlglot_names), frozenset(literal_funcs), sqlglot_aliases, frozenset(pgdevkit_sinks))
|
|
201
218
|
|
|
202
219
|
|
|
203
220
|
def _resolve_single(expr: ast.expr, lookup) -> ast.expr:
|
|
@@ -278,8 +295,47 @@ _FSTRING_FIX = (
|
|
|
278
295
|
_GENERIC_FIX = "Use a psycopg t-string (Python 3.14+), psycopg.sql for dynamic SQL, or load_sql()/a .sql file for static SQL."
|
|
279
296
|
|
|
280
297
|
|
|
298
|
+
# Keyword that must literally be in the text before a variant reading (a fragment as nothing/an assignment) may
|
|
299
|
+
# count: sqlglot is lenient enough to read prose like "Update to version {v}" as an UPDATE with the fragment gone.
|
|
300
|
+
_VARIANT_REQUIRED_KEYWORD: dict[type, re.Pattern[str]] = {}
|
|
301
|
+
if exp is not None:
|
|
302
|
+
_VARIANT_REQUIRED_KEYWORD = {
|
|
303
|
+
exp.Select: re.compile(r"(?i)\bfrom\b"),
|
|
304
|
+
exp.Union: re.compile(r"(?i)\bfrom\b"),
|
|
305
|
+
exp.Insert: re.compile(r"(?i)\binto\b"),
|
|
306
|
+
exp.Update: re.compile(r"(?i)\bset\b"),
|
|
307
|
+
exp.Delete: re.compile(r"(?i)\bfrom\b"),
|
|
308
|
+
exp.Merge: re.compile(r"(?i)\busing\b"),
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _parse_probe_text(text: str) -> "exp.Expression | None":
|
|
313
|
+
"""`parse_sql_text` for text with `_PROBE_PLACEHOLDER` standing in for dynamic fragments: the placeholder
|
|
314
|
+
as an identifier first, then (see `_PROBE_SLOT_VARIANTS`) every fragment as nothing/an assignment -- the
|
|
315
|
+
latter only when the literal text carries the statement's own keyword (`_VARIANT_REQUIRED_KEYWORD`)."""
|
|
316
|
+
parsed = parse_sql_text(text)
|
|
317
|
+
if parsed is not None:
|
|
318
|
+
return parsed
|
|
319
|
+
parts = text.split(_PROBE_PLACEHOLDER)
|
|
320
|
+
slots = len(parts) - 1
|
|
321
|
+
if not 1 <= slots <= _MAX_PROBE_SLOTS:
|
|
322
|
+
return None
|
|
323
|
+
literal_text = " ".join(parts)
|
|
324
|
+
for combination in itertools.product(_PROBE_SLOT_VARIANTS, repeat=slots):
|
|
325
|
+
if all(c == _PROBE_PLACEHOLDER for c in combination):
|
|
326
|
+
continue # that's `text` itself, already tried
|
|
327
|
+
candidate = "".join(a + b for a, b in itertools.zip_longest(parts, combination, fillvalue=""))
|
|
328
|
+
parsed = parse_sql_text(candidate)
|
|
329
|
+
if parsed is None:
|
|
330
|
+
continue
|
|
331
|
+
keyword = next((rx for t, rx in _VARIANT_REQUIRED_KEYWORD.items() if isinstance(parsed, t)), None)
|
|
332
|
+
if keyword is not None and keyword.search(literal_text):
|
|
333
|
+
return parsed
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
281
337
|
def _injection_finding(text: str, path: Path, lineno: int, rule: str, how: str) -> list[Finding]:
|
|
282
|
-
if
|
|
338
|
+
if _parse_probe_text(text) is None:
|
|
283
339
|
return []
|
|
284
340
|
fix = _FSTRING_FIX if rule == "sql-fstring-injection" else _GENERIC_FIX
|
|
285
341
|
return [Finding(path, lineno, rule, f"SQL built with {how} -- injection risk. {fix}")]
|
|
@@ -455,22 +511,32 @@ def _is_sql_named(name: str) -> bool:
|
|
|
455
511
|
return lowered == "sql" or lowered.endswith("_sql")
|
|
456
512
|
|
|
457
513
|
|
|
458
|
-
# pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and `pgdevkit.fastapi.PostgresJsonResponse` run their
|
|
459
|
-
# argument as SQL, exactly like `.execute()`, so they get the same rules
|
|
460
|
-
#
|
|
461
|
-
|
|
514
|
+
# pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and `pgdevkit.fastapi.PostgresJsonResponse` run their
|
|
515
|
+
# first argument as SQL, exactly like `.execute()`, so they get the same rules. As an attribute (`db.fetch_all(...)`,
|
|
516
|
+
# `pgdevkit.db.execute(...)`) they are matched by name only, like `.execute()` itself.
|
|
517
|
+
_PGDEVKIT_SQL_CALLEES = frozenset({"fetch_all", "fetch_one", "fetch_scalar", "execute", "PostgresJsonResponse"})
|
|
518
|
+
# A bare `execute(...)` is far too generic a name (sqlite helpers, subprocess wrappers, ...): it counts only in a file
|
|
519
|
+
# that imports it from `pgdevkit`/`pgdevkit.db` (also under an alias: `from pgdevkit.db import execute as run`; see
|
|
520
|
+
# `_file_trust`). The import is tracked file-wide, so a local def/parameter named like it is not told apart. The sqlglot
|
|
521
|
+
# DML gate still applies on top. The other callees are matched by bare name too, and by their pgdevkit import alias.
|
|
522
|
+
# Not covered: a re-export through the project's own module (`from app.db import execute`), a subclass of
|
|
523
|
+
# PostgresJsonResponse under another name (the documented `class AppJson(PostgresJsonResponse)`), and the pre-pgdevkit
|
|
462
524
|
# `PostgresJsonResponse("postgres", sql, ...)` copy in CCMT2, whose first argument is a connection source -- its SQL
|
|
463
525
|
# only gets checked once that call site moves to pgdevkit.
|
|
464
|
-
|
|
526
|
+
_IMPORT_GATED_BARE_CALLEES = frozenset({"execute"})
|
|
527
|
+
_BARE_NAME_SQL_CALLEES = _PGDEVKIT_SQL_CALLEES - _IMPORT_GATED_BARE_CALLEES
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _is_pgdevkit_sink_call(func: ast.expr, trust: _Trust) -> bool:
|
|
531
|
+
if isinstance(func, ast.Name):
|
|
532
|
+
return func.id in _BARE_NAME_SQL_CALLEES or func.id in trust.pgdevkit_sinks
|
|
533
|
+
return isinstance(func, ast.Attribute) and func.attr in _PGDEVKIT_SQL_CALLEES
|
|
465
534
|
|
|
466
535
|
|
|
467
|
-
def _execute_query_arg(call: ast.Call) -> ast.expr | None:
|
|
536
|
+
def _execute_query_arg(call: ast.Call, trust: _Trust = _NO_TRUST) -> ast.expr | None:
|
|
468
537
|
func = call.func
|
|
469
538
|
is_execute = isinstance(func, ast.Attribute) and func.attr in ("execute", "executemany")
|
|
470
|
-
|
|
471
|
-
isinstance(func, ast.Attribute) and func.attr in _PGDEVKIT_SQL_CALLEES
|
|
472
|
-
)
|
|
473
|
-
if not (is_execute or is_fetch_helper):
|
|
539
|
+
if not (is_execute or _is_pgdevkit_sink_call(func, trust)):
|
|
474
540
|
return None
|
|
475
541
|
if call.args:
|
|
476
542
|
return call.args[0]
|
|
@@ -634,7 +700,7 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
|
|
|
634
700
|
|
|
635
701
|
def visit_Call(self, node: ast.Call) -> None:
|
|
636
702
|
self.findings.extend(_sqlglot_builder_findings(node, self.path, self.trust, self._lookup, self._sqlglot_vars))
|
|
637
|
-
query_arg = _execute_query_arg(node)
|
|
703
|
+
query_arg = _execute_query_arg(node, self.trust)
|
|
638
704
|
if query_arg is not None:
|
|
639
705
|
found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno, self.trust, self.review)
|
|
640
706
|
if isinstance(query_arg, ast.Name) and _is_sql_named(query_arg.id) and self._all_bare_strings(query_arg.id):
|
|
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "bmsdna-devtools"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.35.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
|
|
@@ -123,7 +123,7 @@ vendor a git submodule under that path.
|
|
|
123
123
|
|
|
124
124
|
## Static checks
|
|
125
125
|
|
|
126
|
-
- `bdt lint [paths]` -- SQL/psycopg rules (`.execute()`, pgdevkit `fetch_
|
|
126
|
+
- `bdt lint [paths]` -- SQL/psycopg rules (`.execute()`, pgdevkit `fetch_*`/`execute`, `PostgresJsonResponse`), pydantic-model placement, hand-wired HTTP in TypeScript, baseline tooling.
|
|
127
127
|
- `uv run bdt dead-code` -- dead code a linter can't see, configured under `[tool.bdt.dead_code]` in pyproject.toml:
|
|
128
128
|
`.sql` files no Python code loads (`sql_roots`) and FastAPI routes that neither non-generated frontend code nor a
|
|
129
129
|
`url_for(...)` call uses (`[[tool.bdt.dead_code.apps]]`: app or openapi file, frontends, excludes, optional `baseline`;
|
|
@@ -151,6 +151,18 @@ def test_sql_injection_is_error_and_unverified_call_is_review(tmp_path: Path) ->
|
|
|
151
151
|
assert [f.rule for f in result.reviews] == ["sql-unverified-call"]
|
|
152
152
|
|
|
153
153
|
|
|
154
|
+
def test_pgdevkit_execute_is_a_sql_sink_for_find_injection(tmp_path: Path) -> None:
|
|
155
|
+
(tmp_path / "db.py").write_text(
|
|
156
|
+
"from pgdevkit.db import execute\n\n"
|
|
157
|
+
'async def a(x):\n await execute(f"update t set a = {x}")\n\n'
|
|
158
|
+
"async def b(x):\n await execute(build(x))\n"
|
|
159
|
+
)
|
|
160
|
+
(tmp_path / "other.py").write_text('from lib import execute\n\ndef a(x):\n execute(f"update t set a = {x}")\n')
|
|
161
|
+
result = _run(tmp_path)
|
|
162
|
+
assert [(f.path.name, f.rule) for f in result.errors] == [("db.py", "sql-fstring-injection")]
|
|
163
|
+
assert [(f.path.name, f.rule) for f in result.reviews] == [("db.py", "sql-unverified-call")]
|
|
164
|
+
|
|
165
|
+
|
|
154
166
|
def test_tests_and_missing_paths(tmp_path: Path) -> None:
|
|
155
167
|
(tmp_path / "tests").mkdir()
|
|
156
168
|
(tmp_path / "tests" / "test_x.py").write_text("eval(x)\n")
|
|
@@ -608,3 +608,258 @@ def f(table):
|
|
|
608
608
|
return PostgresJsonResponse("postgres", f"select id from dim.{table}", parameters={})
|
|
609
609
|
'''
|
|
610
610
|
assert _findings(source, tmp_path / "a.py") == []
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# --- pgdevkit.db.execute (bare name, gated on a pgdevkit import) ---
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def test_bare_execute_imported_from_pgdevkit_is_flagged_like_cursor_execute(tmp_path: Path) -> None:
|
|
617
|
+
# Real pattern from CCMT2's _write_rule_change.
|
|
618
|
+
source = '''
|
|
619
|
+
from pgdevkit.db import execute
|
|
620
|
+
|
|
621
|
+
async def f(table, rule_id):
|
|
622
|
+
await execute(f"UPDATE ccmt.{table} SET active = false WHERE id = %(id)s", {"id": rule_id})
|
|
623
|
+
'''
|
|
624
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
625
|
+
assert _rules(findings) == {"sql-fstring-injection"}
|
|
626
|
+
assert [f.line for f in findings] == [5]
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def test_bare_execute_flags_concat_format_percent_and_keyword_query(tmp_path: Path) -> None:
|
|
630
|
+
source = '''
|
|
631
|
+
from pgdevkit.db import execute
|
|
632
|
+
|
|
633
|
+
async def f(value, table):
|
|
634
|
+
await execute("delete from t where id = " + str(value))
|
|
635
|
+
await execute("delete from t where id = %s" % value)
|
|
636
|
+
await execute("delete from t where id = {}".format(value))
|
|
637
|
+
await execute(query=f"delete from {table} where id = 1", pool=None)
|
|
638
|
+
'''
|
|
639
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
640
|
+
assert _rules(findings) == {
|
|
641
|
+
"sql-concat-injection",
|
|
642
|
+
"sql-percent-format-injection",
|
|
643
|
+
"sql-format-injection",
|
|
644
|
+
"sql-fstring-injection",
|
|
645
|
+
}
|
|
646
|
+
assert len(findings) == 4
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def test_bare_execute_follows_a_variable_to_its_unsafe_assignment(tmp_path: Path) -> None:
|
|
650
|
+
source = '''
|
|
651
|
+
from pgdevkit.db import execute
|
|
652
|
+
|
|
653
|
+
async def f(table):
|
|
654
|
+
query = f"update dim.{table} set x = 1"
|
|
655
|
+
await execute(query)
|
|
656
|
+
'''
|
|
657
|
+
assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def test_bare_execute_import_forms_gate_the_rule(tmp_path: Path) -> None:
|
|
661
|
+
bad = 'await {call}(f"update t set x = {{v}}")'
|
|
662
|
+
for i, (imp, call) in enumerate(
|
|
663
|
+
[
|
|
664
|
+
("from pgdevkit.db import execute", "execute"),
|
|
665
|
+
("from pgdevkit.db import execute as run", "run"),
|
|
666
|
+
("from pgdevkit.db import fetch_all, execute", "execute"),
|
|
667
|
+
("from pgdevkit.db.fetch import execute", "execute"),
|
|
668
|
+
]
|
|
669
|
+
):
|
|
670
|
+
source = f"{imp}\n\nasync def f(v):\n {bad.format(call=call)}\n"
|
|
671
|
+
assert _rules(_findings(source, tmp_path / f"a{i}.py")) == {"sql-fstring-injection"}, imp
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def test_execute_attribute_forms_are_flagged(tmp_path: Path) -> None:
|
|
675
|
+
source = '''
|
|
676
|
+
import pgdevkit
|
|
677
|
+
import pgdevkit.db as db
|
|
678
|
+
from pgdevkit import db as pgdb
|
|
679
|
+
|
|
680
|
+
async def f(v):
|
|
681
|
+
await db.execute(f"update t set x = {v}")
|
|
682
|
+
await pgdb.execute("update t set x = " + v)
|
|
683
|
+
await pgdevkit.db.execute("update t set x = {}".format(v))
|
|
684
|
+
'''
|
|
685
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
686
|
+
assert _rules(findings) == {"sql-fstring-injection", "sql-concat-injection", "sql-format-injection"}
|
|
687
|
+
assert len(findings) == 3
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def test_bare_execute_accepts_literal_load_sql_sqlglot_template_and_sql_composed(tmp_path: Path) -> None:
|
|
691
|
+
source = '''
|
|
692
|
+
from psycopg import sql
|
|
693
|
+
from sqlglot import exp
|
|
694
|
+
from pgdevkit.db import execute
|
|
695
|
+
|
|
696
|
+
async def f(sql_loader, user_id, table):
|
|
697
|
+
await execute(sql_loader.load_sql("users", "deactivate"), {"id": user_id})
|
|
698
|
+
await execute(exp.delete("t").where("id = 1").sql(dialect="postgres"))
|
|
699
|
+
await execute(t"update t set x = 1 where user_id = {user_id}")
|
|
700
|
+
await execute(sql.SQL("update {} set x = 1").format(sql.Identifier(table)))
|
|
701
|
+
await execute("update t set x = 1 where user_id = %(id)s", {"id": user_id})
|
|
702
|
+
'''
|
|
703
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def test_bare_execute_applies_inline_complexity_and_positional_rules(tmp_path: Path) -> None:
|
|
707
|
+
source = '''
|
|
708
|
+
from pgdevkit.db import execute
|
|
709
|
+
|
|
710
|
+
async def f():
|
|
711
|
+
await execute("insert into t (id) select id from u")
|
|
712
|
+
await execute("update t set x = 1 where id = %s", (1,))
|
|
713
|
+
'''
|
|
714
|
+
assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-inline-too-complex", "sql-positional-param"}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def test_bare_execute_simple_write_is_clean_and_forbidden_join_is_flagged(tmp_path: Path) -> None:
|
|
718
|
+
source = '''
|
|
719
|
+
from pgdevkit.db import execute
|
|
720
|
+
|
|
721
|
+
async def f():
|
|
722
|
+
await execute("update t set x = 1 where id = %(id)s", {"id": 1})
|
|
723
|
+
await execute("delete from t using u right join v on v.id = u.id where t.id = u.id")
|
|
724
|
+
'''
|
|
725
|
+
assert "sql-forbidden-join" in _rules(_findings(source, tmp_path / "a.py"))
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def test_bare_execute_is_reported_as_unverified_call_in_review_mode(tmp_path: Path) -> None:
|
|
729
|
+
source = '''
|
|
730
|
+
from pgdevkit.db import execute
|
|
731
|
+
|
|
732
|
+
async def f(build_query):
|
|
733
|
+
await execute(build_query("t"))
|
|
734
|
+
'''
|
|
735
|
+
path = tmp_path / "a.py"
|
|
736
|
+
path.write_text(source)
|
|
737
|
+
assert check_sql_file(path) == []
|
|
738
|
+
assert _rules(check_sql_file(path, review=True)) == {"sql-unverified-call"}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def test_bare_execute_without_a_pgdevkit_import_is_not_flagged(tmp_path: Path) -> None:
|
|
742
|
+
# `execute` is a very generic name: no pgdevkit import, no finding -- even when the text is real DML.
|
|
743
|
+
source = '''
|
|
744
|
+
async def execute(sql):
|
|
745
|
+
...
|
|
746
|
+
|
|
747
|
+
async def f(table):
|
|
748
|
+
await execute(f"update {table} set x = 1")
|
|
749
|
+
await execute("update t set x = %s", (1,))
|
|
750
|
+
'''
|
|
751
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def test_bare_execute_imported_from_another_library_is_not_flagged(tmp_path: Path) -> None:
|
|
755
|
+
source = '''
|
|
756
|
+
from sqlalchemy import execute
|
|
757
|
+
from mylib.db import execute as run
|
|
758
|
+
from .db import execute as local_run
|
|
759
|
+
from . import execute as dotted
|
|
760
|
+
from pgdevkitx import execute as lookalike
|
|
761
|
+
|
|
762
|
+
def f(table, name):
|
|
763
|
+
execute(f"update {table} set x = 1")
|
|
764
|
+
run(f"update {table} set x = 1")
|
|
765
|
+
local_run(f"update {table} set x = 1")
|
|
766
|
+
dotted(f"update {table} set x = 1")
|
|
767
|
+
lookalike(f"update {table} set x = 1")
|
|
768
|
+
execute(f"echo {name}")
|
|
769
|
+
'''
|
|
770
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def test_pgdevkit_import_does_not_make_a_non_sql_execute_flagged(tmp_path: Path) -> None:
|
|
774
|
+
source = '''
|
|
775
|
+
from pgdevkit.db import execute
|
|
776
|
+
|
|
777
|
+
async def f(cmd, name):
|
|
778
|
+
await execute(f"echo {name}")
|
|
779
|
+
await execute(f"COPY t TO '/tmp/{name}.csv'")
|
|
780
|
+
'''
|
|
781
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def test_pgdevkit_import_of_other_names_does_not_enable_bare_execute(tmp_path: Path) -> None:
|
|
785
|
+
source = '''
|
|
786
|
+
import pgdevkit
|
|
787
|
+
from pgdevkit.db import fetch_all
|
|
788
|
+
from other import execute
|
|
789
|
+
|
|
790
|
+
async def f(table):
|
|
791
|
+
execute(f"update {table} set x = 1")
|
|
792
|
+
'''
|
|
793
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def test_aliased_pgdevkit_fetch_helpers_and_json_response_are_flagged(tmp_path: Path) -> None:
|
|
797
|
+
source = '''
|
|
798
|
+
from pgdevkit.db import fetch_all as fa
|
|
799
|
+
from pgdevkit.fastapi import PostgresJsonResponse as PJR
|
|
800
|
+
|
|
801
|
+
async def f(table):
|
|
802
|
+
await fa(f"select id from {table}")
|
|
803
|
+
return PJR(f"select id from {table}")
|
|
804
|
+
'''
|
|
805
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
806
|
+
assert _rules(findings) == {"sql-fstring-injection"}
|
|
807
|
+
assert len(findings) == 2
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
# --- dynamic fragments that aren't identifiers (probe variants) ---
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def test_fstring_with_optional_assignment_fragments_is_flagged(tmp_path: Path) -> None:
|
|
814
|
+
# CCMT2's _write_rule_change: `{is_approved_clause}` is "" or "is_approved = false," -- as an identifier probe
|
|
815
|
+
# the statement doesn't parse, as nothing/an assignment it does.
|
|
816
|
+
source = '''
|
|
817
|
+
from pgdevkit.db import execute
|
|
818
|
+
|
|
819
|
+
async def f(con, fields, tiers):
|
|
820
|
+
set_clause = ", ".join(f"{c} = %({c})s" for c in fields)
|
|
821
|
+
is_approved_clause = "is_approved = false," if tiers is not None else ""
|
|
822
|
+
await execute(
|
|
823
|
+
f"""
|
|
824
|
+
UPDATE editing.bonus_rules
|
|
825
|
+
SET {set_clause},
|
|
826
|
+
{is_approved_clause}
|
|
827
|
+
modified_user = %(modified_user)s
|
|
828
|
+
WHERE bonus_rule_id = %(bonus_rule_id)s
|
|
829
|
+
""",
|
|
830
|
+
{**fields, "modified_user": "x", "bonus_rule_id": 1},
|
|
831
|
+
con=con,
|
|
832
|
+
)
|
|
833
|
+
'''
|
|
834
|
+
assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def test_fstring_with_optional_trailing_clause_fragment_is_flagged(tmp_path: Path) -> None:
|
|
838
|
+
source = '''
|
|
839
|
+
async def f(cur, lock):
|
|
840
|
+
await cur.execute(f"update t set x = 1 where id = %(id)s {lock}", {"id": 1})
|
|
841
|
+
'''
|
|
842
|
+
assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def test_probe_variants_do_not_flag_non_sql_or_non_dml_text(tmp_path: Path) -> None:
|
|
846
|
+
source = '''
|
|
847
|
+
async def f(cur, a, b, c, d, e, name):
|
|
848
|
+
await cur.execute(f"{a} {b} {c} {d} {e}")
|
|
849
|
+
await cur.execute(f"update the user {name} please")
|
|
850
|
+
await cur.execute(f"COPY t TO '/tmp/{name}.csv'")
|
|
851
|
+
await cur.execute(f"set {name} {a} {b} {c} {d} {e}")
|
|
852
|
+
'''
|
|
853
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def test_probe_variants_do_not_read_prose_as_dml(tmp_path: Path) -> None:
|
|
857
|
+
# sqlglot parses these once the fragment is dropped/turned into an assignment ("UPDATE to AS version ...").
|
|
858
|
+
source = '''
|
|
859
|
+
def f(cur, v):
|
|
860
|
+
cur.execute(f"Update to version {v}")
|
|
861
|
+
cur.execute(f"Delete the file {v}")
|
|
862
|
+
cur.execute(f"Insert coin {v}")
|
|
863
|
+
cur.execute(f"Update complete, {v} rows changed")
|
|
864
|
+
'''
|
|
865
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
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
|
|
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
|