bmsdna-devtools 0.34.0__tar.gz → 0.36.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 → bmsdna_devtools-0.36.0}/PKG-INFO +32 -6
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/README.md +31 -5
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/cli.py +2 -1
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_sql.py +222 -33
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/skills/bmsdna-devtools/SKILL.md +2 -2
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_find_injection.py +26 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint_sql.py +476 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/uv.lock +1 -1
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/.gitignore +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/.python-version +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_info.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/justfile +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_info.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.0}/tests/test_translate.py +0 -0
- {bmsdna_devtools-0.34.0 → bmsdna_devtools-0.36.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.36.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
|
|
@@ -18,7 +18,7 @@ Shared developer tooling for BMS projects: PR build/check status and a
|
|
|
18
18
|
one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and
|
|
19
19
|
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
|
|
20
20
|
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
|
|
21
|
-
postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
21
|
+
postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
22
22
|
TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
|
|
23
23
|
backend routes nothing calls.
|
|
24
24
|
`bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
|
|
@@ -630,12 +630,36 @@ bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-c
|
|
|
630
630
|
bdt lint --no-tooling-check # skip the tooling-config check for this run
|
|
631
631
|
```
|
|
632
632
|
|
|
633
|
-
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
|
|
634
634
|
`PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
|
|
635
635
|
literal or f-string/concatenation/`%`-format expression is checked (an
|
|
636
636
|
unresolvable argument, e.g. a plain function parameter, is silently skipped --
|
|
637
637
|
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
|
|
638
|
-
it can't see through)
|
|
638
|
+
it can't see through).
|
|
639
|
+
|
|
640
|
+
**arrowbricks** (the Databricks SQL client): `Cursor.execute`/`execute_streamed`, `DatabricksClient.query_agent`/`stream_query_json`
|
|
641
|
+
and the free `arrowbricks.stream_query_json(client, sql)` are checked as well. `execute_streamed`/`query_agent`/`stream_query_json`
|
|
642
|
+
are matched by name (the free function's SQL is its *second* argument; the method's is the first; `sql=` as keyword works too).
|
|
643
|
+
Only the injection rules apply there -- an f-string/concatenation/`%`/`.format()` is flagged and the fix suggests an
|
|
644
|
+
arrowbricks t-string (`{value}` binds a named parameter, `{name:i}` quotes an identifier) or literal SQL with
|
|
645
|
+
`parameters=[...]` (`params=[...]` on `query_agent`/`stream_query_json`) for `:name` markers. The postgres house style (`sql-inline-too-complex`, `sql-positional-param`, `sql-forbidden-join`) is not applied to
|
|
646
|
+
them, since Databricks SQL isn't postgres. The SQL is also parsed as Databricks SQL (backticks, `QUALIFY`, ...), so such
|
|
647
|
+
statements are no longer missed; a plain `.execute()` (incl. `execute(sql=...)`) gets that extra dialect only in a file that
|
|
648
|
+
imports `arrowbricks`, and keeps the postgres rules there (it may be a psycopg cursor too). The free function is recognised
|
|
649
|
+
under `from arrowbricks import stream_query_json as x` and `arrowbricks[._streaming].stream_query_json(...)`.
|
|
650
|
+
Like pgdevkit's helpers the three names match by name wherever they are called (a client injected from another module is the
|
|
651
|
+
common case), so an unrelated `agent.query_agent(f"select ...")` whose text parses as SQL is flagged too.
|
|
652
|
+
arrowbricks' `unsafe_sql(x)` -- its typing-only escape hatch -- is treated like `cast(LiteralString, x)` (in any file):
|
|
653
|
+
only as safe as `x`.
|
|
654
|
+
|
|
655
|
+
pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
|
|
656
|
+
exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
|
|
657
|
+
`pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
|
|
658
|
+
recognised file-wide), so an unrelated `execute()` from another library, or one defined locally in a file without that
|
|
659
|
+
import, is never flagged. Aliased imports of the other helpers (`fetch_all as fa`) are recognised too. Not covered: a re-export through the
|
|
660
|
+
project's own module (`from app.db import execute`; call it as `db.execute(...)` or import it from `pgdevkit.db`
|
|
661
|
+
instead), a subclass of `PostgresJsonResponse` under another name, and CCMT2's pre-pgdevkit
|
|
662
|
+
`PostgresJsonResponse("postgres", sql)`, whose first argument is a connection source, not SQL.
|
|
639
663
|
|
|
640
664
|
- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
|
|
641
665
|
JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
|
|
@@ -648,6 +672,8 @@ it can't see through):
|
|
|
648
672
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
649
673
|
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
|
|
650
674
|
usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
|
|
675
|
+
A dynamic fragment may be an identifier/expression, an optional clause, or a (possibly empty) list of
|
|
676
|
+
assignments -- the statement is flagged if it parses as DML with any of these readings of its fragments.
|
|
651
677
|
- **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
|
|
652
678
|
sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
|
|
653
679
|
The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
|
|
@@ -835,8 +861,8 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
|
|
|
835
861
|
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
|
|
836
862
|
code 0 unless `--strict`).
|
|
837
863
|
|
|
838
|
-
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
839
|
-
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
|
|
864
|
+
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()` / arrowbricks `execute_streamed()` / `query_agent()` / `stream_query_json()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
865
|
+
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)`/`unsafe_sql(...)` (the cast is only as safe as its argument) or
|
|
840
866
|
a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
|
|
841
867
|
call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
|
|
842
868
|
reports unverified calls.)
|
|
@@ -4,7 +4,7 @@ Shared developer tooling for BMS projects: PR build/check status and a
|
|
|
4
4
|
one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and
|
|
5
5
|
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
|
|
6
6
|
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
|
|
7
|
-
postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
7
|
+
postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
8
8
|
TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
|
|
9
9
|
backend routes nothing calls.
|
|
10
10
|
`bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
|
|
@@ -616,12 +616,36 @@ bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-c
|
|
|
616
616
|
bdt lint --no-tooling-check # skip the tooling-config check for this run
|
|
617
617
|
```
|
|
618
618
|
|
|
619
|
-
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
|
|
620
620
|
`PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
|
|
621
621
|
literal or f-string/concatenation/`%`-format expression is checked (an
|
|
622
622
|
unresolvable argument, e.g. a plain function parameter, is silently skipped --
|
|
623
623
|
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
|
|
624
|
-
it can't see through)
|
|
624
|
+
it can't see through).
|
|
625
|
+
|
|
626
|
+
**arrowbricks** (the Databricks SQL client): `Cursor.execute`/`execute_streamed`, `DatabricksClient.query_agent`/`stream_query_json`
|
|
627
|
+
and the free `arrowbricks.stream_query_json(client, sql)` are checked as well. `execute_streamed`/`query_agent`/`stream_query_json`
|
|
628
|
+
are matched by name (the free function's SQL is its *second* argument; the method's is the first; `sql=` as keyword works too).
|
|
629
|
+
Only the injection rules apply there -- an f-string/concatenation/`%`/`.format()` is flagged and the fix suggests an
|
|
630
|
+
arrowbricks t-string (`{value}` binds a named parameter, `{name:i}` quotes an identifier) or literal SQL with
|
|
631
|
+
`parameters=[...]` (`params=[...]` on `query_agent`/`stream_query_json`) for `:name` markers. The postgres house style (`sql-inline-too-complex`, `sql-positional-param`, `sql-forbidden-join`) is not applied to
|
|
632
|
+
them, since Databricks SQL isn't postgres. The SQL is also parsed as Databricks SQL (backticks, `QUALIFY`, ...), so such
|
|
633
|
+
statements are no longer missed; a plain `.execute()` (incl. `execute(sql=...)`) gets that extra dialect only in a file that
|
|
634
|
+
imports `arrowbricks`, and keeps the postgres rules there (it may be a psycopg cursor too). The free function is recognised
|
|
635
|
+
under `from arrowbricks import stream_query_json as x` and `arrowbricks[._streaming].stream_query_json(...)`.
|
|
636
|
+
Like pgdevkit's helpers the three names match by name wherever they are called (a client injected from another module is the
|
|
637
|
+
common case), so an unrelated `agent.query_agent(f"select ...")` whose text parses as SQL is flagged too.
|
|
638
|
+
arrowbricks' `unsafe_sql(x)` -- its typing-only escape hatch -- is treated like `cast(LiteralString, x)` (in any file):
|
|
639
|
+
only as safe as `x`.
|
|
640
|
+
|
|
641
|
+
pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
|
|
642
|
+
exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
|
|
643
|
+
`pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
|
|
644
|
+
recognised file-wide), so an unrelated `execute()` from another library, or one defined locally in a file without that
|
|
645
|
+
import, is never flagged. Aliased imports of the other helpers (`fetch_all as fa`) are recognised too. Not covered: a re-export through the
|
|
646
|
+
project's own module (`from app.db import execute`; call it as `db.execute(...)` or import it from `pgdevkit.db`
|
|
647
|
+
instead), a subclass of `PostgresJsonResponse` under another name, and CCMT2's pre-pgdevkit
|
|
648
|
+
`PostgresJsonResponse("postgres", sql)`, whose first argument is a connection source, not SQL.
|
|
625
649
|
|
|
626
650
|
- **`sql-inline-too-complex`** — more than a trivial (≤4 line) query, or a
|
|
627
651
|
JOIN/CTE/subquery/aggregation, inline instead of `load_sql()`/a `.sql` file.
|
|
@@ -634,6 +658,8 @@ it can't see through):
|
|
|
634
658
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
635
659
|
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
|
|
636
660
|
usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
|
|
661
|
+
A dynamic fragment may be an identifier/expression, an optional clause, or a (possibly empty) list of
|
|
662
|
+
assignments -- the statement is flagged if it parses as DML with any of these readings of its fragments.
|
|
637
663
|
- **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
|
|
638
664
|
sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
|
|
639
665
|
The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
|
|
@@ -821,8 +847,8 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
|
|
|
821
847
|
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
|
|
822
848
|
code 0 unless `--strict`).
|
|
823
849
|
|
|
824
|
-
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
825
|
-
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
|
|
850
|
+
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()` / arrowbricks `execute_streamed()` / `query_agent()` / `stream_query_json()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
851
|
+
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)`/`unsafe_sql(...)` (the cast is only as safe as its argument) or
|
|
826
852
|
a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
|
|
827
853
|
call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
|
|
828
854
|
reports unverified calls.)
|
|
@@ -1093,7 +1093,8 @@ 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 (and arrowbricks'
|
|
1097
|
+
`execute_streamed`/`query_agent`/`stream_query_json`, injection rules only)
|
|
1097
1098
|
(must use load_sql()/a .sql file, a t-string, or psycopg.sql for anything beyond a trivial
|
|
1098
1099
|
query; never an f-string/concatenation/`%`-formatting), pydantic-model placement under api/
|
|
1099
1100
|
directories, hand-wired HTTP in TypeScript, and that the repo declares/configures ty, ruff, pytest and prek.
|
|
@@ -1,6 +1,6 @@
|
|
|
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
|
|
3
|
-
`PostgresJsonResponse`) is classified by how its SQL argument was
|
|
2
|
+
every `.execute()`/`.executemany()` call (and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and
|
|
3
|
+
`PostgresJsonResponse`, and arrowbricks' `execute_streamed`/`query_agent`/`stream_query_json`) 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).
|
|
6
6
|
|
|
@@ -24,10 +24,11 @@ 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
|
|
30
|
-
from dataclasses import dataclass, field
|
|
31
|
+
from dataclasses import dataclass, field, replace
|
|
31
32
|
from pathlib import Path
|
|
32
33
|
|
|
33
34
|
from .lint_findings import Finding
|
|
@@ -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,15 @@ 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>`
|
|
140
|
+
arrowbricks_modules: frozenset[str] = frozenset() # local names bound by `import arrowbricks[.x] [as y]`
|
|
141
|
+
arrowbricks_file: bool = False # the file imports arrowbricks (`import arrowbricks` / `from arrowbricks[.x] import ...`)
|
|
142
|
+
arrowbricks_stream_funcs: frozenset[str] = frozenset() # local names of the free `stream_query_json(client, sql)`
|
|
143
|
+
# Per-call, set by the visitor (see `_call_trust`): this sink's SQL is Databricks SQL, so a probe that does not parse
|
|
144
|
+
# as postgres is retried as Databricks (backticks, `:name` markers, QUALIFY, ...) ...
|
|
145
|
+
databricks: bool = False
|
|
146
|
+
# ... and the postgres house style (inline-complexity, `%s` params, forbidden joins) is not applied to it.
|
|
147
|
+
postgres_rules: bool = True
|
|
130
148
|
|
|
131
149
|
|
|
132
150
|
_NO_TRUST = _Trust()
|
|
@@ -166,10 +184,13 @@ def _is_trusted_sql_call(node: ast.AST, trust: _Trust = _NO_TRUST) -> bool:
|
|
|
166
184
|
|
|
167
185
|
|
|
168
186
|
def _literal_string_cast_inner(node: ast.Call) -> ast.expr | None:
|
|
169
|
-
"""`cast(LiteralString, x)` -> `x
|
|
187
|
+
"""`cast(LiteralString, x)` -> `x`, and arrowbricks' `unsafe_sql(x)` (its no-op spelling of the same cast) -> `x`.
|
|
188
|
+
The cast itself proves nothing: it is only as safe as `x`."""
|
|
170
189
|
func = node.func
|
|
171
|
-
|
|
172
|
-
if
|
|
190
|
+
name = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None
|
|
191
|
+
if name == "unsafe_sql" and len(node.args) == 1 and not node.keywords:
|
|
192
|
+
return node.args[0]
|
|
193
|
+
if name == "cast" and len(node.args) == 2 and _is_literal_string_ref(node.args[0]):
|
|
173
194
|
return node.args[1]
|
|
174
195
|
return None
|
|
175
196
|
|
|
@@ -185,19 +206,52 @@ def _unwrap_str_call(node: ast.Call) -> ast.expr | None:
|
|
|
185
206
|
return None
|
|
186
207
|
|
|
187
208
|
|
|
209
|
+
def _is_pgdevkit_module(module: str | None) -> bool:
|
|
210
|
+
return module is not None and (module == "pgdevkit" or module.startswith("pgdevkit."))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _is_arrowbricks_module(module: str | None) -> bool:
|
|
214
|
+
return module is not None and (module == "arrowbricks" or module.startswith("arrowbricks."))
|
|
215
|
+
|
|
216
|
+
|
|
188
217
|
def _file_trust(tree: ast.AST) -> _Trust:
|
|
189
218
|
literal_funcs: set[str] = set()
|
|
190
219
|
sqlglot_names: set[str] = set()
|
|
191
220
|
sqlglot_aliases: dict[str, str] = {}
|
|
221
|
+
pgdevkit_sinks: set[str] = set()
|
|
222
|
+
arrowbricks_modules: set[str] = set()
|
|
223
|
+
arrowbricks_stream_funcs: set[str] = set()
|
|
224
|
+
arrowbricks_file = False
|
|
192
225
|
for node in ast.walk(tree):
|
|
193
226
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None and _is_literal_string_ref(node.returns):
|
|
194
227
|
literal_funcs.add(node.name)
|
|
195
228
|
elif isinstance(node, ast.Import):
|
|
196
229
|
sqlglot_names.update((a.asname or a.name).split(".")[0] for a in node.names if a.name.split(".")[0] == "sqlglot")
|
|
230
|
+
for alias in node.names:
|
|
231
|
+
if _is_arrowbricks_module(alias.name):
|
|
232
|
+
arrowbricks_file = True
|
|
233
|
+
arrowbricks_modules.add(alias.asname or alias.name.split(".")[0])
|
|
197
234
|
elif isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] == "sqlglot":
|
|
198
235
|
sqlglot_names.update(a.asname or a.name for a in node.names)
|
|
199
236
|
sqlglot_aliases.update({a.asname or a.name: a.name for a in node.names})
|
|
200
|
-
|
|
237
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and _is_pgdevkit_module(node.module):
|
|
238
|
+
pgdevkit_sinks.update(a.asname or a.name for a in node.names if a.name in _PGDEVKIT_SQL_CALLEES)
|
|
239
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and _is_arrowbricks_module(node.module):
|
|
240
|
+
arrowbricks_file = True
|
|
241
|
+
for alias in node.names:
|
|
242
|
+
if alias.name == "stream_query_json":
|
|
243
|
+
arrowbricks_stream_funcs.add(alias.asname or alias.name)
|
|
244
|
+
elif alias.name == "_streaming": # the module that defines the free function
|
|
245
|
+
arrowbricks_modules.add(alias.asname or alias.name)
|
|
246
|
+
return _Trust(
|
|
247
|
+
frozenset(sqlglot_names),
|
|
248
|
+
frozenset(literal_funcs),
|
|
249
|
+
sqlglot_aliases,
|
|
250
|
+
frozenset(pgdevkit_sinks),
|
|
251
|
+
frozenset(arrowbricks_modules),
|
|
252
|
+
arrowbricks_file,
|
|
253
|
+
frozenset(arrowbricks_stream_funcs),
|
|
254
|
+
)
|
|
201
255
|
|
|
202
256
|
|
|
203
257
|
def _resolve_single(expr: ast.expr, lookup) -> ast.expr:
|
|
@@ -276,12 +330,77 @@ _FSTRING_FIX = (
|
|
|
276
330
|
"`{name:i}` quotes a table/column identifier. Or use load_sql()/a .sql file for static SQL."
|
|
277
331
|
)
|
|
278
332
|
_GENERIC_FIX = "Use a psycopg t-string (Python 3.14+), psycopg.sql for dynamic SQL, or load_sql()/a .sql file for static SQL."
|
|
333
|
+
_ARROWBRICKS_FSTRING_FIX = (
|
|
334
|
+
"Change the `f` prefix to `t` (arrowbricks t-string, Python 3.14+): `{value}` is bound as a named parameter and "
|
|
335
|
+
"`{name:i}` quotes a table/column identifier. Or keep the SQL literal and pass its `:name` markers' values as "
|
|
336
|
+
"`parameters=[...]` (`params=[...]` for `query_agent`/`stream_query_json`)."
|
|
337
|
+
)
|
|
338
|
+
_ARROWBRICKS_GENERIC_FIX = (
|
|
339
|
+
"Use an arrowbricks t-string (Python 3.14+: `{value}` binds a named parameter, `{name:i}` quotes an identifier), or a "
|
|
340
|
+
"literal SQL string with `parameters=[...]` (`params=[...]` for `query_agent`/`stream_query_json`) for its `:name` markers."
|
|
341
|
+
)
|
|
342
|
+
# A plain `.execute()` in a file that imports arrowbricks may be either driver's cursor: name neither.
|
|
343
|
+
_MIXED_FIX = (
|
|
344
|
+
"Change the `f` prefix to `t` (psycopg/arrowbricks t-string, Python 3.14+): `{value}` becomes a bound parameter and "
|
|
345
|
+
"`{name:i}` quotes a table/column identifier. Or use load_sql()/a .sql file for static SQL."
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# Keyword that must literally be in the text before a variant reading (a fragment as nothing/an assignment) may
|
|
350
|
+
# count: sqlglot is lenient enough to read prose like "Update to version {v}" as an UPDATE with the fragment gone.
|
|
351
|
+
_VARIANT_REQUIRED_KEYWORD: dict[type, re.Pattern[str]] = {}
|
|
352
|
+
if exp is not None:
|
|
353
|
+
_VARIANT_REQUIRED_KEYWORD = {
|
|
354
|
+
exp.Select: re.compile(r"(?i)\bfrom\b"),
|
|
355
|
+
exp.Union: re.compile(r"(?i)\bfrom\b"),
|
|
356
|
+
exp.Insert: re.compile(r"(?i)\binto\b"),
|
|
357
|
+
exp.Update: re.compile(r"(?i)\bset\b"),
|
|
358
|
+
exp.Delete: re.compile(r"(?i)\bfrom\b"),
|
|
359
|
+
exp.Merge: re.compile(r"(?i)\busing\b"),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _parse_any_dialect(text: str, databricks: bool) -> "exp.Expression | None":
|
|
364
|
+
parsed = parse_sql_text(text)
|
|
365
|
+
if parsed is None and databricks:
|
|
366
|
+
parsed = parse_sql_text(text, "databricks")
|
|
367
|
+
return parsed
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _parse_probe_text(text: str, databricks: bool = False) -> "exp.Expression | None":
|
|
371
|
+
"""`parse_sql_text` for text with `_PROBE_PLACEHOLDER` standing in for dynamic fragments: the placeholder
|
|
372
|
+
as an identifier first, then (see `_PROBE_SLOT_VARIANTS`) every fragment as nothing/an assignment -- the
|
|
373
|
+
latter only when the literal text carries the statement's own keyword (`_VARIANT_REQUIRED_KEYWORD`)."""
|
|
374
|
+
parsed = _parse_any_dialect(text, databricks)
|
|
375
|
+
if parsed is not None:
|
|
376
|
+
return parsed
|
|
377
|
+
parts = text.split(_PROBE_PLACEHOLDER)
|
|
378
|
+
slots = len(parts) - 1
|
|
379
|
+
if not 1 <= slots <= _MAX_PROBE_SLOTS:
|
|
380
|
+
return None
|
|
381
|
+
literal_text = " ".join(parts)
|
|
382
|
+
for combination in itertools.product(_PROBE_SLOT_VARIANTS, repeat=slots):
|
|
383
|
+
if all(c == _PROBE_PLACEHOLDER for c in combination):
|
|
384
|
+
continue # that's `text` itself, already tried
|
|
385
|
+
candidate = "".join(a + b for a, b in itertools.zip_longest(parts, combination, fillvalue=""))
|
|
386
|
+
parsed = _parse_any_dialect(candidate, databricks)
|
|
387
|
+
if parsed is None:
|
|
388
|
+
continue
|
|
389
|
+
keyword = next((rx for t, rx in _VARIANT_REQUIRED_KEYWORD.items() if isinstance(parsed, t)), None)
|
|
390
|
+
if keyword is not None and keyword.search(literal_text):
|
|
391
|
+
return parsed
|
|
392
|
+
return None
|
|
279
393
|
|
|
280
394
|
|
|
281
|
-
def _injection_finding(text: str, path: Path, lineno: int, rule: str, how: str) -> list[Finding]:
|
|
282
|
-
if
|
|
395
|
+
def _injection_finding(text: str, path: Path, lineno: int, rule: str, how: str, trust: _Trust = _NO_TRUST) -> list[Finding]:
|
|
396
|
+
if _parse_probe_text(text, trust.databricks) is None:
|
|
283
397
|
return []
|
|
284
|
-
|
|
398
|
+
if trust.databricks and not trust.postgres_rules:
|
|
399
|
+
fix = _ARROWBRICKS_FSTRING_FIX if rule == "sql-fstring-injection" else _ARROWBRICKS_GENERIC_FIX
|
|
400
|
+
elif trust.databricks:
|
|
401
|
+
fix = _MIXED_FIX
|
|
402
|
+
else:
|
|
403
|
+
fix = _FSTRING_FIX if rule == "sql-fstring-injection" else _GENERIC_FIX
|
|
285
404
|
return [Finding(path, lineno, rule, f"SQL built with {how} -- injection risk. {fix}")]
|
|
286
405
|
|
|
287
406
|
|
|
@@ -360,30 +479,30 @@ def _check_resolved_candidate(
|
|
|
360
479
|
if _is_load_sql_call(resolved) or _is_sql_composed_call(resolved) or _is_trusted_sql_call(resolved, trust):
|
|
361
480
|
return []
|
|
362
481
|
if isinstance(resolved, getattr(ast, "TemplateStr", ())):
|
|
363
|
-
return [] # psycopg t-string: params are always bound, never interpolated
|
|
482
|
+
return [] # psycopg/arrowbricks t-string: params are always bound, never interpolated
|
|
364
483
|
|
|
365
484
|
if isinstance(resolved, ast.JoinedStr):
|
|
366
|
-
return _injection_finding(_fstring_probe_text(resolved), path, lineno, "sql-fstring-injection", "an f-string")
|
|
485
|
+
return _injection_finding(_fstring_probe_text(resolved), path, lineno, "sql-fstring-injection", "an f-string", trust)
|
|
367
486
|
|
|
368
487
|
if isinstance(resolved, ast.BinOp) and isinstance(resolved.op, ast.Mod):
|
|
369
488
|
left = _resolve_single(resolved.left, lookup)
|
|
370
489
|
if isinstance(left, ast.Constant) and isinstance(left.value, str):
|
|
371
|
-
return _injection_finding(left.value, path, lineno, "sql-percent-format-injection", "the `%` string-formatting operator")
|
|
490
|
+
return _injection_finding(left.value, path, lineno, "sql-percent-format-injection", "the `%` string-formatting operator", trust)
|
|
372
491
|
return []
|
|
373
492
|
|
|
374
493
|
if isinstance(resolved, ast.BinOp) and isinstance(resolved.op, ast.Add):
|
|
375
494
|
text = _concat_probe_text(resolved, lookup)
|
|
376
|
-
return _injection_finding(text, path, lineno, "sql-concat-injection", "string concatenation") if text is not None else []
|
|
495
|
+
return _injection_finding(text, path, lineno, "sql-concat-injection", "string concatenation", trust) if text is not None else []
|
|
377
496
|
|
|
378
497
|
if isinstance(resolved, ast.Call) and isinstance(resolved.func, ast.Attribute) and resolved.func.attr == "format":
|
|
379
498
|
template = _resolve_single(resolved.func.value, lookup)
|
|
380
499
|
if isinstance(template, ast.Constant) and isinstance(template.value, str):
|
|
381
500
|
text = _FORMAT_FIELD_RE.sub(_PROBE_PLACEHOLDER, template.value)
|
|
382
|
-
return _injection_finding(text, path, lineno, "sql-format-injection", "`str.format()`")
|
|
501
|
+
return _injection_finding(text, path, lineno, "sql-format-injection", "`str.format()`", trust)
|
|
383
502
|
return []
|
|
384
503
|
|
|
385
504
|
if isinstance(resolved, ast.Constant) and isinstance(resolved.value, str):
|
|
386
|
-
return _literal_findings(resolved.value, path, lineno)
|
|
505
|
+
return _literal_findings(resolved.value, path, lineno) if trust.postgres_rules else []
|
|
387
506
|
|
|
388
507
|
if isinstance(resolved, ast.Call):
|
|
389
508
|
cast_inner = _literal_string_cast_inner(resolved)
|
|
@@ -418,7 +537,7 @@ def _unverified_cast_finding(path: Path, lineno: int) -> Finding:
|
|
|
418
537
|
path,
|
|
419
538
|
lineno,
|
|
420
539
|
"sql-unverified-cast",
|
|
421
|
-
"`cast(LiteralString, ...)` wraps a value that isn't visibly a sqlglot expression or a literal. AI reviewer: verify it "
|
|
540
|
+
"`cast(LiteralString, ...)`/`unsafe_sql(...)` wraps a value that isn't visibly a sqlglot expression or a literal. AI reviewer: verify it "
|
|
422
541
|
"cannot carry user-controlled text (the cast doesn't make it safe); pass a sqlglot expression, or add "
|
|
423
542
|
"`# bdt-lint: ignore sql-unverified-cast`.",
|
|
424
543
|
severity="review",
|
|
@@ -455,26 +574,37 @@ def _is_sql_named(name: str) -> bool:
|
|
|
455
574
|
return lowered == "sql" or lowered.endswith("_sql")
|
|
456
575
|
|
|
457
576
|
|
|
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
|
-
|
|
577
|
+
# pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar`/`execute` and `pgdevkit.fastapi.PostgresJsonResponse` run their
|
|
578
|
+
# first argument as SQL, exactly like `.execute()`, so they get the same rules. As an attribute (`db.fetch_all(...)`,
|
|
579
|
+
# `pgdevkit.db.execute(...)`) they are matched by name only, like `.execute()` itself.
|
|
580
|
+
_PGDEVKIT_SQL_CALLEES = frozenset({"fetch_all", "fetch_one", "fetch_scalar", "execute", "PostgresJsonResponse"})
|
|
581
|
+
# A bare `execute(...)` is far too generic a name (sqlite helpers, subprocess wrappers, ...): it counts only in a file
|
|
582
|
+
# that imports it from `pgdevkit`/`pgdevkit.db` (also under an alias: `from pgdevkit.db import execute as run`; see
|
|
583
|
+
# `_file_trust`). The import is tracked file-wide, so a local def/parameter named like it is not told apart. The sqlglot
|
|
584
|
+
# DML gate still applies on top. The other callees are matched by bare name too, and by their pgdevkit import alias.
|
|
585
|
+
# Not covered: a re-export through the project's own module (`from app.db import execute`), a subclass of
|
|
586
|
+
# PostgresJsonResponse under another name (the documented `class AppJson(PostgresJsonResponse)`), and the pre-pgdevkit
|
|
462
587
|
# `PostgresJsonResponse("postgres", sql, ...)` copy in CCMT2, whose first argument is a connection source -- its SQL
|
|
463
588
|
# only gets checked once that call site moves to pgdevkit.
|
|
464
|
-
|
|
589
|
+
_IMPORT_GATED_BARE_CALLEES = frozenset({"execute"})
|
|
590
|
+
_BARE_NAME_SQL_CALLEES = _PGDEVKIT_SQL_CALLEES - _IMPORT_GATED_BARE_CALLEES
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _is_pgdevkit_sink_call(func: ast.expr, trust: _Trust) -> bool:
|
|
594
|
+
if isinstance(func, ast.Name):
|
|
595
|
+
return func.id in _BARE_NAME_SQL_CALLEES or func.id in trust.pgdevkit_sinks
|
|
596
|
+
return isinstance(func, ast.Attribute) and func.attr in _PGDEVKIT_SQL_CALLEES
|
|
465
597
|
|
|
466
598
|
|
|
467
|
-
def _execute_query_arg(call: ast.Call) -> ast.expr | None:
|
|
599
|
+
def _execute_query_arg(call: ast.Call, trust: _Trust = _NO_TRUST) -> ast.expr | None:
|
|
468
600
|
func = call.func
|
|
469
601
|
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):
|
|
602
|
+
if not (is_execute or _is_pgdevkit_sink_call(func, trust)):
|
|
474
603
|
return None
|
|
475
604
|
if call.args:
|
|
476
605
|
return call.args[0]
|
|
477
|
-
|
|
606
|
+
keywords = ("query", "sql") if trust.arrowbricks_file else ("query",) # arrowbricks' Cursor.execute(sql=...)
|
|
607
|
+
return next((kw.value for kw in call.keywords if kw.arg in keywords), None)
|
|
478
608
|
|
|
479
609
|
|
|
480
610
|
# sqlglot's builders parse plain strings as SQL (`select(f"a, {x}")`, `.where(f"id = {x}")`), so text
|
|
@@ -568,6 +698,52 @@ def _sqlglot_builder_findings(
|
|
|
568
698
|
return []
|
|
569
699
|
|
|
570
700
|
|
|
701
|
+
# arrowbricks' (Databricks SQL client) calls that take SQL: `Cursor.execute_streamed(sql, ...)`,
|
|
702
|
+
# `DatabricksClient.query_agent(sql, ...)`, `DatabricksClient.stream_query_json(sql, ...)` and the free function
|
|
703
|
+
# `arrowbricks.stream_query_json(client, sql, ...)`. (`Cursor.execute` is already matched as any `.execute()`.) The names are
|
|
704
|
+
# specific enough to match anywhere, like pgdevkit's `fetch_all`. Their SQL is Databricks SQL, so only the injection rules apply
|
|
705
|
+
# (`_call_trust`); a t-string or `unsafe_sql(...)` is handled like psycopg's t-string / `cast(LiteralString, ...)`.
|
|
706
|
+
_ARROWBRICKS_SQL_CALLEES = frozenset({"execute_streamed", "query_agent", "stream_query_json"})
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _attribute_root(node: ast.expr) -> str | None:
|
|
710
|
+
while isinstance(node, ast.Attribute):
|
|
711
|
+
node = node.value
|
|
712
|
+
return node.id if isinstance(node, ast.Name) else None
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _arrowbricks_sink_arg(call: ast.Call, trust: _Trust) -> ast.expr | None:
|
|
716
|
+
func = call.func
|
|
717
|
+
if isinstance(func, ast.Name):
|
|
718
|
+
# a bare name is the free function (also under an import alias) or, for the other two, the user's own helper
|
|
719
|
+
name = "stream_query_json" if func.id in trust.arrowbricks_stream_funcs else func.id
|
|
720
|
+
free_function = True
|
|
721
|
+
elif isinstance(func, ast.Attribute):
|
|
722
|
+
name = func.attr
|
|
723
|
+
free_function = _attribute_root(func.value) in trust.arrowbricks_modules # `arrowbricks.stream_query_json(client, sql)`
|
|
724
|
+
else:
|
|
725
|
+
return None
|
|
726
|
+
if name not in _ARROWBRICKS_SQL_CALLEES:
|
|
727
|
+
return None
|
|
728
|
+
index = 1 if name == "stream_query_json" and free_function else 0 # the free function takes the client first
|
|
729
|
+
if len(call.args) > index and not isinstance(call.args[index], ast.Starred):
|
|
730
|
+
return call.args[index]
|
|
731
|
+
return next((kw.value for kw in call.keywords if kw.arg == "sql"), None)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _call_trust(call: ast.Call, trust: _Trust) -> tuple[ast.expr | None, _Trust]:
|
|
735
|
+
"""The SQL argument of `call` (None if it isn't a SQL sink) and the trust to check it with: an arrowbricks call is
|
|
736
|
+
Databricks SQL without the postgres house style; a plain `.execute()` in a file that imports arrowbricks keeps the
|
|
737
|
+
postgres rules but may also be Databricks SQL (arrowbricks' `Cursor.execute`)."""
|
|
738
|
+
arrowbricks_arg = _arrowbricks_sink_arg(call, trust)
|
|
739
|
+
if arrowbricks_arg is not None:
|
|
740
|
+
return arrowbricks_arg, replace(trust, databricks=True, postgres_rules=False)
|
|
741
|
+
query_arg = _execute_query_arg(call, trust)
|
|
742
|
+
if query_arg is not None and trust.arrowbricks_file:
|
|
743
|
+
return query_arg, replace(trust, databricks=True)
|
|
744
|
+
return query_arg, trust
|
|
745
|
+
|
|
746
|
+
|
|
571
747
|
class _ExecuteCallVisitor(ast.NodeVisitor):
|
|
572
748
|
"""Walks a module, tracking a stack of (function/class/module) local-variable scopes so a
|
|
573
749
|
`cur.execute(query, ...)` call can resolve `query` back to every assignment it had in that
|
|
@@ -575,10 +751,11 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
|
|
|
575
751
|
branch-shadowed unsafe assignment is still seen), then applies the SQL rules to every
|
|
576
752
|
`.execute()`/`.executemany()` call found."""
|
|
577
753
|
|
|
578
|
-
def __init__(self, path: Path, trust: _Trust = _NO_TRUST, review: bool = False) -> None:
|
|
754
|
+
def __init__(self, path: Path, trust: _Trust = _NO_TRUST, review: bool = False, arrowbricks_names: frozenset[str] = frozenset()) -> None:
|
|
579
755
|
self.path = path
|
|
580
756
|
self.trust = trust
|
|
581
757
|
self.review = review
|
|
758
|
+
self._arrowbricks_names = arrowbricks_names # `*_sql` variables passed to an arrowbricks call (no postgres house style)
|
|
582
759
|
self.findings: list[Finding] = []
|
|
583
760
|
self._scopes: list[dict[str, list[ast.expr]]] = [{}]
|
|
584
761
|
self._sqlglot_vars: set[str] = set() # names bound from a sqlglot expression chain
|
|
@@ -622,6 +799,8 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
|
|
|
622
799
|
`.execute()` call in this file (e.g. it's passed to a helper) -- apply the complexity rule."""
|
|
623
800
|
if not (_is_sql_named(name) and isinstance(value, ast.Constant) and isinstance(value.value, str)):
|
|
624
801
|
return
|
|
802
|
+
if name in self._arrowbricks_names:
|
|
803
|
+
return
|
|
625
804
|
parsed = parse_sql_text(value.value)
|
|
626
805
|
if parsed is not None:
|
|
627
806
|
self.findings.extend(_complexity_findings(parsed, value.value, self.path, lineno))
|
|
@@ -634,9 +813,9 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
|
|
|
634
813
|
|
|
635
814
|
def visit_Call(self, node: ast.Call) -> None:
|
|
636
815
|
self.findings.extend(_sqlglot_builder_findings(node, self.path, self.trust, self._lookup, self._sqlglot_vars))
|
|
637
|
-
query_arg =
|
|
816
|
+
query_arg, trust = _call_trust(node, self.trust)
|
|
638
817
|
if query_arg is not None:
|
|
639
|
-
found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno,
|
|
818
|
+
found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno, trust, self.review)
|
|
640
819
|
if isinstance(query_arg, ast.Name) and _is_sql_named(query_arg.id) and self._all_bare_strings(query_arg.id):
|
|
641
820
|
found = [f for f in found if f.rule != "sql-inline-too-complex"] # already reported at the assignment
|
|
642
821
|
self.findings.extend(found)
|
|
@@ -660,6 +839,16 @@ def check_sql_file(path: Path, source: str | None = None, *, review: bool = Fals
|
|
|
660
839
|
tree = ast.parse(text, filename=str(path))
|
|
661
840
|
except SyntaxError:
|
|
662
841
|
return []
|
|
663
|
-
|
|
842
|
+
trust = _file_trust(tree)
|
|
843
|
+
arrowbricks_names: set[str] = set() # `*_sql` variables handed to an arrowbricks call ...
|
|
844
|
+
other_names: set[str] = set() # ... minus any name that is also handed to some other sink (it could be a psycopg query)
|
|
845
|
+
for call in ast.walk(tree):
|
|
846
|
+
if not isinstance(call, ast.Call):
|
|
847
|
+
continue
|
|
848
|
+
if isinstance(arg := _arrowbricks_sink_arg(call, trust), ast.Name):
|
|
849
|
+
arrowbricks_names.add(arg.id)
|
|
850
|
+
elif isinstance(arg := _execute_query_arg(call, trust), ast.Name):
|
|
851
|
+
other_names.add(arg.id)
|
|
852
|
+
visitor = _ExecuteCallVisitor(path, trust, review, frozenset(arrowbricks_names - other_names))
|
|
664
853
|
visitor.visit(tree)
|
|
665
854
|
return visitor.findings
|
|
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "bmsdna-devtools"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.36.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
|
|
@@ -5,7 +5,7 @@ description: >
|
|
|
5
5
|
commands or repo-local scripts for: checking PR build/check status, creating
|
|
6
6
|
a PR, creating a git worktree, committing and pushing files (with pre-flight
|
|
7
7
|
checks), claiming an issue / work item (`bdt issue take`), a one-shot PR summary
|
|
8
|
-
(`bdt pr info`: link, build state, closed issues), querying Azure logs, and static checks (`bdt lint`: SQL/psycopg rules, pydantic placement, tooling; `bdt dead-code`:
|
|
8
|
+
(`bdt pr info`: link, build state, closed issues), querying Azure logs, and static checks (`bdt lint`: SQL/psycopg/arrowbricks rules, pydantic placement, tooling; `bdt dead-code`:
|
|
9
9
|
unreferenced .sql files and backend routes nothing calls), listing which env vars a repo defines (`bdt env get-keys`, names only). `bdt pr *` works against both Azure DevOps
|
|
10
10
|
and GitHub — it auto-detects which one from the `origin` remote. Trigger
|
|
11
11
|
whenever the user asks to check a build/PR status, create a PR, make a
|
|
@@ -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`, arrowbricks `execute_streamed`/`query_agent`/`stream_query_json`), 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,32 @@ 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
|
+
|
|
166
|
+
def test_arrowbricks_calls_are_sql_sinks_for_find_injection(tmp_path: Path) -> None:
|
|
167
|
+
(tmp_path / "ab.py").write_text(
|
|
168
|
+
"from arrowbricks import unsafe_sql\n\n"
|
|
169
|
+
'async def a(client, x):\n await client.query_agent(f"select * from t where id = {x}")\n\n'
|
|
170
|
+
"async def b(client, x):\n await client.query_agent(build(x))\n\n"
|
|
171
|
+
"async def c(client, request_sql):\n await client.query_agent(unsafe_sql(request_sql))\n\n"
|
|
172
|
+
'async def d(client, x):\n await client.query_agent(unsafe_sql(f"select * from t where id = {x}"))\n\n'
|
|
173
|
+
'async def e(client):\n await client.query_agent(unsafe_sql("select 1"))\n await client.query_agent(t"select {1}")\n'
|
|
174
|
+
)
|
|
175
|
+
result = _run(tmp_path)
|
|
176
|
+
assert [(f.rule, f.line) for f in result.errors] == [("sql-fstring-injection", 4), ("sql-fstring-injection", 13)]
|
|
177
|
+
assert [(f.rule, f.line) for f in result.reviews] == [("sql-unverified-call", 7), ("sql-unverified-cast", 10)]
|
|
178
|
+
|
|
179
|
+
|
|
154
180
|
def test_tests_and_missing_paths(tmp_path: Path) -> None:
|
|
155
181
|
(tmp_path / "tests").mkdir()
|
|
156
182
|
(tmp_path / "tests" / "test_x.py").write_text("eval(x)\n")
|