├── .git-blame-ignore-revs ├── .github └── workflows │ ├── dependency-review.yml │ ├── publish.yml │ ├── test.yml │ └── timescaledb.yml ├── .gitignore ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── REFERENCE.md ├── SECURITY.md ├── gendoc ├── pylintrc ├── pyproject.toml ├── renovate.json ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── shell.nix ├── src └── pgspot │ ├── __init__.py │ ├── __main__.py │ ├── cli.py │ ├── codes.py │ ├── formatters.py │ ├── path.py │ ├── pg_catalog │ ├── __init__.py │ └── format.py │ ├── plpgsql.py │ ├── state.py │ └── visitors.py ├── testdata ├── aggregate_tracking.sql ├── cast.sql ├── created_schema.sql ├── createfunc.sql ├── do.sql ├── dyn_foreach.sql ├── exists.sql ├── expected │ ├── aggregate_tracking.out │ ├── cast.out │ ├── created_schema.out │ ├── createfunc.out │ ├── do.out │ ├── dyn_foreach.out │ ├── exists.out │ ├── foreach_array.out │ ├── loop.out │ ├── nested_searchpath.out │ ├── operator.out │ ├── plpgsql_function.out │ ├── ps009-simplified-case.out │ ├── range_function.out │ ├── replace.out │ ├── return_query.out │ ├── search_path.out │ ├── security_definer.out │ ├── set_local.out │ ├── sql_function.out │ ├── unqualified_cte.out │ └── while.out ├── foreach_array.sql ├── loop.sql ├── nested_searchpath.sql ├── operator.sql ├── plpgsql_function.sql ├── ps009-simplified-case.sql ├── range_function.sql ├── replace.sql ├── return_query.sql ├── search_path.sql ├── security_definer.sql ├── set_local.sql ├── sql_function.sql ├── unqualified_cte.sql └── while.sql ├── tests ├── create_aggregate_test.py ├── format_string_test.py ├── global_ignore_test.py ├── ignore_lang_test.py ├── plpgsql_path_if_test.py ├── plpgsql_path_loop_test.py ├── plpgsql_paths_test.py ├── search_path_test.py ├── snapshot_test.py ├── sql_accepting_function_test.py └── util.py └── tox.ini /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # reformatted sources with black formatter 2 | fbc1de2a0d712fe6c58db830aa81c2d94b999b81 3 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, 6 | # PRs introducing known-vulnerable packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | name: 'Dependency Review' 10 | on: [pull_request] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | dependency-review: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Harden Runner 20 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 21 | with: 22 | egress-policy: audit 23 | 24 | - name: 'Checkout Repository' 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | - name: 'Dependency Review' 27 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | publish: 11 | name: Build and publish to PyPI 12 | runs-on: ubuntu-latest 13 | permissions: 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | 18 | - name: Set up Python 3.10 19 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 20 | with: 21 | python-version: '3.13' 22 | 23 | - name: Install pypa/build 24 | run: python -m pip install build --user 25 | 26 | - name: Build a binary wheel and a source tarball 27 | run: python -m build --sdist --wheel --outdir dist/ . 28 | 29 | - name: Publish distribution to PyPI 30 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # release/v1 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | 14 | - name: Harden Runner 15 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 16 | with: 17 | egress-policy: audit 18 | 19 | - name: Setup python 3.13 20 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 21 | with: 22 | python-version: '3.13' 23 | 24 | - name: Checkout 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | 27 | - name: Install tox 28 | run: python -m pip install tox 29 | 30 | - name: Run tox 31 | run: tox -v 32 | 33 | lint: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Harden Runner 37 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 38 | with: 39 | egress-policy: audit 40 | 41 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 42 | - run: python -m pip install -r requirements.txt -r requirements-dev.txt 43 | - run: black --check --diff . 44 | - run: pylint src 45 | 46 | doc: 47 | runs-on: ubuntu-latest 48 | 49 | steps: 50 | 51 | - name: Harden Runner 52 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 53 | with: 54 | egress-policy: audit 55 | 56 | - name: Setup python 3.12 57 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 58 | with: 59 | python-version: '3.13' 60 | 61 | - name: Checkout pgspot 62 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 63 | 64 | - name: Ensure docs up-to-date 65 | run: | 66 | ./gendoc 67 | git diff --exit-code 68 | -------------------------------------------------------------------------------- /.github/workflows/timescaledb.yml: -------------------------------------------------------------------------------- 1 | name: TimescaleDB 2 | on: 3 | schedule: 4 | - cron: '0 22 * * *' 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | pgspot: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | 15 | - name: Setup python 3.10 16 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 17 | with: 18 | python-version: '3.13' 19 | 20 | - name: Checkout pgspot 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | 23 | - name: Install pgspot 24 | run: | 25 | python -m pip install . 26 | 27 | - name: Checkout timescaledb 28 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | with: 30 | repository: 'timescale/timescaledb' 31 | path: 'timescaledb' 32 | 33 | - name: Build timescaledb sqlfiles 34 | run: | 35 | cd timescaledb 36 | ./bootstrap 37 | make -C build sqlfile # sqlupdatescripts 38 | ls -la build/sql/timescaledb--*.sql 39 | 40 | # time_bucket with offset is intentional without explicit search_path to allow for inlining 41 | # _timescaledb_internal.policy_compression and _timescaledb_internal.policy_compression_execute 42 | # do not have explicit search_path because this would prevent them doing transaction control 43 | - name: Run pgspot 44 | run: | 45 | pgspot \ 46 | --proc-without-search-path 'extschema.time_bucket(bucket_width interval,ts timestamp,"offset" interval)' \ 47 | --proc-without-search-path 'extschema.time_bucket(bucket_width interval,ts timestamptz,"offset" interval)' \ 48 | --proc-without-search-path 'extschema.time_bucket(bucket_width interval,ts date,"offset" interval)' \ 49 | --proc-without-search-path 'extschema.recompress_chunk(chunk regclass,if_not_compressed boolean)' \ 50 | --proc-without-search-path '_timescaledb_internal.policy_compression(job_id integer,config jsonb)' \ 51 | --proc-without-search-path '_timescaledb_internal.policy_compression_execute(job_id integer,htid integer,lag anyelement,maxchunks integer,verbose_log boolean,recompress_enabled boolean)' \ 52 | --proc-without-search-path '_timescaledb_internal.cagg_migrate_execute_plan(_cagg_data _timescaledb_catalog.continuous_agg)' \ 53 | --proc-without-search-path '_timescaledb_functions.policy_compression(job_id integer,config jsonb)' \ 54 | --proc-without-search-path '_timescaledb_functions.policy_compression_execute(job_id integer,htid integer,lag anyelement,maxchunks integer,verbose_log boolean,recompress_enabled boolean,use_creation_time boolean,useam boolean)' \ 55 | --proc-without-search-path '_timescaledb_functions.cagg_migrate_execute_plan(_cagg_data _timescaledb_catalog.continuous_agg)' \ 56 | --proc-without-search-path 'extschema.cagg_migrate(cagg regclass,override boolean,drop_old boolean)' \ 57 | timescaledb/build/sql/timescaledb--*.sql 58 | 59 | - name: Notify slack on failure 60 | if: failure() 61 | uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 62 | with: 63 | channel-id: 'CEKV5LMK3' 64 | slack-message: 'Nightly pgspot run for timescaledb repository failed. <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Details>' 65 | env: 66 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 67 | 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | build 3 | __pycache__ 4 | .pytest_cache 5 | .tox 6 | dist 7 | .idea -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## 0.9.1 (2025-05-18) 3 | 4 | - Fix plpgsql FOR IN EXECUTE handling 5 | 6 | ## 0.9.0 (2025-01-06) 7 | 8 | - Fix plpgsql RETURN QUERY EXECUTE handling 9 | - Update pglast to 7.2 which makes pgspot use the PostgreSQL 17 parser 10 | 11 | ## 0.8.1 (2024-10-08) 12 | 13 | - Add handling for plpgsql LOOP and EXIT 14 | - Fix plpgsql WHILE handling 15 | - Add initial implemention for plpgsql ast traversal 16 | - Change severity of CREATE OR REPLACE FUNCTION 17 | 18 | ## 0.8.0 (2024-08-02) 19 | 20 | - Add flag to ignore specific procedural languages (#185) 21 | - Support @extschema:name@ placeholders introduced with PG16 22 | - Warn about C security definer functions without search_path 23 | 24 | ## 0.7.1 (2024-04-12) 25 | 26 | - Fix handling of FOREACH IN ARRAY 27 | 28 | ## 0.7.0 (2024-01-31) 29 | 30 | - Update pglast to 6.1 which makes pgspot use the PostgreSQL 16 parser 31 | 32 | ## 0.6.0 (2023-08-23) 33 | 34 | - Ignore default values when comparing functions signatures #88 35 | - Add --version flag #86 36 | 37 | ## 0.5.0 (2023-02-24) 38 | 39 | - Update pglast to 5.0 which makes pgspot use the PostgreSQL 15 parser #84 40 | 41 | ## 0.4.0 (2023-01-03) 42 | 43 | - Update pglast to 4.1 which makes pgspot use the PostgreSQL 14 parser #79 44 | 45 | ## 0.3.3 (2022-08-14) 46 | 47 | - Adjust documentation to mention PG upstream changes regarding CREATE OR REPLACE and CREATE IF NOT EXISTS 48 | - Update pglast to >= 3.13 #74 49 | 50 | ## 0.3.2 (2022-06-20) 51 | 52 | - Add support for pg_catalog.set_config() for search_path tracking #66 53 | - Fix RangeFunction handling #69 54 | - Update pglast to >= 3.11 #71 55 | 56 | ## 0.3.1 (2022-05-12) 57 | 58 | ## 0.3.0 (2022-05-12) 59 | 60 | - Fix counter reporting for append mode #56 61 | - Update pglast to >= 3.10 #58 62 | 63 | ## 0.2.0 (2022-05-02) 64 | 65 | - Print line numbers in warnings and errors #44 66 | - Don't raise exception on unknown DO block language #49 67 | - Add per file counter tracking in multiple file mode #50 68 | - Don't warn about search_path of C SECURITY DEFINER functions #52 69 | - Fix search_path evaluation #53 70 | 71 | ## 0.1.1 (2022-04-22) 72 | 73 | - Add aggregate creation tracking #42 74 | 75 | ## 0.1.0 (2022-04-21) 76 | 77 | - Initial release 78 | 79 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | 2 | * @svenklemm 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to pgspot 2 | 3 | We appreciate any help in making pgspot better! 4 | 5 | You can help in different ways: 6 | 7 | * Open an [issue](https://github.com/timescale/pgspot/issues) with a 8 | bug report, build issue, feature request, suggestion, etc. 9 | 10 | * Fork this repository and submit a pull request 11 | 12 | For any particular improvement you want to make, it can be beneficial to 13 | begin discussion on the GitHub issues page. This is the best place to 14 | discuss your proposed improvement (and its implementation) with the core 15 | development team. 16 | 17 | ## Style guide 18 | 19 | Before submitting any contributions, please ensure that it adheres to 20 | our [Style Guide](https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html). 21 | 22 | ## Testing 23 | 24 | Every non-trivial change to the code base should be accompanied by a 25 | relevant addition to or modification of the test suite. 26 | 27 | Please check that the full test suite (including your test additions 28 | or changes) passes successfully on your local machine **before you 29 | open a pull request**. 30 | 31 | All submitted pull requests are also automatically 32 | run against our test suite via [Github Actions](https://github.com/timescale/timescaledb/actions) 33 | (that link shows the latest build status of the repository). 34 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The PostgreSQL License 2 | 3 | Copyright (c) 2022-2025, Timescale, Inc. 4 | 5 | Permission to use, copy, modify, and distribute this software and its 6 | documentation for any purpose, without fee, and without a written agreement 7 | is hereby granted, provided that the above copyright notice and this paragraph 8 | and the following two paragraphs appear in all copies. 9 | 10 | IN NO EVENT SHALL TIMESCALE BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, 11 | SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING 12 | OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF Timescale HAS 13 | BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | 15 | TIMESCALE SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 16 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. 17 | THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND TIMESCALE HAS NO 18 | OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR 19 | MODIFICATIONS. 20 | 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## pgspot 2 |

3 | Actions Status 4 | License: PostgreSQL 5 | PyPI 6 | Downloads 7 | Code style: black 8 | 9 |

10 | 11 | Spot vulnerabilities in PostgreSQL extension scripts. 12 | 13 | pgspot checks extension scripts for following PostgreSQL security best 14 | practices. In addition to checking extension scripts it can also be 15 | used to check security definer functions or any other PostgreSQL SQL code. 16 | 17 | pgspot checks for the following vulnerabilities: 18 | - search_path-based attacks 19 | - unsafe object creation 20 | 21 | Consult the [reference] for detailed documentation of the vulnerabilities which 22 | pgspot detects, and their potential mitigations. 23 | 24 | [reference]: https://github.com/timescale/pgspot/blob/main/REFERENCE.md 25 | 26 | ## Useful links 27 | - [PostgreSQL security recommendations for extensions](https://www.postgresql.org/docs/current/extend-extensions.html#EXTEND-EXTENSIONS-SECURITY) 28 | - [PostgreSQL security recommendations for SECURITY DEFINER functions](https://www.postgresql.org/docs/current/sql-createfunction.html#SQL-CREATEFUNCTION-SECURITY) 29 | 30 | ## Installation 31 | 32 | pip install pgspot 33 | 34 | ## Requirements 35 | 36 | - python >= 3.10 37 | - [pglast](https://github.com/lelit/pglast) 38 | - [libpg_query](https://github.com/pganalyze/libpg_query) (through pglast) 39 | 40 | To install the runtime requirements, use `pip install -r requirements.txt`. 41 | 42 | 43 | ### Usage 44 | 45 | ``` 46 | > pgspot -h 47 | usage: pgspot [-h] [-a] [--proc-without-search-path PROC] [--summary-only] [--plpgsql | --no-plpgsql] [--explain EXPLAIN] [--ignore IGNORE] [--sql-accepting SQL_FN] [FILE ...] 48 | 49 | Spot vulnerabilities in PostgreSQL SQL scripts 50 | 51 | positional arguments: 52 | FILE file to check for vulnerabilities 53 | 54 | options: 55 | -h, --help show this help message and exit 56 | -a, --append append files before checking 57 | --proc-without-search-path PROC 58 | whitelist functions without explicit search_path 59 | --summary-only only print number of errors, warnings and unknowns 60 | --plpgsql, --no-plpgsql 61 | Analyze PLpgSQL code (default: True) 62 | --explain EXPLAIN Describe an error/warning code 63 | --ignore IGNORE Ignore error or warning code 64 | --ignore-lang LANG Ignore unknown procedural language 65 | --sql-accepting SQL_FN 66 | Specify one or more sql-accepting functions 67 | ``` 68 | 69 | ``` 70 | > pgspot --ignore PS017 <<<"CREATE TABLE IF NOT EXISTS foo();" 71 | PS012: Unsafe table creation: foo 72 | 73 | Errors: 1 Warnings: 0 Unknown: 0 74 | ``` 75 | 76 | #### SQL-accepting functions 77 | 78 | It is a common pattern that SQL-accepting functions exist, which take a 79 | string-like argument which will be executed as SQL. This can "hide" some SQL 80 | from pgspot, as the string-like argument masks the SQL. With the 81 | `--sql-accepting` argument, pgspot can be told about such functions. 82 | 83 | Assuming a function named `execute_sql` which takes a SQL string as its first 84 | argument, and executes it. With `pgspot --sql-accepting=execute_sql` we can 85 | tell pgspot `execute_sql` may accept SQL. pgspot will attempt to unpack and 86 | evaluate all arguments to that function as SQL. 87 | -------------------------------------------------------------------------------- /REFERENCE.md: -------------------------------------------------------------------------------- 1 | # Reference 2 | 3 | This document serves as a reference for the different vulnerabilities which 4 | pgspot detects. 5 | 6 | ## PS001: Unqualified operator 7 | An operator was used with an unsafe search path, or not fully schema-qualified. 8 | 9 | Erroneous example: 10 | 11 | ``` 12 | SELECT foo + bar; 13 | ``` 14 | 15 | The `+` operator is used here without either a) setting the search path or 16 | b) fully-qualifying the operator. 17 | 18 | An attacker could create a custom `+` operator and redirect execution to 19 | their operator by either making their operator have better matching types 20 | or by modifying search_path so that an attacker-controlled schema is searched 21 | first. 22 | 23 | To mitigate, either 24 | a) explicitly set the search path: 25 | 26 | ``` 27 | SET search_path = pg_catalog, pg_temp; 28 | SELECT foo + bar; 29 | ``` 30 | 31 | or b) fully schema-qualify the operator 32 | 33 | ``` 34 | SELECT foo OPERATOR(pg_catalog.+) bar; 35 | ``` 36 | 37 | ## PS002: Unsafe function creation 38 | A function was created using `CREATE OR REPLACE` in an insecure schema. 39 | 40 | Erroneous example: 41 | 42 | ``` 43 | CREATE OR REPLACE FUNCTION public.foo() RETURNS INTEGER LANGUAGE SQL AS $$SELECT 1;$$; 44 | ``` 45 | 46 | Using `CREATE OR REPLACE` in a schema which is not owned by the extension is 47 | insecure. An attacker can pre-create the desired function, becoming owner of 48 | the function, and allowing them to later change the body of the function. 49 | 50 | To mitigate this issue, either 51 | 52 | a) Use `CREATE OR REPLACE FUNCTION` in an extension-owned schema: 53 | 54 | ``` 55 | CREATE SCHEMA extension_schema; 56 | CREATE OR REPLACE FUNCTION extension_schema.foo() RETURNS INTEGER LANGUAGE SQL AS $$SELECT 1;$$; 57 | ``` 58 | 59 | or b) use `CREATE FUNCTION` (without `OR REPLACE`): 60 | 61 | ``` 62 | CREATE FUNCTION public.foo() RETURNS INTEGER LANGUAGE SQL AS $$SELECT 1;$$; 63 | ``` 64 | 65 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 66 | CREATE OR REPLACE in an extension script if the statement would replace an 67 | object not belonging to the extension which will prevent exploitation in an 68 | extension context. 69 | 70 | ## PS003: SECURITY DEFINER function without explicit search_path 71 | A function with `SECURITY DEFINER` was created without setting a fixed search path. 72 | 73 | Erroneous example: 74 | 75 | ``` 76 | CREATE FUNCTION my_extension.security_definer_function() 77 | RETURNS VOID 78 | SECURITY DEFINER 79 | LANGUAGE SQL 80 | AS $$ 81 | -- function body 82 | $$; 83 | ``` 84 | 85 | In general, `SECURITY DEFINER` functions require extra care, as they are 86 | executed as a different user than the calling user, so can be abused for 87 | privilege escalation. 88 | 89 | In particular, it is highly advised to set a fixed, secure `search_path` for 90 | these functions, as it prevents a number of attacks which rely on an insecure 91 | `search_path`. 92 | 93 | To mitigate, set the `search_path` to the secure search path `pg_catalog, pg_temp` 94 | 95 | ``` 96 | CREATE FUNCTION my_extension.security_definer_function() 97 | RETURNS VOID 98 | SECURITY DEFINER 99 | SET search_path = pg_catalog, pg_temp 100 | LANGUAGE SQL 101 | AS $$ 102 | -- function body 103 | $$; 104 | ``` 105 | 106 | ## PS004: SECURITY DEFINER function with insecure search_path 107 | A function with `SECURITY DEFINER` was created with an insecure search path. 108 | 109 | Erroneous example: 110 | 111 | ``` 112 | CREATE FUNCTION my_extension.security_definer_function() 113 | RETURNS VOID 114 | SECURITY DEFINER 115 | SET search_path = public, pg_catalog 116 | LANGUAGE SQL 117 | AS $$ 118 | -- function body 119 | $$; 120 | ``` 121 | 122 | In general, `SECURITY DEFINER` functions require extra care, as they are 123 | executed as a different user than the calling user, so can be abused for 124 | privilege escalation. 125 | 126 | In particular, it is highly advised to set a fixed, secure `search_path` for 127 | these functions, as it prevents a number of attacks which rely on an insecure 128 | `search_path`. 129 | 130 | To mitigate, set the `search_path` to the secure search path `pg_catalog, pg_temp` 131 | 132 | ``` 133 | CREATE FUNCTION my_extension.security_definer_function() 134 | RETURNS VOID 135 | SECURITY DEFINER 136 | SET search_path = pg_catalog, pg_temp 137 | LANGUAGE SQL 138 | AS $$ 139 | -- function body 140 | $$; 141 | ``` 142 | 143 | ## PS005: Function without explicit search_path 144 | A function was created without an explicit search_path defined. 145 | 146 | Erroneous example: 147 | 148 | ``` 149 | CREATE FUNCTION my_extension.function() 150 | RETURNS VOID 151 | LANGUAGE SQL 152 | AS $$ 153 | -- function body 154 | $$; 155 | ``` 156 | 157 | In general, it is preferable to define an explicit search path for functions, 158 | as this can prevent insecure search_path attacks. 159 | 160 | To mitigate, set the `search_path` to the secure search path `pg_catalog, pg_temp` 161 | 162 | ``` 163 | CREATE FUNCTION my_extension.function() 164 | RETURNS VOID 165 | SET search_path = pg_catalog, pg_temp 166 | LANGUAGE SQL 167 | AS $$ 168 | -- function body 169 | $$; 170 | ``` 171 | 172 | Note: There are legitimate cases in which it is not possible to set a fixed 173 | search path for a function (e.g. when it should be inlined, or participate in 174 | transactions). For this reason, PS005 is a warning, and can be ignored in those 175 | cases. 176 | 177 | ## PS006: Unsafe transform creation 178 | A transform was created using `CREATE OR REPLACE`. 179 | 180 | Erroneous example: 181 | 182 | ``` 183 | CREATE OR REPLACE TRANSFORM rxid FOR LANGUAGE plpgsql(from sql with function f1); 184 | ``` 185 | 186 | This warning is produced for consistency with all the other `CREATE OR REPLACE` 187 | statements. Currently this cannot be exploited to escalate privileges as the steps 188 | required to set this up require superuser privileges. 189 | 190 | To mitigate this issue, use `CREATE ...` (without `OR REPLACE`): 191 | 192 | ``` 193 | CREATE TRANSFORM rxid FOR LANGUAGE plpgsql(from sql with function f1); 194 | ``` 195 | 196 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 197 | CREATE OR REPLACE in an extension script if the statement would replace an 198 | object not belonging to the extension which will prevent exploitation in an 199 | extension context. 200 | 201 | ## PS007: Unsafe object creation 202 | An object was created using `CREATE OR REPLACE` in an insecure schema. 203 | 204 | Erroneous example: 205 | 206 | ``` 207 | CREATE OR REPLACE AGGREGATE public.aggregate(BASETYPE=my_type,SFUNC=agg_sfunc,STYPE=internal); 208 | ``` 209 | 210 | Using `CREATE OR REPLACE` in a schema which is not owned by the extension is 211 | insecure. An attacker can pre-create the desired object, becoming owner of 212 | the object, and allowing them to later change attributes of object. This 213 | ultimately leads to malicious code execution. 214 | 215 | To mitigate this issue, either 216 | 217 | a) Use `CREATE OR REPLACE ...` in an extension-owned schema: 218 | 219 | ``` 220 | CREATE SCHEMA extension_schema; 221 | CREATE OR REPLACE AGGREGATE extension_schema.aggregate(BASETYPE=my_type,SFUNC=agg_sfunc,STYPE=internal); 222 | ``` 223 | 224 | or b) use `CREATE ...` (without `OR REPLACE`): 225 | 226 | ``` 227 | CREATE AGGREGATE public.aggregate(SFUNC=agg_sfunc,STYPE=internal); 228 | ``` 229 | 230 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 231 | CREATE OR REPLACE in an extension script if the statement would replace an 232 | object not belonging to the extension which will prevent exploitation in an 233 | extension context. 234 | 235 | ## PS008: Unqualified alter sequence 236 | NOTE: This warning is not produced. 237 | 238 | ## PS009: Unsafe CASE expression 239 | A "simple" `CASE` expression was used without a secure search_path. 240 | 241 | Erroneous example: 242 | 243 | ``` 244 | SELECT 245 | CASE a OPERATOR(pg_catalog.=) b 246 | WHEN true THEN 'true' 247 | WHEN false THEN 'false' 248 | END 249 | FROM my_schema.foo; 250 | ``` 251 | 252 | The "simple" `CASE` expression evaluates an expression once, and then compares 253 | the result of that evaluation with the branches of the `CASE` expression. This 254 | comparison is performed with an equality operator which cannot be 255 | schema-qualified. 256 | 257 | To mitigate, either 258 | 259 | a) explicitly set the search path: 260 | 261 | ``` 262 | SET search_path = pg_catalog, pg_temp; 263 | SELECT 264 | CASE a = b 265 | WHEN true THEN 'true' 266 | WHEN false THEN 'false' 267 | END 268 | FROM my_schema.foo; 269 | ``` 270 | 271 | or b) use the alternate form of case expression: 272 | 273 | ``` 274 | SELECT 275 | CASE 276 | WHEN a OPERATOR(pg_catalog.=) b THEN 'true' 277 | WHEN a OPERATOR(pg_catalog.!=) b THEN 'false' 278 | END 279 | FROM my_schema.foo; 280 | ``` 281 | 282 | ## PS010: Unsafe schema creation 283 | A schema was created using `IF NOT EXISTS`. 284 | 285 | Erroneous example: 286 | 287 | ``` 288 | CREATE SCHEMA IF NOT EXISTS my_schema; 289 | ``` 290 | 291 | Using `IF NOT EXISTS` to create a schema means that the schema could have been 292 | pre-created by an attacker. As the attacker would own the schema, they would be 293 | able to modify arbitrary objects added to that schema by the extension. This 294 | allows for the execution of malicious code. 295 | 296 | To mitigate this issue use only `CREATE SCHEMA` without `IF NOT EXISTS`: 297 | 298 | ``` 299 | CREATE SCHEMA my_schema; 300 | ``` 301 | 302 | If the schema already exists when the extension is installed, then this 303 | statement will fail, which is desired behaviour. 304 | 305 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 306 | CREATE IF NOT EXISTS in an extension script if the statement would replace an 307 | object not belonging to the extension which will prevent exploitation in an 308 | extension context. 309 | 310 | ## PS011: Unsafe sequence creation 311 | A sequence was created using `IF NOT EXISTS` in an insecure schema. 312 | 313 | Erroneous example: 314 | 315 | ``` 316 | CREATE SEQUENCE IF NOT EXISTS public.s1; 317 | ``` 318 | 319 | Using `CREATE ... IF NOT EXISTS` in a schema which is not owned by the 320 | extension is insecure. An attacker can pre-create the desired sequence, becoming 321 | owner of the sequence, allowing them to later modify attributes of the sequence 322 | and thereby controlling the values generated by the sequence. 323 | 324 | To mitigate this issue, either 325 | 326 | a) Use `CREATE SEQUENCE IF NOT EXISTS` in an extension-owned schema: 327 | 328 | ``` 329 | CREATE SCHEMA extension_schema; 330 | CREATE SEQUENCE IF NOT EXISTS extension_schema.s1; 331 | ``` 332 | 333 | or b) use `CREATE SEQUENCE` (without `IF NOT EXISTS`): 334 | 335 | ``` 336 | CREATE SEQUENCE public.s1; 337 | ``` 338 | 339 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 340 | CREATE IF NOT EXISTS in an extension script if the statement would replace an 341 | object not belonging to the extension which will prevent exploitation in an 342 | extension context. 343 | 344 | ## PS012: Unsafe table creation 345 | A table was created using `IF NOT EXISTS` in an insecure schema. 346 | 347 | Erroneous example: 348 | 349 | ``` 350 | CREATE TABLE IF NOT EXISTS public.test_table(col text); 351 | ``` 352 | 353 | Using `CREATE ... IF NOT EXISTS` in a schema which is not owned by the 354 | extension is insecure. An attacker can pre-create the desired table, becoming 355 | owner of the table, allowing them to later modify attributes of the table. This 356 | ultimately leads to malicious code execution. 357 | 358 | To mitigate this issue, either 359 | 360 | a) Use `CREATE TABLE IF NOT EXISTS` in an extension-owned schema: 361 | 362 | ``` 363 | CREATE SCHEMA extension_schema; 364 | CREATE TABLE IF NOT EXISTS extension_schema.test_table(col text); 365 | ``` 366 | 367 | or b) use `CREATE TABLE` (without `IF NOT EXISTS`): 368 | 369 | ``` 370 | CREATE TABLE public.test_table(col text); 371 | ``` 372 | 373 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 374 | CREATE IF NOT EXISTS in an extension script if the statement would replace an 375 | object not belonging to the extension which will prevent exploitation in an 376 | extension context. 377 | 378 | ## PS013: Unsafe foreign server creation 379 | A foreign server was created using `IF NOT EXISTS`. 380 | 381 | Erroneous example: 382 | 383 | ``` 384 | CREATE SERVER IF NOT EXISTS s1 FOREIGN DATA WRAPPER postgres_fdw; 385 | ``` 386 | 387 | Using `CREATE ... IF NOT EXISTS` is insecure. An attacker may pre-create 388 | the desired server, becoming owner of the server, allowing them to later 389 | modify attributes of the server. Since a user needs USAGE privilege on 390 | the FOREIGN DATA WRAPPER to create a server this might not be 391 | exploitable in most enviroments. 392 | 393 | To mitigate this issue, use `CREATE SERVER` (without `IF NOT EXISTS`): 394 | 395 | ``` 396 | CREATE SERVER s1 FOREIGN DATA WRAPPER postgres_fdw; 397 | ``` 398 | 399 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 400 | CREATE IF NOT EXISTS in an extension script if the statement would replace an 401 | object not belonging to the extension which will prevent exploitation in an 402 | extension context. 403 | 404 | ## PS014: Unsafe index creation 405 | An index was created using `IF NOT EXISTS`. 406 | 407 | Erroneous example: 408 | 409 | ``` 410 | CREATE INDEX IF NOT EXISTS i1 ON t(time); 411 | ``` 412 | 413 | Using `CREATE ... IF NOT EXISTS` is insecure. An attacker can pre-create 414 | the index preventing the creation of unique constraints on the table. 415 | Indexes may also be used to execute malicious code. 416 | 417 | To mitigate this issue use `CREATE INDEX` (without `IF NOT EXISTS`): 418 | 419 | ``` 420 | CREATE INDEX i1 ON t(time); 421 | ``` 422 | 423 | ## PS015: Unsafe view creation 424 | A view was created using `CREATE OR REPLACE` in an insecure schema. 425 | 426 | Erroneous example: 427 | 428 | ``` 429 | CREATE OR REPLACE VIEW public.test_view AS SELECT pg_catalog.now(); 430 | ``` 431 | 432 | Using `CREATE OR REPLACE` in a schema which is not owned by the extension is 433 | insecure. An attacker can pre-create the desired view, becoming owner of 434 | the view, allowing them to later modify the view. This ultimately leads to 435 | malicious code execution. 436 | 437 | To mitigate this issue, either 438 | 439 | a) Use `CREATE OR REPLACE VIEW` in an extension-owned schema: 440 | 441 | ``` 442 | CREATE SCHEMA extension_schema; 443 | CREATE OR REPLACE VIEW extension_schema.test_view AS SELECT pg_catalog.now(); 444 | ``` 445 | 446 | or b) use `CREATE VIEW` (without `OR REPLACE`): 447 | 448 | ``` 449 | CREATE VIEW public.test_view AS SELECT pg_catalog.now(); 450 | ``` 451 | 452 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 453 | CREATE OR REPLACE in an extension script if the statement would replace an 454 | object not belonging to the extension which will prevent exploitation in an 455 | extension context. 456 | 457 | ## PS016: Unqualified function call 458 | A function was used with an unsafe search path, or not fully schema-qualified. 459 | 460 | Erroneous example: 461 | 462 | ``` 463 | SELECT my_function(foo); 464 | ``` 465 | 466 | The call to my_function was made without either a) setting the search path or 467 | b) fully schema-qualifying the function. 468 | 469 | An attacker could create a custom `my_function` function and redirect execution 470 | to their function by either making their function have better-matching types or 471 | by modifying the search_path so that an attacker-controlled schema is searched 472 | first. 473 | 474 | Either a) explicitly set the search path. 475 | 476 | ``` 477 | SET search_path = pg_catalog, pg_temp; 478 | SELECT my_function(foo); 479 | ``` 480 | 481 | or b) fully-qualify the function 482 | 483 | ``` 484 | SELECT extension_schema.my_function(foo); 485 | ``` 486 | 487 | ## PS017: Unqualified object reference 488 | An object was referenced with an unsafe search path, or not fully schema-qualified. 489 | 490 | Erroneous example: 491 | 492 | ``` 493 | SELECT * FROM foo; 494 | ``` 495 | 496 | The reference to `foo` was made without either a) setting the search path or 497 | b) fully schema-qualifying the relation. 498 | 499 | An attacker could create a temporary relation `foo`, or modify the search path 500 | so that an attacker-controlled relation is referenced instead of the desired. 501 | This could result in erroneous behaviour of a routine or function. 502 | 503 | To mitigate this, either a) explicitly set the search path: 504 | 505 | ``` 506 | SET search_path = extension_schema; 507 | SELECT * FROM foo; 508 | ``` 509 | 510 | or b) fully-qualify the object 511 | 512 | ``` 513 | SELECT * FROM extension_schema.foo; 514 | ``` 515 | 516 | ## PS018: Unsafe SET search_path 517 | When using SET search_path the schemas must not be enclosed in single quotes 518 | as otherwise the list of schemas will be treated as a single schema name instead. 519 | 520 | Erroneous example: 521 | 522 | ``` 523 | SET search_path TO 'pg_catalog, temp'; 524 | ``` 525 | 526 | Setting the search_path this way will result in the actual search_path being 527 | pg_temp, pg_catalog, "pg_catalog, pg_temp" instead of the intended value of 528 | pg_catalog, pg_temp. This would allow shadowing catalog relations with relations 529 | in the pg_temp schema. 530 | 531 | To mitigate this do not enclose the list of schemas in single quotes 532 | 533 | ``` 534 | SET search_path TO pg_catalog, pg_temp; 535 | ``` 536 | 537 | or b) use pg_catalog.set_config 538 | 539 | ``` 540 | SELECT pg_catalog.set_config('search_path','pg_catalog, pg_temp', false); 541 | ``` 542 | 543 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Publicly disclosing security bugs in a public forum can put everyone at risk. Therefore, we ask that people follow the below instructions to report security vulnerability. 4 | 5 | ## Supported Versions 6 | 7 | The supported version is always the latest major release available in our repository. 8 | We also release regular minor versions with fixes and corrections alongside some new features as well as patchfix releases, that you should keep upgrading to. 9 | Vulnerability fixes are made available as part of these patchfix releases and you can read our list of [Security Advisories](https://github.com/timescale/pgspot/security/advisories?state=published). 10 | 11 | ## Reporting a Vulnerability 12 | 13 | If you find a vulnerability in our software, please email the Timescale Security Team at security@timescale.com. 14 | 15 | Please note that the e-mail address should only be used for reporting undisclosed security vulnerabilities in pgspot. 16 | Regular bug reports should be submitted as GitHub issues. 17 | -------------------------------------------------------------------------------- /gendoc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from src.pgspot.codes import codes 4 | from textwrap import dedent 5 | 6 | header = """# Reference 7 | 8 | This document serves as a reference for the different vulnerabilities which 9 | pgspot detects. 10 | 11 | """ 12 | 13 | with open('REFERENCE.md', 'w') as ref: 14 | ref.write(dedent(header)) 15 | for code, data in codes.items(): 16 | ref.write("## {}: {}".format(code, data['title'])) 17 | ref.write(dedent(data['description'])) 18 | ref.write("\n") 19 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list=pglast 29 | 30 | # A comma-separated list of package or module names from where C extensions may 31 | # be loaded. Extensions are loading into the active Python interpreter and may 32 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 33 | # for backward compatibility.) 34 | extension-pkg-whitelist= 35 | 36 | # Return non-zero exit code if any of these messages/categories are detected, 37 | # even if score is above --fail-under value. Syntax same as enable. Messages 38 | # specified are enabled, while categories only check already-enabled messages. 39 | fail-on= 40 | 41 | # Specify a score threshold under which the program will exit with error. 42 | fail-under=10 43 | 44 | # Interpret the stdin as a python script, whose filename needs to be passed as 45 | # the module_or_package argument. 46 | #from-stdin= 47 | 48 | # Files or directories to be skipped. They should be base names, not paths. 49 | ignore=CVS 50 | 51 | # Add files or directories matching the regular expressions patterns to the 52 | # ignore-list. The regex matches against paths and can be in Posix or Windows 53 | # format. Because '\\' represents the directory delimiter on Windows systems, 54 | # it can't be used as an escape character. 55 | ignore-paths= 56 | 57 | # Files or directories matching the regular expression patterns are skipped. 58 | # The regex matches against base names, not paths. The default value ignores 59 | # Emacs file locks 60 | ignore-patterns=^\.# 61 | 62 | # List of module names for which member attributes should not be checked 63 | # (useful for modules/projects where namespaces are manipulated during runtime 64 | # and thus existing member attributes cannot be deduced by static analysis). It 65 | # supports qualified module names, as well as Unix pattern matching. 66 | ignored-modules= 67 | 68 | # Python code to execute, usually for sys.path manipulation such as 69 | # pygtk.require(). 70 | #init-hook= 71 | 72 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 73 | # number of processors available to use, and will cap the count on Windows to 74 | # avoid hangs. 75 | jobs=1 76 | 77 | # Control the amount of potential inferred values when inferring a single 78 | # object. This can help the performance when dealing with large functions or 79 | # complex, nested conditions. 80 | limit-inference-results=100 81 | 82 | # List of plugins (as comma separated values of python module names) to load, 83 | # usually to register additional checkers. 84 | load-plugins= 85 | 86 | # Pickle collected data for later comparisons. 87 | persistent=yes 88 | 89 | # Minimum Python version to use for version dependent checks. Will default to 90 | # the version used to run pylint. 91 | py-version=3.10 92 | 93 | # Discover python modules and packages in the file system subtree. 94 | recursive=no 95 | 96 | # Add paths to the list of the source roots. Supports globbing patterns. The 97 | # source root is an absolute path or a path relative to the current working 98 | # directory used to determine a package namespace for modules located under the 99 | # source root. 100 | source-roots= 101 | 102 | # When enabled, pylint would attempt to guess common misconfiguration and emit 103 | # user-friendly hints instead of false-positive error messages. 104 | suggestion-mode=yes 105 | 106 | # Allow loading of arbitrary C extensions. Extensions are imported into the 107 | # active Python interpreter and may run arbitrary code. 108 | unsafe-load-any-extension=no 109 | 110 | # In verbose mode, extra non-checker-related info will be displayed. 111 | #verbose= 112 | 113 | 114 | [BASIC] 115 | 116 | # Naming style matching correct argument names. 117 | argument-naming-style=snake_case 118 | 119 | # Regular expression matching correct argument names. Overrides argument- 120 | # naming-style. If left empty, argument names will be checked with the set 121 | # naming style. 122 | #argument-rgx= 123 | 124 | # Naming style matching correct attribute names. 125 | attr-naming-style=snake_case 126 | 127 | # Regular expression matching correct attribute names. Overrides attr-naming- 128 | # style. If left empty, attribute names will be checked with the set naming 129 | # style. 130 | #attr-rgx= 131 | 132 | # Bad variable names which should always be refused, separated by a comma. 133 | bad-names=foo, 134 | bar, 135 | baz, 136 | toto, 137 | tutu, 138 | tata 139 | 140 | # Bad variable names regexes, separated by a comma. If names match any regex, 141 | # they will always be refused 142 | bad-names-rgxs= 143 | 144 | # Naming style matching correct class attribute names. 145 | class-attribute-naming-style=any 146 | 147 | # Regular expression matching correct class attribute names. Overrides class- 148 | # attribute-naming-style. If left empty, class attribute names will be checked 149 | # with the set naming style. 150 | #class-attribute-rgx= 151 | 152 | # Naming style matching correct class constant names. 153 | class-const-naming-style=UPPER_CASE 154 | 155 | # Regular expression matching correct class constant names. Overrides class- 156 | # const-naming-style. If left empty, class constant names will be checked with 157 | # the set naming style. 158 | #class-const-rgx= 159 | 160 | # Naming style matching correct class names. 161 | class-naming-style=PascalCase 162 | 163 | # Regular expression matching correct class names. Overrides class-naming- 164 | # style. If left empty, class names will be checked with the set naming style. 165 | #class-rgx= 166 | 167 | # Naming style matching correct constant names. 168 | const-naming-style=UPPER_CASE 169 | 170 | # Regular expression matching correct constant names. Overrides const-naming- 171 | # style. If left empty, constant names will be checked with the set naming 172 | # style. 173 | #const-rgx= 174 | 175 | # Minimum line length for functions/classes that require docstrings, shorter 176 | # ones are exempt. 177 | docstring-min-length=-1 178 | 179 | # Naming style matching correct function names. 180 | function-naming-style=snake_case 181 | 182 | # Regular expression matching correct function names. Overrides function- 183 | # naming-style. If left empty, function names will be checked with the set 184 | # naming style. 185 | #function-rgx= 186 | 187 | # Good variable names which should always be accepted, separated by a comma. 188 | good-names=i, 189 | j, 190 | k, 191 | ex, 192 | Run, 193 | _ 194 | 195 | # Good variable names regexes, separated by a comma. If names match any regex, 196 | # they will always be accepted 197 | good-names-rgxs= 198 | 199 | # Include a hint for the correct naming format with invalid-name. 200 | include-naming-hint=no 201 | 202 | # Naming style matching correct inline iteration names. 203 | inlinevar-naming-style=any 204 | 205 | # Regular expression matching correct inline iteration names. Overrides 206 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 207 | # with the set naming style. 208 | #inlinevar-rgx= 209 | 210 | # Naming style matching correct method names. 211 | method-naming-style=snake_case 212 | 213 | # Regular expression matching correct method names. Overrides method-naming- 214 | # style. If left empty, method names will be checked with the set naming style. 215 | #method-rgx= 216 | 217 | # Naming style matching correct module names. 218 | module-naming-style=snake_case 219 | 220 | # Regular expression matching correct module names. Overrides module-naming- 221 | # style. If left empty, module names will be checked with the set naming style. 222 | #module-rgx= 223 | 224 | # Colon-delimited sets of names that determine each other's naming style when 225 | # the name regexes allow several styles. 226 | name-group= 227 | 228 | # Regular expression which should only match function or class names that do 229 | # not require a docstring. 230 | no-docstring-rgx=^_ 231 | 232 | # List of decorators that produce properties, such as abc.abstractproperty. Add 233 | # to this list to register other decorators that produce valid properties. 234 | # These decorators are taken in consideration only for invalid-name. 235 | property-classes=abc.abstractproperty 236 | 237 | # Regular expression matching correct type alias names. If left empty, type 238 | # alias names will be checked with the set naming style. 239 | #typealias-rgx= 240 | 241 | # Regular expression matching correct type variable names. If left empty, type 242 | # variable names will be checked with the set naming style. 243 | #typevar-rgx= 244 | 245 | # Naming style matching correct variable names. 246 | variable-naming-style=snake_case 247 | 248 | # Regular expression matching correct variable names. Overrides variable- 249 | # naming-style. If left empty, variable names will be checked with the set 250 | # naming style. 251 | #variable-rgx= 252 | 253 | 254 | [CLASSES] 255 | 256 | # Warn about protected attribute access inside special methods 257 | check-protected-access-in-special-methods=no 258 | 259 | # List of method names used to declare (i.e. assign) instance attributes. 260 | defining-attr-methods=__init__, 261 | __new__, 262 | setUp, 263 | asyncSetUp, 264 | __post_init__ 265 | 266 | # List of member names, which should be excluded from the protected access 267 | # warning. 268 | exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit 269 | 270 | # List of valid names for the first argument in a class method. 271 | valid-classmethod-first-arg=cls 272 | 273 | # List of valid names for the first argument in a metaclass class method. 274 | valid-metaclass-classmethod-first-arg=mcs 275 | 276 | 277 | [DESIGN] 278 | 279 | # List of regular expressions of class ancestor names to ignore when counting 280 | # public methods (see R0903) 281 | exclude-too-few-public-methods= 282 | 283 | # List of qualified class names to ignore when counting class parents (see 284 | # R0901) 285 | ignored-parents= 286 | 287 | # Maximum number of arguments for function / method. 288 | max-args=5 289 | 290 | # Maximum number of attributes for a class (see R0902). 291 | max-attributes=7 292 | 293 | # Maximum number of boolean expressions in an if statement (see R0916). 294 | max-bool-expr=5 295 | 296 | # Maximum number of branch for function / method body. 297 | max-branches=12 298 | 299 | # Maximum number of locals for function / method body. 300 | max-locals=15 301 | 302 | # Maximum number of parents for a class (see R0901). 303 | max-parents=7 304 | 305 | # Maximum number of public methods for a class (see R0904). 306 | max-public-methods=20 307 | 308 | # Maximum number of return / yield for function / method body. 309 | max-returns=6 310 | 311 | # Maximum number of statements in function / method body. 312 | max-statements=50 313 | 314 | # Minimum number of public methods for a class (see R0903). 315 | min-public-methods=2 316 | 317 | 318 | [EXCEPTIONS] 319 | 320 | # Exceptions that will emit a warning when caught. 321 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 322 | 323 | 324 | [FORMAT] 325 | 326 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 327 | expected-line-ending-format= 328 | 329 | # Regexp for a line that is allowed to be longer than the limit. 330 | ignore-long-lines=^\s*(# )??$ 331 | 332 | # Number of spaces of indent required inside a hanging or continued line. 333 | indent-after-paren=4 334 | 335 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 336 | # tab). 337 | indent-string=' ' 338 | 339 | # Maximum number of characters on a single line. 340 | max-line-length=100 341 | 342 | # Maximum number of lines in a module. 343 | max-module-lines=1000 344 | 345 | # Allow the body of a class to be on the same line as the declaration if body 346 | # contains single statement. 347 | single-line-class-stmt=no 348 | 349 | # Allow the body of an if to be on the same line as the test if there is no 350 | # else. 351 | single-line-if-stmt=no 352 | 353 | 354 | [IMPORTS] 355 | 356 | # List of modules that can be imported at any level, not just the top level 357 | # one. 358 | allow-any-import-level= 359 | 360 | # Allow explicit reexports by alias from a package __init__. 361 | allow-reexport-from-package=no 362 | 363 | # Allow wildcard imports from modules that define __all__. 364 | allow-wildcard-with-all=no 365 | 366 | # Deprecated modules which should not be used, separated by a comma. 367 | deprecated-modules= 368 | 369 | # Output a graph (.gv or any supported image format) of external dependencies 370 | # to the given file (report RP0402 must not be disabled). 371 | ext-import-graph= 372 | 373 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 374 | # external) dependencies to the given file (report RP0402 must not be 375 | # disabled). 376 | import-graph= 377 | 378 | # Output a graph (.gv or any supported image format) of internal dependencies 379 | # to the given file (report RP0402 must not be disabled). 380 | int-import-graph= 381 | 382 | # Force import order to recognize a module as part of the standard 383 | # compatibility libraries. 384 | known-standard-library= 385 | 386 | # Force import order to recognize a module as part of a third party library. 387 | known-third-party=enchant 388 | 389 | # Couples of modules and preferred modules, separated by a comma. 390 | preferred-modules= 391 | 392 | 393 | [LOGGING] 394 | 395 | # The type of string formatting that logging methods do. `old` means using % 396 | # formatting, `new` is for `{}` formatting. 397 | logging-format-style=old 398 | 399 | # Logging modules to check that the string format arguments are in logging 400 | # function parameter format. 401 | logging-modules=logging 402 | 403 | 404 | [MESSAGES CONTROL] 405 | 406 | # Only show warnings with the listed confidence levels. Leave empty to show 407 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 408 | # UNDEFINED. 409 | confidence=HIGH, 410 | CONTROL_FLOW, 411 | INFERENCE, 412 | INFERENCE_FAILURE, 413 | UNDEFINED 414 | 415 | # Disable the message, report, category or checker with the given id(s). You 416 | # can either give multiple identifiers separated by comma (,) or put this 417 | # option multiple times (only on the command line, not in the configuration 418 | # file where it should appear only once). You can also use "--disable=all" to 419 | # disable everything first and then re-enable specific checks. For example, if 420 | # you want to run only the similarities checker, you can use "--disable=all 421 | # --enable=similarities". If you want to run only the classes checker, but have 422 | # no Warning level messages displayed, use "--disable=all --enable=classes 423 | # --disable=W". 424 | disable=broad-exception-caught, 425 | broad-exception-raised, 426 | missing-class-docstring, 427 | missing-function-docstring, 428 | missing-module-docstring, 429 | too-many-branches, 430 | too-many-return-statements, 431 | too-many-statements 432 | 433 | # Enable the message, report, category or checker with the given id(s). You can 434 | # either give multiple identifier separated by comma (,) or put this option 435 | # multiple time (only on the command line, not in the configuration file where 436 | # it should appear only once). See also the "--disable" option for examples. 437 | enable= 438 | 439 | 440 | [METHOD_ARGS] 441 | 442 | # List of qualified names (i.e., library.method) which require a timeout 443 | # parameter e.g. 'requests.api.get,requests.api.post' 444 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 445 | 446 | 447 | [MISCELLANEOUS] 448 | 449 | # List of note tags to take in consideration, separated by a comma. 450 | notes=FIXME, 451 | XXX, 452 | TODO 453 | 454 | # Regular expression of note tags to take in consideration. 455 | notes-rgx= 456 | 457 | 458 | [REFACTORING] 459 | 460 | # Maximum number of nested blocks for function / method body 461 | max-nested-blocks=5 462 | 463 | # Complete name of functions that never returns. When checking for 464 | # inconsistent-return-statements if a never returning function is called then 465 | # it will be considered as an explicit return statement and no message will be 466 | # printed. 467 | never-returning-functions=sys.exit,argparse.parse_error 468 | 469 | 470 | [REPORTS] 471 | 472 | # Python expression which should return a score less than or equal to 10. You 473 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 474 | # 'convention', and 'info' which contain the number of messages in each 475 | # category, as well as 'statement' which is the total number of statements 476 | # analyzed. This score is used by the global evaluation report (RP0004). 477 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 478 | 479 | # Template used to display messages. This is a python new-style format string 480 | # used to format the message information. See doc for all details. 481 | msg-template= 482 | 483 | # Set the output format. Available formats are: text, parseable, colorized, 484 | # json2 (improved json format), json (old json format) and msvs (visual 485 | # studio). You can also give a reporter class, e.g. 486 | # mypackage.mymodule.MyReporterClass. 487 | #output-format= 488 | 489 | # Tells whether to display a full report or only the messages. 490 | reports=no 491 | 492 | # Activate the evaluation score. 493 | score=yes 494 | 495 | 496 | [SIMILARITIES] 497 | 498 | # Comments are removed from the similarity computation 499 | ignore-comments=yes 500 | 501 | # Docstrings are removed from the similarity computation 502 | ignore-docstrings=yes 503 | 504 | # Imports are removed from the similarity computation 505 | ignore-imports=yes 506 | 507 | # Signatures are removed from the similarity computation 508 | ignore-signatures=yes 509 | 510 | # Minimum lines number of a similarity. 511 | min-similarity-lines=4 512 | 513 | 514 | [SPELLING] 515 | 516 | # Limits count of emitted suggestions for spelling mistakes. 517 | max-spelling-suggestions=4 518 | 519 | # Spelling dictionary name. No available dictionaries : You need to install 520 | # both the python package and the system dependency for enchant to work. 521 | spelling-dict= 522 | 523 | # List of comma separated words that should be considered directives if they 524 | # appear at the beginning of a comment and should not be checked. 525 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 526 | 527 | # List of comma separated words that should not be checked. 528 | spelling-ignore-words= 529 | 530 | # A path to a file that contains the private dictionary; one word per line. 531 | spelling-private-dict-file= 532 | 533 | # Tells whether to store unknown words to the private dictionary (see the 534 | # --spelling-private-dict-file option) instead of raising a message. 535 | spelling-store-unknown-words=no 536 | 537 | 538 | [STRING] 539 | 540 | # This flag controls whether inconsistent-quotes generates a warning when the 541 | # character used as a quote delimiter is used inconsistently within a module. 542 | check-quote-consistency=no 543 | 544 | # This flag controls whether the implicit-str-concat should generate a warning 545 | # on implicit string concatenation in sequences defined over several lines. 546 | check-str-concat-over-line-jumps=no 547 | 548 | 549 | [TYPECHECK] 550 | 551 | # List of decorators that produce context managers, such as 552 | # contextlib.contextmanager. Add to this list to register other decorators that 553 | # produce valid context managers. 554 | contextmanager-decorators=contextlib.contextmanager 555 | 556 | # List of members which are set dynamically and missed by pylint inference 557 | # system, and so shouldn't trigger E1101 when accessed. Python regular 558 | # expressions are accepted. 559 | generated-members= 560 | 561 | # Tells whether to warn about missing members when the owner of the attribute 562 | # is inferred to be None. 563 | ignore-none=yes 564 | 565 | # This flag controls whether pylint should warn about no-member and similar 566 | # checks whenever an opaque object is returned when inferring. The inference 567 | # can return multiple potential results while evaluating a Python object, but 568 | # some branches might not be evaluated, which results in partial inference. In 569 | # that case, it might be useful to still emit no-member and other checks for 570 | # the rest of the inferred objects. 571 | ignore-on-opaque-inference=yes 572 | 573 | # List of symbolic message names to ignore for Mixin members. 574 | ignored-checks-for-mixins=no-member, 575 | not-async-context-manager, 576 | not-context-manager, 577 | attribute-defined-outside-init 578 | 579 | # List of class names for which member attributes should not be checked (useful 580 | # for classes with dynamically set attributes). This supports the use of 581 | # qualified names. 582 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 583 | 584 | # Show a hint with possible names when a member name was not found. The aspect 585 | # of finding the hint is based on edit distance. 586 | missing-member-hint=yes 587 | 588 | # The minimum edit distance a name should have in order to be considered a 589 | # similar match for a missing member name. 590 | missing-member-hint-distance=1 591 | 592 | # The total number of similar names that should be taken in consideration when 593 | # showing a hint for a missing member. 594 | missing-member-max-choices=1 595 | 596 | # Regex pattern to define which classes are considered mixins. 597 | mixin-class-rgx=.*[Mm]ixin 598 | 599 | # List of decorators that change the signature of a decorated function. 600 | signature-mutators= 601 | 602 | 603 | [VARIABLES] 604 | 605 | # List of additional names supposed to be defined in builtins. Remember that 606 | # you should avoid defining new builtins when possible. 607 | additional-builtins= 608 | 609 | # Tells whether unused global variables should be treated as a violation. 610 | allow-global-unused-variables=yes 611 | 612 | # List of names allowed to shadow builtins 613 | allowed-redefined-builtins= 614 | 615 | # List of strings which can identify a callback function by name. A callback 616 | # name must start or end with one of those strings. 617 | callbacks=cb_, 618 | _cb 619 | 620 | # A regular expression matching the name of dummy variables (i.e. expected to 621 | # not be used). 622 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 623 | 624 | # Argument names that match this expression will be ignored. 625 | ignored-argument-names=_.*|^ignored_|^unused_ 626 | 627 | # Tells whether we should check for unused import in __init__ files. 628 | init-import=no 629 | 630 | # List of qualified module names which can have objects that can redefine 631 | # builtins. 632 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 633 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchUpdateTypes": ["patch", "minor", "digest"], 9 | "groupName": "maintenance", 10 | "schedule": ["every month"] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black==25.1.0 2 | click==8.2.0 3 | distlib==0.3.9 4 | filelock==3.18.0 5 | mypy-extensions==1.1.0 6 | packaging==25.0 7 | pathspec==0.12.1 8 | platformdirs==4.3.8 9 | pluggy==1.6.0 10 | pylint==3.3.7 11 | pyparsing==3.2.3 12 | six==1.17.0 13 | toml==0.10.2 14 | tomli==2.2.1 15 | tox==4.26.0 16 | virtualenv==20.31.2 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pglast==7.7 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pgspot 3 | version = attr: pgspot.__version__ 4 | description = Spot vulnerabilities in postgres extension scripts 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | author = Timescale, Inc. 8 | license = The PostgreSQL License 9 | license_file = LICENSE 10 | url = https://github.com/timescale/pgspot 11 | project_urls = 12 | Bug Tracker = https://github.com/timescale/pgspot/issues 13 | keywords = postgresql 14 | classifiers = 15 | Development Status :: 4 - Beta 16 | Intended Audience :: Developers 17 | License :: OSI Approved :: PostgreSQL License 18 | Operating System :: OS Independent 19 | Programming Language :: Python 20 | Programming Language :: Python :: 3.10 21 | Topic :: Software Development :: Libraries :: Python Modules 22 | Topic :: Software Development :: Quality Assurance 23 | 24 | [options] 25 | package_dir = 26 | = src 27 | packages = find: 28 | python_requires = >= 3.10 29 | 30 | install_requires = 31 | pglast==7.7 32 | 33 | tests_require = 34 | pytest>=7.2.0 35 | pytest-snapshot 36 | 37 | [options.packages.find] 38 | where = src 39 | 40 | [options.entry_points] 41 | console_scripts = 42 | pgspot = pgspot.cli:run 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | let 2 | # pin nixpkgs to a specific version, so that we're not relying on the system version 3 | pkgs = import (builtins.fetchGit { 4 | name = "nixpkgs-unstable"; 5 | url = "https://github.com/nixos/nixpkgs/"; 6 | # `git ls-remote https://github.com/nixos/nixpkgs nixos-unstable` 7 | ref = "refs/heads/nixpkgs-unstable"; 8 | rev = "bd4dffcdb7c577d74745bd1eff6230172bd176d5"; 9 | }) {}; 10 | 11 | # Use svenklemm's fork of pglast, which adds support for SET, COMMIT, ROLLBACK, CALL 12 | pglast = pkgs.python310Packages.buildPythonPackage rec { 13 | name = "pglast"; 14 | version = "v3.10"; 15 | 16 | src = pkgs.fetchFromGitHub { 17 | owner = "lelit"; 18 | repo = "${name}"; 19 | rev = "${version}"; 20 | fetchSubmodules = true; 21 | sha256 = "sha256-lBAhdqLTt7x/NYYfgcMm/qk04r4YuLDeWYmI8WaMZm8="; 22 | }; 23 | }; 24 | 25 | py310 = pkgs.python310; 26 | py-with-packages = py310.withPackages (p: with p; [ 27 | pglast 28 | p.black 29 | p.tox 30 | p.setuptools 31 | p.build 32 | p.twine 33 | ]); 34 | 35 | in 36 | pkgs.mkShell { 37 | buildInputs = [ 38 | py-with-packages 39 | ]; 40 | shellHook = '' 41 | export PYTHONPATH=${py-with-packages}/${py-with-packages.sitePackages} 42 | ''; 43 | } 44 | -------------------------------------------------------------------------------- /src/pgspot/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.9.1" 2 | -------------------------------------------------------------------------------- /src/pgspot/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import run 2 | 3 | 4 | if __name__ == "__main__": 5 | run() 6 | -------------------------------------------------------------------------------- /src/pgspot/cli.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser, BooleanOptionalAction 2 | from textwrap import dedent 3 | import sys 4 | from pgspot import __version__ 5 | from .codes import codes 6 | from .state import State, Counter 7 | from .visitors import visit_sql 8 | 9 | 10 | def run(): 11 | parser = ArgumentParser( 12 | description="Spot vulnerabilities in PostgreSQL SQL scripts" 13 | ) 14 | parser.add_argument( 15 | "--version", action="version", version=f"%(prog)s {__version__}" 16 | ) 17 | parser.add_argument( 18 | "-a", 19 | "--append", 20 | dest="append", 21 | action="store_true", 22 | default=False, 23 | help="append files before checking", 24 | ) 25 | parser.add_argument( 26 | "--proc-without-search-path", 27 | metavar="PROC", 28 | dest="proc_without_search_path", 29 | action="append", 30 | default=[], 31 | help="whitelist functions without explicit search_path", 32 | ) 33 | parser.add_argument( 34 | "--summary-only", 35 | dest="summary_only", 36 | action="store_true", 37 | default=False, 38 | help="only print number of errors, warnings and unknowns", 39 | ) 40 | parser.add_argument( 41 | "--plpgsql", 42 | action=BooleanOptionalAction, 43 | default=True, 44 | help="Analyze PLpgSQL code", 45 | ) 46 | parser.add_argument( 47 | "--explain", dest="explain", default=None, help="Describe an error/warning code" 48 | ) 49 | parser.add_argument( 50 | "--ignore", 51 | dest="ignore", 52 | action="append", 53 | default=[], 54 | type=str, 55 | help="Ignore error or warning code", 56 | ) 57 | parser.add_argument( 58 | "--ignore-lang", 59 | dest="ignore_lang", 60 | action="append", 61 | default=[], 62 | type=str, 63 | help="Ignore one or more unknown procedural languages", 64 | ) 65 | parser.add_argument( 66 | "--sql-accepting", 67 | dest="sql_fn", 68 | action="append", 69 | default=[], 70 | help="Specify one or more sql-accepting functions", 71 | ) 72 | parser.add_argument( 73 | "files", 74 | metavar="FILE", 75 | type=str, 76 | nargs="*", 77 | help="file to check for vulnerabilities", 78 | ) 79 | 80 | args = parser.parse_args() 81 | args.ignore_lang = [lang.lower() for lang in args.ignore_lang] 82 | 83 | counter = Counter(args) 84 | state = State(counter) 85 | 86 | if args.files: 87 | linebreak = "" if args.summary_only else "\n" 88 | # process all files 89 | for filename in args.files: 90 | if len(args.files) > 1: 91 | print(f"{filename}: ", end=linebreak) 92 | 93 | with open(filename, encoding="utf8") as file: 94 | data = file.read() 95 | 96 | file_counter = Counter(args) 97 | # reset state unless we are in append mode 98 | if args.append: 99 | file_state = state 100 | else: 101 | file_state = State(file_counter) 102 | 103 | try: 104 | visit_sql(file_state, data, toplevel=True) 105 | except Exception as err: 106 | print(linebreak, file_counter, linebreak, err) 107 | file_counter.fatals += 1 108 | else: 109 | print(linebreak, file_counter, linebreak) 110 | 111 | counter.add(file_counter) 112 | 113 | if len(args.files) > 1: 114 | print("TOTAL:", counter) 115 | 116 | elif args.explain: 117 | code = args.explain 118 | if code in codes: 119 | title = codes[code]["title"] 120 | desc = dedent(codes[code]["description"]) 121 | print(f"{code}: {title}\n{desc}") 122 | else: 123 | print(f"Unknown error or warning: {code}") 124 | sys.exit(1) 125 | sys.exit(0) 126 | 127 | else: 128 | # read from stdin 129 | data = sys.stdin.read() 130 | 131 | visit_sql(state, data, toplevel=True) 132 | 133 | print(counter) 134 | 135 | if not counter.is_clean(): 136 | sys.exit(1) 137 | -------------------------------------------------------------------------------- /src/pgspot/codes.py: -------------------------------------------------------------------------------- 1 | codes = { 2 | "PS001": { 3 | "title": "Unqualified operator", 4 | "description": """ 5 | An operator was used with an unsafe search path, or not fully schema-qualified. 6 | 7 | Erroneous example: 8 | 9 | ``` 10 | SELECT foo + bar; 11 | ``` 12 | 13 | The `+` operator is used here without either a) setting the search path or 14 | b) fully-qualifying the operator. 15 | 16 | An attacker could create a custom `+` operator and redirect execution to 17 | their operator by either making their operator have better matching types 18 | or by modifying search_path so that an attacker-controlled schema is searched 19 | first. 20 | 21 | To mitigate, either 22 | a) explicitly set the search path: 23 | 24 | ``` 25 | SET search_path = pg_catalog, pg_temp; 26 | SELECT foo + bar; 27 | ``` 28 | 29 | or b) fully schema-qualify the operator 30 | 31 | ``` 32 | SELECT foo OPERATOR(pg_catalog.+) bar; 33 | ``` 34 | """, 35 | }, 36 | "PS002": { 37 | "title": "Unsafe function creation", 38 | "description": """ 39 | A function was created using `CREATE OR REPLACE` in an insecure schema. 40 | 41 | Erroneous example: 42 | 43 | ``` 44 | CREATE OR REPLACE FUNCTION public.foo() RETURNS INTEGER LANGUAGE SQL AS $$SELECT 1;$$; 45 | ``` 46 | 47 | Using `CREATE OR REPLACE` in a schema which is not owned by the extension is 48 | insecure. An attacker can pre-create the desired function, becoming owner of 49 | the function, and allowing them to later change the body of the function. 50 | 51 | To mitigate this issue, either 52 | 53 | a) Use `CREATE OR REPLACE FUNCTION` in an extension-owned schema: 54 | 55 | ``` 56 | CREATE SCHEMA extension_schema; 57 | CREATE OR REPLACE FUNCTION extension_schema.foo() RETURNS INTEGER LANGUAGE SQL AS $$SELECT 1;$$; 58 | ``` 59 | 60 | or b) use `CREATE FUNCTION` (without `OR REPLACE`): 61 | 62 | ``` 63 | CREATE FUNCTION public.foo() RETURNS INTEGER LANGUAGE SQL AS $$SELECT 1;$$; 64 | ``` 65 | 66 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 67 | CREATE OR REPLACE in an extension script if the statement would replace an 68 | object not belonging to the extension which will prevent exploitation in an 69 | extension context. 70 | """, 71 | }, 72 | "PS003": { 73 | "title": "SECURITY DEFINER function without explicit search_path", 74 | "description": """ 75 | A function with `SECURITY DEFINER` was created without setting a fixed search path. 76 | 77 | Erroneous example: 78 | 79 | ``` 80 | CREATE FUNCTION my_extension.security_definer_function() 81 | RETURNS VOID 82 | SECURITY DEFINER 83 | LANGUAGE SQL 84 | AS $$ 85 | -- function body 86 | $$; 87 | ``` 88 | 89 | In general, `SECURITY DEFINER` functions require extra care, as they are 90 | executed as a different user than the calling user, so can be abused for 91 | privilege escalation. 92 | 93 | In particular, it is highly advised to set a fixed, secure `search_path` for 94 | these functions, as it prevents a number of attacks which rely on an insecure 95 | `search_path`. 96 | 97 | To mitigate, set the `search_path` to the secure search path `pg_catalog, pg_temp` 98 | 99 | ``` 100 | CREATE FUNCTION my_extension.security_definer_function() 101 | RETURNS VOID 102 | SECURITY DEFINER 103 | SET search_path = pg_catalog, pg_temp 104 | LANGUAGE SQL 105 | AS $$ 106 | -- function body 107 | $$; 108 | ``` 109 | """, 110 | }, 111 | "PS004": { 112 | "title": "SECURITY DEFINER function with insecure search_path", 113 | "description": """ 114 | A function with `SECURITY DEFINER` was created with an insecure search path. 115 | 116 | Erroneous example: 117 | 118 | ``` 119 | CREATE FUNCTION my_extension.security_definer_function() 120 | RETURNS VOID 121 | SECURITY DEFINER 122 | SET search_path = public, pg_catalog 123 | LANGUAGE SQL 124 | AS $$ 125 | -- function body 126 | $$; 127 | ``` 128 | 129 | In general, `SECURITY DEFINER` functions require extra care, as they are 130 | executed as a different user than the calling user, so can be abused for 131 | privilege escalation. 132 | 133 | In particular, it is highly advised to set a fixed, secure `search_path` for 134 | these functions, as it prevents a number of attacks which rely on an insecure 135 | `search_path`. 136 | 137 | To mitigate, set the `search_path` to the secure search path `pg_catalog, pg_temp` 138 | 139 | ``` 140 | CREATE FUNCTION my_extension.security_definer_function() 141 | RETURNS VOID 142 | SECURITY DEFINER 143 | SET search_path = pg_catalog, pg_temp 144 | LANGUAGE SQL 145 | AS $$ 146 | -- function body 147 | $$; 148 | ``` 149 | """, 150 | }, 151 | "PS005": { 152 | "title": "Function without explicit search_path", 153 | "description": """ 154 | A function was created without an explicit search_path defined. 155 | 156 | Erroneous example: 157 | 158 | ``` 159 | CREATE FUNCTION my_extension.function() 160 | RETURNS VOID 161 | LANGUAGE SQL 162 | AS $$ 163 | -- function body 164 | $$; 165 | ``` 166 | 167 | In general, it is preferable to define an explicit search path for functions, 168 | as this can prevent insecure search_path attacks. 169 | 170 | To mitigate, set the `search_path` to the secure search path `pg_catalog, pg_temp` 171 | 172 | ``` 173 | CREATE FUNCTION my_extension.function() 174 | RETURNS VOID 175 | SET search_path = pg_catalog, pg_temp 176 | LANGUAGE SQL 177 | AS $$ 178 | -- function body 179 | $$; 180 | ``` 181 | 182 | Note: There are legitimate cases in which it is not possible to set a fixed 183 | search path for a function (e.g. when it should be inlined, or participate in 184 | transactions). For this reason, PS005 is a warning, and can be ignored in those 185 | cases. 186 | """, 187 | }, 188 | "PS006": { 189 | "title": "Unsafe transform creation", 190 | "description": """ 191 | A transform was created using `CREATE OR REPLACE`. 192 | 193 | Erroneous example: 194 | 195 | ``` 196 | CREATE OR REPLACE TRANSFORM rxid FOR LANGUAGE plpgsql(from sql with function f1); 197 | ``` 198 | 199 | This warning is produced for consistency with all the other `CREATE OR REPLACE` 200 | statements. Currently this cannot be exploited to escalate privileges as the steps 201 | required to set this up require superuser privileges. 202 | 203 | To mitigate this issue, use `CREATE ...` (without `OR REPLACE`): 204 | 205 | ``` 206 | CREATE TRANSFORM rxid FOR LANGUAGE plpgsql(from sql with function f1); 207 | ``` 208 | 209 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 210 | CREATE OR REPLACE in an extension script if the statement would replace an 211 | object not belonging to the extension which will prevent exploitation in an 212 | extension context. 213 | """, 214 | }, 215 | "PS007": { 216 | "title": "Unsafe object creation", 217 | "description": """ 218 | An object was created using `CREATE OR REPLACE` in an insecure schema. 219 | 220 | Erroneous example: 221 | 222 | ``` 223 | CREATE OR REPLACE AGGREGATE public.aggregate(BASETYPE=my_type,SFUNC=agg_sfunc,STYPE=internal); 224 | ``` 225 | 226 | Using `CREATE OR REPLACE` in a schema which is not owned by the extension is 227 | insecure. An attacker can pre-create the desired object, becoming owner of 228 | the object, and allowing them to later change attributes of object. This 229 | ultimately leads to malicious code execution. 230 | 231 | To mitigate this issue, either 232 | 233 | a) Use `CREATE OR REPLACE ...` in an extension-owned schema: 234 | 235 | ``` 236 | CREATE SCHEMA extension_schema; 237 | CREATE OR REPLACE AGGREGATE extension_schema.aggregate(BASETYPE=my_type,SFUNC=agg_sfunc,STYPE=internal); 238 | ``` 239 | 240 | or b) use `CREATE ...` (without `OR REPLACE`): 241 | 242 | ``` 243 | CREATE AGGREGATE public.aggregate(SFUNC=agg_sfunc,STYPE=internal); 244 | ``` 245 | 246 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 247 | CREATE OR REPLACE in an extension script if the statement would replace an 248 | object not belonging to the extension which will prevent exploitation in an 249 | extension context. 250 | """, 251 | }, 252 | "PS008": { 253 | "title": "Unqualified alter sequence", 254 | "description": """ 255 | NOTE: This warning is not produced. 256 | """, 257 | }, 258 | "PS009": { 259 | "title": "Unsafe CASE expression", 260 | "description": """ 261 | A "simple" `CASE` expression was used without a secure search_path. 262 | 263 | Erroneous example: 264 | 265 | ``` 266 | SELECT 267 | CASE a OPERATOR(pg_catalog.=) b 268 | WHEN true THEN 'true' 269 | WHEN false THEN 'false' 270 | END 271 | FROM my_schema.foo; 272 | ``` 273 | 274 | The "simple" `CASE` expression evaluates an expression once, and then compares 275 | the result of that evaluation with the branches of the `CASE` expression. This 276 | comparison is performed with an equality operator which cannot be 277 | schema-qualified. 278 | 279 | To mitigate, either 280 | 281 | a) explicitly set the search path: 282 | 283 | ``` 284 | SET search_path = pg_catalog, pg_temp; 285 | SELECT 286 | CASE a = b 287 | WHEN true THEN 'true' 288 | WHEN false THEN 'false' 289 | END 290 | FROM my_schema.foo; 291 | ``` 292 | 293 | or b) use the alternate form of case expression: 294 | 295 | ``` 296 | SELECT 297 | CASE 298 | WHEN a OPERATOR(pg_catalog.=) b THEN 'true' 299 | WHEN a OPERATOR(pg_catalog.!=) b THEN 'false' 300 | END 301 | FROM my_schema.foo; 302 | ``` 303 | """, 304 | }, 305 | "PS010": { 306 | "title": "Unsafe schema creation", 307 | "description": """ 308 | A schema was created using `IF NOT EXISTS`. 309 | 310 | Erroneous example: 311 | 312 | ``` 313 | CREATE SCHEMA IF NOT EXISTS my_schema; 314 | ``` 315 | 316 | Using `IF NOT EXISTS` to create a schema means that the schema could have been 317 | pre-created by an attacker. As the attacker would own the schema, they would be 318 | able to modify arbitrary objects added to that schema by the extension. This 319 | allows for the execution of malicious code. 320 | 321 | To mitigate this issue use only `CREATE SCHEMA` without `IF NOT EXISTS`: 322 | 323 | ``` 324 | CREATE SCHEMA my_schema; 325 | ``` 326 | 327 | If the schema already exists when the extension is installed, then this 328 | statement will fail, which is desired behaviour. 329 | 330 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 331 | CREATE IF NOT EXISTS in an extension script if the statement would replace an 332 | object not belonging to the extension which will prevent exploitation in an 333 | extension context. 334 | """, 335 | }, 336 | "PS011": { 337 | "title": "Unsafe sequence creation", 338 | "description": """ 339 | A sequence was created using `IF NOT EXISTS` in an insecure schema. 340 | 341 | Erroneous example: 342 | 343 | ``` 344 | CREATE SEQUENCE IF NOT EXISTS public.s1; 345 | ``` 346 | 347 | Using `CREATE ... IF NOT EXISTS` in a schema which is not owned by the 348 | extension is insecure. An attacker can pre-create the desired sequence, becoming 349 | owner of the sequence, allowing them to later modify attributes of the sequence 350 | and thereby controlling the values generated by the sequence. 351 | 352 | To mitigate this issue, either 353 | 354 | a) Use `CREATE SEQUENCE IF NOT EXISTS` in an extension-owned schema: 355 | 356 | ``` 357 | CREATE SCHEMA extension_schema; 358 | CREATE SEQUENCE IF NOT EXISTS extension_schema.s1; 359 | ``` 360 | 361 | or b) use `CREATE SEQUENCE` (without `IF NOT EXISTS`): 362 | 363 | ``` 364 | CREATE SEQUENCE public.s1; 365 | ``` 366 | 367 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 368 | CREATE IF NOT EXISTS in an extension script if the statement would replace an 369 | object not belonging to the extension which will prevent exploitation in an 370 | extension context. 371 | """, 372 | }, 373 | "PS012": { 374 | "title": "Unsafe table creation", 375 | "description": """ 376 | A table was created using `IF NOT EXISTS` in an insecure schema. 377 | 378 | Erroneous example: 379 | 380 | ``` 381 | CREATE TABLE IF NOT EXISTS public.test_table(col text); 382 | ``` 383 | 384 | Using `CREATE ... IF NOT EXISTS` in a schema which is not owned by the 385 | extension is insecure. An attacker can pre-create the desired table, becoming 386 | owner of the table, allowing them to later modify attributes of the table. This 387 | ultimately leads to malicious code execution. 388 | 389 | To mitigate this issue, either 390 | 391 | a) Use `CREATE TABLE IF NOT EXISTS` in an extension-owned schema: 392 | 393 | ``` 394 | CREATE SCHEMA extension_schema; 395 | CREATE TABLE IF NOT EXISTS extension_schema.test_table(col text); 396 | ``` 397 | 398 | or b) use `CREATE TABLE` (without `IF NOT EXISTS`): 399 | 400 | ``` 401 | CREATE TABLE public.test_table(col text); 402 | ``` 403 | 404 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 405 | CREATE IF NOT EXISTS in an extension script if the statement would replace an 406 | object not belonging to the extension which will prevent exploitation in an 407 | extension context. 408 | """, 409 | }, 410 | "PS013": { 411 | "title": "Unsafe foreign server creation", 412 | "description": """ 413 | A foreign server was created using `IF NOT EXISTS`. 414 | 415 | Erroneous example: 416 | 417 | ``` 418 | CREATE SERVER IF NOT EXISTS s1 FOREIGN DATA WRAPPER postgres_fdw; 419 | ``` 420 | 421 | Using `CREATE ... IF NOT EXISTS` is insecure. An attacker may pre-create 422 | the desired server, becoming owner of the server, allowing them to later 423 | modify attributes of the server. Since a user needs USAGE privilege on 424 | the FOREIGN DATA WRAPPER to create a server this might not be 425 | exploitable in most enviroments. 426 | 427 | To mitigate this issue, use `CREATE SERVER` (without `IF NOT EXISTS`): 428 | 429 | ``` 430 | CREATE SERVER s1 FOREIGN DATA WRAPPER postgres_fdw; 431 | ``` 432 | 433 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 434 | CREATE IF NOT EXISTS in an extension script if the statement would replace an 435 | object not belonging to the extension which will prevent exploitation in an 436 | extension context. 437 | """, 438 | }, 439 | "PS014": { 440 | "title": "Unsafe index creation", 441 | "description": """ 442 | An index was created using `IF NOT EXISTS`. 443 | 444 | Erroneous example: 445 | 446 | ``` 447 | CREATE INDEX IF NOT EXISTS i1 ON t(time); 448 | ``` 449 | 450 | Using `CREATE ... IF NOT EXISTS` is insecure. An attacker can pre-create 451 | the index preventing the creation of unique constraints on the table. 452 | Indexes may also be used to execute malicious code. 453 | 454 | To mitigate this issue use `CREATE INDEX` (without `IF NOT EXISTS`): 455 | 456 | ``` 457 | CREATE INDEX i1 ON t(time); 458 | ``` 459 | """, 460 | }, 461 | "PS015": { 462 | "title": "Unsafe view creation", 463 | "description": """ 464 | A view was created using `CREATE OR REPLACE` in an insecure schema. 465 | 466 | Erroneous example: 467 | 468 | ``` 469 | CREATE OR REPLACE VIEW public.test_view AS SELECT pg_catalog.now(); 470 | ``` 471 | 472 | Using `CREATE OR REPLACE` in a schema which is not owned by the extension is 473 | insecure. An attacker can pre-create the desired view, becoming owner of 474 | the view, allowing them to later modify the view. This ultimately leads to 475 | malicious code execution. 476 | 477 | To mitigate this issue, either 478 | 479 | a) Use `CREATE OR REPLACE VIEW` in an extension-owned schema: 480 | 481 | ``` 482 | CREATE SCHEMA extension_schema; 483 | CREATE OR REPLACE VIEW extension_schema.test_view AS SELECT pg_catalog.now(); 484 | ``` 485 | 486 | or b) use `CREATE VIEW` (without `OR REPLACE`): 487 | 488 | ``` 489 | CREATE VIEW public.test_view AS SELECT pg_catalog.now(); 490 | ``` 491 | 492 | PostgreSQL versions 14.5+, 13.8+, 12.12+, 11.17+ and 10.22+ will block the 493 | CREATE OR REPLACE in an extension script if the statement would replace an 494 | object not belonging to the extension which will prevent exploitation in an 495 | extension context. 496 | """, 497 | }, 498 | "PS016": { 499 | "title": "Unqualified function call", 500 | "description": """ 501 | A function was used with an unsafe search path, or not fully schema-qualified. 502 | 503 | Erroneous example: 504 | 505 | ``` 506 | SELECT my_function(foo); 507 | ``` 508 | 509 | The call to my_function was made without either a) setting the search path or 510 | b) fully schema-qualifying the function. 511 | 512 | An attacker could create a custom `my_function` function and redirect execution 513 | to their function by either making their function have better-matching types or 514 | by modifying the search_path so that an attacker-controlled schema is searched 515 | first. 516 | 517 | Either a) explicitly set the search path. 518 | 519 | ``` 520 | SET search_path = pg_catalog, pg_temp; 521 | SELECT my_function(foo); 522 | ``` 523 | 524 | or b) fully-qualify the function 525 | 526 | ``` 527 | SELECT extension_schema.my_function(foo); 528 | ``` 529 | """, 530 | }, 531 | "PS017": { 532 | "title": "Unqualified object reference", 533 | "description": """ 534 | An object was referenced with an unsafe search path, or not fully schema-qualified. 535 | 536 | Erroneous example: 537 | 538 | ``` 539 | SELECT * FROM foo; 540 | ``` 541 | 542 | The reference to `foo` was made without either a) setting the search path or 543 | b) fully schema-qualifying the relation. 544 | 545 | An attacker could create a temporary relation `foo`, or modify the search path 546 | so that an attacker-controlled relation is referenced instead of the desired. 547 | This could result in erroneous behaviour of a routine or function. 548 | 549 | To mitigate this, either a) explicitly set the search path: 550 | 551 | ``` 552 | SET search_path = extension_schema; 553 | SELECT * FROM foo; 554 | ``` 555 | 556 | or b) fully-qualify the object 557 | 558 | ``` 559 | SELECT * FROM extension_schema.foo; 560 | ``` 561 | """, 562 | }, 563 | "PS018": { 564 | "title": "Unsafe SET search_path", 565 | "description": """ 566 | When using SET search_path the schemas must not be enclosed in single quotes 567 | as otherwise the list of schemas will be treated as a single schema name instead. 568 | 569 | Erroneous example: 570 | 571 | ``` 572 | SET search_path TO 'pg_catalog, temp'; 573 | ``` 574 | 575 | Setting the search_path this way will result in the actual search_path being 576 | pg_temp, pg_catalog, "pg_catalog, pg_temp" instead of the intended value of 577 | pg_catalog, pg_temp. This would allow shadowing catalog relations with relations 578 | in the pg_temp schema. 579 | 580 | To mitigate this do not enclose the list of schemas in single quotes 581 | 582 | ``` 583 | SET search_path TO pg_catalog, pg_temp; 584 | ``` 585 | 586 | or b) use pg_catalog.set_config 587 | 588 | ``` 589 | SELECT pg_catalog.set_config('search_path','pg_catalog, pg_temp', false); 590 | ``` 591 | """, 592 | }, 593 | } 594 | -------------------------------------------------------------------------------- /src/pgspot/formatters.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from pglast.stream import RawStream 3 | from pglast import ast 4 | 5 | 6 | def raw_sql(node): 7 | return RawStream()(node) 8 | 9 | 10 | def get_text(node): 11 | match (node): 12 | case str(): 13 | return node 14 | case ast.A_Const(): 15 | return get_text(node.val) 16 | case ast.String(): 17 | return node.sval 18 | case _: 19 | return str(node) 20 | 21 | 22 | def format_name(name): 23 | match (name): 24 | case str(): 25 | return name 26 | case list() | tuple(): 27 | return ".".join([format_name(p) for p in name]) 28 | case ast.String(): 29 | return name.sval 30 | case ast.RangeVar(): 31 | if name.schemaname: 32 | return f"{name.schemaname}.{name.relname}" 33 | return name.relname 34 | case ast.TypeName(): 35 | return ".".join([format_name(p) for p in name.names]) 36 | case _: 37 | return str(name) 38 | 39 | 40 | def format_function(node): 41 | args = [] 42 | if node.parameters: 43 | for p in node.parameters: 44 | arg_copy = copy(p) 45 | # strip out default expressions 46 | arg_copy.defexpr = None 47 | args.append(raw_sql(arg_copy)) 48 | 49 | return f"{format_name(node.funcname)}({','.join(args)})" 50 | 51 | 52 | def format_aggregate(node): 53 | if node.oldstyle: 54 | basetype = [b.arg.names for b in node.definition if b.defname == "basetype"] 55 | if basetype: 56 | basetype = basetype[0] 57 | 58 | if not basetype: 59 | args = "" 60 | elif len(basetype) == 2 and basetype[0].sval == "pg_catalog": 61 | args = basetype[1].sval 62 | else: 63 | args = ",".join([s.sval for s in basetype]) 64 | else: 65 | args = ",".join([raw_sql(arg.argType) for arg in node.args[0]]) 66 | return f"{format_name(node.defnames)}({args})" 67 | -------------------------------------------------------------------------------- /src/pgspot/path.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=fixme 2 | 3 | 4 | class Path: 5 | """A path is a sequence of steps that will be executed in a PLpgSQL function.""" 6 | 7 | def __init__(self, root, steps=None, stack=None): 8 | self.root = root 9 | # steps is the list of nodes that have been processed 10 | self.steps = steps.copy() if steps else [] 11 | # stack is a list of nodes that are yet to be processed 12 | self.stack = stack.copy() if stack else [] 13 | 14 | def copy(self): 15 | return Path(self.root, self.steps, self.stack) 16 | 17 | def __str__(self): 18 | return " -> ".join([str(step) for step in self.steps]) 19 | 20 | 21 | def paths(root): 22 | p = Path(root) 23 | pathes = [] 24 | dfs(root, p, pathes) 25 | yield p 26 | 27 | while pathes: 28 | p = pathes.pop(0) 29 | t = p.stack.pop(0) 30 | dfs(t, p, pathes) 31 | yield p 32 | 33 | 34 | def dfs(node, path, pathes): 35 | """traverse tree depth first similar to how it would get executed""" 36 | if not node: 37 | return 38 | if node: 39 | match node.type: 40 | case "PLpgSQL_function": 41 | # This should be top level node and so stack should be empty 42 | assert not path.stack 43 | path.stack = [node.action] + path.stack 44 | case "PLpgSQL_stmt_block": 45 | # FIXME: Add support for exception handling 46 | path.stack = node.body + path.stack 47 | case "PLpgSQL_stmt_if": 48 | path.steps.append(node) 49 | if node.elsif_list: 50 | for elsif in node.elsif_list: 51 | alt = path.copy() 52 | alt.stack = elsif.stmts + alt.stack 53 | pathes.append(alt) 54 | if node.else_body: 55 | alt = path.copy() 56 | alt.stack = node.else_body + alt.stack 57 | pathes.append(alt) 58 | 59 | path.stack = node.then_body + path.stack 60 | 61 | # different types of loops 62 | # FIXME: Add support for loop exit 63 | case ( 64 | "PLpgSQL_stmt_loop" 65 | | "PLpgSQL_stmt_while" 66 | | "PLpgSQL_stmt_forc" 67 | | "PLpgSQL_stmt_fori" 68 | | "PLpgSQL_stmt_fors" 69 | | "PLpgSQL_stmt_dynfors" 70 | ): 71 | path.stack = node.body + path.stack 72 | 73 | # nodes with no children 74 | case ( 75 | "PLpgSQL_stmt_assert" 76 | | "PLpgSQL_stmt_assign" 77 | | "PLpgSQL_stmt_call" 78 | | "PLpgSQL_stmt_close" 79 | | "PLpgSQL_stmt_commit" 80 | | "PLpgSQL_stmt_dynexecute" 81 | | "PLpgSQL_stmt_execsql" 82 | | "PLpgSQL_stmt_fetch" 83 | | "PLpgSQL_stmt_getdiag" 84 | | "PLpgSQL_stmt_open" 85 | | "PLpgSQL_stmt_perform" 86 | | "PLpgSQL_stmt_raise" 87 | | "PLpgSQL_stmt_return_next" 88 | | "PLpgSQL_stmt_return_query" 89 | | "PLpgSQL_stmt_rollback" 90 | ): 91 | path.steps.append(node) 92 | 93 | # nodes not yet implemented 94 | case "PLpgSQL_stmt_case" | "PLpgSQL_stmt_exit" | "PLpgSQL_stmt_foreach_a": 95 | raise Exception(f"Not yet implemented {node.type}") 96 | 97 | # nodes that will end current path 98 | case "PLpgSQL_stmt_return": 99 | path.steps.append(node) 100 | path.stack.clear() 101 | return 102 | 103 | case _: 104 | raise Exception(f"Unknown node type {node.type}") 105 | 106 | while path.stack: 107 | t = path.stack.pop(0) 108 | dfs(t, path, pathes) 109 | -------------------------------------------------------------------------------- /src/pgspot/pg_catalog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timescale/pgspot/a8b9df76542bba8f7c9bfc0d622b59ce143d7c2e/src/pgspot/pg_catalog/__init__.py -------------------------------------------------------------------------------- /src/pgspot/pg_catalog/format.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | # https://www.postgresql.org/docs/current/functions-string.html#FUNCTIONS-STRING-FORMAT 4 | # 5 | # Format specifiers are introduced by a % character and have the form 6 | # 7 | # %[position][flags][width]type 8 | # where the component fields are: 9 | # 10 | # position (optional) 11 | # A string of the form n$ where n is the index of the argument to print. 12 | # Index 1 means the first argument after formatstr. If the position is 13 | # omitted, the default is to use the next argument in sequence. 14 | # 15 | # flags (optional) 16 | # Additional options controlling how the format specifier's output is formatted. 17 | # Currently the only supported flag is a minus sign (-) which will cause the 18 | # format specifier's output to be left-justified. This has no effect unless the 19 | # width field is also specified. 20 | # 21 | # width (optional) 22 | # Specifies the minimum number of characters to use to display the format 23 | # specifier's output. The output is padded on the left or right (depending on 24 | # the - flag) with spaces as needed to fill the width. A too-small width does 25 | # not cause truncation of the output, but is simply ignored. The width may be 26 | # specified using any of the following: a positive integer; an asterisk (*) to 27 | # use the next function argument as the width; or a string of the form *n$ to 28 | # use the nth function argument as the width. 29 | # 30 | # If the width comes from a function argument, that argument is consumed before 31 | # the argument that is used for the format specifier's value. If the width 32 | # argument is negative, the result is left aligned (as if the - flag had been 33 | # specified) within a field of length abs(width). 34 | # 35 | # type (required) 36 | # The type of format conversion to use to produce the format specifier's output. 37 | # The following types are supported: 38 | # s formats the argument value as a simple string. A null value is treated as 39 | # an empty string. 40 | # I treats the argument value as an SQL identifier, double-quoting it if 41 | # necessary. It is an error for the value to be null (equivalent to quote_ident). 42 | # L quotes the argument value as an SQL literal. A null value is displayed as 43 | # the string NULL, without quotes (equivalent to quote_nullable). 44 | # 45 | # In addition to the format specifiers described above, the special sequence %% 46 | # may be used to output a literal % character. 47 | 48 | 49 | def parse_format_string(fmt_string): 50 | ret = [] 51 | current_index = 1 52 | for i in re.findall( 53 | r"(%(([1-9])[$])?[-]?([1-9][0-9]*|[*]|[*][1-9][$])?([sIL]))", fmt_string 54 | ): 55 | # i[0] is the full match 56 | # i[1] is the position specifier 57 | # i[2] is the position specifier index 58 | # i[3] is the width specifier 59 | # i[4] is the type specifier 60 | if i[2]: 61 | ret.append((i[4], int(i[2]))) 62 | else: 63 | # width may consume a function argument 64 | if i[3] == "*": 65 | current_index += 1 66 | ret.append((i[4], current_index)) 67 | current_index += 1 68 | 69 | return ret 70 | -------------------------------------------------------------------------------- /src/pgspot/plpgsql.py: -------------------------------------------------------------------------------- 1 | # 2 | # pglast returns PLpgSQL AST as a nested dict. This file contains some glue code 3 | # to convert the nested dict into a tree of objects. 4 | # 5 | # Disable pylint warnings about class names cause we are trying to match the 6 | # AST names used by PLpgSQL parser. 7 | # pylint: disable-msg=invalid-name,too-few-public-methods 8 | 9 | 10 | class PLpgSQLNode: 11 | def __init__(self, raw): 12 | self.type = list(raw.keys())[0] 13 | self.lineno = "" 14 | for k, v in raw[self.type].items(): 15 | setattr(self, k, build_node(v)) 16 | 17 | def __str__(self): 18 | return f"{self.type}({self.lineno})" 19 | 20 | def __repr__(self): 21 | fields = self.__dict__.copy() 22 | fields.pop("type") 23 | return f"{self.type}({fields})" 24 | 25 | 26 | class PLpgSQL_stmt_if(PLpgSQLNode): 27 | def __init__(self, raw): 28 | self.then_body = None 29 | self.elsif_list = None 30 | self.else_body = None 31 | super().__init__(raw) 32 | 33 | 34 | class PLpgSQL_row(PLpgSQLNode): 35 | def __init__(self, raw): 36 | # PLpgSQL_row has a fields attribute which is a list of dicts that 37 | # don't have the same structure as other node dicts. So we pop it out 38 | # and set it as an attribute directly instead of having it handled by 39 | # recursion. 40 | self.fields = raw["PLpgSQL_row"].pop("fields") 41 | super().__init__(raw) 42 | 43 | 44 | class PLpgSQL_var(PLpgSQLNode): 45 | def __init__(self, raw): 46 | self.refname = None 47 | self.datatype = None 48 | super().__init__(raw) 49 | 50 | def __str__(self): 51 | return f"Variable(name={self.refname} type={self.datatype})" 52 | 53 | 54 | def build_node(node): 55 | if isinstance(node, list): 56 | return [build_node(n) for n in node] 57 | if isinstance(node, dict): 58 | name = list(node.keys())[0] 59 | if globals().get(name) is not None: 60 | return globals()[name](node) 61 | return PLpgSQLNode(node) 62 | 63 | return node 64 | -------------------------------------------------------------------------------- /src/pgspot/state.py: -------------------------------------------------------------------------------- 1 | from pglast import ast 2 | from .codes import codes 3 | from .formatters import get_text 4 | 5 | 6 | class Counter: 7 | def __init__(self, args): 8 | self.args = args 9 | self.warnings = 0 10 | self.unknowns = 0 11 | self.errors = 0 12 | self.fatals = 0 13 | 14 | # for tracking current position in input stream 15 | # only toplevel visitor should update these 16 | self.sql = "" 17 | self.stmt_location = 0 18 | 19 | def add(self, counter): 20 | self.warnings += counter.warnings 21 | self.unknowns += counter.unknowns 22 | self.errors += counter.errors 23 | self.fatals += counter.fatals 24 | 25 | def print_issue(self, code, context): 26 | if code not in codes: 27 | raise ValueError 28 | if not self.args.summary_only: 29 | line = self.line_number() 30 | title = codes[code]["title"] 31 | print(f"{code}: {title}: {context} at line {line}") 32 | 33 | def warn(self, code, context): 34 | if code not in self.args.ignore: 35 | self.warnings += 1 36 | self.print_issue(code, context) 37 | 38 | def error(self, code, context): 39 | if code not in self.args.ignore: 40 | self.errors += 1 41 | self.print_issue(code, context) 42 | 43 | # Unfortunately the line_number handling is not perfect. 44 | # This will be the line of the first character after the 45 | # previous statement has ended. On files with comments 46 | # before a statement it will indicate the line with comments 47 | # instead of the line with the actual statement. 48 | # Since these numbers are reported to us by pglast/libpg_query 49 | # there is not much more here we can do to improve accuracy. 50 | def line_number(self): 51 | return 1 + self.sql.count("\n", 0, self.stmt_location + 1) 52 | 53 | def unknown(self, message): 54 | self.unknowns += 1 55 | if not self.args.summary_only: 56 | print(message) 57 | 58 | def is_clean(self): 59 | return self.errors + self.warnings + self.unknowns + self.fatals == 0 60 | 61 | def __str__(self): 62 | return ( 63 | f"Errors: {self.errors} Warnings: {self.warnings} Unknown: {self.unknowns}" 64 | ) 65 | 66 | 67 | class State: 68 | def __init__(self, counter): 69 | self.counter = counter 70 | self.args = counter.args 71 | self.created_schemas = [] 72 | self.created_aggregates = [] 73 | self.created_functions = [] 74 | self.searchpath_secure = False 75 | self.searchpath_local = False 76 | 77 | def warn(self, code, context): 78 | self.counter.warn(code, context) 79 | 80 | def error(self, code, context): 81 | self.counter.error(code, context) 82 | 83 | def unknown(self, message): 84 | self.counter.unknown(message) 85 | 86 | def set_searchpath(self, stmt, local=False): 87 | self.searchpath_secure = self.is_secure_searchpath(stmt) 88 | self.searchpath_local = local 89 | 90 | def reset_searchpath(self): 91 | self.searchpath_secure = False 92 | self.searchpath_local = False 93 | 94 | def extract_schemas(self, setters): 95 | match (setters): 96 | case str(): 97 | return [setters] 98 | case list(): 99 | return setters 100 | case ast.VariableSetStmt(): 101 | return [get_text(item) for item in setters.args] 102 | case _: 103 | raise Exception(f"Unhandled type in extract_schemas: {setters}") 104 | 105 | # we consider the search path safe when it only contains 106 | # pg_catalog and any schema created in this script and 107 | # pg_temp as last entry 108 | def is_secure_searchpath(self, setters): 109 | schemas = self.extract_schemas(setters) 110 | 111 | # explicit pg_catalog at start of search_path is fine 112 | if schemas[0] == "pg_catalog": 113 | schemas = schemas[1:] 114 | 115 | # any schema created by us is fine too 116 | while schemas and schemas[0] in self.created_schemas: 117 | schemas = schemas[1:] 118 | 119 | # we require explicit pg_temp as last entry for a schema to 120 | # be safe because not having explicit pg_temp in search_path 121 | # will result in a search_path with pg_temp as first entry 122 | if schemas == ["pg_temp"]: 123 | return True 124 | 125 | return False 126 | -------------------------------------------------------------------------------- /src/pgspot/visitors.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from pglast import ast, parse_sql, parse_plpgsql 4 | from pglast.parser import ParseError 5 | from pglast.stream import RawStream 6 | from pglast.visitors import Visitor 7 | from pglast.enums.parsenodes import VariableSetKind, TransactionStmtKind, ObjectType 8 | from .formatters import ( 9 | get_text, 10 | raw_sql, 11 | format_name, 12 | format_function, 13 | format_aggregate, 14 | ) 15 | from .state import State 16 | 17 | 18 | def visit_sql(state, sql, toplevel=False): 19 | # We have to iterate over toplevel items ourselves cause the visitor does 20 | # breadth-first iteration, which would conflict with our search_path state 21 | # tracking. 22 | 23 | # @extschema@ is placeholder in extension scripts for 24 | # the schema the extension gets installed in 25 | sql = sql.replace("@extschema@", "extschema") 26 | sql = re.sub(r"@extschema:([^@]+)@", r"extschema_\1", sql, flags=re.MULTILINE) 27 | sql = sql.replace("@extowner@", "extowner") 28 | sql = sql.replace("@database_owner@", "database_owner") 29 | # postgres contrib modules are protected by psql meta commands to 30 | # prevent running extension files in psql. 31 | # The SQL parser will error on those since they are not valid 32 | # SQL, so we comment out all psql meta commands before parsing. 33 | sql = re.sub(r"^\\", "-- \\\\", sql, flags=re.MULTILINE) 34 | 35 | if toplevel: 36 | state.counter.sql = sql 37 | 38 | visitor = SQLVisitor(state) 39 | # try: 40 | for stmt in parse_sql(sql): 41 | if toplevel: 42 | state.counter.stmt_location = stmt.stmt_location 43 | visitor(stmt) 44 | # except Exception: 45 | # print("Error while parsing SQL:", sql) 46 | 47 | 48 | def visit_plpgsql(state, node): 49 | if not state.args.plpgsql: 50 | return 51 | 52 | match (node): 53 | case ast.CreateFunctionStmt(): 54 | raw = raw_sql(node) 55 | 56 | case ast.DoStmt(): 57 | raw = raw_sql(node) 58 | 59 | case _: 60 | state.unknown(f"Unknown node in visit_plpgsql: {node}") 61 | return 62 | 63 | parsed = parse_plpgsql(raw) 64 | 65 | visitor = PLPGSQLVisitor(state) 66 | for item in parsed: 67 | visitor(item) 68 | 69 | 70 | class PLPGSQLVisitor: 71 | def __init__(self, state): 72 | super().__init__() 73 | self.state = state 74 | 75 | def __call__(self, node): 76 | self.visit(node) 77 | 78 | def visit(self, node): 79 | if isinstance(node, list): 80 | for item in node: 81 | self.visit(item) 82 | if isinstance(node, dict): 83 | for key, value in node.items(): 84 | match (key): 85 | # Work around inconsistent PL/pgSQL expression handling 86 | # PLpgSQL expressions handed to us have either full SQL as query 87 | # or subexpression which will be rejected by sql parser 88 | # To work around this we prepend the latter with SELECT before 89 | # handing it to SQL parser. 90 | case "PLpgSQL_expr": 91 | visit_sql(self.state, value["query"]) 92 | case "PLpgSQL_var": 93 | if "default_val" in value: 94 | visit_sql( 95 | self.state, 96 | "SELECT " 97 | + value["default_val"]["PLpgSQL_expr"]["query"], 98 | ) 99 | case "PLpgSQL_stmt_assert": 100 | if "cond" in value: 101 | visit_sql( 102 | self.state, 103 | "SELECT " + value["cond"]["PLpgSQL_expr"]["query"], 104 | ) 105 | case "PLpgSQL_stmt_assign": 106 | if "expr" in value: 107 | expr = value["expr"]["PLpgSQL_expr"]["query"] 108 | if ":=" in expr: 109 | expr = expr.split(":=", 1)[1] 110 | else: 111 | expr = expr.split("=", 1)[1] 112 | visit_sql(self.state, "SELECT " + expr) 113 | case "PLpgSQL_stmt_case": 114 | if "t_expr" in value: 115 | visit_sql( 116 | self.state, 117 | "SELECT " + value["t_expr"]["PLpgSQL_expr"]["query"], 118 | ) 119 | if "case_when_list" in value: 120 | self.visit(value["case_when_list"]) 121 | if "else_stmts" in value: 122 | self.visit(value["else_stmts"]) 123 | case "PLpgSQL_stmt_dynexecute": 124 | if "query" in value: 125 | visit_sql( 126 | self.state, 127 | "SELECT " + value["query"]["PLpgSQL_expr"]["query"], 128 | ) 129 | if "params" in value: 130 | for item in value["params"]: 131 | visit_sql( 132 | self.state, 133 | "SELECT " + item["PLpgSQL_expr"]["query"], 134 | ) 135 | case "PLpgSQL_stmt_dynfors": 136 | if "query" in value: 137 | visit_sql( 138 | self.state, 139 | "SELECT " + value["query"]["PLpgSQL_expr"]["query"], 140 | ) 141 | if "body" in value: 142 | self.visit(value["body"]) 143 | case "PLpgSQL_case_when": 144 | if "expr" in value: 145 | visit_sql( 146 | self.state, 147 | "SELECT " + value["expr"]["PLpgSQL_expr"]["query"], 148 | ) 149 | case "PLpgSQL_stmt_exit": 150 | if "cond" in value: 151 | visit_sql( 152 | self.state, 153 | "SELECT " + value["cond"]["PLpgSQL_expr"]["query"], 154 | ) 155 | case "PLpgSQL_stmt_if": 156 | if "cond" in value: 157 | visit_sql( 158 | self.state, 159 | "SELECT " + value["cond"]["PLpgSQL_expr"]["query"], 160 | ) 161 | if "then_body" in value: 162 | self.visit(value["then_body"]) 163 | if "else_body" in value: 164 | self.visit(value["else_body"]) 165 | case "PLpgSQL_stmt_fori": 166 | if "lower" in value: 167 | visit_sql( 168 | self.state, 169 | "SELECT " + value["lower"]["PLpgSQL_expr"]["query"], 170 | ) 171 | if "upper" in value: 172 | visit_sql( 173 | self.state, 174 | "SELECT " + value["upper"]["PLpgSQL_expr"]["query"], 175 | ) 176 | if "body" in value: 177 | self.visit(value["body"]) 178 | case "PLpgSQL_stmt_foreach_a": 179 | if "expr" in value: 180 | visit_sql( 181 | self.state, 182 | "SELECT " + value["expr"]["PLpgSQL_expr"]["query"], 183 | ) 184 | if "body" in value: 185 | self.visit(value["body"]) 186 | case "PLpgSQL_stmt_loop": 187 | if "body" in value: 188 | self.visit(value["body"]) 189 | case "PLpgSQL_stmt_raise": 190 | if "params" in value: 191 | for item in value["params"]: 192 | visit_sql( 193 | self.state, 194 | "SELECT " + item["PLpgSQL_expr"]["query"], 195 | ) 196 | case "PLpgSQL_stmt_return": 197 | if "expr" in value: 198 | visit_sql( 199 | self.state, 200 | "SELECT " + value["expr"]["PLpgSQL_expr"]["query"], 201 | ) 202 | case "PLpgSQL_stmt_return_query": 203 | if "dynquery" in value: 204 | query = ( 205 | "SELECT " + value["dynquery"]["PLpgSQL_expr"]["query"] 206 | ) 207 | parsed = parse_sql(query)[0].stmt.targetList[0].val 208 | 209 | # When the query is a string literal we can analyze it's content 210 | if ( 211 | isinstance(parsed, ast.A_Const) 212 | and parsed.isnull is False 213 | and isinstance(parsed.val, ast.String) 214 | ): 215 | visit_sql( 216 | self.state, 217 | parsed.val.sval, 218 | ) 219 | 220 | case "PLpgSQL_stmt_while": 221 | if "cond" in value: 222 | visit_sql( 223 | self.state, 224 | "SELECT " + value["cond"]["PLpgSQL_expr"]["query"], 225 | ) 226 | if "body" in value: 227 | self.visit(value["body"]) 228 | case _: 229 | self.visit(value) 230 | 231 | 232 | # pylint: disable=unused-argument,invalid-name 233 | class SQLVisitor(Visitor): 234 | def __init__(self, state): 235 | self.state = state 236 | super().__init__() 237 | 238 | def visit_A_Expr(self, ancestors, node): 239 | if len(node.name) != 2 and not self.state.searchpath_secure: 240 | self.state.warn( 241 | "PS001", f"'{format_name(node.name)}' in {RawStream()(node)}" 242 | ) 243 | 244 | def visit_CreateFunctionStmt(self, ancestors, node): 245 | # If the function creation is in a schema we created before we 246 | # consider it safe even with CREATE OR REPLACE since there would be 247 | # no way to precreate it. 248 | if ( 249 | len(node.funcname) == 2 250 | and node.funcname[0].sval in self.state.created_schemas 251 | ): 252 | pass 253 | # This function was created without OR REPLACE previously so 254 | # CREATE OR REPLACE is safe now. 255 | elif format_function(node) in self.state.created_functions: 256 | pass 257 | elif node.replace: 258 | self.state.warn("PS002", format_function(node)) 259 | 260 | # keep track of functions created in this script in case they get replaced later 261 | if not node.replace: 262 | self.state.created_functions.append(format_function(node)) 263 | 264 | # check function body 265 | language = [l.arg.sval for l in node.options if l.defname == "language"][0] 266 | # check for security definer explicitly 267 | security = [s.arg.boolval == 1 for s in node.options if s.defname == "security"] 268 | if len(security) > 0: 269 | security = security[0] 270 | else: 271 | security = False 272 | 273 | body = [b.arg[0].sval for b in node.options if b.defname == "as"][0] 274 | setter = [ 275 | s.arg 276 | for s in node.options 277 | if s.defname == "set" and s.arg.name == "search_path" 278 | ] 279 | 280 | if len(setter) > 1: 281 | self.state.error("multiple search_path settings", format_function(node)) 282 | 283 | if setter: 284 | body_secure = self.state.is_secure_searchpath(setter[0]) 285 | else: 286 | body_secure = False 287 | 288 | # functions without explicit search_path will generate a warning 289 | # unless they are SECURITY DEFINER 290 | if security: 291 | if not setter: 292 | self.state.error("PS003", format_function(node)) 293 | elif not body_secure: 294 | self.state.error("PS004", format_function(node)) 295 | else: 296 | if language in ["sql", "plpgsql"]: 297 | if ( 298 | not body_secure 299 | and format_function(node) 300 | not in self.state.args.proc_without_search_path 301 | ): 302 | self.state.warn("PS005", format_function(node)) 303 | 304 | match (language): 305 | case "sql": 306 | state = State(self.state.counter) 307 | state.searchpath_secure = body_secure 308 | 309 | visit_sql(state, body) 310 | case "plpgsql": 311 | state = State(self.state.counter) 312 | state.searchpath_secure = body_secure 313 | visit_plpgsql(state, node) 314 | case "c" | "internal": 315 | pass 316 | case language if language in self.state.args.ignore_lang: 317 | pass 318 | case _: 319 | self.state.unknown(f"Unknown function language: {language}") 320 | 321 | def visit_CreateTransformStmt(self, ancestors, node): 322 | if node.replace: 323 | self.state.warn("PS006", format_name(node.type_name)) 324 | 325 | def visit_DefineStmt(self, ancestors, node): 326 | if len(node.defnames) == 1 and not self.state.searchpath_secure: 327 | self.state.warn("PS017", format_name(node.defnames)) 328 | 329 | match node.kind: 330 | # CREATE AGGREGATE 331 | case ObjectType.OBJECT_AGGREGATE: 332 | if node.replace: 333 | if format_aggregate(node) not in self.state.created_aggregates: 334 | if ( 335 | len(node.defnames) != 2 336 | or node.defnames[0].sval not in self.state.created_schemas 337 | ): 338 | self.state.error("PS007", format_aggregate(node)) 339 | 340 | if not node.replace: 341 | self.state.created_aggregates.append(format_aggregate(node)) 342 | 343 | case _: 344 | if (hasattr(node, "replace") and node.replace) or ( 345 | hasattr(node, "if_not_exists") and node.if_not_exists 346 | ): 347 | if ( 348 | len(node.defnames) != 2 349 | or node.defnames[0].sval not in self.state.created_schemas 350 | ): 351 | self.state.error("PS007", format_name(node.defnames)) 352 | 353 | def visit_VariableSetStmt(self, ancestors, node): 354 | # only search_path relevant 355 | if node.name == "search_path": 356 | if node.kind == VariableSetKind.VAR_SET_VALUE: 357 | self.state.set_searchpath(node, node.is_local) 358 | 359 | # check misquoted search_path 360 | # When we detect a , in a schema name we assume this is due to misquoting 361 | if len(node.args) == 1 and "," in node.args[0].val.sval: 362 | self.state.error("PS018", "") 363 | 364 | if node.kind == VariableSetKind.VAR_RESET: 365 | self.state.reset_searchpath() 366 | 367 | def visit_CaseExpr(self, ancestors, node): 368 | if node.arg and not self.state.searchpath_secure: 369 | self.state.error("PS009", raw_sql(node)) 370 | 371 | def visit_CreateSchemaStmt(self, ancestors, node): 372 | if node.if_not_exists and node.schemaname not in self.state.created_schemas: 373 | self.state.error("PS010", node.schemaname) 374 | self.state.created_schemas.append(node.schemaname) 375 | 376 | def visit_CreateSeqStmt(self, ancestors, node): 377 | if ( 378 | node.if_not_exists 379 | and node.sequence.schemaname not in self.state.created_schemas 380 | ): 381 | self.state.error("PS011", raw_sql(node.sequence)) 382 | 383 | def visit_CreateStmt(self, ancestors, node): 384 | # We consider table creation safe even with IF NOT EXISTS if it happens in a 385 | # schema created in this context 386 | if ( 387 | "schemaname" in node.relation 388 | and node.relation.schemaname in self.state.created_schemas 389 | ): 390 | pass 391 | elif node.if_not_exists: 392 | self.state.error("PS012", format_name(node.relation)) 393 | 394 | def visit_CreateTableAsStmt(self, ancestors, node): 395 | if ( 396 | node.if_not_exists 397 | and node.into.rel.schemaname not in self.state.created_schemas 398 | ): 399 | self.state.error("PS007", format_name(node.into.rel)) 400 | 401 | def visit_CreateForeignServerStmt(self, ancestors, node): 402 | if node.if_not_exists: 403 | self.state.error("PS013", node.servername) 404 | 405 | def visit_IndexStmt(self, ancestors, node): 406 | if ( 407 | node.if_not_exists 408 | and node.relation.schemaname not in self.state.created_schemas 409 | ): 410 | self.state.error("PS014", format_name(node.idxname)) 411 | 412 | def visit_TypeCast(self, ancestors, node): 413 | if len(node.typeName.names) == 1 and not self.state.searchpath_secure: 414 | self.state.error( 415 | "PS017", f"{format_name(node.typeName.names)} in {RawStream()(node)}" 416 | ) 417 | 418 | def visit_ViewStmt(self, ancestors, node): 419 | if ( 420 | "schemaname" in node.view 421 | and node.view.schemaname in self.state.created_schemas 422 | ): 423 | pass 424 | elif node.replace: 425 | self.state.error("PS015", format_name(node.view)) 426 | 427 | def visit_DoStmt(self, ancestors, node): 428 | language = [l.arg.sval for l in node.args if l.defname == "language"] 429 | 430 | if language: 431 | language = language[0] 432 | else: 433 | language = "plpgsql" 434 | 435 | match (language): 436 | case "plpgsql": 437 | visit_plpgsql(self.state, node) 438 | case _: 439 | self.state.unknown(f"Unknown language: {language}") 440 | 441 | def visit_FuncCall(self, ancestors, node): 442 | if len(node.funcname) != 2 and not self.state.searchpath_secure: 443 | self.state.warn("PS016", format_name(node.funcname)) 444 | # Possibly evaluate argument to "sql-accepting" function 445 | function_name = format_name(node.funcname[-1]) 446 | function_args = self.state.counter.args.sql_fn 447 | if function_name in function_args: 448 | for arg in node.args: 449 | # we can only evaluate constant expressions 450 | if isinstance(arg, ast.A_Const): 451 | sql = arg.val.sval 452 | try: 453 | # let's try and treat this as SQL. Might not work. 454 | visit_sql(self.state, sql) 455 | except ParseError: 456 | pass 457 | 458 | # we want to treat pg_catalog.set_config('search_path',...) similar to SET search_path 459 | if ( 460 | format_name(node.funcname) == "pg_catalog.set_config" 461 | and len(node.args) == 3 462 | ): 463 | if get_text(node.args[0]) == "search_path": 464 | schemas = [s.strip() for s in get_text(node.args[1]).split(",")] 465 | local = get_text(node.args[2]) in ["t", "true"] 466 | self.state.set_searchpath(schemas, local) 467 | 468 | def visit_RangeVar(self, ancestors, node): 469 | # a rangevar can reference CTEs which were previously defined 470 | cte_names = self.extract_cte_names(ancestors) 471 | if ( 472 | not node.schemaname 473 | and node.relname not in cte_names 474 | and not self.state.searchpath_secure 475 | ): 476 | self.state.warn("PS017", node.relname) 477 | 478 | def extract_cte_names(self, ancestor): 479 | # Iterate through parents, obtaining the names of CTEs which were directly defined 480 | cte_names = set() 481 | while ancestor.parent.node is not None: 482 | node = ancestor.node 483 | if hasattr(node, "withClause") and node.withClause is not None: 484 | for cte in node.withClause.ctes: 485 | cte_names.add(cte.ctename) 486 | ancestor = ancestor.parent 487 | return cte_names 488 | 489 | # SET LOCAL is only effective until end of transaction so we have to reset 490 | # searchpath_secure when we encounter transaction statement 491 | def visit_TransactionStmt(self, ancestors, node): 492 | # we ignore BEGIN here since you have to be in transaction to use SET LOCAL 493 | # so BEGIN would be noop 494 | if node.kind == TransactionStmtKind.TRANS_STMT_BEGIN: 495 | return 496 | 497 | if self.state.searchpath_local: 498 | self.state.searchpath_secure = False 499 | self.state.searchpath_local = False 500 | -------------------------------------------------------------------------------- /testdata/aggregate_tracking.sql: -------------------------------------------------------------------------------- 1 | 2 | -- should warn twice 3 | CREATE OR REPLACE AGGREGATE s1.agg3(int) (sfunc=abc); 4 | CREATE OR REPLACE AGGREGATE s1.agg3(int) (sfunc=abc); 5 | 6 | CREATE AGGREGATE s1.agg6(int) (sfunc=abc); 7 | -- should not warn since it was previously created in this script 8 | CREATE OR REPLACE AGGREGATE s1.agg6(int) (sfunc=abc); 9 | -- different schema should warn 10 | CREATE OR REPLACE AGGREGATE s2.agg6(int) (sfunc=abc); 11 | -- should warn because no schema 12 | CREATE OR REPLACE AGGREGATE agg6(int) (sfunc=abc); 13 | 14 | -- different signature should warn 15 | CREATE OR REPLACE AGGREGATE s1.agg6(int,int) (sfunc=abc); 16 | 17 | -- old style 18 | CREATE AGGREGATE s3.agg14 (BASETYPE=int,SFUNC=int4and,STYPE=int); 19 | -- should not warn since it was previously created in this script 20 | CREATE OR REPLACE AGGREGATE s3.agg14 (BASETYPE=int,SFUNC=int4and,STYPE=int); 21 | -- should warn because of different basetype 22 | CREATE OR REPLACE AGGREGATE s3.agg14 (BASETYPE=int8,SFUNC=int4and,STYPE=int); 23 | 24 | -- new style with different signature should warn 25 | CREATE OR REPLACE AGGREGATE s3.agg14(int8) (SFUNC=int4and,STYPE=int); 26 | 27 | -------------------------------------------------------------------------------- /testdata/cast.sql: -------------------------------------------------------------------------------- 1 | -- safe: int is automatically interpreted as pg_catalog.int 2 | SELECT 1::int; 3 | 4 | -- safe: int4 is the built-in pg_catalog.int4 5 | SELECT 1::pg_catalog.int4; 6 | 7 | -- unsafe: the cast is to a custom type, and the type is unqualified 8 | SELECT 1::custom_type; 9 | 10 | -- safe: the cast is to a schema-qualified custom type 11 | SELECT 1::custom_schema.type; 12 | 13 | -- unsafe: the destination type is not fully-qualified 14 | SELECT '1'::int4; 15 | 16 | -- unsafe: the destination type is not fully-qualified 17 | SELECT int4 '1'; 18 | 19 | -- unsafe: the destination type is not fully-qualified 20 | SELECT CAST('1' AS int4); 21 | -------------------------------------------------------------------------------- /testdata/created_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA test_schema; 2 | 3 | CREATE COLLATION IF NOT EXISTS test_schema.collation3 (locale = 'fr_FR.utf8'); 4 | CREATE FOREIGN TABLE IF NOT EXISTS test_schema.unsafe_table4(c4444 text) SERVER server4; 5 | CREATE MATERIALIZED VIEW IF NOT EXISTS test_schema.matview6 AS SELECT pg_catalog.now(); 6 | CREATE SEQUENCE IF NOT EXISTS test_schema.sequence8; 7 | CREATE TABLE IF NOT EXISTS test_schema.table10(c10 text); 8 | CREATE TABLE IF NOT EXISTS test_schema.table11 AS SELECT pg_catalog.now(); 9 | CREATE INDEX IF NOT EXISTS index5 ON test_schema.table5(column5); 10 | CREATE SCHEMA IF NOT EXISTS test_schema; 11 | -------------------------------------------------------------------------------- /testdata/createfunc.sql: -------------------------------------------------------------------------------- 1 | 2 | -- should warn about unsafe function and format 3 | CREATE OR REPLACE FUNCTION unsafe3() RETURNS TEXT LANGUAGE SQL AS $$ SELECT unsafe_call3('%I','foo'); $$; 4 | -- should warn about call 5 | CREATE FUNCTION safe5() RETURNS TEXT LANGUAGE SQL AS $$ SELECT unsafe_call5('%I','foo'); $$; 6 | 7 | -- safe 8 | CREATE FUNCTION f8() RETURNS TEXT LANGUAGE SQL AS $$ SELECT unsafe_call8('%I','foo'); $$; 9 | 10 | -- should not warn about since f2 was created in this script so replace is safe 11 | CREATE OR REPLACE FUNCTION f8() RETURNS TEXT LANGUAGE SQL AS $$ SELECT pg_catalog.safe_call11('%I','foo'); $$; 12 | 13 | -- not safe since signature is different 14 | CREATE OR REPLACE FUNCTION f8(int) RETURNS TEXT LANGUAGE SQL AS $$ SELECT pg_catalog.safe_call14('%I','foo'); $$; 15 | 16 | CREATE FUNCTION safe16() RETURNS TEXT LANGUAGE plpgsql AS $$ 17 | BEGIN 18 | RETURN unsafe_call18('%I','foo'); 19 | END; $$; 20 | 21 | -- safe creation 22 | CREATE FUNCTION f22(a int = 1) RETURNS TEXT LANGUAGE SQL AS $$ SELECT 'foo'; $$ SET search_path TO pg_catalog,pg_temp; 23 | 24 | -- safe as well since it was previously created with same signature but different default 25 | CREATE OR REPLACE FUNCTION f22(a int = 2) RETURNS TEXT LANGUAGE SQL AS $$ SELECT 'foo'; $$ SET search_path TO pg_catalog,pg_temp; 26 | 27 | -- not safe since it's new signature 28 | CREATE OR REPLACE FUNCTION f22(b int) RETURNS TEXT LANGUAGE SQL AS $$ SELECT 'foo'; $$ SET search_path TO pg_catalog,pg_temp; 29 | -------------------------------------------------------------------------------- /testdata/do.sql: -------------------------------------------------------------------------------- 1 | 2 | DO LANGUAGE PLPGSQL $$ BEGIN SELECT unsafe_call2(); END; $$; 3 | DO LANGUAGE PLPGSQL $$ BEGIN PERFORM unsafe_call3(); END; $$; 4 | 5 | -- nested DO 6 | DO LANGUAGE PLPGSQL $$ 7 | BEGIN 8 | PERFORM unsafe_call8(); 9 | DO $i$ 10 | BEGIN 11 | PERFORM unsafe_call10(); 12 | END; 13 | $i$; 14 | SET search_path TO pg_catalog,pg_temp; 15 | DO $i$ 16 | BEGIN 17 | PERFORM safe_call17(); 18 | RESET search_path; 19 | PERFORM unsafe_call19(); 20 | END; 21 | $i$; 22 | END; 23 | $$; 24 | 25 | -- unknown language 26 | DO LANGUAGE PLLUA $$ 27 | print("Hello World") 28 | $$; 29 | 30 | -------------------------------------------------------------------------------- /testdata/dyn_foreach.sql: -------------------------------------------------------------------------------- 1 | create function public.fn() returns void 2 | as $func$ 3 | declare 4 | _rec record; 5 | begin 6 | for _rec in execute format 7 | ( $sql$ 8 | select generate_series(%L, %L) as x 9 | $sql$ 10 | , _catalog_id 11 | ) 12 | loop 13 | raise notice '%', _rec.x; 14 | PERFORM format('%s', _rec.x); 15 | end loop; 16 | end 17 | $func$ language plpgsql volatile security invoker 18 | ; 19 | -------------------------------------------------------------------------------- /testdata/exists.sql: -------------------------------------------------------------------------------- 1 | -- test if not exists 2 | 3 | CREATE COLLATION IF NOT EXISTS collation3 (locale = 'fr_FR.utf8'); 4 | CREATE FOREIGN TABLE IF NOT EXISTS unsafe_table4(c4444 text) SERVER server4; 5 | CREATE INDEX IF NOT EXISTS index5 ON table5(column5); 6 | CREATE MATERIALIZED VIEW IF NOT EXISTS matview6 AS SELECT pg_catalog.now(); 7 | CREATE SCHEMA IF NOT EXISTS schema7; 8 | CREATE SEQUENCE IF NOT EXISTS sequence8; 9 | CREATE SERVER IF NOT EXISTS server9 FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host 'foo', dbname 'foodb', port '5432'); 10 | CREATE TABLE IF NOT EXISTS table10(c10 text); 11 | CREATE TABLE IF NOT EXISTS table11 AS SELECT pg_catalog.now(); 12 | 13 | -------------------------------------------------------------------------------- /testdata/expected/aggregate_tracking.out: -------------------------------------------------------------------------------- 1 | PS007: Unsafe object creation: s1.agg3(integer) at line 2 2 | PS007: Unsafe object creation: s1.agg3(integer) at line 4 3 | PS007: Unsafe object creation: s2.agg6(integer) at line 9 4 | PS017: Unqualified object reference: agg6 at line 11 5 | PS007: Unsafe object creation: agg6(integer) at line 11 6 | PS007: Unsafe object creation: s1.agg6(integer,integer) at line 13 7 | PS007: Unsafe object creation: s3.agg14(int8) at line 21 8 | PS007: Unsafe object creation: s3.agg14(int8) at line 23 9 | 10 | Errors: 7 Warnings: 1 Unknown: 0 11 | 12 | -------------------------------------------------------------------------------- /testdata/expected/cast.out: -------------------------------------------------------------------------------- 1 | PS017: Unqualified object reference: custom_type in CAST(1 AS custom_type) at line 6 2 | PS017: Unqualified object reference: int4 in CAST('1' AS int4) at line 12 3 | PS017: Unqualified object reference: int4 in CAST('1' AS int4) at line 15 4 | PS017: Unqualified object reference: int4 in CAST('1' AS int4) at line 18 5 | 6 | Errors: 4 Warnings: 0 Unknown: 0 7 | 8 | -------------------------------------------------------------------------------- /testdata/expected/created_schema.out: -------------------------------------------------------------------------------- 1 | 2 | Errors: 0 Warnings: 0 Unknown: 0 3 | 4 | -------------------------------------------------------------------------------- /testdata/expected/createfunc.out: -------------------------------------------------------------------------------- 1 | PS002: Unsafe function creation: unsafe3() at line 2 2 | PS005: Function without explicit search_path: unsafe3() at line 2 3 | PS016: Unqualified function call: unsafe_call3 at line 2 4 | PS005: Function without explicit search_path: safe5() at line 4 5 | PS016: Unqualified function call: unsafe_call5 at line 4 6 | PS005: Function without explicit search_path: f8() at line 6 7 | PS016: Unqualified function call: unsafe_call8 at line 6 8 | PS005: Function without explicit search_path: f8() at line 9 9 | PS002: Unsafe function creation: f8(integer) at line 12 10 | PS005: Function without explicit search_path: f8(integer) at line 12 11 | PS005: Function without explicit search_path: safe16() at line 15 12 | PS016: Unqualified function call: unsafe_call18 at line 15 13 | PS002: Unsafe function creation: f22(b integer) at line 26 14 | 15 | Errors: 0 Warnings: 13 Unknown: 0 16 | 17 | -------------------------------------------------------------------------------- /testdata/expected/do.out: -------------------------------------------------------------------------------- 1 | PS016: Unqualified function call: unsafe_call2 at line 2 2 | PS016: Unqualified function call: unsafe_call3 at line 3 3 | PS016: Unqualified function call: unsafe_call8 at line 4 4 | PS016: Unqualified function call: unsafe_call10 at line 4 5 | PS016: Unqualified function call: unsafe_call19 at line 4 6 | Unknown language: pllua 7 | 8 | Errors: 0 Warnings: 5 Unknown: 1 9 | 10 | -------------------------------------------------------------------------------- /testdata/expected/dyn_foreach.out: -------------------------------------------------------------------------------- 1 | PS005: Function without explicit search_path: public.fn() at line 1 2 | PS016: Unqualified function call: format at line 1 3 | PS016: Unqualified function call: format at line 1 4 | 5 | Errors: 0 Warnings: 3 Unknown: 0 6 | 7 | -------------------------------------------------------------------------------- /testdata/expected/exists.out: -------------------------------------------------------------------------------- 1 | PS017: Unqualified object reference: collation3 at line 1 2 | PS007: Unsafe object creation: collation3 at line 1 3 | PS012: Unsafe table creation: unsafe_table4 at line 4 4 | PS017: Unqualified object reference: unsafe_table4 at line 4 5 | PS014: Unsafe index creation: index5 at line 5 6 | PS017: Unqualified object reference: table5 at line 5 7 | PS007: Unsafe object creation: matview6 at line 6 8 | PS017: Unqualified object reference: matview6 at line 6 9 | PS010: Unsafe schema creation: schema7 at line 7 10 | PS011: Unsafe sequence creation: sequence8 at line 8 11 | PS017: Unqualified object reference: sequence8 at line 8 12 | PS013: Unsafe foreign server creation: server9 at line 9 13 | PS012: Unsafe table creation: table10 at line 10 14 | PS017: Unqualified object reference: table10 at line 10 15 | PS007: Unsafe object creation: table11 at line 11 16 | PS017: Unqualified object reference: table11 at line 11 17 | 18 | Errors: 9 Warnings: 7 Unknown: 0 19 | 20 | -------------------------------------------------------------------------------- /testdata/expected/foreach_array.out: -------------------------------------------------------------------------------- 1 | PS016: Unqualified function call: format at line 1 2 | 3 | Errors: 0 Warnings: 1 Unknown: 0 4 | 5 | -------------------------------------------------------------------------------- /testdata/expected/loop.out: -------------------------------------------------------------------------------- 1 | PS002: Unsafe function creation: l() at line 1 2 | PS005: Function without explicit search_path: l() at line 1 3 | PS016: Unqualified function call: f1 at line 1 4 | PS016: Unqualified function call: f2 at line 1 5 | 6 | Errors: 0 Warnings: 4 Unknown: 0 7 | 8 | -------------------------------------------------------------------------------- /testdata/expected/nested_searchpath.out: -------------------------------------------------------------------------------- 1 | PS016: Unqualified function call: unsafe_call2 at line 2 2 | PS005: Function without explicit search_path: f10_no_path() at line 10 3 | PS016: Unqualified function call: unsafe_call10 at line 10 4 | PS016: Unqualified function call: unsafe_call_in_do14 at line 13 5 | PS005: Function without explicit search_path: f18_no_path() at line 18 6 | PS016: Unqualified function call: unsafe_call18 at line 18 7 | PS005: Function without explicit search_path: f19_path() at line 20 8 | PS016: Unqualified function call: unsafe_call19 at line 20 9 | PS005: Function without explicit search_path: f25_nested_no_path() at line 23 10 | PS016: Unqualified function call: unsafe_call25 at line 23 11 | 12 | Errors: 0 Warnings: 10 Unknown: 0 13 | 14 | -------------------------------------------------------------------------------- /testdata/expected/operator.out: -------------------------------------------------------------------------------- 1 | PS017: Unqualified object reference: #? at line 2 2 | 3 | Errors: 0 Warnings: 1 Unknown: 0 4 | 5 | -------------------------------------------------------------------------------- /testdata/expected/plpgsql_function.out: -------------------------------------------------------------------------------- 1 | PS005: Function without explicit search_path: plpgsqlfunc3() at line 2 2 | PS016: Unqualified function call: unsafe_call3 at line 2 3 | PS005: Function without explicit search_path: plpgsqlfunc4() at line 4 4 | PS016: Unqualified function call: unsafe_call4 at line 4 5 | PS005: Function without explicit search_path: plpgsqlfunc5() at line 5 6 | PS016: Unqualified function call: unsafe_call5 at line 5 7 | PS005: Function without explicit search_path: plpgsqlfunc13() at line 13 8 | PS016: Unqualified function call: unsafe_call13 at line 13 9 | PS005: Function without explicit search_path: plpgsqlfunc16() at line 14 10 | PS016: Unqualified function call: unsafe_call17 at line 14 11 | PS016: Unqualified function call: unsafe_call21 at line 14 12 | 13 | Errors: 0 Warnings: 11 Unknown: 0 14 | 15 | -------------------------------------------------------------------------------- /testdata/expected/ps009-simplified-case.out: -------------------------------------------------------------------------------- 1 | PS009: Unsafe CASE expression: CASE a OPERATOR(pg_catalog.=) b WHEN TRUE THEN 'true' WHEN FALSE THEN 'false' END at line 1 2 | 3 | Errors: 1 Warnings: 0 Unknown: 0 4 | 5 | -------------------------------------------------------------------------------- /testdata/expected/range_function.out: -------------------------------------------------------------------------------- 1 | PS016: Unqualified function call: unsafe_call1 at line 1 2 | PS016: Unqualified function call: unsafe_call2 at line 2 3 | 4 | Errors: 0 Warnings: 2 Unknown: 0 5 | 6 | -------------------------------------------------------------------------------- /testdata/expected/replace.out: -------------------------------------------------------------------------------- 1 | PS017: Unqualified object reference: unsafe_agg3 at line 1 2 | PS007: Unsafe object creation: unsafe_agg3() at line 1 3 | PS002: Unsafe function creation: unsafe_func4() at line 4 4 | PS005: Function without explicit search_path: unsafe_func4() at line 4 5 | PS002: Unsafe function creation: unsafe_proc5() at line 5 6 | PS005: Function without explicit search_path: unsafe_proc5() at line 5 7 | PS006: Unsafe transform creation: type6 at line 6 8 | PS015: Unsafe view creation: view9 at line 7 9 | PS017: Unqualified object reference: view9 at line 7 10 | 11 | Errors: 2 Warnings: 7 Unknown: 0 12 | 13 | -------------------------------------------------------------------------------- /testdata/expected/return_query.out: -------------------------------------------------------------------------------- 1 | PS005: Function without explicit search_path: ret_query(y integer) at line 2 2 | PS016: Unqualified function call: format at line 2 3 | PS016: Unqualified function call: format at line 2 4 | 5 | Errors: 0 Warnings: 3 Unknown: 0 6 | 7 | -------------------------------------------------------------------------------- /testdata/expected/search_path.out: -------------------------------------------------------------------------------- 1 | PS016: Unqualified function call: unsafe_call3 at line 2 2 | PS016: Unqualified function call: unsafe_call15 at line 13 3 | PS016: Unqualified function call: unsafe_call21 at line 21 4 | PS016: Unqualified function call: unsafe_call26 at line 26 5 | PS018: Unsafe SET search_path: at line 27 6 | PS018: Unsafe SET search_path: at line 30 7 | PS005: Function without explicit search_path: f() at line 31 8 | PS018: Unsafe SET search_path: at line 31 9 | PS005: Function without explicit search_path: f() at line 33 10 | PS018: Unsafe SET search_path: at line 33 11 | 12 | Errors: 4 Warnings: 6 Unknown: 0 13 | 14 | -------------------------------------------------------------------------------- /testdata/expected/security_definer.out: -------------------------------------------------------------------------------- 1 | PS003: SECURITY DEFINER function without explicit search_path: unsafe_sec_definer2() at line 1 2 | PS016: Unqualified function call: now at line 1 3 | PS005: Function without explicit search_path: safe_sec_invoker4() at line 3 4 | PS003: SECURITY DEFINER function without explicit search_path: unsafe_sec_definer6() at line 5 5 | PS005: Function without explicit search_path: safe_sec_definer8() at line 7 6 | PS003: SECURITY DEFINER function without explicit search_path: unsafe_sec_definer10() at line 9 7 | PS003: SECURITY DEFINER function without explicit search_path: c_sec_definer12() at line 11 8 | 9 | Errors: 4 Warnings: 3 Unknown: 0 10 | 11 | -------------------------------------------------------------------------------- /testdata/expected/set_local.out: -------------------------------------------------------------------------------- 1 | PS016: Unqualified function call: unsafe_call21 at line 19 2 | PS016: Unqualified function call: unsafe_call30 at line 28 3 | 4 | Errors: 0 Warnings: 2 Unknown: 0 5 | 6 | -------------------------------------------------------------------------------- /testdata/expected/sql_function.out: -------------------------------------------------------------------------------- 1 | PS005: Function without explicit search_path: sqlfunc() at line 2 2 | PS016: Unqualified function call: unsafe_call3 at line 2 3 | PS005: Function without explicit search_path: sqlfunc() at line 10 4 | PS016: Unqualified function call: unsafe_call10 at line 10 5 | PS005: Function without explicit search_path: sqlfunc() at line 11 6 | PS016: Unqualified function call: unsafe_call13 at line 11 7 | PS016: Unqualified function call: unsafe_call17 at line 11 8 | 9 | Errors: 0 Warnings: 7 Unknown: 0 10 | 11 | -------------------------------------------------------------------------------- /testdata/expected/unqualified_cte.out: -------------------------------------------------------------------------------- 1 | PS017: Unqualified object reference: foo at line 1 2 | 3 | Errors: 0 Warnings: 1 Unknown: 0 4 | 5 | -------------------------------------------------------------------------------- /testdata/expected/while.out: -------------------------------------------------------------------------------- 1 | PS002: Unsafe function creation: w1() at line 1 2 | PS005: Function without explicit search_path: w1() at line 1 3 | PS017: Unqualified object reference: bool in CAST(format1('%s', 'false') AS bool) at line 1 4 | PS016: Unqualified function call: format1 at line 1 5 | PS001: Unqualified operator: '<' in 0 < _count at line 1 6 | PS001: Unqualified operator: '-' in _count - 1 at line 1 7 | PS016: Unqualified function call: format2 at line 1 8 | 9 | Errors: 1 Warnings: 6 Unknown: 0 10 | 11 | -------------------------------------------------------------------------------- /testdata/foreach_array.sql: -------------------------------------------------------------------------------- 1 | DO LANGUAGE plpgsql $$ 2 | DECLARE 3 | desired_roles text[] := '{pg_checkpoint,pg_signal_backend,pg_read_all_stats,pg_stat_scan_tables}'; 4 | desired_role text; 5 | BEGIN 6 | FOREACH desired_role IN ARRAY desired_roles LOOP 7 | IF pg_catalog.to_regrole(desired_role) IS NOT NULL THEN 8 | EXECUTE format('GRANT %I TO tsdbadmin', desired_role); 9 | END IF; 10 | END LOOP; 11 | END; 12 | $$; 13 | -------------------------------------------------------------------------------- /testdata/loop.sql: -------------------------------------------------------------------------------- 1 | create or replace function l() returns void as $func$ 2 | declare 3 | begin 4 | loop 5 | end loop; 6 | loop 7 | PERFORM f1('select 1'); 8 | exit when f2(); 9 | end loop; 10 | end 11 | $func$ language plpgsql; 12 | -------------------------------------------------------------------------------- /testdata/nested_searchpath.sql: -------------------------------------------------------------------------------- 1 | 2 | SELECT unsafe_call2(); 3 | 4 | SET search_path TO pg_catalog, pg_temp; 5 | 6 | SELECT safe_call6(); 7 | 8 | -- safe because search_path is locked down 9 | DO $$ BEGIN SELECT safe_call_in_do9(); END; $$; 10 | CREATE FUNCTION f10_no_path() RETURNS TEXT LANGUAGE SQL AS $$ SELECT unsafe_call10(); $$; 11 | 12 | RESET search_path; 13 | 14 | -- unsafe because search_path is not locked down 15 | DO $$ BEGIN SELECT unsafe_call_in_do14(); END; $$; 16 | 17 | SET search_path TO pg_catalog, pg_temp; 18 | 19 | CREATE FUNCTION f18_no_path() RETURNS TEXT LANGUAGE SQL AS $$ SELECT unsafe_call18(); $$; 20 | CREATE FUNCTION f19_path() RETURNS TEXT LANGUAGE SQL SET search_path TO public AS $$ SELECT unsafe_call19(); $$; 21 | 22 | CREATE FUNCTION f21_path() RETURNS TEXT LANGUAGE SQL SET search_path TO pg_catalog, pg_temp AS $$ SELECT safe_call21(); $$; 23 | 24 | CREATE FUNCTION f23_path() RETURNS TEXT LANGUAGE SQL SET search_path TO pg_catalog, pg_temp AS $$ 25 | -- search_path is not inherited in inner function body 26 | CREATE FUNCTION f25_nested_no_path() RETURNS TEXT LANGUAGE SQL AS $f1$ SELECT unsafe_call25(); $f1$; 27 | $$; 28 | -------------------------------------------------------------------------------- /testdata/operator.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE OPERATOR #? (LEFTARG=bool,RIGHTARG=bool,FUNCTION=boolge); 3 | 4 | -------------------------------------------------------------------------------- /testdata/plpgsql_function.sql: -------------------------------------------------------------------------------- 1 | 2 | -- unsafe call inside function 3 | CREATE FUNCTION plpgsqlfunc3() RETURNS TEXT LANGUAGE PLPGSQL AS $$ BEGIN SELECT unsafe_call3(); END; $$; 4 | CREATE FUNCTION plpgsqlfunc4() RETURNS TEXT LANGUAGE PLPGSQL AS $$ BEGIN PERFORM unsafe_call4(); END; $$; 5 | CREATE FUNCTION plpgsqlfunc5() RETURNS TEXT LANGUAGE PLPGSQL AS $$ BEGIN RETURN unsafe_call5(); END; $$; 6 | 7 | -- safe call inside function 8 | CREATE FUNCTION plpgsqlfunc7() RETURNS TEXT LANGUAGE PLPGSQL AS $$ BEGIN SELECT safe_call7(); END; $$ SET search_path TO 'pg_catalog', 'pg_temp'; 9 | CREATE FUNCTION plpgsqlfunc8() RETURNS TEXT LANGUAGE PLPGSQL AS $$ BEGIN PERFORM safe_call8(); END; $$ SET search_path TO 'pg_catalog', 'pg_temp'; 10 | 11 | -- unsafe call inside function, since search_path for body is not safe 12 | SET search_path TO pg_catalog, pg_temp; 13 | CREATE FUNCTION plpgsqlfunc13() RETURNS TEXT LANGUAGE PLPGSQL AS $$ BEGIN SELECT unsafe_call13(); END; $$; 14 | 15 | CREATE FUNCTION plpgsqlfunc16() RETURNS TEXT LANGUAGE PLPGSQL AS $$ 16 | BEGIN 17 | ASSERT true; 18 | SELECT unsafe_call17(); 19 | SET search_path TO pg_catalog, pg_temp; 20 | SELECT safe_call19(); 21 | RESET search_path; 22 | SELECT unsafe_call21(); 23 | SELECT foo.safe_call22(); 24 | END; 25 | $$; 26 | -------------------------------------------------------------------------------- /testdata/ps009-simplified-case.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | CASE a OPERATOR(pg_catalog.=) b 3 | WHEN true THEN 'true' 4 | WHEN false THEN 'false' 5 | END 6 | FROM my_schema.foo; 7 | -------------------------------------------------------------------------------- /testdata/range_function.sql: -------------------------------------------------------------------------------- 1 | SELECT * FROM pg_catalog.safe_call1(unsafe_call1()); 2 | SELECT * FROM ROWS FROM (pg_catalog.safe_call2(), unsafe_call2()); 3 | -------------------------------------------------------------------------------- /testdata/replace.sql: -------------------------------------------------------------------------------- 1 | -- test OR REPLACE for various create statements 2 | 3 | CREATE OR REPLACE AGGREGATE unsafe_agg3(SFUNC=agg_sfunc,STYPE=internal); 4 | CREATE OR REPLACE FUNCTION unsafe_func4() RETURNS TEXT LANGUAGE SQL AS $$ SELECT '123'; $$; 5 | CREATE OR REPLACE PROCEDURE unsafe_proc5() LANGUAGE SQL AS $$ SELECT '123'; $$; 6 | CREATE OR REPLACE TRANSFORM FOR type6 LANGUAGE lang6 (FROM SQL WITH FUNCTION hstore_to_plpython(internal), TO SQL WITH FUNCTION plpython_to_hstore(internal)); 7 | -- this is pg14 only which is not yet supported by sql parser 8 | -- CREATE OR REPLACE TRIGGER trigger8 BEFORE INSERT ON table8 FOR EACH ROW EXECUTE PROCEDURE proc8(); 9 | CREATE OR REPLACE VIEW view9 AS SELECT pg_catalog.now(); 10 | 11 | -------------------------------------------------------------------------------- /testdata/return_query.sql: -------------------------------------------------------------------------------- 1 | 2 | create function ret_query() returns table(y int) 3 | as $func$ 4 | declare 5 | a text; 6 | b text; 7 | begin 8 | return query execute $$select format('abc',1,2)::int$$; 9 | return query execute $$select format('abc',$1,2)::int$$ using a; 10 | return query execute b; 11 | return query execute b || b; 12 | end; 13 | $func$ language plpgsql; 14 | 15 | -------------------------------------------------------------------------------- /testdata/search_path.sql: -------------------------------------------------------------------------------- 1 | 2 | -- should warn since its unqualified function call 3 | SELECT unsafe_call3('%s','abc'); 4 | SELECT pg_catalog.safe_call4(); 5 | 6 | SET search_path TO pg_catalog, pg_temp; 7 | 8 | -- safe since we have safe search_path now 9 | SELECT safe_call9('%s','abc'); 10 | SELECT pg_catalog.safe_call10(); 11 | 12 | RESET search_path; 13 | 14 | -- should warn since search_path got reset 15 | SELECT unsafe_call15('%s','abc'); 16 | SELECT pg_catalog.safe_call16(); 17 | 18 | CREATE SCHEMA new; 19 | 20 | SELECT new.safe_call20('%s','abc'); 21 | SELECT unsafe_call21('%s','abc'); 22 | 23 | SELECT pg_catalog.set_config('search_path','pg_catalog,pg_temp',true); 24 | SELECT safe_call24('%s','abc'); 25 | SELECT pg_catalog.set_config('search_path','public',true); 26 | SELECT unsafe_call26('%s','abc'); 27 | 28 | -- check for search_path setting with wrong quoting 29 | SET search_path TO 'pg_catalog, pg_temp'; 30 | SET search_path = 'pg_catalog, pg_temp'; 31 | 32 | CREATE FUNCTION f() RETURNS VOID AS '' LANGUAGE SQL SET search_path TO 'pg_catalog, pg_temp'; 33 | CREATE FUNCTION f() RETURNS VOID AS '' LANGUAGE SQL SET search_path = 'pg_catalog, pg_temp'; 34 | 35 | -------------------------------------------------------------------------------- /testdata/security_definer.sql: -------------------------------------------------------------------------------- 1 | -- unsafe sec definer with no search_path 2 | CREATE FUNCTION unsafe_sec_definer2() RETURNS TEXT LANGUAGE SQL SECURITY DEFINER AS $$ SELECT now(); $$; 3 | -- same function but with security invoker should only give warning 4 | CREATE FUNCTION safe_sec_invoker4() RETURNS TEXT LANGUAGE SQL SECURITY INVOKER AS $$ SELECT pg_catalog.now(); $$; 5 | -- unsafe plpgsql sec definer with no search_path 6 | CREATE FUNCTION unsafe_sec_definer6() RETURNS TEXT LANGUAGE PLPGSQL SECURITY DEFINER AS $$ BEGIN RETURN pg_catalog.now(); END; $$; 7 | -- same function but with security invoker should only give warning 8 | CREATE FUNCTION safe_sec_definer8() RETURNS TEXT LANGUAGE PLPGSQL SECURITY INVOKER AS $$ BEGIN RETURN pg_catalog.now(); END; $$; 9 | -- unsafe plpgsql sec definer procedure with no search_path 10 | CREATE PROCEDURE unsafe_sec_definer10() LANGUAGE PLPGSQL SECURITY DEFINER AS $$ BEGIN PERFORM pg_catalog.now(); END; $$; 11 | -- security definer function in C without search_path 12 | CREATE FUNCTION c_sec_definer12() RETURNS TEXT LANGUAGE C SECURITY DEFINER AS 'c_sec_definer12'; 13 | -------------------------------------------------------------------------------- /testdata/set_local.sql: -------------------------------------------------------------------------------- 1 | 2 | SET search_path TO pg_catalog, pg_temp; 3 | 4 | -- safe since search_path is locked down 5 | SELECT safe_call5(); 6 | 7 | COMMIT; 8 | 9 | -- safe since search_path is still locked down 10 | SELECT safe_call10(); 11 | 12 | RESET search_path; 13 | 14 | BEGIN; 15 | SET LOCAL search_path TO pg_catalog, pg_temp; 16 | -- safe since search_path is still locked down 17 | SELECT safe_call17(); 18 | COMMIT; 19 | 20 | -- unsafe since rollback ended SET LOCAL 21 | SELECT unsafe_call21(); 22 | 23 | BEGIN; 24 | SET LOCAL search_path TO pg_catalog, pg_temp; 25 | -- safe since search_path is still locked down 26 | SELECT safe_call26(); 27 | ROLLBACK; 28 | 29 | -- unsafe since rollback ended SET LOCAL 30 | SELECT unsafe_call30(); 31 | 32 | -------------------------------------------------------------------------------- /testdata/sql_function.sql: -------------------------------------------------------------------------------- 1 | 2 | -- unsafe call inside function 3 | CREATE FUNCTION sqlfunc() RETURNS TEXT LANGUAGE SQL AS $$ SELECT unsafe_call3(); $$; 4 | 5 | -- safe call inside function 6 | CREATE FUNCTION sqlfunc() RETURNS TEXT LANGUAGE SQL AS $$ SELECT safe_call6(); $$ SET search_path TO 'pg_catalog', 'pg_temp'; 7 | 8 | -- unsafe call inside function, since search_path for body is not safe 9 | SET search_path TO pg_catalog, pg_temp; 10 | CREATE FUNCTION sqlfunc() RETURNS TEXT LANGUAGE SQL AS $$ SELECT unsafe_call10(); $$; 11 | 12 | CREATE FUNCTION sqlfunc() RETURNS TEXT LANGUAGE SQL AS $$ 13 | SELECT unsafe_call13(); 14 | SET search_path TO pg_catalog, pg_temp; 15 | SELECT safe_call15(); 16 | RESET search_path; 17 | SELECT unsafe_call17(); 18 | SELECT foo.safe_call18(); 19 | $$; 20 | -------------------------------------------------------------------------------- /testdata/unqualified_cte.sql: -------------------------------------------------------------------------------- 1 | WITH cte1 AS ( 2 | VALUES (1) 3 | ), cte2 AS ( 4 | WITH foo AS ( 5 | SELECT * FROM cte1 6 | ) 7 | SELECT * FROM foo 8 | ), cte3 AS ( 9 | SELECT * FROM foo -- This will warn, foo is not a previously defined CTE 10 | ) 11 | SELECT * FROM cte1 AS c1 12 | CROSS JOIN cte2 13 | CROSS JOIN cte3; 14 | -------------------------------------------------------------------------------- /testdata/while.sql: -------------------------------------------------------------------------------- 1 | create or replace function w1() returns void as $func$ 2 | declare 3 | _count bigint; 4 | begin 5 | select 100 into strict _count; 6 | while format1('%s','false')::bool loop 7 | end loop; 8 | while 0 < _count loop 9 | _count := _count - 1; 10 | PERFORM format2('%s','true'); 11 | end loop; 12 | end 13 | $func$ language plpgsql; 14 | -------------------------------------------------------------------------------- /tests/create_aggregate_test.py: -------------------------------------------------------------------------------- 1 | from util import run 2 | 3 | 4 | def test_create_old_style_aggregate(): 5 | sql = """ 6 | CREATE AGGREGATE aggregate(BASETYPE=complex,SFUNC=agg_sfunc,STYPE=internal); 7 | """ 8 | output = run(sql) 9 | 10 | assert "PS017" in output 11 | 12 | 13 | def test_create_new_style_aggregate(): 14 | sql = """ 15 | CREATE AGGREGATE sum (complex) 16 | ( 17 | sfunc = complex_add, 18 | stype = complex, 19 | initcond = '(0,0)' 20 | ); 21 | """ 22 | output = run(sql) 23 | 24 | assert "PS017" in output 25 | -------------------------------------------------------------------------------- /tests/format_string_test.py: -------------------------------------------------------------------------------- 1 | from pgspot.pg_catalog.format import parse_format_string 2 | 3 | # %[position][flags][width]type 4 | 5 | 6 | def test_no_variables(): 7 | assert parse_format_string("") == [] 8 | assert parse_format_string("SELECT * FROM table") == [] 9 | assert parse_format_string("%%") == [] 10 | 11 | 12 | def test_single_variable(): 13 | assert parse_format_string("%s") == [("s", 1)] 14 | assert parse_format_string("%I") == [("I", 1)] 15 | assert parse_format_string("%L") == [("L", 1)] 16 | 17 | assert parse_format_string("%%%s%%") == [("s", 1)] 18 | 19 | assert parse_format_string("%1$s") == [("s", 1)] 20 | assert parse_format_string("%3$s") == [("s", 3)] 21 | 22 | assert parse_format_string("%-s") == [("s", 1)] 23 | assert parse_format_string("%-10s") == [("s", 1)] 24 | assert parse_format_string("%-*s") == [("s", 2)] 25 | assert parse_format_string("%-*1$s") == [("s", 1)] 26 | 27 | assert parse_format_string("%7$-s") == [("s", 7)] 28 | assert parse_format_string("%7$-10s") == [("s", 7)] 29 | assert parse_format_string("%7$-*s") == [("s", 7)] 30 | assert parse_format_string("%7$-*1$s") == [("s", 7)] 31 | 32 | 33 | def test_multiple_variable(): 34 | assert parse_format_string("%s%I%L") == [("s", 1), ("I", 2), ("L", 3)] 35 | assert parse_format_string("%3$s%2$I%1$L") == [("s", 3), ("I", 2), ("L", 1)] 36 | -------------------------------------------------------------------------------- /tests/global_ignore_test.py: -------------------------------------------------------------------------------- 1 | from util import run 2 | 3 | 4 | def test_global_ignore(): 5 | sql = """ 6 | CREATE TABLE IF NOT EXISTS foo(); 7 | """ 8 | output = run(sql) 9 | 10 | assert "PS012" in output 11 | assert "PS017" in output 12 | 13 | sql = """ 14 | CREATE TABLE IF NOT EXISTS foo(); 15 | """ 16 | args = ["--ignore PS012"] 17 | output = run(sql, args) 18 | 19 | assert "PS012" not in output 20 | assert "PS017" in output 21 | -------------------------------------------------------------------------------- /tests/ignore_lang_test.py: -------------------------------------------------------------------------------- 1 | from util import run 2 | 3 | 4 | sql = """ 5 | CREATE FUNCTION python_max(a integer, b integer) RETURNS integer AS $$ 6 | return max(a, b) 7 | $$ LANGUAGE plpython3u SET search_path TO 'pg_catalog', 'pg_temp' 8 | ; 9 | 10 | CREATE FUNCTION tcl_max(integer, integer) RETURNS integer AS $$ 11 | if {$1 > $2} {return $1} 12 | return $2 13 | $$ LANGUAGE pltcl STRICT SET search_path TO 'pg_catalog', 'pg_temp' 14 | ; 15 | """ 16 | 17 | 18 | def test_unknown_lang_plpython3u(): 19 | output = run(sql) 20 | assert "Unknown function language: plpython3u" in output 21 | 22 | 23 | def test_unknown_lang_pltcl(): 24 | output = run(sql) 25 | assert "Unknown function language: pltcl" in output 26 | 27 | 28 | def test_ignore_lang_plpython3u(): 29 | output = run(sql, list("--ignore-lang=plpython3u")) 30 | assert "Unknown function language: plpython3u" not in output 31 | 32 | 33 | def test_ignore_lang_pltcl(): 34 | output = run(sql, list("--ignore-lang=pltcl")) 35 | assert "Unknown function language: pltcl" not in output 36 | 37 | 38 | def test_ignore_lang_plpython3u_pltcl(): 39 | output = run(sql, ["--ignore-lang=plpython3u", "--ignore-lang=pltcl"]) 40 | assert ( 41 | "Unknown function language: pltcl" not in output 42 | and "Unknown function language: plpython3u" not in output 43 | ) 44 | 45 | 46 | def test_ignore_lang_upper(): 47 | output = run(sql, ["--ignore-lang=PLPYTHON3U", "--ignore-lang=PLTCL"]) 48 | assert ( 49 | "Unknown function language: pltcl" not in output 50 | and "Unknown function language: plpython3u" not in output 51 | ) 52 | -------------------------------------------------------------------------------- /tests/plpgsql_path_if_test.py: -------------------------------------------------------------------------------- 1 | from pglast import parse_plpgsql 2 | from pgspot.plpgsql import build_node 3 | from pgspot.path import paths 4 | 5 | 6 | def test_if_minimal_stmt(): 7 | sql = """ 8 | CREATE FUNCTION foo(cmd TEXT) RETURNS void AS $$ 9 | BEGIN 10 | IF EXISTS (SELECT FROM pg_stat_activity) THEN 11 | EXECUTE cmd || '1'; 12 | END IF; 13 | END 14 | $$; 15 | """ 16 | parsed = parse_plpgsql(sql) 17 | node = build_node(parsed[0]) 18 | assert node.type == "PLpgSQL_function" 19 | 20 | pathes = list(paths(node)) 21 | assert len(pathes) == 1 22 | 23 | assert ( 24 | str(pathes[0]) 25 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_dynexecute(4) -> PLpgSQL_stmt_return()" 26 | ) 27 | 28 | 29 | def test_if_else(): 30 | sql = """ 31 | CREATE FUNCTION foo(cmd TEXT) RETURNS void AS $$ 32 | BEGIN 33 | IF EXISTS (SELECT FROM pg_stat_activity) THEN 34 | EXECUTE cmd || '1'; 35 | ELSIF EXISTS (SELECT FROM pg_stat_activity) THEN 36 | EXECUTE cmd || '2'; 37 | ELSIF EXISTS (SELECT FROM pg_stat_activity) THEN 38 | EXECUTE cmd || '3'; 39 | ELSIF EXISTS (SELECT FROM pg_stat_activity) THEN 40 | EXECUTE cmd || '4'; 41 | ELSE 42 | EXECUTE cmd || '5'; 43 | END IF; 44 | END 45 | $$; 46 | """ 47 | parsed = parse_plpgsql(sql) 48 | node = build_node(parsed[0]) 49 | assert node.type == "PLpgSQL_function" 50 | 51 | pathes = list(paths(node)) 52 | assert len(pathes) == 5 53 | 54 | assert ( 55 | str(pathes[0]) 56 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_dynexecute(4) -> PLpgSQL_stmt_return()" 57 | ) 58 | assert ( 59 | str(pathes[1]) 60 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_dynexecute(6) -> PLpgSQL_stmt_return()" 61 | ) 62 | assert ( 63 | str(pathes[2]) 64 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_dynexecute(8) -> PLpgSQL_stmt_return()" 65 | ) 66 | assert ( 67 | str(pathes[3]) 68 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_dynexecute(10) -> PLpgSQL_stmt_return()" 69 | ) 70 | assert ( 71 | str(pathes[4]) 72 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_dynexecute(12) -> PLpgSQL_stmt_return()" 73 | ) 74 | 75 | 76 | def test_if_stmt(): 77 | sql = """ 78 | CREATE FUNCTION foo(cmd TEXT) RETURNS void AS $$ 79 | BEGIN 80 | IF EXISTS (SELECT 1 FROM pg_stat_activity) THEN 81 | EXECUTE cmd || '1'; 82 | ELSE 83 | EXECUTE cmd || '2'; 84 | RETURN 'foo'; 85 | END IF; 86 | IF EXISTS (SELECT 1 FROM pg_stat_activity) THEN 87 | EXECUTE cmd; 88 | ELSE 89 | EXECUTE cmd; 90 | END IF; 91 | END 92 | $$; 93 | """ 94 | parsed = parse_plpgsql(sql) 95 | node = build_node(parsed[0]) 96 | assert node.type == "PLpgSQL_function" 97 | 98 | pathes = list(paths(node)) 99 | assert len(pathes) == 3 100 | 101 | assert ( 102 | str(pathes[0]) 103 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_dynexecute(4) -> PLpgSQL_stmt_if(9) -> PLpgSQL_stmt_dynexecute(10) -> PLpgSQL_stmt_return()" 104 | ) 105 | assert ( 106 | str(pathes[1]) 107 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_dynexecute(6) -> PLpgSQL_stmt_return(7)" 108 | ) 109 | assert ( 110 | str(pathes[2]) 111 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_dynexecute(4) -> PLpgSQL_stmt_if(9) -> PLpgSQL_stmt_dynexecute(12) -> PLpgSQL_stmt_return()" 112 | ) 113 | 114 | 115 | def test_nested_if_stmt(): 116 | sql = """ 117 | CREATE FUNCTION foo(cmd TEXT) RETURNS void AS $$ 118 | BEGIN 119 | IF EXISTS (SELECT FROM pg_stat_activity) THEN 120 | EXECUTE cmd || '1'; 121 | ELSE 122 | IF EXISTS (SELECT FROM pg_stat_activity) THEN 123 | EXECUTE cmd || '2'; 124 | ELSE 125 | EXECUTE cmd || '3'; 126 | END IF; 127 | END IF; 128 | END 129 | $$; 130 | """ 131 | parsed = parse_plpgsql(sql) 132 | node = build_node(parsed[0]) 133 | assert node.type == "PLpgSQL_function" 134 | 135 | pathes = list(paths(node)) 136 | assert len(pathes) == 3 137 | 138 | assert ( 139 | str(pathes[0]) 140 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_dynexecute(4) -> PLpgSQL_stmt_return()" 141 | ) 142 | assert ( 143 | str(pathes[1]) 144 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_if(6) -> PLpgSQL_stmt_dynexecute(7) -> PLpgSQL_stmt_return()" 145 | ) 146 | assert ( 147 | str(pathes[2]) 148 | == "PLpgSQL_stmt_if(3) -> PLpgSQL_stmt_if(6) -> PLpgSQL_stmt_dynexecute(9) -> PLpgSQL_stmt_return()" 149 | ) 150 | -------------------------------------------------------------------------------- /tests/plpgsql_path_loop_test.py: -------------------------------------------------------------------------------- 1 | from pglast import parse_plpgsql 2 | from pgspot.plpgsql import build_node 3 | from pgspot.path import paths 4 | 5 | 6 | def test_loop(): 7 | sql = """ 8 | CREATE FUNCTION foo(cmd TEXT) RETURNS void AS $$ 9 | BEGIN 10 | LOOP 11 | EXECUTE cmd || '1'; 12 | EXECUTE cmd || '2'; 13 | END LOOP; 14 | END 15 | $$; 16 | """ 17 | parsed = parse_plpgsql(sql) 18 | node = build_node(parsed[0]) 19 | pathes = list(paths(node)) 20 | assert len(pathes) == 1 21 | 22 | assert ( 23 | str(pathes[0]) 24 | == "PLpgSQL_stmt_dynexecute(4) -> PLpgSQL_stmt_dynexecute(5) -> PLpgSQL_stmt_return()" 25 | ) 26 | 27 | 28 | def test_while_loop(): 29 | sql = """ 30 | CREATE FUNCTION foo(cmd TEXT) RETURNS void AS $$ 31 | BEGIN 32 | WHILE true LOOP 33 | EXECUTE cmd || '1'; 34 | EXECUTE cmd || '2'; 35 | END LOOP; 36 | END 37 | $$; 38 | """ 39 | parsed = parse_plpgsql(sql) 40 | node = build_node(parsed[0]) 41 | pathes = list(paths(node)) 42 | assert len(pathes) == 1 43 | 44 | assert ( 45 | str(pathes[0]) 46 | == "PLpgSQL_stmt_dynexecute(4) -> PLpgSQL_stmt_dynexecute(5) -> PLpgSQL_stmt_return()" 47 | ) 48 | 49 | 50 | def test_fori_loop(): 51 | sql = """ 52 | CREATE FUNCTION foo(cmd TEXT) RETURNS void AS $$ 53 | DECLARE 54 | i INT; 55 | BEGIN 56 | FOR i IN 1..10 LOOP 57 | RAISE NOTICE 'i is %',i; 58 | END LOOP; 59 | END 60 | $$; 61 | """ 62 | parsed = parse_plpgsql(sql) 63 | node = build_node(parsed[0]) 64 | pathes = list(paths(node)) 65 | assert len(pathes) == 1 66 | 67 | assert str(pathes[0]) == "PLpgSQL_stmt_raise(6) -> PLpgSQL_stmt_return()" 68 | 69 | 70 | def test_fors_loop(): 71 | sql = """ 72 | CREATE FUNCTION foo(cmd TEXT) RETURNS void AS $$ 73 | DECLARE 74 | i INT; 75 | BEGIN 76 | FOR i IN SELECT generate_series(1,10) LOOP 77 | RAISE NOTICE 'i is %',i; 78 | END LOOP; 79 | END 80 | $$ LANGUAGE plpgsql; 81 | """ 82 | parsed = parse_plpgsql(sql) 83 | node = build_node(parsed[0]) 84 | pathes = list(paths(node)) 85 | assert len(pathes) == 1 86 | 87 | assert str(pathes[0]) == "PLpgSQL_stmt_raise(6) -> PLpgSQL_stmt_return()" 88 | 89 | 90 | def test_dynfors_loop(): 91 | sql = """ 92 | CREATE FUNCTION foo(cmd TEXT) RETURNS void AS $$ 93 | DECLARE 94 | i INT; 95 | BEGIN 96 | FOR i IN EXECUTE 'SELECT generate_series(1,10)' LOOP 97 | RAISE NOTICE 'i is %',i; 98 | END LOOP; 99 | END 100 | $$ LANGUAGE plpgsql; 101 | """ 102 | parsed = parse_plpgsql(sql) 103 | node = build_node(parsed[0]) 104 | pathes = list(paths(node)) 105 | assert len(pathes) == 1 106 | 107 | assert str(pathes[0]) == "PLpgSQL_stmt_raise(6) -> PLpgSQL_stmt_return()" 108 | 109 | 110 | def test_forc_loop(): 111 | sql = """ 112 | CREATE FUNCTION foo(cmd TEXT) RETURNS void AS $$ 113 | DECLARE 114 | i INT; 115 | c CURSOR FOR SELECT generate_series(1,10); 116 | BEGIN 117 | FOR i IN c LOOP 118 | RAISE NOTICE 'i is %',i; 119 | END LOOP; 120 | END 121 | $$ LANGUAGE plpgsql; 122 | """ 123 | parsed = parse_plpgsql(sql) 124 | node = build_node(parsed[0]) 125 | pathes = list(paths(node)) 126 | assert len(pathes) == 1 127 | 128 | assert str(pathes[0]) == "PLpgSQL_stmt_raise(7) -> PLpgSQL_stmt_return()" 129 | -------------------------------------------------------------------------------- /tests/plpgsql_paths_test.py: -------------------------------------------------------------------------------- 1 | from pglast import parse_plpgsql 2 | from pgspot.plpgsql import build_node 3 | from pgspot.path import paths 4 | 5 | 6 | def test_minimal_function(): 7 | sql = """ 8 | CREATE FUNCTION mini() RETURNS TEXT AS $$ BEGIN END $$; 9 | """ 10 | parsed = parse_plpgsql(sql) 11 | node = build_node(parsed[0]) 12 | assert node.type == "PLpgSQL_function" 13 | 14 | pathes = list(paths(node)) 15 | assert len(pathes) == 1 16 | 17 | path = pathes[0] 18 | assert path.root == node 19 | 20 | assert str(path) == "PLpgSQL_stmt_return()" 21 | 22 | 23 | def test_do_block(): 24 | sql = """ 25 | DO $$ BEGIN END $$; 26 | """ 27 | parsed = parse_plpgsql(sql) 28 | node = build_node(parsed[0]) 29 | assert node.type == "PLpgSQL_function" 30 | 31 | pathes = list(paths(node)) 32 | assert len(pathes) == 1 33 | 34 | path = pathes[0] 35 | assert path.root == node 36 | 37 | assert str(path) == "PLpgSQL_stmt_return()" 38 | -------------------------------------------------------------------------------- /tests/search_path_test.py: -------------------------------------------------------------------------------- 1 | from pgspot import state 2 | import pglast 3 | 4 | 5 | def test_search_path(): 6 | s = state.State(state.Counter({})) 7 | 8 | assert s.is_secure_searchpath("pg_temp") 9 | assert s.is_secure_searchpath(["pg_temp"]) 10 | assert s.is_secure_searchpath(["pg_catalog", "pg_temp"]) 11 | assert not s.is_secure_searchpath("pg_catalog") 12 | assert not s.is_secure_searchpath(["pg_catalog"]) 13 | assert not s.is_secure_searchpath(["pg_temp", "pg_catalog"]) 14 | 15 | stmts = pglast.parse_sql("SET search_path TO pg_catalog;") 16 | assert not s.is_secure_searchpath(stmts[0].stmt) 17 | 18 | stmts = pglast.parse_sql("SET search_path TO pg_catalog, pg_temp;") 19 | assert s.is_secure_searchpath(stmts[0].stmt) 20 | 21 | stmts = pglast.parse_sql("SET search_path TO pg_temp, pg_catalog;") 22 | assert not s.is_secure_searchpath(stmts[0].stmt) 23 | -------------------------------------------------------------------------------- /tests/snapshot_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import subprocess 3 | 4 | from pathlib import Path 5 | from os.path import splitext, basename 6 | 7 | 8 | @pytest.mark.parametrize("sql_file", list(Path("testdata").glob("*.sql"))) 9 | def test_golden_sql(sql_file, snapshot): 10 | result = subprocess.run( 11 | ["pgspot {}".format(str(sql_file))], 12 | shell=True, 13 | capture_output=True, 14 | text=True, 15 | ) 16 | output = result.stdout 17 | 18 | snapshot.snapshot_dir = "testdata/expected" 19 | snapshot.assert_match(output, "{}.out".format(splitext(basename(sql_file))[0])) 20 | -------------------------------------------------------------------------------- /tests/sql_accepting_function_test.py: -------------------------------------------------------------------------------- 1 | from util import run 2 | 3 | 4 | def test_call_sql_accepting_function(): 5 | sql = """ 6 | CALL some_schema.execute_sql($ee$ 7 | DO $DO$ 8 | BEGIN 9 | SELECT unsafe_call(); 10 | END $DO$; 11 | $ee$); 12 | """ 13 | args = ["--sql-accepting=execute_sql"] 14 | output = run(sql, args) 15 | 16 | assert "PS016" in output 17 | 18 | 19 | def test_select_sql_accepting_function(): 20 | sql = """ 21 | SELECT some_schema.execute_sql($ee$ 22 | DO $DO$ 23 | BEGIN 24 | SELECT unsafe_call(); 25 | END $DO$; 26 | $ee$); 27 | """ 28 | args = ["--sql-accepting=execute_sql"] 29 | output = run(sql, args) 30 | 31 | assert "PS016" in output 32 | 33 | 34 | def test_select_sql_accepting_function_with_non_sql(): 35 | sql = """ 36 | SELECT some_schema.execute_sql(some_parameter); 37 | """ 38 | args = ["--sql-accepting=execute_sql"] 39 | output = run(sql, args) 40 | 41 | assert "Errors: 0 Warnings: 0" in output 42 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | import subprocess 3 | 4 | 5 | def run(sql, args=None): 6 | if args is None: 7 | args = [] 8 | return subprocess.run( 9 | ["echo {} | pgspot {}".format(shlex.quote(sql), " ".join(args))], 10 | shell=True, 11 | capture_output=True, 12 | text=True, 13 | ).stdout 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py313 3 | [testenv] 4 | deps = 5 | pytest 6 | pytest-snapshot 7 | commands = pytest 8 | 9 | --------------------------------------------------------------------------------