├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── bug_report.md │ └── feature_request.md ├── workflows │ ├── test-nodeb-package.yml │ ├── build-and-tests.yml │ ├── pr-title-check.yml │ ├── test-deb-package.yml │ └── build-pgrx-image.yml ├── actions │ ├── reusable-build-action │ │ └── action.yml │ ├── reusable-release-action │ │ └── action.yml │ ├── reusable-package-action │ │ └── action.yml │ ├── build-oci-image │ │ └── action.yml │ └── reusable-test-nodeb-package │ │ └── action.yml └── copilot-instructions.md ├── mise.toml ├── src ├── bin │ └── pgrx_embed.rs └── fixtures.rs ├── docs ├── images │ └── pglinter_logo.png ├── requirements.txt ├── how-to │ └── README.md ├── index.md └── Makefile ├── docker ├── pgrx │ ├── goreleaser.repo │ ├── rust.Dockerfile │ └── Dockerfile ├── init_pglinter.sh ├── ci │ ├── init_pglinter.sh │ ├── Dockerfile.pg-deb │ ├── nodeb-start-with-pglinter.sh │ ├── deb-start-with-pglinter.sh │ └── Dockerfile.pg-nodeb ├── docker-compose.yml ├── Dockerfile ├── oci │ ├── Dockerfile.local │ ├── Dockerfile.pg-deb │ └── Dockerfile.nodeb └── README.md ├── .cargo └── config.toml ├── pglinter.control ├── .gitignore ├── pre-commit-hook.sh ├── .dockerignore ├── .readthedocs.yaml ├── tests ├── sql │ ├── b012_composite_pk.sql │ ├── s004_owner_schema_is_internal_role.sql │ ├── s001_schema_with_default_role_not_granted.sql │ ├── s003_unsecured_public_schema.sql │ ├── s002_schema_prefixed_or_suffixed_with_envt.sql │ ├── rule_management.sql │ ├── c002_hba_security_test.sql │ ├── s005_several_table_owner_in_schema.sql │ ├── b011_several_table_owner_in_schema.sql │ ├── b001.sql │ ├── b001_configurable.sql │ ├── quick_demo_levels.sql │ ├── b002_non_redundant_idx.sql │ ├── schema_rules.sql │ ├── c003_md5_pwd_PG17-.sql │ ├── demo_rule_levels.sql │ ├── b002_redundant_idx.sql │ ├── integration_test.sql │ ├── import_rules_from_file.sql │ ├── s003_public_schema.sql │ ├── b005_uppercase_test.sql │ ├── b004_idx_not_used.sql │ ├── import_rules_from_yaml.sql │ └── c003_scram_PG17-.sql └── expected │ ├── s001_schema_with_default_role_not_granted.out │ ├── s004_owner_schema_is_internal_role.out │ ├── b012_composite_pk.out │ ├── s002_schema_prefixed_or_suffixed_with_envt.out │ ├── s003_unsecured_public_schema.out │ ├── s005_several_table_owner_in_schema.out │ ├── b011_several_table_owner_in_schema.out │ ├── b001_configurable.out │ ├── b001.out │ ├── quick_demo_levels.out │ ├── b002_non_redundant_idx.out │ ├── schema_rules.out │ ├── demo_rule_levels.out │ ├── rule_management.out │ ├── import_rules_from_file.out │ └── b002_redundant_idx.out ├── AUTHORS.md ├── .pre-commit-config.yaml ├── Cargo.toml ├── .editorconfig ├── mkdocs.yml ├── nfpm.template.yaml ├── META.json ├── Dockerfile └── sql └── install_for_users.sql /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreo: PmPetit 2 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | nfpm = "latest" 3 | -------------------------------------------------------------------------------- /src/bin/pgrx_embed.rs: -------------------------------------------------------------------------------- 1 | ::pgrx::pgrx_embed!(); 2 | -------------------------------------------------------------------------------- /docs/images/pglinter_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmpetit/pglinter/HEAD/docs/images/pglinter_logo.png -------------------------------------------------------------------------------- /docker/pgrx/goreleaser.repo: -------------------------------------------------------------------------------- 1 | [goreleaser] 2 | name=GoReleaser 3 | baseurl=https://repo.goreleaser.com/yum/ 4 | enabled=1 5 | gpgcheck=0 6 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs>=1.5.0 2 | mkdocs-material>=9.0.0 3 | pymdown-extensions>=10.0.0 4 | mkdocs-awesome-pages-plugin>=2.8.0 5 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.'cfg(target_os="macos")'] 2 | # Postgres symbols won't be available until runtime 3 | rustflags = ["-Clink-arg=-Wl,-undefined,dynamic_lookup"] 4 | -------------------------------------------------------------------------------- /pglinter.control: -------------------------------------------------------------------------------- 1 | comment = 'pglinter: PostgreSQL Database Linting and Analysis Extension' 2 | default_version = '@CARGO_VERSION@' 3 | module_pathname = '$libdir/pglinter' 4 | relocatable = false 5 | superuser = true 6 | trusted = true 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | /target 4 | *.iml 5 | **/*.rs.bk 6 | Cargo.lock 7 | .vscode 8 | 9 | # MkDocs 10 | /site/ 11 | docs/.venv 12 | 13 | # tests 14 | regression.out 15 | *.diffs 16 | _blackbox_* 17 | contrib_regression_test_restore.pgsql 18 | results/* 19 | tests/results/* 20 | tests/tmp/* 21 | generated/ 22 | *.code-workspace 23 | -------------------------------------------------------------------------------- /docker/init_pglinter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Perform all actions as $POSTGRES_USER 6 | export PGUSER="$POSTGRES_USER" 7 | 8 | echo "Creating extension inside template1 and postgres databases" 9 | SQL="CREATE EXTENSION IF NOT EXISTS pglinter CASCADE;" 10 | psql --dbname="template1" -c "$SQL" 11 | psql --dbname="postgres" -c "$SQL" 12 | -------------------------------------------------------------------------------- /docker/ci/init_pglinter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Perform all actions as $POSTGRES_USER 6 | export PGUSER="$POSTGRES_USER" 7 | 8 | echo "Creating extension inside template1 and postgres databases" 9 | SQL="CREATE EXTENSION IF NOT EXISTS pglinter CASCADE;" 10 | psql --dbname="template1" -c "$SQL" 11 | psql --dbname="postgres" -c "$SQL" 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discussion Forum 4 | url: https://github.com/pmpetit/pglinter/discussions 5 | about: Ask questions and discuss ideas with the community 6 | - name: Documentation 7 | url: https://github.com/pmpetit/pglinter/blob/main/README.md 8 | about: Read the project documentation 9 | -------------------------------------------------------------------------------- /pre-commit-hook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Pre-commit hook for pglinter 3 | # 4 | # To install this hook: 5 | # cp pre-commit-hook.sh .git/hooks/pre-commit 6 | # chmod +x .git/hooks/pre-commit 7 | # 8 | # To run manually: 9 | # make precommit 10 | 11 | set -e 12 | 13 | echo "🔍 Running pglinter pre-commit checks..." 14 | 15 | # Run the precommit target 16 | make precommit-fast 17 | 18 | echo "" 19 | echo "🎉 Pre-commit checks passed! Committing..." 20 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3' 3 | 4 | services: 5 | PostgreSQL: 6 | image: registry.gitlab.com/dalibo/postgresql_anonymizer 7 | ports: 8 | - "54322:5432" 9 | environment: 10 | - POSTGRES_PASSWORD=CHANGEME 11 | - PGUSER=postgres # required for `make installcheck` 12 | volumes: 13 | - $PWD:/tmp/source 14 | working_dir: /tmp/source 15 | command: > 16 | postgres -c shared_preload_libraries='anon' 17 | -c hba_file=/tmp/source/pg_dump_anon/tests/pg_hba.conf 18 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Build artifacts 2 | target/ 3 | *.so 4 | *.dylib 5 | *.dll 6 | 7 | # IDE files 8 | .vscode/ 9 | .idea/ 10 | *.swp 11 | *.swo 12 | *~ 13 | 14 | # OS files 15 | .DS_Store 16 | Thumbs.db 17 | 18 | # Git 19 | .git/ 20 | .gitignore 21 | 22 | # Documentation build 23 | site/ 24 | docs/site/ 25 | 26 | # Test results 27 | results/ 28 | regression.diffs 29 | regression.out 30 | 31 | # Local development 32 | .env 33 | .env.local 34 | 35 | # Cache 36 | .cache/ 37 | node_modules/ 38 | 39 | # Logs 40 | *.log 41 | 42 | # Temporary files 43 | tmp/ 44 | temp/ 45 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version, and other tools you might need 8 | build: 9 | os: ubuntu-24.04 10 | tools: 11 | python: "3.13" 12 | 13 | # Build documentation with Mkdocs 14 | mkdocs: 15 | configuration: mkdocs.yml 16 | 17 | # Optionally, but recommended, 18 | # declare the Python requirements required to build your documentation 19 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | 24 | -------------------------------------------------------------------------------- /tests/sql/b012_composite_pk.sql: -------------------------------------------------------------------------------- 1 | -- Test for B012: Composite primary keys with more than 4 columns 2 | CREATE EXTENSION IF NOT EXISTS pglinter; 3 | 4 | -- First, disable all rules to isolate B001 testing 5 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 6 | 7 | -- Enable only B012 for focused testing 8 | SELECT pglinter.enable_rule('B012') AS b012_enabled; 9 | 10 | 11 | CREATE TABLE test_composite_pk ( 12 | a INT, 13 | b INT, 14 | c INT, 15 | d INT, 16 | e INT, 17 | f INT, 18 | PRIMARY KEY (a, b, c, d, e, f) 19 | ); 20 | 21 | -- Run pglinter check 22 | SELECT pglinter.check(); 23 | 24 | DROP TABLE test_composite_pk CASCADE; 25 | 26 | DROP EXTENSION pglinter CASCADE; 27 | -------------------------------------------------------------------------------- /tests/sql/s004_owner_schema_is_internal_role.sql: -------------------------------------------------------------------------------- 1 | -- Regression test for S004: OwnerSchemaIsInternalRole 2 | -- This test creates a schema owned by a superuser and checks that S004 identifies it. 3 | 4 | CREATE EXTENSION IF NOT EXISTS pglinter; 5 | 6 | \pset pager off 7 | 8 | 9 | -- Setup: Create test role and schema 10 | CREATE ROLE s004_owner LOGIN; 11 | CREATE SCHEMA s004_schema AUTHORIZATION postgres; 12 | 13 | SELECT 'Testing S004 in isolation...' AS test_step; 14 | SELECT pglinter.disable_all_rules() AS all_disabled; 15 | SELECT pglinter.enable_rule('S004') AS S004_only_enabled; 16 | SELECT pglinter.perform_schema_check(); -- Should only run S004 17 | 18 | -- Cleanup 19 | DROP SCHEMA s004_schema CASCADE; 20 | DROP ROLE s004_owner; 21 | -------------------------------------------------------------------------------- /docs/how-to/README.md: -------------------------------------------------------------------------------- 1 | # How-To Guides 2 | 3 | Practical guides for common pglinter scenarios and use cases. 4 | 5 | ## 💻 Usage 6 | 7 | After installation, enable the extension in your PostgreSQL database: 8 | 9 | ```sql 10 | -- Connect to your database 11 | \c your_database 12 | 13 | -- Create the extension 14 | CREATE EXTENSION pglinter; 15 | 16 | -- Run a basic check 17 | SELECT pglinter.check(); 18 | 19 | -- Check specific rules 20 | SELECT pglinter.check_rule('B001'); -- Tables without primary keys 21 | SELECT pglinter.check_rule('B002'); -- Redundant indexes 22 | ``` 23 | 24 | ### 📋 Available Rules 25 | 26 | - **B00**: Base database rules (primary keys, indexes, schemas, etc.) 27 | - **C00**: Cluster security rules 28 | - **S00**: Schema rules 29 | -------------------------------------------------------------------------------- /tests/sql/s001_schema_with_default_role_not_granted.sql: -------------------------------------------------------------------------------- 1 | -- Regression test for S001: SchemaWithDefaultRoleNotGranted 2 | -- This test creates a schema without a default role privilege and checks that S001 identifies it. 3 | 4 | CREATE EXTENSION IF NOT EXISTS pglinter; 5 | 6 | \pset pager off 7 | 8 | -- Setup: Create test role and schema 9 | CREATE ROLE s001_owner LOGIN; 10 | CREATE SCHEMA s001_schema AUTHORIZATION s001_owner; 11 | 12 | SELECT 'Testing S001 in isolation...' AS test_step; 13 | SELECT pglinter.disable_all_rules() AS all_disabled; 14 | SELECT pglinter.enable_rule('S001') AS S001_only_enabled; 15 | SELECT pglinter.perform_schema_check(); -- Should only run S001 16 | 17 | -- Cleanup 18 | DROP SCHEMA s001_schema CASCADE; 19 | DROP ROLE s001_owner; 20 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | pglinter Development Team 2 | =============================================================================== 3 | 4 | This is an open project. Feel free to join us and improve this tool. To find out 5 | how you can get involved, please read [CONTRIBUTING.md]. 6 | 7 | The folders/files structure were inspired from Dalibo's pg_anonymizer repository. 8 | 9 | [CONTRIBUTING.md]: CONTRIBUTING.md 10 | 11 | Maintainer 12 | ------------------------------------------------------------------------------- 13 | 14 | This software is mainly developed and maintained by Pierre-Marie Petit with the 15 | help of contributors. 16 | 17 | Contributors 18 | ------------------------------------------------------------------------------- 19 | 20 | * Gregoire Waymel 21 | * Stephane Defenin 22 | * Damien Clochard 23 | -------------------------------------------------------------------------------- /tests/sql/s003_unsecured_public_schema.sql: -------------------------------------------------------------------------------- 1 | -- Regression test for S003: UnsecuredPublicSchema 2 | -- This test creates a schema and grants CREATE privilege to PUBLIC, then checks that S003 identifies it. 3 | 4 | CREATE EXTENSION IF NOT EXISTS pglinter; 5 | 6 | \pset pager off 7 | 8 | -- Setup: Create test role and schema 9 | CREATE ROLE s003_owner LOGIN; 10 | CREATE SCHEMA s003_schema AUTHORIZATION s003_owner; 11 | 12 | -- Grant CREATE privilege to PUBLIC 13 | GRANT CREATE ON SCHEMA s003_schema TO PUBLIC; 14 | 15 | SELECT 'Testing S003 in isolation...' AS test_step; 16 | SELECT pglinter.disable_all_rules() AS all_disabled; 17 | SELECT pglinter.enable_rule('S003') AS S003_only_enabled; 18 | SELECT pglinter.perform_schema_check(); -- Should only run S003 19 | 20 | -- Cleanup 21 | REVOKE CREATE ON SCHEMA s003_schema FROM PUBLIC; 22 | DROP SCHEMA s003_schema CASCADE; 23 | DROP ROLE s003_owner; 24 | -------------------------------------------------------------------------------- /docker/ci/Dockerfile.pg-deb: -------------------------------------------------------------------------------- 1 | 2 | ARG PG_MAJOR_VERSION=13 3 | FROM postgres:${PG_MAJOR_VERSION} 4 | 5 | # Re-declare ARG variables for use inside the build stage 6 | ARG PG_MAJOR_VERSION=13 7 | ARG PACKAGE_PATH=docker/ci 8 | ARG PACKAGE_NAME 9 | 10 | RUN apt-get update \ 11 | && apt-get install -y --no-install-recommends \ 12 | ca-certificates \ 13 | wget \ 14 | && rm -rf /var/lib/apt/lists/* 15 | 16 | # Install extension 17 | COPY ${PACKAGE_PATH}/${PACKAGE_NAME} /tmp 18 | RUN dpkg -i /tmp/${PACKAGE_NAME} && \ 19 | rm -f /tmp/${PACKAGE_NAME} 20 | 21 | # Set environment variable for the script to use at runtime 22 | ENV PG_MAJOR_VERSION=${PG_MAJOR_VERSION} 23 | 24 | # init script 25 | COPY docker/ci/deb-start-with-pglinter.sh /usr/local/bin/start-with-pglinter.sh 26 | RUN chmod +x /usr/local/bin/start-with-pglinter.sh 27 | 28 | 29 | 30 | CMD ["/usr/local/bin/start-with-pglinter.sh"] 31 | -------------------------------------------------------------------------------- /tests/sql/s002_schema_prefixed_or_suffixed_with_envt.sql: -------------------------------------------------------------------------------- 1 | -- Regression test for S002: SchemaPrefixedOrSuffixedWithEnvt 2 | -- This test creates schemas with environment prefixes/suffixes and checks that S002 identifies them. 3 | 4 | -- Setup: Create test role 5 | CREATE ROLE s002_owner LOGIN; 6 | 7 | -- Create schemas with environment prefixes/suffixes 8 | CREATE SCHEMA prod_schema AUTHORIZATION s002_owner; 9 | CREATE SCHEMA dev_schema AUTHORIZATION s002_owner; 10 | CREATE SCHEMA s002_schema AUTHORIZATION s002_owner; 11 | 12 | SELECT 'Testing S002 in isolation...' AS test_step; 13 | SELECT pglinter.disable_all_rules() AS all_disabled; 14 | SELECT pglinter.enable_rule('S002') AS S002_only_enabled; 15 | SELECT pglinter.perform_schema_check(); -- Should only run S002 16 | 17 | -- Cleanup 18 | DROP SCHEMA prod_schema CASCADE; 19 | DROP SCHEMA dev_schema CASCADE; 20 | DROP SCHEMA s002_schema CASCADE; 21 | DROP ROLE s002_owner; 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.5.0 5 | hooks: 6 | - id: trailing-whitespace 7 | exclude: ^tests/expected/ 8 | - id: end-of-file-fixer 9 | exclude: ^tests/expected/ 10 | - id: check-added-large-files 11 | - repo: local 12 | hooks: 13 | - id: codespell 14 | name: codespell 15 | entry: codespell 16 | language: system 17 | types: [file] 18 | - repo: https://github.com/markdownlint/markdownlint 19 | rev: v0.13.0 20 | hooks: 21 | - id: markdownlint 22 | exclude: docs/(dev|how-to|tutorials|runbooks) 23 | - repo: local 24 | hooks: 25 | - id: rustfmt 26 | name: rustfmt 27 | description: Check if all files follow the rustfmt style 28 | entry: cargo fmt --all 29 | language: system 30 | pass_filenames: false 31 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pglinter" 3 | version = "1.0.1" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "lib"] 8 | 9 | [[bin]] 10 | name = "pgrx_embed_pglinter" 11 | path = "./src/bin/pgrx_embed.rs" 12 | 13 | [features] 14 | default = ["pg13"] 15 | pg13 = ["pgrx/pg13", "pgrx-tests/pg13" ] 16 | pg14 = ["pgrx/pg14", "pgrx-tests/pg14" ] 17 | pg15 = ["pgrx/pg15", "pgrx-tests/pg15" ] 18 | pg16 = ["pgrx/pg16", "pgrx-tests/pg16" ] 19 | pg17 = ["pgrx/pg17", "pgrx-tests/pg17" ] 20 | pg18 = ["pgrx/pg18", "pgrx-tests/pg18" ] 21 | pg_test = [] 22 | 23 | [dependencies] 24 | pgrx = "0.16.1" 25 | serde = { version = "1.0", features = ["derive"] } 26 | serde_json = "1.0" 27 | serde_yaml = "0.9" 28 | chrono = { version = "0.4", features = ["serde"] } 29 | 30 | [dev-dependencies] 31 | pgrx-tests = "0.16.1" 32 | 33 | [profile.dev] 34 | panic = "unwind" 35 | 36 | [profile.release] 37 | panic = "unwind" 38 | opt-level = 3 39 | lto = "fat" 40 | codegen-units = 1 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 1. Run command '...' 15 | 2. Execute query '...' 16 | 3. See error 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Actual behavior** 22 | A clear and concise description of what actually happened. 23 | 24 | **Environment (please complete the following information):** 25 | - OS: [e.g. Ubuntu 22.04] 26 | - PostgreSQL version: [e.g. 15.2] 27 | - pglinter version: [e.g. 1.0.0] 28 | 29 | **Error logs/output** 30 | If applicable, add error logs or output to help explain your problem. 31 | 32 | ``` 33 | Paste error logs here 34 | ``` 35 | 36 | **Additional context** 37 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | **Is your feature request related to a problem? Please describe.** 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | A clear and concise description of what you want to happen. 16 | 17 | **Describe alternatives you've considered** 18 | A clear and concise description of any alternative solutions or features you've considered. 19 | 20 | **Use case** 21 | Describe the specific use case for this feature and how it would benefit users. 22 | 23 | **Additional context** 24 | Add any other context, screenshots, or examples about the feature request here. 25 | -------------------------------------------------------------------------------- /docker/ci/nodeb-start-with-pglinter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "🚀 Starting PostgreSQL..." 5 | su - postgres -c "/usr/pgsql-${PG_MAJOR_VERSION}/bin/pg_ctl -D /var/lib/pgsql/${PG_MAJOR_VERSION}/data -l /var/lib/pgsql/${PG_MAJOR_VERSION}/data/logfile start" 6 | 7 | echo "⏳ Waiting for PostgreSQL to be ready..." 8 | until su - postgres -c "/usr/pgsql-${PG_MAJOR_VERSION}/bin/pg_isready -q"; do 9 | echo "PostgreSQL is not ready yet..." 10 | sleep 1 11 | done 12 | 13 | echo "✅ PostgreSQL is ready!" 14 | 15 | echo "📦 Creating pglinter extension..." 16 | if ! su - postgres -c "psql -c 'CREATE EXTENSION IF NOT EXISTS pglinter;'"; then 17 | echo "❌ Failed to create pglinter extension!" 18 | exit 1 19 | fi 20 | 21 | echo "🔍 Testing pglinter installation..." 22 | echo "Testing hello_pglinter:" 23 | if ! su - postgres -c "psql -c 'SELECT hello_pglinter();'"; then 24 | echo "❌ Failed to get hello from pglinter!" 25 | exit 1 26 | fi 27 | 28 | echo "🎉 pglinter test passed successfully!" 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/sql/rule_management.sql: -------------------------------------------------------------------------------- 1 | -- Test for pglinter rule management functionality 2 | CREATE EXTENSION pglinter; 3 | 4 | CREATE TABLE test_table_for_rules ( 5 | id INT, 6 | name TEXT 7 | ); 8 | 9 | -- Show initial rule status 10 | SELECT pglinter.show_rules(); 11 | 12 | -- Test enabling and disabling a specific rule 13 | SELECT pglinter.is_rule_enabled('B001') AS b001_initially_enabled; 14 | 15 | -- Disable B001 rule 16 | SELECT pglinter.disable_rule('B001') AS b001_disabled; 17 | 18 | -- Check if it's disabled 19 | SELECT pglinter.is_rule_enabled('B001') AS b001_after_disable; 20 | 21 | -- Run base check (should skip B001) 22 | SELECT pglinter.check(); 23 | 24 | -- Re-enable B001 rule 25 | SELECT pglinter.enable_rule('B001') AS b001_enabled; 26 | 27 | -- Check if it's enabled again 28 | SELECT pglinter.is_rule_enabled('B001') AS b001_after_enable; 29 | 30 | -- Test with non-existent rule 31 | SELECT pglinter.disable_rule('NONEXISTENT') AS nonexistent_disable; 32 | 33 | -- Show final rule status 34 | SELECT pglinter.show_rules(); 35 | 36 | DROP TABLE test_table_for_rules CASCADE; 37 | 38 | DROP EXTENSION pglinter CASCADE; 39 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | # This is the project's root directory 4 | root = true 5 | 6 | [*] 7 | # Use spaces for indentation 8 | indent_style = space 9 | # Each indent should contain 2 spaces 10 | indent_size = 2 11 | # Use Unix line endings 12 | end_of_line = lf 13 | # The files are utf-8 encoded 14 | charset = utf-8 15 | # No whitespace at the end of line 16 | trim_trailing_whitespace = true 17 | # A file must end with an empty line - this is good for version control systems 18 | insert_final_newline = true 19 | # A line should not have more than this amount of chars (not supported by all plugins) 20 | max_line_length = 80 21 | 22 | [*.csv] 23 | indent_style = tab 24 | 25 | [*.tsv] 26 | indent_style = tab 27 | 28 | [*.md] 29 | #trim_trailing_whitespace = false 30 | 31 | [{Makefile,**.mk,debian/rules}] 32 | # Use tabs for indentation (Makefiles require tabs) 33 | indent_style = tab 34 | 35 | # ignore test output 36 | [tests/expected/*] 37 | trim_trailing_whitespace = false 38 | insert_final_newline = false 39 | indent_size = unset 40 | indent_style = unset 41 | 42 | [*.rs] 43 | indent_size = 4 44 | indent_style = space 45 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | # This instructions must be declared before any FROM 3 | # You can override it and build your own image based another version 4 | # like this: `PG_MAJOR_VERSION=13 make docker_image` 5 | # An ARG declared before a FROM is outside of a build stage, so it can’t be 6 | # used in any instruction after a FROM. We need to declare it again. 7 | ARG DOCKER_PG_MAJOR_VERSION=18 8 | 9 | FROM postgres:$DOCKER_PG_MAJOR_VERSION 10 | 11 | 12 | RUN apt-get update \ 13 | && apt-get install -y --no-install-recommends \ 14 | ca-certificates \ 15 | wget \ 16 | && rm -rf /var/lib/apt/lists/* 17 | 18 | # Install extension 19 | ARG PGLINTER_VERSION=1.0.1 20 | ARG DOCKER_PG_MAJOR_VERSION=18 21 | 22 | RUN ARCH=$(dpkg --print-architecture) \ 23 | && wget https://github.com/pmpetit/pglinter/releases/download/$PGLINTER_VERSION/postgresql_pglinter_${DOCKER_PG_MAJOR_VERSION}_${PGLINTER_VERSION}_${ARCH}.deb \ 24 | && dpkg -i postgresql_pglinter_${DOCKER_PG_MAJOR_VERSION}_${PGLINTER_VERSION}_${ARCH}.deb 25 | 26 | # init script 27 | RUN mkdir -p /docker-entrypoint-initdb.d 28 | COPY ./docker/init_pglinter.sh /docker-entrypoint-initdb.d/init_pglinter.sh 29 | -------------------------------------------------------------------------------- /docker/pgrx/rust.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1 2 | 3 | ARG UID=1000 4 | ARG GID=1000 5 | ARG PGRX_VERSION=0.12.4 6 | 7 | # Create the postgres user with the given uid/gid 8 | # If you're not using Docker Desktop and your UID / GID is not 1000 then 9 | # you'll get permission errors with volumes. 10 | # 11 | # You can fix that by rebuilding the image locally with: 12 | # 13 | # docker compose build --build-arg UID=`id -u` --build-arg GID=`id- g` 14 | # 15 | RUN groupadd -g "${GID}" postgres \ 16 | && useradd --create-home --no-log-init -u "${UID}" -g "${GID}" postgres 17 | 18 | RUN echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' \ 19 | | tee /etc/apt/sources.list.d/goreleaser.list 20 | 21 | RUN apt-get update && apt-get install -y \ 22 | bison \ 23 | flex \ 24 | gettext-base \ 25 | libclang-dev \ 26 | nfpm \ 27 | postgresql-server-dev-all 28 | 29 | USER postgres 30 | 31 | ENV USER=postgres 32 | 33 | ENV PATH="${PATH}:/usr/local/cargo/bin/:~postgres/.cargo/bin" 34 | 35 | RUN rustup component add clippy && \ 36 | cargo install --locked --version "${PGRX_VERSION}" cargo-pgrx && \ 37 | cargo pgrx init 38 | 39 | WORKDIR /pgrx 40 | VOLUME /pgrx 41 | -------------------------------------------------------------------------------- /tests/sql/c002_hba_security_test.sql: -------------------------------------------------------------------------------- 1 | -- Comprehensive test for pglinter C002 rule: Insecure pg_hba.conf entries 2 | -- This script tests the detection of insecure authentication methods in pg_hba.conf 3 | CREATE EXTENSION pglinter; 4 | 5 | 6 | \pset pager off 7 | 8 | SELECT 'Testing C002 rule - pg_hba.conf security checks...' AS test_info; 9 | 10 | -- First, disable all rules to isolate C002 testing 11 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 12 | 13 | -- Enable only C002 for focused testing 14 | SELECT pglinter.enable_rule('C002') AS c002_enabled; 15 | 16 | -- Verify C002 is enabled 17 | SELECT pglinter.is_rule_enabled('C002') AS c002_status; 18 | 19 | -- Test 1: Run C002 check with current settings 20 | SELECT '=== Test 2: C002 Rule Execution ===' AS test_section; 21 | SELECT pglinter.perform_cluster_check(); 22 | 23 | -- Test if file exists and show checksum 24 | SELECT pglinter.check('/tmp/pglinter_c002_results.sarif'); 25 | \! md5sum /tmp/pglinter_c002_results.sarif 26 | 27 | 28 | -- Test rule explanation 29 | SELECT 'C002 rule explanation:' AS explanation_info; 30 | SELECT pglinter.explain_rule('C002') AS rule_explanation; 31 | 32 | 33 | DROP EXTENSION pglinter CASCADE; 34 | -------------------------------------------------------------------------------- /tests/sql/s005_several_table_owner_in_schema.sql: -------------------------------------------------------------------------------- 1 | -- Regression test for S005: SeveralTableOwnerInSchema 2 | -- This test creates two tables in the same schema with different owners 3 | -- and checks that the rule S005 correctly identifies the schema as problematic. 4 | 5 | CREATE EXTENSION IF NOT EXISTS pglinter; 6 | 7 | \pset pager off 8 | 9 | -- Setup: Create test roles and schema 10 | CREATE ROLE s005_owner1 LOGIN; 11 | CREATE ROLE s005_owner2 LOGIN; 12 | CREATE SCHEMA s005_schema AUTHORIZATION s005_owner1; 13 | 14 | -- Create tables with different owners in the same schema 15 | CREATE TABLE s005_schema.table1 (id INT); 16 | ALTER TABLE s005_schema.table1 OWNER TO s005_owner1; 17 | CREATE TABLE s005_schema.table2 (id INT); 18 | ALTER TABLE s005_schema.table2 OWNER TO s005_owner2; 19 | 20 | SELECT 'Testing S005 in isolation...' AS test_step; 21 | SELECT pglinter.disable_all_rules() AS all_disabled; 22 | SELECT pglinter.enable_rule('S005') AS S005_only_enabled; 23 | SELECT pglinter.perform_schema_check(); -- Should only run S005 24 | 25 | -- Cleanup 26 | DROP TABLE s005_schema.table1; 27 | DROP TABLE s005_schema.table2; 28 | DROP SCHEMA s005_schema; 29 | DROP ROLE s005_owner1; 30 | DROP ROLE s005_owner2; 31 | -------------------------------------------------------------------------------- /tests/sql/b011_several_table_owner_in_schema.sql: -------------------------------------------------------------------------------- 1 | -- Regression test for B011: SeveralTableOwnerInSchema 2 | -- This test creates two tables in the same schema with different owners 3 | -- and checks that the rule B011 correctly identifies the schema as problematic. 4 | 5 | CREATE EXTENSION pglinter; 6 | 7 | \pset pager off 8 | 9 | -- Setup: Create test roles and schema 10 | CREATE ROLE s005_owner1 LOGIN; 11 | CREATE ROLE s005_owner2 LOGIN; 12 | CREATE SCHEMA s005_schema AUTHORIZATION s005_owner1; 13 | 14 | -- Create tables with different owners in the same schema 15 | CREATE TABLE s005_schema.table1 (id INT); 16 | ALTER TABLE s005_schema.table1 OWNER TO s005_owner1; 17 | CREATE TABLE s005_schema.table2 (id INT); 18 | ALTER TABLE s005_schema.table2 OWNER TO s005_owner2; 19 | 20 | SELECT 'Testing B011 in isolation...' AS test_step; 21 | SELECT pglinter.disable_all_rules() AS all_disabled; 22 | SELECT pglinter.enable_rule('B011') AS B011_only_enabled; 23 | SELECT pglinter.check(); -- Should only run B011 24 | 25 | -- Cleanup 26 | DROP TABLE s005_schema.table1; 27 | DROP TABLE s005_schema.table2; 28 | DROP SCHEMA s005_schema; 29 | DROP ROLE s005_owner1; 30 | DROP ROLE s005_owner2; 31 | 32 | DROP EXTENSION pglinter; 33 | -------------------------------------------------------------------------------- /.github/workflows/test-nodeb-package.yml: -------------------------------------------------------------------------------- 1 | name: Test NODEB Package 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | pg_version: 7 | description: 'PostgreSQL version to test' 8 | required: true 9 | default: '17' 10 | type: choice 11 | options: ['13', '14', '15', '16', '17', '18'] 12 | pglinter_version: 13 | description: 'pglinter version' 14 | required: true 15 | default: '0.0.19' 16 | platform: 17 | description: 'Target platform' 18 | required: false 19 | default: 'linux/amd64' 20 | timeout: 21 | description: 'Container timeout (seconds)' 22 | required: false 23 | default: '120' 24 | 25 | jobs: 26 | test-nodeb-image: 27 | runs-on: ubuntu-24.04-arm 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v4 31 | 32 | - name: Test PostgreSQL NODEB image 33 | uses: ./.github/actions/reusable-test-nodeb-package 34 | with: 35 | pg_version: ${{ inputs.pg_version }} 36 | pglinter_version: ${{ inputs.pglinter_version }} 37 | platform: ${{ inputs.platform }} 38 | timeout: ${{ inputs.timeout }} 39 | -------------------------------------------------------------------------------- /tests/sql/b001.sql: -------------------------------------------------------------------------------- 1 | -- Test for pglinter B001 rule with file output 2 | CREATE EXTENSION pglinter; 3 | 4 | CREATE TABLE my_table_without_pk ( 5 | id INT, 6 | name TEXT, 7 | code TEXT, 8 | enable BOOL DEFAULT TRUE, 9 | query TEXT, 10 | warning_level INT, 11 | error_level INT, 12 | scope TEXT 13 | ); 14 | 15 | -- Disable all rules first 16 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 17 | 18 | -- Run table check to detect tables without PK 19 | SELECT pglinter.check(); 20 | 21 | -- Test rule management for B001 22 | SELECT pglinter.explain_rule('B001'); 23 | SELECT pglinter.is_rule_enabled('B001') AS is_b001_enabled; 24 | 25 | -- Test with file output 26 | SELECT pglinter.check('/tmp/pglinter_base_results.sarif'); 27 | 28 | -- Test if file exists 29 | \! md5sum /tmp/pglinter_base_results.sarif 30 | 31 | -- Re-enable B001 rule 32 | SELECT pglinter.enable_rule('B001') AS enable_b001; 33 | 34 | -- Test again with B001 enabled 35 | SELECT pglinter.check(); 36 | 37 | -- Test with file output 38 | SELECT pglinter.check('/tmp/pglinter_base_results.sarif'); 39 | 40 | -- Test if file exists 41 | \! md5sum /tmp/pglinter_base_results.sarif 42 | 43 | DROP TABLE my_table_without_pk CASCADE; 44 | 45 | DROP EXTENSION pglinter CASCADE; 46 | -------------------------------------------------------------------------------- /docker/ci/deb-start-with-pglinter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | DATA_DIR="/var/lib/postgresql/${PG_MAJOR_VERSION}/main" 5 | PG_BIN="/usr/lib/postgresql/${PG_MAJOR_VERSION}/bin" 6 | 7 | if [ ! -d "$DATA_DIR" ]; then 8 | echo "🛠 Initializing PostgreSQL data directory at $DATA_DIR" 9 | mkdir -p "$DATA_DIR" 10 | chown -R postgres:postgres "$(dirname "$DATA_DIR")" 11 | su - postgres -c "${PG_BIN}/initdb -D $DATA_DIR" 12 | fi 13 | 14 | echo "🚀 Starting PostgreSQL..." 15 | su - postgres -c "${PG_BIN}/pg_ctl -D $DATA_DIR -l ${DATA_DIR}/logfile start" 16 | 17 | echo "⏳ Waiting for PostgreSQL to be ready..." 18 | until su - postgres -c "${PG_BIN}/pg_isready -q"; do 19 | echo "PostgreSQL is not ready yet..." 20 | sleep 1 21 | done 22 | 23 | echo "✅ PostgreSQL is ready!" 24 | 25 | echo "📦 Creating pglinter extension..." 26 | if ! su - postgres -c "psql -c 'CREATE EXTENSION IF NOT EXISTS pglinter;'"; then 27 | echo "❌ Failed to create pglinter extension!" 28 | exit 1 29 | fi 30 | 31 | echo "🔍 Testing pglinter installation..." 32 | echo "Testing hello_pglinter:" 33 | if ! su - postgres -c "psql -c 'SELECT hello_pglinter();'"; then 34 | echo "❌ Failed to get hello from pglinter!" 35 | exit 1 36 | fi 37 | 38 | echo "🎉 pglinter test passed successfully!" 39 | 40 | -------------------------------------------------------------------------------- /.github/workflows/build-and-tests.yml: -------------------------------------------------------------------------------- 1 | 2 | name: 'Build and Test pglinter' 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main # Optional: specify branches for the pull request to merge into 8 | paths-ignore: 9 | - 'docs/**' 10 | - '*.md' 11 | - 'README.md' 12 | - 'mkdocs.yml' 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | 19 | jobs: 20 | ## 21 | ## B U I L D / T E S T 22 | ## 23 | build: 24 | strategy: 25 | matrix: 26 | pgver: [pg13,pg14,pg15,pg16,pg17,pg18] 27 | fail-fast: false 28 | name: 'build-and-tests-pglinter-for-${{ matrix.pgver }}' 29 | runs-on: ubuntu-latest 30 | # Use GitHub Container Registry for pmpetit 31 | container: 32 | image: ghcr.io/pmpetit/postgresql_pglinter:pgrx 33 | options: --user root 34 | credentials: 35 | username: ${{ github.actor }} 36 | password: ${{ secrets.GH_TOKEN }} 37 | 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v4 41 | - name: Call reusable build action 42 | uses: ./.github/actions/reusable-build-action 43 | with: 44 | pgver: ${{ matrix.pgver }} 45 | always: ${{ matrix.pgver == 'pg13' && 'yes' || '' }} 46 | -------------------------------------------------------------------------------- /tests/expected/s001_schema_with_default_role_not_granted.out: -------------------------------------------------------------------------------- 1 | -- Regression test for S001: SchemaWithDefaultRoleNotGranted 2 | -- This test creates a schema without a default role privilege and checks that S001 identifies it. 3 | CREATE EXTENSION IF NOT EXISTS pglinter; 4 | \pset pager off 5 | -- Setup: Create test role and schema 6 | CREATE ROLE s001_owner LOGIN; 7 | CREATE SCHEMA s001_schema AUTHORIZATION s001_owner; 8 | SELECT 'Testing S001 in isolation...' AS test_step; 9 | test_step 10 | ------------------------------ 11 | Testing S001 in isolation... 12 | (1 row) 13 | 14 | SELECT pglinter.disable_all_rules() AS all_disabled; 15 | NOTICE: 🔴 Disabled 20 rule(s) 16 | all_disabled 17 | -------------- 18 | 20 19 | (1 row) 20 | 21 | SELECT pglinter.enable_rule('S001') AS S001_only_enabled; 22 | NOTICE: ✅ Rule S001 has been enabled 23 | s001_only_enabled 24 | ------------------- 25 | t 26 | (1 row) 27 | 28 | SELECT pglinter.perform_schema_check(); -- Should only run S001 29 | ERROR: function pglinter.perform_schema_check() does not exist 30 | LINE 1: SELECT pglinter.perform_schema_check(); 31 | ^ 32 | HINT: No function matches the given name and argument types. You might need to add explicit type casts. 33 | -- Cleanup 34 | DROP SCHEMA s001_schema CASCADE; 35 | DROP ROLE s001_owner; 36 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: pglinter 2 | site_description: PostgreSQL Database Linting and Analysis Extension 3 | site_author: pglinter contributors 4 | copyright: Copyright 2024-2025 pglinter contributors 5 | repo_url: https://github.com/pmpetit/pglinter 6 | edit_uri: edit/main/docs/ 7 | 8 | theme: 9 | name: readthedocs 10 | highlightjs: true 11 | hljs_languages: 12 | - yaml 13 | - sql 14 | - bash 15 | - rust 16 | 17 | nav: 18 | - 'Home': index.md 19 | - 'Configuration': configure.md 20 | - 'Security': SECURITY.md 21 | - Install: 22 | - 'Installation': install.md 23 | - How-To Guides: 24 | - 'Overview': how-to/README.md 25 | - Functions Reference: 26 | - 'Functions': functions/README.md 27 | - Rules Reference: 28 | - 'Rules': rules/README.md 29 | - Tutorial: 30 | - 'Create rules': tutorial/how_to_create_rules.md 31 | - Examples: 32 | - 'Examples': examples/README.md 33 | 34 | # Markdown settings 35 | markdown_extensions: 36 | - admonition 37 | - codehilite: 38 | guess_lang: false 39 | - toc: 40 | permalink: true 41 | - tables 42 | - fenced_code 43 | 44 | # Additional settings 45 | use_directory_urls: true 46 | strict: false 47 | 48 | # Plugin settings 49 | plugins: 50 | - search 51 | 52 | # Extra settings for ReadTheDocs 53 | extra: 54 | version: 55 | provider: mike 56 | -------------------------------------------------------------------------------- /tests/expected/s004_owner_schema_is_internal_role.out: -------------------------------------------------------------------------------- 1 | -- Regression test for S004: OwnerSchemaIsInternalRole 2 | -- This test creates a schema owned by a superuser and checks that S004 identifies it. 3 | CREATE EXTENSION IF NOT EXISTS pglinter; 4 | NOTICE: extension "pglinter" already exists, skipping 5 | \pset pager off 6 | -- Setup: Create test role and schema 7 | CREATE ROLE s004_owner LOGIN; 8 | CREATE SCHEMA s004_schema AUTHORIZATION postgres; 9 | SELECT 'Testing S004 in isolation...' AS test_step; 10 | test_step 11 | ------------------------------ 12 | Testing S004 in isolation... 13 | (1 row) 14 | 15 | SELECT pglinter.disable_all_rules() AS all_disabled; 16 | NOTICE: 🔴 Disabled 1 rule(s) 17 | all_disabled 18 | -------------- 19 | 1 20 | (1 row) 21 | 22 | SELECT pglinter.enable_rule('S004') AS S004_only_enabled; 23 | NOTICE: ✅ Rule S004 has been enabled 24 | s004_only_enabled 25 | ------------------- 26 | t 27 | (1 row) 28 | 29 | SELECT pglinter.perform_schema_check(); -- Should only run S004 30 | ERROR: function pglinter.perform_schema_check() does not exist 31 | LINE 1: SELECT pglinter.perform_schema_check(); 32 | ^ 33 | HINT: No function matches the given name and argument types. You might need to add explicit type casts. 34 | -- Cleanup 35 | DROP SCHEMA s004_schema CASCADE; 36 | DROP ROLE s004_owner; 37 | -------------------------------------------------------------------------------- /tests/expected/b012_composite_pk.out: -------------------------------------------------------------------------------- 1 | -- Test for B012: Composite primary keys with more than 4 columns 2 | CREATE EXTENSION IF NOT EXISTS pglinter; 3 | -- First, disable all rules to isolate B001 testing 4 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 5 | NOTICE: 🔴 Disabled 20 rule(s) 6 | all_rules_disabled 7 | -------------------- 8 | 20 9 | (1 row) 10 | 11 | -- Enable only B012 for focused testing 12 | SELECT pglinter.enable_rule('B012') AS b012_enabled; 13 | NOTICE: ✅ Rule B012 has been enabled 14 | b012_enabled 15 | -------------- 16 | t 17 | (1 row) 18 | 19 | CREATE TABLE test_composite_pk ( 20 | a INT, 21 | b INT, 22 | c INT, 23 | d INT, 24 | e INT, 25 | f INT, 26 | PRIMARY KEY (a, b, c, d, e, f) 27 | ); 28 | -- Run pglinter check 29 | SELECT pglinter.check(); 30 | NOTICE: 🔍 pglinter found 1 issue(s): 31 | NOTICE: ================================================== 32 | NOTICE: ❌ [B012] ERROR: 1 table(s) have composite primary keys with more than 4 columns. Object list: 33 | public.test_composite_pk(a, b, c, d, e, f) 34 | 35 | NOTICE: ================================================== 36 | NOTICE: 📊 Summary: 1 error(s), 0 warning(s), 0 info 37 | NOTICE: 🔴 Critical issues found - please review and fix errors 38 | check 39 | ------- 40 | t 41 | (1 row) 42 | 43 | DROP TABLE test_composite_pk CASCADE; 44 | DROP EXTENSION pglinter CASCADE; 45 | -------------------------------------------------------------------------------- /.github/workflows/pr-title-check.yml: -------------------------------------------------------------------------------- 1 | name: 'PR Title Validation' 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - reopened 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | - 'README.md' 13 | 14 | permissions: 15 | pull-requests: write 16 | statuses: write 17 | 18 | jobs: 19 | validate-title: 20 | name: Validate PR title 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: amannn/action-semantic-pull-request@v6 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | requireScope: false 28 | 29 | - uses: marocchino/sticky-pull-request-comment@v2 30 | if: always() && failure() 31 | with: 32 | header: pr-title-lint-error 33 | message: | 34 | 👋 Hi! Your PR title doesn't follow our [Conventional Commits](https://www.conventionalcommits.org/) format. 35 | 36 | Please update your PR title to match this pattern: 37 | ``` 38 | type(scope): description 39 | ``` 40 | 41 | Examples: 42 | - `feat: add new linting rule` 43 | - `fix(docs): correct typo in README` 44 | - `refactor(core): improve error handling` 45 | 46 | - uses: marocchino/sticky-pull-request-comment@v2 47 | if: success() 48 | with: 49 | header: pr-title-lint-error 50 | delete: true 51 | -------------------------------------------------------------------------------- /tests/expected/s002_schema_prefixed_or_suffixed_with_envt.out: -------------------------------------------------------------------------------- 1 | -- Regression test for S002: SchemaPrefixedOrSuffixedWithEnvt 2 | -- This test creates schemas with environment prefixes/suffixes and checks that S002 identifies them. 3 | -- Setup: Create test role 4 | CREATE ROLE s002_owner LOGIN; 5 | -- Create schemas with environment prefixes/suffixes 6 | CREATE SCHEMA prod_schema AUTHORIZATION s002_owner; 7 | CREATE SCHEMA dev_schema AUTHORIZATION s002_owner; 8 | CREATE SCHEMA s002_schema AUTHORIZATION s002_owner; 9 | SELECT 'Testing S002 in isolation...' AS test_step; 10 | test_step 11 | ------------------------------ 12 | Testing S002 in isolation... 13 | (1 row) 14 | 15 | SELECT pglinter.disable_all_rules() AS all_disabled; 16 | NOTICE: 🔴 Disabled 1 rule(s) 17 | all_disabled 18 | -------------- 19 | 1 20 | (1 row) 21 | 22 | SELECT pglinter.enable_rule('S002') AS S002_only_enabled; 23 | NOTICE: ✅ Rule S002 has been enabled 24 | s002_only_enabled 25 | ------------------- 26 | t 27 | (1 row) 28 | 29 | SELECT pglinter.perform_schema_check(); -- Should only run S002 30 | ERROR: function pglinter.perform_schema_check() does not exist 31 | LINE 1: SELECT pglinter.perform_schema_check(); 32 | ^ 33 | HINT: No function matches the given name and argument types. You might need to add explicit type casts. 34 | -- Cleanup 35 | DROP SCHEMA prod_schema CASCADE; 36 | DROP SCHEMA dev_schema CASCADE; 37 | DROP SCHEMA s002_schema CASCADE; 38 | DROP ROLE s002_owner; 39 | -------------------------------------------------------------------------------- /.github/actions/reusable-build-action/action.yml: -------------------------------------------------------------------------------- 1 | # .github/actions/reusable-build-action/action.yml 2 | name: 'Build and Test PostgreSQL Version' 3 | 4 | description: 'Builds and tests the pglinter extension for a given PostgreSQL version.' 5 | 6 | inputs: 7 | pgver: 8 | description: 'The PostgreSQL major version with the "pg" prefix (e.g. `pg13`, `pg16`, etc.)' 9 | required: true 10 | always: 11 | description: 'If defined, all jobs are launched in any case.' 12 | required: false 13 | 14 | runs: 15 | using: "composite" 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup workspace for postgres user 21 | run: | 22 | chown -R postgres:postgres $GITHUB_WORKSPACE 23 | chmod -R 755 $GITHUB_WORKSPACE 24 | shell: bash 25 | 26 | - name: Lint (only for default PG version) 27 | if: inputs.always != '' 28 | run: su - postgres -c "cd $GITHUB_WORKSPACE && make lint" 29 | shell: bash 30 | 31 | - name: Build the extension package 32 | run: su - postgres -c "cd $GITHUB_WORKSPACE && make" 33 | shell: bash 34 | 35 | - name: Launch postgres instance and run unit tests 36 | run: su - postgres -c "cd $GITHUB_WORKSPACE && make test" 37 | shell: bash 38 | 39 | - name: Run regressions tests 40 | run: | 41 | su - postgres -c "cd $GITHUB_WORKSPACE && make install" 42 | su - postgres -c "cd $GITHUB_WORKSPACE && make installcheck" 43 | shell: bash 44 | -------------------------------------------------------------------------------- /tests/expected/s003_unsecured_public_schema.out: -------------------------------------------------------------------------------- 1 | -- Regression test for S003: UnsecuredPublicSchema 2 | -- This test creates a schema and grants CREATE privilege to PUBLIC, then checks that S003 identifies it. 3 | CREATE EXTENSION IF NOT EXISTS pglinter; 4 | NOTICE: extension "pglinter" already exists, skipping 5 | \pset pager off 6 | -- Setup: Create test role and schema 7 | CREATE ROLE s003_owner LOGIN; 8 | CREATE SCHEMA s003_schema AUTHORIZATION s003_owner; 9 | -- Grant CREATE privilege to PUBLIC 10 | GRANT CREATE ON SCHEMA s003_schema TO PUBLIC; 11 | SELECT 'Testing S003 in isolation...' AS test_step; 12 | test_step 13 | ------------------------------ 14 | Testing S003 in isolation... 15 | (1 row) 16 | 17 | SELECT pglinter.disable_all_rules() AS all_disabled; 18 | NOTICE: 🔴 Disabled 1 rule(s) 19 | all_disabled 20 | -------------- 21 | 1 22 | (1 row) 23 | 24 | SELECT pglinter.enable_rule('S003') AS S003_only_enabled; 25 | NOTICE: ✅ Rule S003 has been enabled 26 | s003_only_enabled 27 | ------------------- 28 | t 29 | (1 row) 30 | 31 | SELECT pglinter.perform_schema_check(); -- Should only run S003 32 | ERROR: function pglinter.perform_schema_check() does not exist 33 | LINE 1: SELECT pglinter.perform_schema_check(); 34 | ^ 35 | HINT: No function matches the given name and argument types. You might need to add explicit type casts. 36 | -- Cleanup 37 | REVOKE CREATE ON SCHEMA s003_schema FROM PUBLIC; 38 | DROP SCHEMA s003_schema CASCADE; 39 | DROP ROLE s003_owner; 40 | -------------------------------------------------------------------------------- /tests/sql/b001_configurable.sql: -------------------------------------------------------------------------------- 1 | -- Quick test to verify B001 rule uses configurable thresholds 2 | DROP EXTENSION IF EXISTS pglinter CASCADE; 3 | CREATE EXTENSION pglinter; 4 | 5 | \pset pager off 6 | 7 | -- Create a table without primary key 8 | CREATE TABLE test_no_pk ( 9 | id INTEGER, 10 | name TEXT 11 | ); 12 | 13 | -- Create a table with a primary key 14 | CREATE TABLE test_with_pk ( 15 | id INTEGER PRIMARY KEY, 16 | name TEXT 17 | ); 18 | 19 | -- First, disable all rules to isolate B001 testing 20 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 21 | 22 | -- Enable only B001 for focused testing 23 | SELECT pglinter.enable_rule('B001') AS b001_enabled; 24 | 25 | -- Test B001 rule - should show it uses the configured thresholds 26 | SELECT pglinter.check(); 27 | SELECT pglinter.check('/tmp/pglinter_b001_results.sarif'); 28 | \! md5sum /tmp/pglinter_b001_results.sarif 29 | 30 | -- Update B001 thresholds to very large values (60%, 80%) not to trigger on any table without PK 31 | SELECT pglinter.update_rule_levels('B001', 60, 80); 32 | 33 | -- Check updated thresholds 34 | SELECT 35 | warning_level, 36 | error_level 37 | FROM pglinter.rules 38 | WHERE code = 'B001'; 39 | 40 | -- Test B001 rule again - should now trigger with new thresholds 41 | SELECT pglinter.check(); 42 | 43 | -- Update B001 thresholds to very low values (1%, 2%) not to trigger on any table without PK 44 | SELECT pglinter.update_rule_levels('B001', 60, 80); 45 | 46 | 47 | DROP TABLE test_no_pk CASCADE; 48 | DROP TABLE test_with_pk CASCADE; 49 | 50 | DROP EXTENSION pglinter CASCADE; 51 | -------------------------------------------------------------------------------- /.github/workflows/test-deb-package.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/test-single-pg.yml 2 | name: Test DEB Package 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | pg_version: 8 | description: 'PostgreSQL version to test' 9 | required: true 10 | default: '17' 11 | type: choice 12 | options: ['13', '14', '15', '16', '17', '18'] 13 | platform: 14 | description: 'Target platform' 15 | required: false 16 | default: 'linux/amd64' 17 | type: choice 18 | options: ['linux/amd64', 'linux/arm64'] 19 | pglinter_version: 20 | description: 'pglinter version to test' 21 | required: false 22 | default: '0.0.19' 23 | type: string 24 | 25 | 26 | jobs: 27 | test-deb-amd64-pglinter: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | - name: Test deb package 33 | uses: ./.github/actions/reusable-test-deb-package 34 | with: 35 | pg_version: ${{ inputs.pg_version }} 36 | pglinter_version: ${{ inputs.pglinter_version }} 37 | platform: ${{ inputs.platform }} 38 | timeout: '180' 39 | test-deb-arm64-pglinter: 40 | runs-on: ubuntu-24.04-arm 41 | steps: 42 | - name: Checkout code 43 | uses: actions/checkout@v4 44 | - name: Test deb package 45 | uses: ./.github/actions/reusable-test-deb-package 46 | with: 47 | pg_version: ${{ inputs.pg_version }} 48 | pglinter_version: ${{ inputs.pglinter_version }} 49 | platform: ${{ inputs.platform }} 50 | timeout: '180' 51 | 52 | -------------------------------------------------------------------------------- /tests/sql/quick_demo_levels.sql: -------------------------------------------------------------------------------- 1 | -- Quick demonstration of the new rule level management functions 2 | -- Run this in PostgreSQL after installing the updated pglinter extension 3 | CREATE EXTENSION pglinter; 4 | 5 | \echo '=== Rule Level Management Demo ===' 6 | 7 | -- Show current T005 levels (should be 50, 90 from rules.sql) 8 | \echo 'Current T005 levels:' 9 | SELECT pglinter.get_rule_levels('T005') as current_t005_levels; 10 | 11 | -- Update T005 to be more strict (lower thresholds) 12 | \echo 'Making T005 more strict (warning=20, error=40):' 13 | SELECT pglinter.update_rule_levels('T005', 20, 40) as update_success; 14 | 15 | -- Verify the change 16 | \echo 'T005 levels after update:' 17 | SELECT pglinter.get_rule_levels('T005') as updated_t005_levels; 18 | 19 | -- Check a few other rules 20 | \echo 'Current levels for various rules:' 21 | SELECT 22 | code, 23 | warning_level, 24 | error_level, 25 | pglinter.get_rule_levels(code) as formatted_levels 26 | FROM pglinter.rules 27 | WHERE code IN ('B001', 'T001', 'T005') 28 | ORDER BY code; 29 | 30 | -- Update only warning level for B001 31 | \echo 'Updating only B001 warning level to 5:' 32 | SELECT pglinter.update_rule_levels('B001', 5, NULL) as partial_update; 33 | 34 | -- Show the result 35 | \echo 'B001 after partial update:' 36 | SELECT pglinter.get_rule_levels('B001') as b001_levels; 37 | 38 | \echo '=== Demo Complete ===' 39 | \echo 'New functions available:' 40 | \echo ' - pglinter.get_rule_levels(rule_code)' 41 | \echo ' - pglinter.update_rule_levels(rule_code, warning_level, error_level)' 42 | \echo '' 43 | \echo 'Use NULL for warning_level or error_level to keep current value unchanged.' 44 | 45 | DROP EXTENSION pglinter CASCADE; 46 | -------------------------------------------------------------------------------- /tests/expected/s005_several_table_owner_in_schema.out: -------------------------------------------------------------------------------- 1 | -- Regression test for S005: SeveralTableOwnerInSchema 2 | -- This test creates two tables in the same schema with different owners 3 | -- and checks that the rule S005 correctly identifies the schema as problematic. 4 | CREATE EXTENSION IF NOT EXISTS pglinter; 5 | NOTICE: extension "pglinter" already exists, skipping 6 | \pset pager off 7 | -- Setup: Create test roles and schema 8 | CREATE ROLE s005_owner1 LOGIN; 9 | CREATE ROLE s005_owner2 LOGIN; 10 | CREATE SCHEMA s005_schema AUTHORIZATION s005_owner1; 11 | -- Create tables with different owners in the same schema 12 | CREATE TABLE s005_schema.table1 (id INT); 13 | ALTER TABLE s005_schema.table1 OWNER TO s005_owner1; 14 | CREATE TABLE s005_schema.table2 (id INT); 15 | ALTER TABLE s005_schema.table2 OWNER TO s005_owner2; 16 | SELECT 'Testing S005 in isolation...' AS test_step; 17 | test_step 18 | ------------------------------ 19 | Testing S005 in isolation... 20 | (1 row) 21 | 22 | SELECT pglinter.disable_all_rules() AS all_disabled; 23 | NOTICE: 🔴 Disabled 1 rule(s) 24 | all_disabled 25 | -------------- 26 | 1 27 | (1 row) 28 | 29 | SELECT pglinter.enable_rule('S005') AS S005_only_enabled; 30 | NOTICE: ✅ Rule S005 has been enabled 31 | s005_only_enabled 32 | ------------------- 33 | t 34 | (1 row) 35 | 36 | SELECT pglinter.perform_schema_check(); -- Should only run S005 37 | ERROR: function pglinter.perform_schema_check() does not exist 38 | LINE 1: SELECT pglinter.perform_schema_check(); 39 | ^ 40 | HINT: No function matches the given name and argument types. You might need to add explicit type casts. 41 | -- Cleanup 42 | DROP TABLE s005_schema.table1; 43 | DROP TABLE s005_schema.table2; 44 | DROP SCHEMA s005_schema; 45 | DROP ROLE s005_owner1; 46 | DROP ROLE s005_owner2; 47 | -------------------------------------------------------------------------------- /nfpm.template.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "postgresql_pglinter_${PG_MAJOR_VERSION}" 3 | arch: "${PACKAGE_ARCH}" 4 | platform: "linux" 5 | version: "v${PGLINTER_MINOR_VERSION}" 6 | section: "default" 7 | priority: "extra" 8 | maintainer: "pmpetit" 9 | description: | 10 | pglinter analyzes database for best practice in PostgreSQL ${PG_MAJOR_VERSION} 11 | vendor: "pmpetit" 12 | homepage: "https://github.com/pmpetit/pglinter" 13 | license: "PostgreSQL" 14 | depends: 15 | - postgresql-${PG_MAJOR_VERSION} 16 | contents: 17 | - src: ${PG_PKGLIBDIR}/pglinter.so 18 | dst: /usr/lib/postgresql/${PG_MAJOR_VERSION}/lib/pglinter.so 19 | # Install only the specific extension files, not the entire directory tree 20 | - src: ${PG_SHAREDIR}/extension/pglinter.control 21 | dst: /usr/share/postgresql/${PG_MAJOR_VERSION}/extension/pglinter.control 22 | - src: ${PG_SHAREDIR}/extension/pglinter--${PGLINTER_MINOR_VERSION}.sql 23 | dst: /usr/share/postgresql/${PG_MAJOR_VERSION}/extension/pglinter--${PGLINTER_MINOR_VERSION}.sql 24 | 25 | overrides: 26 | rpm: 27 | # The postgres server package is named `postgresql-server` on the RHEL repo 28 | # and it is named `postgresql16-server` in the PGDG repo. 29 | # With this `depends` clause we're making sure that postgres itself is 30 | # installed from the PGDG repo.. 31 | depends: 32 | - postgresql${PG_MAJOR_VERSION}-server 33 | # These locations are based on the PGDG packages, not the RedHat ones 34 | contents: 35 | - src: ${PG_PKGLIBDIR}/pglinter.so 36 | dst: /usr/pgsql-${PG_MAJOR_VERSION}/lib/pglinter.so 37 | # Install only the specific extension files, not the entire directory tree 38 | - src: ${PG_SHAREDIR}/extension/pglinter.control 39 | dst: /usr/pgsql-${PG_MAJOR_VERSION}/share/extension/pglinter.control 40 | - src: ${PG_SHAREDIR}/extension/pglinter--${PGLINTER_MINOR_VERSION}.sql 41 | dst: /usr/pgsql-${PG_MAJOR_VERSION}/share/extension/pglinter--${PGLINTER_MINOR_VERSION}.sql 42 | -------------------------------------------------------------------------------- /tests/sql/b002_non_redundant_idx.sql: -------------------------------------------------------------------------------- 1 | -- Test for pglinter B002 rule: No Redundant indexes exists, no warning should be raised. 2 | CREATE EXTENSION pglinter; 3 | 4 | -- Create test tables with redundant indexes 5 | CREATE TABLE test_table_without_redundant_indexes ( 6 | id INT PRIMARY KEY, 7 | name TEXT, 8 | email VARCHAR(255), 9 | status VARCHAR(50), 10 | created_at TIMESTAMP DEFAULT NOW() 11 | ); 12 | 13 | 14 | -- Create table with one index and a unique constrainte on the same column 15 | CREATE TABLE orders_table_with_constraint ( 16 | order_id SERIAL PRIMARY KEY, 17 | customer_id INT UNIQUE, 18 | product_name VARCHAR(255), 19 | order_date DATE, 20 | amount DECIMAL(10, 2) 21 | ); 22 | 23 | -- Create another table for more redundant index scenarios 24 | CREATE TABLE orders_table ( 25 | order_id SERIAL PRIMARY KEY, 26 | customer_id INT, 27 | product_name VARCHAR(255), 28 | order_date DATE, 29 | amount DECIMAL(10, 2) 30 | ); 31 | 32 | -- First, disable all rules to isolate B001 testing 33 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 34 | 35 | -- Enable only B002 for focused testing 36 | SELECT pglinter.enable_rule('B002') AS b001_enabled; 37 | 38 | -- Test with file output 39 | SELECT pglinter.check('/tmp/pglinter_b002_results.sarif'); 40 | 41 | -- Test if file exists and show checksum 42 | \! md5sum /tmp/pglinter_b002_results.sarif 43 | 44 | -- Test with no output file (should output to prompt) 45 | SELECT pglinter.check(); 46 | 47 | -- Test rule management for B002 48 | SELECT pglinter.explain_rule('B002'); 49 | 50 | -- Show that B002 is enabled 51 | SELECT pglinter.is_rule_enabled('B002') AS b002_enabled; 52 | 53 | -- Disable B002 temporarily and test 54 | SELECT pglinter.disable_rule('B002') AS b002_disabled; 55 | SELECT pglinter.check(); 56 | 57 | DROP TABLE orders_table CASCADE; 58 | DROP TABLE orders_table_with_constraint CASCADE; 59 | DROP TABLE test_table_without_redundant_indexes CASCADE; 60 | 61 | DROP EXTENSION pglinter CASCADE; 62 | -------------------------------------------------------------------------------- /tests/expected/b011_several_table_owner_in_schema.out: -------------------------------------------------------------------------------- 1 | -- Regression test for B011: SeveralTableOwnerInSchema 2 | -- This test creates two tables in the same schema with different owners 3 | -- and checks that the rule B011 correctly identifies the schema as problematic. 4 | CREATE EXTENSION pglinter; 5 | \pset pager off 6 | -- Setup: Create test roles and schema 7 | CREATE ROLE s005_owner1 LOGIN; 8 | CREATE ROLE s005_owner2 LOGIN; 9 | CREATE SCHEMA s005_schema AUTHORIZATION s005_owner1; 10 | -- Create tables with different owners in the same schema 11 | CREATE TABLE s005_schema.table1 (id INT); 12 | ALTER TABLE s005_schema.table1 OWNER TO s005_owner1; 13 | CREATE TABLE s005_schema.table2 (id INT); 14 | ALTER TABLE s005_schema.table2 OWNER TO s005_owner2; 15 | SELECT 'Testing B011 in isolation...' AS test_step; 16 | test_step 17 | ------------------------------ 18 | Testing B011 in isolation... 19 | (1 row) 20 | 21 | SELECT pglinter.disable_all_rules() AS all_disabled; 22 | NOTICE: 🔴 Disabled 20 rule(s) 23 | all_disabled 24 | -------------- 25 | 20 26 | (1 row) 27 | 28 | SELECT pglinter.enable_rule('B011') AS B011_only_enabled; 29 | NOTICE: ✅ Rule B011 has been enabled 30 | b011_only_enabled 31 | ------------------- 32 | t 33 | (1 row) 34 | 35 | SELECT pglinter.check(); -- Should only run B011 36 | NOTICE: 🔍 pglinter found 1 issue(s): 37 | NOTICE: ================================================== 38 | NOTICE: ⚠️ [B011] WARNING: 1/2 schemas have tables owned by different owners. Exceed the warning threshold: 50%. Object list: 39 | s005_schema.table1 owner is s005_owner1 40 | s005_schema.table2 owner is s005_owner2 41 | 42 | NOTICE: ================================================== 43 | NOTICE: 📊 Summary: 0 error(s), 1 warning(s), 0 info 44 | NOTICE: 🟡 Some warnings found - consider reviewing for optimization 45 | check 46 | ------- 47 | t 48 | (1 row) 49 | 50 | -- Cleanup 51 | DROP TABLE s005_schema.table1; 52 | DROP TABLE s005_schema.table2; 53 | DROP SCHEMA s005_schema; 54 | DROP ROLE s005_owner1; 55 | DROP ROLE s005_owner2; 56 | DROP EXTENSION pglinter; 57 | -------------------------------------------------------------------------------- /tests/sql/schema_rules.sql: -------------------------------------------------------------------------------- 1 | -- Test for pglinter schema-level rules 2 | -- This script demonstrates schema-level checks and rule management 3 | CREATE EXTENSION pglinter; 4 | 5 | -- Create test schemas that should trigger S002 (environment prefixes/suffixes) 6 | CREATE SCHEMA prod_sales; 7 | CREATE SCHEMA dev_analytics; 8 | CREATE SCHEMA testing_data; 9 | CREATE SCHEMA reports_staging; 10 | 11 | -- Create a clean schema that should not trigger rules 12 | CREATE SCHEMA business_logic; 13 | 14 | -- Create some objects in the schemas to make them more realistic 15 | CREATE TABLE prod_sales.customers ( 16 | id SERIAL PRIMARY KEY, 17 | name TEXT NOT NULL 18 | ); 19 | 20 | CREATE TABLE dev_analytics.metrics ( 21 | id SERIAL PRIMARY KEY, 22 | metric_name TEXT NOT NULL, 23 | value NUMERIC 24 | ); 25 | 26 | CREATE TABLE business_logic.rules ( 27 | id SERIAL PRIMARY KEY, 28 | rule_name TEXT NOT NULL 29 | ); 30 | 31 | 32 | 33 | -- Enable only S002 34 | SELECT pglinter.disable_all_rules(); 35 | SELECT pglinter.enable_rule('S002'); 36 | 37 | -- Test the schema rules 38 | SELECT 'Testing schema rules S002...' as test_info; 39 | 40 | -- Run schema check to detect environment-named schemas and default privilege issues 41 | SELECT pglinter.perform_schema_check(); 42 | 43 | -- Test individual schema rules 44 | SELECT pglinter.explain_rule('S002'); 45 | 46 | -- Test rule management for schema rules 47 | SELECT pglinter.is_rule_enabled('S002') AS s002_enabled; 48 | 49 | -- Test disabling S002 (environment prefixes) 50 | SELECT pglinter.disable_rule('S002') AS s002_disabled; 51 | SELECT pglinter.perform_schema_check(); -- Should skip S002 52 | 53 | -- Re-enable S002 54 | SELECT pglinter.enable_rule('S002') AS s002_reenabled; 55 | SELECT pglinter.perform_schema_check(); -- Should include S002 again 56 | 57 | -- Test the comprehensive check including schemas 58 | SELECT pglinter.check(); 59 | 60 | DROP SCHEMA prod_sales CASCADE; 61 | DROP SCHEMA dev_analytics CASCADE; 62 | DROP SCHEMA testing_data CASCADE; 63 | DROP SCHEMA reports_staging CASCADE; 64 | DROP SCHEMA business_logic CASCADE; 65 | 66 | DROP EXTENSION pglinter CASCADE; 67 | -------------------------------------------------------------------------------- /tests/sql/c003_md5_pwd_PG17-.sql: -------------------------------------------------------------------------------- 1 | -- Comprehensive test for pglinter C003 rule: MD5 encrypted passwords 2 | -- This script tests the detection of MD5 password encryption which is deprecated and insecure 3 | -- Note: MD5 password encryption was removed in PostgreSQL 18 4 | 5 | CREATE EXTENSION pglinter; 6 | 7 | \pset pager off 8 | 9 | SELECT 'Testing C003 rule - MD5 password encryption checks...' AS test_info; 10 | 11 | -- First, disable all rules to isolate C003 testing 12 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 13 | 14 | -- Enable only C003 for focused testing 15 | SELECT pglinter.enable_rule('C003') AS c003_enabled; 16 | 17 | -- Verify C003 is enabled 18 | SELECT pglinter.is_rule_enabled('C003') AS c003_status; 19 | 20 | -- Test 1: Check current password_encryption setting 21 | SELECT '=== Test 1: Current password_encryption setting ===' AS test_section; 22 | SELECT name, setting, context 23 | FROM pg_settings 24 | WHERE name = 'password_encryption'; 25 | 26 | -- Test 2: Run C003 check with current settings 27 | SELECT '=== Test 2: C003 Rule Execution ===' AS test_section; 28 | SELECT pglinter.perform_cluster_check(); 29 | 30 | -- Test 3: Rule explanation 31 | SELECT '=== Test 3: C003 Rule Explanation ===' AS test_section; 32 | SELECT pglinter.explain_rule('C003') AS rule_explanation; 33 | 34 | -- Test 4: Rule details 35 | SELECT '=== Test 4: C003 Rule Details ===' AS test_section; 36 | SELECT code, name, description, message, fixes 37 | FROM pglinter.rules 38 | WHERE code = 'C003'; 39 | 40 | -- Test 5: Show the actual query used by C003 41 | SELECT '=== Test 5: C003 Query Details ===' AS test_section; 42 | SELECT code, q1 as query 43 | FROM pglinter.rules 44 | WHERE code = 'C003'; 45 | 46 | -- Test 6: Manual execution of C003 query to understand results 47 | SELECT '=== Test 6: Manual C003 Query Execution ===' AS test_section; 48 | SELECT count(*) as md5_password_count 49 | FROM pg_catalog.pg_settings 50 | WHERE name='password_encryption' AND setting='md5'; 51 | 52 | -- Test 7: Export results to SARIF format 53 | SELECT '=== Test 7: Export to SARIF ===' AS test_section; 54 | SELECT pglinter.check('/tmp/pglinter_c003_results.sarif'); 55 | 56 | -- Show checksum of generated file if it exists 57 | \! test -f /tmp/pglinter_c003_results.sarif && md5sum /tmp/pglinter_c003_results.sarif || echo "SARIF file not generated" 58 | 59 | 60 | DROP EXTENSION pglinter CASCADE; 61 | -------------------------------------------------------------------------------- /.github/actions/reusable-release-action/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Create GitHub Release' 2 | 3 | description: 'Downloads artifacts and creates a GitHub release with all packages' 4 | 5 | inputs: 6 | tag: 7 | description: 'The tag/version for the release' 8 | required: true 9 | github-token: 10 | description: 'GitHub token for creating releases' 11 | required: true 12 | prerelease: 13 | description: 'Mark as prerelease' 14 | required: false 15 | default: 'false' 16 | draft: 17 | description: 'Create as draft' 18 | required: false 19 | default: 'false' 20 | 21 | runs: 22 | using: "composite" 23 | steps: 24 | - name: Download all artifacts 25 | uses: actions/download-artifact@v4 26 | with: 27 | path: ./artifacts 28 | merge-multiple: true 29 | 30 | - name: Display downloaded artifacts 31 | run: | 32 | echo "Downloaded artifacts:" 33 | find ./artifacts -name "*.rpm" -o -name "*.deb" | sort 34 | echo "" 35 | echo "Artifact count:" 36 | find ./artifacts -name "*.rpm" -o -name "*.deb" | wc -l 37 | shell: bash 38 | 39 | - name: Validate artifacts 40 | run: | 41 | rpm_count=$(find ./artifacts -name "*.rpm" | wc -l) 42 | deb_count=$(find ./artifacts -name "*.deb" | wc -l) 43 | 44 | echo "Found $rpm_count RPM packages" 45 | echo "Found $deb_count DEB packages" 46 | 47 | if [ "$rpm_count" -eq 0 ] && [ "$deb_count" -eq 0 ]; then 48 | echo "ERROR: No packages found to release!" 49 | exit 1 50 | fi 51 | shell: bash 52 | 53 | - name: Create Release 54 | id: create_release 55 | uses: softprops/action-gh-release@v1 56 | env: 57 | GITHUB_TOKEN: ${{ inputs.github-token }} 58 | with: 59 | tag_name: ${{ inputs.tag }} 60 | name: pglinter v${{ inputs.tag }} 61 | draft: ${{ inputs.draft }} 62 | prerelease: ${{ inputs.prerelease }} 63 | files: | 64 | ./artifacts/*.rpm 65 | ./artifacts/*.deb 66 | 67 | - name: List release assets 68 | run: | 69 | echo "Release created successfully!" 70 | echo "Release URL: ${{ steps.create_release.outputs.url }}" 71 | echo "" 72 | echo "Uploaded files:" 73 | find ./artifacts \( -name "*.rpm" -o -name "*.deb" \) -exec basename {} \; | sort 74 | shell: bash 75 | -------------------------------------------------------------------------------- /META.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pglinter", 3 | "abstract": "PostgreSQL Database Linting and Analysis Extension", 4 | "description": "A comprehensive PostgreSQL extension for analyzing databases to identify potential issues, performance problems, and best practice violations. Originally based on the Python pglinter, this Rust implementation provides better performance, deep PostgreSQL integration, SARIF output support, and YAML-based rule configuration management.", 5 | "version": "0.0.19", 6 | "maintainer": [ 7 | "Pierre-Marie Petit " 8 | ], 9 | "license": "postgresql", 10 | "prereqs": { 11 | "runtime": { 12 | "requires": { 13 | "PostgreSQL": "13.0.0" 14 | }, 15 | "recommends": { 16 | "PostgreSQL": "17.0.0" 17 | } 18 | }, 19 | "build": { 20 | "requires": { 21 | "PostgreSQL": "13.0.0" 22 | } 23 | }, 24 | "test": { 25 | "requires": { 26 | "PostgreSQL": "13.0.0" 27 | } 28 | } 29 | }, 30 | "provides": { 31 | "pglinter": { 32 | "abstract": "PostgreSQL Database Linting and Analysis Extension", 33 | "file": "sql/pglinter--0.0.17.sql", 34 | "version": "0.0.17" 35 | } 36 | }, 37 | "resources": { 38 | "homepage": "https://github.com/pmpetit/pglinter", 39 | "bugtracker": { 40 | "web": "https://github.com/pmpetit/pglinter/issues" 41 | }, 42 | "repository": { 43 | "url": "git://github.com/pmpetit/pglinter.git", 44 | "web": "https://github.com/pmpetit/pglinter", 45 | "type": "git" 46 | } 47 | }, 48 | "generated_by": "Pierre-Marie Petit", 49 | "meta-spec": { 50 | "version": "1.0.0", 51 | "url": "https://pgxn.org/meta/spec.txt" 52 | }, 53 | "tags": [ 54 | "analysis", 55 | "linting", 56 | "database quality", 57 | "performance", 58 | "best practices", 59 | "sarif", 60 | "yaml", 61 | "configuration management", 62 | "rule management", 63 | "rust", 64 | "pgrx", 65 | "postgresql extension", 66 | "database health", 67 | "schema analysis", 68 | "index optimization", 69 | "foreign keys", 70 | "primary keys", 71 | "import export" 72 | ], 73 | "no_index": { 74 | "file": [ 75 | "src/rules.sql" 76 | ], 77 | "directory": [ 78 | "src/private", 79 | "target/", 80 | "docs/dev/", 81 | "sql/test/", 82 | "tests/expected/", 83 | "coverage/" 84 | ] 85 | }, 86 | "release_status": "stable" 87 | } 88 | -------------------------------------------------------------------------------- /tests/sql/demo_rule_levels.sql: -------------------------------------------------------------------------------- 1 | -- Demo script for the new rule level management functions 2 | -- This script demonstrates how to get and update warning_level and error_level for rules 3 | CREATE EXTENSION pglinter; 4 | 5 | \echo 'Testing rule level management functions...' 6 | 7 | -- First, let's see the current levels for T005 8 | \echo 'Current T005 rule levels:' 9 | SELECT pglinter.get_rule_levels('T005') as current_levels; 10 | 11 | -- Let's also check a few other rules 12 | \echo 'Current levels for some rules:' 13 | SELECT 14 | code, 15 | pglinter.get_rule_levels(code) as levels 16 | FROM pglinter.list_rules() 17 | WHERE code IN ('B001', 'T001', 'T005', 'C002') 18 | ORDER BY code; 19 | 20 | -- Update T005 to have different thresholds 21 | \echo 'Updating T005 warning level to 25 and error level to 75:' 22 | SELECT pglinter.update_rule_levels('T005', 25, 75) as update_success; 23 | 24 | -- Verify the update 25 | \echo 'T005 levels after update:' 26 | SELECT pglinter.get_rule_levels('T005') as updated_levels; 27 | 28 | -- Update only the warning level of B001 29 | \echo 'Updating only B001 warning level to 5:' 30 | SELECT pglinter.update_rule_levels('B001', 5, NULL) as update_success; 31 | 32 | -- Verify B001 update 33 | \echo 'B001 levels after warning level update:' 34 | SELECT pglinter.get_rule_levels('B001') as updated_levels; 35 | 36 | -- Update only the error level of T001 37 | \echo 'Updating only T001 error level to 3:' 38 | SELECT pglinter.update_rule_levels('T001', NULL, 3) as update_success; 39 | 40 | -- Verify T001 update 41 | \echo 'T001 levels after error level update:' 42 | SELECT pglinter.get_rule_levels('T001') as updated_levels; 43 | 44 | -- Try to update a non-existent rule 45 | \echo 'Trying to update non-existent rule (should return false):' 46 | SELECT pglinter.update_rule_levels('NONEXISTENT', 10, 20) as should_be_false; 47 | 48 | -- You can also query the rules table directly to see the raw values 49 | \echo 'Raw warning_level and error_level from rules table:' 50 | SELECT code, name, warning_level, error_level, enable 51 | FROM pglinter.rules 52 | WHERE code IN ('B001', 'T001', 'T005', 'C002') 53 | ORDER BY code; 54 | 55 | \echo 'Rule level management demo completed!' 56 | \echo '' 57 | \echo 'Usage Summary:' 58 | \echo ' - Get levels: SELECT pglinter.get_rule_levels(''RULE_CODE'');' 59 | \echo ' - Update both: SELECT pglinter.update_rule_levels(''RULE_CODE'', warning, error);' 60 | \echo ' - Update warning only: SELECT pglinter.update_rule_levels(''RULE_CODE'', warning, NULL);' 61 | \echo ' - Update error only: SELECT pglinter.update_rule_levels(''RULE_CODE'', NULL, error);' 62 | \echo '' 63 | \echo 'Note: Changes affect rule behavior immediately. Higher values mean more permissive thresholds.' 64 | 65 | DROP EXTENSION pglinter CASCADE; 66 | -------------------------------------------------------------------------------- /tests/expected/b001_configurable.out: -------------------------------------------------------------------------------- 1 | -- Quick test to verify B001 rule uses configurable thresholds 2 | DROP EXTENSION IF EXISTS pglinter CASCADE; 3 | NOTICE: extension "pglinter" does not exist, skipping 4 | CREATE EXTENSION pglinter; 5 | \pset pager off 6 | -- Create a table without primary key 7 | CREATE TABLE test_no_pk ( 8 | id INTEGER, 9 | name TEXT 10 | ); 11 | -- Create a table with a primary key 12 | CREATE TABLE test_with_pk ( 13 | id INTEGER PRIMARY KEY, 14 | name TEXT 15 | ); 16 | -- First, disable all rules to isolate B001 testing 17 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 18 | NOTICE: 🔴 Disabled 20 rule(s) 19 | all_rules_disabled 20 | -------------------- 21 | 20 22 | (1 row) 23 | 24 | -- Enable only B001 for focused testing 25 | SELECT pglinter.enable_rule('B001') AS b001_enabled; 26 | NOTICE: ✅ Rule B001 has been enabled 27 | b001_enabled 28 | -------------- 29 | t 30 | (1 row) 31 | 32 | -- Test B001 rule - should show it uses the configured thresholds 33 | SELECT pglinter.check(); 34 | NOTICE: 🔍 pglinter found 1 issue(s): 35 | NOTICE: ================================================== 36 | NOTICE: ⚠️ [B001] WARNING: 1/2 table(s) without primary key exceed the warning threshold: 50%. Object list: 37 | public.test_no_pk 38 | 39 | NOTICE: ================================================== 40 | NOTICE: 📊 Summary: 0 error(s), 1 warning(s), 0 info 41 | NOTICE: 🟡 Some warnings found - consider reviewing for optimization 42 | check 43 | ------- 44 | t 45 | (1 row) 46 | 47 | SELECT pglinter.check('/tmp/pglinter_b001_results.sarif'); 48 | check 49 | ------- 50 | t 51 | (1 row) 52 | 53 | \! md5sum /tmp/pglinter_b001_results.sarif 54 | a47a470d966e0d6f54fe7c27201a1953 /tmp/pglinter_b001_results.sarif 55 | -- Update B001 thresholds to very large values (60%, 80%) not to trigger on any table without PK 56 | SELECT pglinter.update_rule_levels('B001', 60, 80); 57 | NOTICE: ✅ Updated rule B001 levels: warning=60, error=80 58 | update_rule_levels 59 | -------------------- 60 | t 61 | (1 row) 62 | 63 | -- Check updated thresholds 64 | SELECT 65 | warning_level, 66 | error_level 67 | FROM pglinter.rules 68 | WHERE code = 'B001'; 69 | warning_level | error_level 70 | ---------------+------------- 71 | 60 | 80 72 | (1 row) 73 | 74 | -- Test B001 rule again - should now trigger with new thresholds 75 | SELECT pglinter.check(); 76 | NOTICE: ✅ No issues found - database schema looks good! 77 | check 78 | ------- 79 | t 80 | (1 row) 81 | 82 | -- Update B001 thresholds to very low values (1%, 2%) not to trigger on any table without PK 83 | SELECT pglinter.update_rule_levels('B001', 60, 80); 84 | NOTICE: ✅ Updated rule B001 levels: warning=60, error=80 85 | update_rule_levels 86 | -------------------- 87 | t 88 | (1 row) 89 | 90 | DROP TABLE test_no_pk CASCADE; 91 | DROP TABLE test_with_pk CASCADE; 92 | DROP EXTENSION pglinter CASCADE; 93 | -------------------------------------------------------------------------------- /tests/expected/b001.out: -------------------------------------------------------------------------------- 1 | -- Test for pglinter B001 rule with file output 2 | CREATE EXTENSION pglinter; 3 | CREATE TABLE my_table_without_pk ( 4 | id INT, 5 | name TEXT, 6 | code TEXT, 7 | enable BOOL DEFAULT TRUE, 8 | query TEXT, 9 | warning_level INT, 10 | error_level INT, 11 | scope TEXT 12 | ); 13 | -- Disable all rules first 14 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 15 | NOTICE: 🔴 Disabled 20 rule(s) 16 | all_rules_disabled 17 | -------------------- 18 | 20 19 | (1 row) 20 | 21 | -- Run table check to detect tables without PK 22 | SELECT pglinter.check(); 23 | NOTICE: ✅ No issues found - database schema looks good! 24 | check 25 | ------- 26 | t 27 | (1 row) 28 | 29 | -- Test rule management for B001 30 | SELECT pglinter.explain_rule('B001'); 31 | NOTICE: 📖 Rule Explanation for B001 32 | ============================================================ 33 | 34 | 🎯 Rule Name: HowManyTableWithoutPrimaryKey 35 | 📋 Scope: BASE 36 | 37 | 📝 Description: 38 | Count number of tables without primary key. 39 | 40 | ⚠️ Message Template: 41 | {0}/{1} table(s) without primary key exceed the {2} threshold: {3}%. Object list:\n{4} 42 | 43 | 🔧 How to Fix: 44 | 1. create a primary key or change warning/error threshold 45 | ============================================================ 46 | explain_rule 47 | -------------- 48 | t 49 | (1 row) 50 | 51 | SELECT pglinter.is_rule_enabled('B001') AS is_b001_enabled; 52 | is_b001_enabled 53 | ----------------- 54 | f 55 | (1 row) 56 | 57 | -- Test with file output 58 | SELECT pglinter.check('/tmp/pglinter_base_results.sarif'); 59 | check 60 | ------- 61 | t 62 | (1 row) 63 | 64 | -- Test if file exists 65 | \! md5sum /tmp/pglinter_base_results.sarif 66 | 7cd6073b52ee9a1173c05c016e2458f4 /tmp/pglinter_base_results.sarif 67 | -- Re-enable B001 rule 68 | SELECT pglinter.enable_rule('B001') AS enable_b001; 69 | NOTICE: ✅ Rule B001 has been enabled 70 | enable_b001 71 | ------------- 72 | t 73 | (1 row) 74 | 75 | -- Test again with B001 enabled 76 | SELECT pglinter.check(); 77 | NOTICE: 🔍 pglinter found 1 issue(s): 78 | NOTICE: ================================================== 79 | NOTICE: ❌ [B001] ERROR: 1/1 table(s) without primary key exceed the error threshold: 100%. Object list: 80 | public.my_table_without_pk 81 | 82 | NOTICE: ================================================== 83 | NOTICE: 📊 Summary: 1 error(s), 0 warning(s), 0 info 84 | NOTICE: 🔴 Critical issues found - please review and fix errors 85 | check 86 | ------- 87 | t 88 | (1 row) 89 | 90 | -- Test with file output 91 | SELECT pglinter.check('/tmp/pglinter_base_results.sarif'); 92 | check 93 | ------- 94 | t 95 | (1 row) 96 | 97 | -- Test if file exists 98 | \! md5sum /tmp/pglinter_base_results.sarif 99 | abcbc2dd1c791c3445280188aa4a9386 /tmp/pglinter_base_results.sarif 100 | DROP TABLE my_table_without_pk CASCADE; 101 | DROP EXTENSION pglinter CASCADE; 102 | -------------------------------------------------------------------------------- /tests/expected/quick_demo_levels.out: -------------------------------------------------------------------------------- 1 | -- Quick demonstration of the new rule level management functions 2 | -- Run this in PostgreSQL after installing the updated pglinter extension 3 | CREATE EXTENSION pglinter; 4 | \echo '=== Rule Level Management Demo ===' 5 | === Rule Level Management Demo === 6 | -- Show current T005 levels (should be 50, 90 from rules.sql) 7 | \echo 'Current T005 levels:' 8 | Current T005 levels: 9 | SELECT pglinter.get_rule_levels('T005') as current_t005_levels; 10 | current_t005_levels 11 | ---------------------------------- 12 | warning_level=50, error_level=90 13 | (1 row) 14 | 15 | -- Update T005 to be more strict (lower thresholds) 16 | \echo 'Making T005 more strict (warning=20, error=40):' 17 | Making T005 more strict (warning=20, error=40): 18 | SELECT pglinter.update_rule_levels('T005', 20, 40) as update_success; 19 | WARNING: ⚠️ Rule T005 not found 20 | update_success 21 | ---------------- 22 | f 23 | (1 row) 24 | 25 | -- Verify the change 26 | \echo 'T005 levels after update:' 27 | T005 levels after update: 28 | SELECT pglinter.get_rule_levels('T005') as updated_t005_levels; 29 | updated_t005_levels 30 | ---------------------------------- 31 | warning_level=50, error_level=90 32 | (1 row) 33 | 34 | -- Check a few other rules 35 | \echo 'Current levels for various rules:' 36 | Current levels for various rules: 37 | SELECT 38 | code, 39 | warning_level, 40 | error_level, 41 | pglinter.get_rule_levels(code) as formatted_levels 42 | FROM pglinter.rules 43 | WHERE code IN ('B001', 'T001', 'T005') 44 | ORDER BY code; 45 | code | warning_level | error_level | formatted_levels 46 | ------+---------------+-------------+--------------------------------- 47 | B001 | 1 | 80 | warning_level=1, error_level=80 48 | (1 row) 49 | 50 | -- Update only warning level for B001 51 | \echo 'Updating only B001 warning level to 5:' 52 | Updating only B001 warning level to 5: 53 | SELECT pglinter.update_rule_levels('B001', 5, NULL) as partial_update; 54 | NOTICE: ✅ Updated rule B001 levels: warning=5, error=80 55 | partial_update 56 | ---------------- 57 | t 58 | (1 row) 59 | 60 | -- Show the result 61 | \echo 'B001 after partial update:' 62 | B001 after partial update: 63 | SELECT pglinter.get_rule_levels('B001') as b001_levels; 64 | b001_levels 65 | --------------------------------- 66 | warning_level=5, error_level=80 67 | (1 row) 68 | 69 | \echo '=== Demo Complete ===' 70 | === Demo Complete === 71 | \echo 'New functions available:' 72 | New functions available: 73 | \echo ' - pglinter.get_rule_levels(rule_code)' 74 | - pglinter.get_rule_levels(rule_code) 75 | \echo ' - pglinter.update_rule_levels(rule_code, warning_level, error_level)' 76 | - pglinter.update_rule_levels(rule_code, warning_level, error_level) 77 | \echo '' 78 | 79 | \echo 'Use NULL for warning_level or error_level to keep current value unchanged.' 80 | Use NULL for warning_level or error_level to keep current value unchanged. 81 | DROP EXTENSION pglinter CASCADE; 82 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Multi-stage Dockerfile for pglinter PostgreSQL extension 2 | # Supports PostgreSQL 13-18 3 | 4 | # Build argument for PostgreSQL version 5 | ARG PG_MAJOR_VERSION=17 6 | 7 | ## 8 | ## First Stage: Rust Build Environment 9 | ## 10 | FROM rust:1.89.0-slim as builder 11 | 12 | # Install system dependencies for building 13 | RUN apt-get update && apt-get install -y \ 14 | build-essential \ 15 | clang \ 16 | libclang-dev \ 17 | pkg-config \ 18 | git \ 19 | wget \ 20 | ca-certificates \ 21 | libssl-dev \ 22 | openssl \ 23 | && rm -rf /var/lib/apt/lists/* 24 | 25 | # Set working directory 26 | WORKDIR /build 27 | 28 | # Copy source code 29 | COPY Cargo.toml Cargo.lock ./ 30 | COPY src/ ./src/ 31 | COPY pglinter.control ./ 32 | COPY sql/ ./sql/ 33 | 34 | # Install cargo-pgrx 35 | RUN cargo install --locked cargo-pgrx --version 0.16.0 36 | 37 | # Build arguments for PostgreSQL version 38 | ARG PG_MAJOR_VERSION=17 39 | 40 | # Install PostgreSQL for the specified version 41 | RUN apt-get update && apt-get install -y \ 42 | postgresql-${PG_MAJOR_VERSION} \ 43 | postgresql-server-dev-${PG_MAJOR_VERSION} \ 44 | postgresql-client-${PG_MAJOR_VERSION} \ 45 | && rm -rf /var/lib/apt/lists/* 46 | 47 | # Initialize pgrx with system PostgreSQL 48 | RUN cargo pgrx init --pg${PG_MAJOR_VERSION} /usr/lib/postgresql/${PG_MAJOR_VERSION}/bin/pg_config 49 | 50 | # Build the extension 51 | RUN cargo pgrx package --pg-config /usr/lib/postgresql/${PG_MAJOR_VERSION}/bin/pg_config 52 | 53 | ## 54 | ## Second Stage: PostgreSQL Runtime 55 | ## 56 | ARG PG_MAJOR_VERSION=17 57 | FROM postgres:${PG_MAJOR_VERSION} 58 | 59 | # Re-declare the ARG for this stage 60 | ARG PG_MAJOR_VERSION=17 61 | 62 | # Install runtime dependencies 63 | RUN apt-get update && apt-get install -y \ 64 | ca-certificates \ 65 | wget \ 66 | && rm -rf /var/lib/apt/lists/* 67 | 68 | # Copy the built extension from the builder stage 69 | COPY --from=builder /build/target/release/pglinter-pg${PG_MAJOR_VERSION}/usr/share/postgresql/${PG_MAJOR_VERSION}/extension/* \ 70 | /usr/share/postgresql/${PG_MAJOR_VERSION}/extension/ 71 | 72 | COPY --from=builder /build/target/release/pglinter-pg${PG_MAJOR_VERSION}/usr/lib/postgresql/${PG_MAJOR_VERSION}/lib/pglinter.so \ 73 | /usr/lib/postgresql/${PG_MAJOR_VERSION}/lib/ 74 | 75 | # Copy test files for regression testing 76 | COPY docker/tests/ /tests/ 77 | 78 | # Create initialization script 79 | RUN mkdir -p /docker-entrypoint-initdb.d 80 | COPY docker/init_pglinter.sh /docker-entrypoint-initdb.d/ 81 | 82 | # Set environment variables 83 | ENV POSTGRES_DB=pglinter_test 84 | ENV POSTGRES_USER=postgres 85 | ENV POSTGRES_PASSWORD=postgres 86 | 87 | # Expose PostgreSQL port 88 | EXPOSE 5432 89 | 90 | # Health check 91 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 92 | CMD pg_isready -U postgres -d pglinter_test || exit 1 93 | 94 | # Labels for GitHub Container Registry 95 | LABEL org.opencontainers.image.source="https://github.com/pmpetit/pglinter" 96 | LABEL org.opencontainers.image.description="PostgreSQL extension for linting and code quality" 97 | LABEL org.opencontainers.image.licenses="MIT" 98 | LABEL org.opencontainers.image.title="pglinter" 99 | -------------------------------------------------------------------------------- /tests/sql/b002_redundant_idx.sql: -------------------------------------------------------------------------------- 1 | -- Test for pglinter B002 rule: Redundant indexes 2 | CREATE EXTENSION pglinter; 3 | 4 | -- Create test tables with redundant indexes 5 | CREATE TABLE test_table_with_redundant_indexes ( 6 | id INT PRIMARY KEY, 7 | name TEXT, 8 | email VARCHAR(255), 9 | status VARCHAR(50), 10 | created_at TIMESTAMP DEFAULT NOW() 11 | ); 12 | 13 | 14 | -- Create table with one index and a unique constrainte on the same column 15 | CREATE TABLE orders_table_with_constraint ( 16 | order_id SERIAL PRIMARY KEY, 17 | customer_id INT UNIQUE, 18 | product_name VARCHAR(255), 19 | order_date DATE, 20 | amount DECIMAL(10, 2) 21 | ); 22 | 23 | -- Create an index that is redundant with the unique constraint 24 | CREATE INDEX my_idx_customer ON orders_table_with_constraint (customer_id); 25 | 26 | -- Create another table for more redundant index scenarios 27 | CREATE TABLE orders_table ( 28 | order_id SERIAL PRIMARY KEY, 29 | customer_id INT, 30 | product_name VARCHAR(255), 31 | order_date DATE, 32 | amount DECIMAL(10, 2) 33 | ); 34 | 35 | -- Create redundant indexes to trigger B002 rule 36 | -- Case 1: Exact duplicate indexes on same columns 37 | CREATE INDEX idx_name_1 ON test_table_with_redundant_indexes (name); 38 | CREATE INDEX idx_name_2 ON test_table_with_redundant_indexes (name, created_at); 39 | CREATE INDEX idx_name_3 ON test_table_with_redundant_indexes ( 40 | name, created_at, email 41 | ); 42 | 43 | -- Case 2: Multiple indexes on same composite key 44 | CREATE INDEX idx_email_status_1 ON test_table_with_redundant_indexes ( 45 | email, status 46 | ); 47 | CREATE INDEX idx_email_status_2 ON test_table_with_redundant_indexes ( 48 | email, status, created_at 49 | ); 50 | 51 | -- Case 3: Redundant indexes on the orders table 52 | CREATE INDEX idx_customer_1 ON orders_table (order_id); 53 | 54 | -- Case 3-bis: Non Redundant indexes on the orders table 55 | CREATE INDEX idx_customer_2 ON orders_table (customer_id, order_id); 56 | 57 | -- Case 4: Composite index redundancy 58 | CREATE INDEX idx_customer_date_1 ON orders_table (product_name, order_date); 59 | CREATE INDEX idx_customer_date_2 ON orders_table (product_name, order_date); 60 | 61 | -- First, disable all rules to isolate B001 testing 62 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 63 | 64 | -- Enable only B002 for focused testing 65 | SELECT pglinter.enable_rule('B002') AS b001_enabled; 66 | 67 | -- Test with file output 68 | SELECT pglinter.check('/tmp/pglinter_b002_results.sarif'); 69 | 70 | -- Test if file exists and show checksum 71 | \! md5sum /tmp/pglinter_b002_results.sarif 72 | 73 | -- Test with no output file (should output to prompt) 74 | SELECT pglinter.check(); 75 | 76 | -- Test rule management for B002 77 | SELECT pglinter.explain_rule('B002'); 78 | 79 | -- Show that B002 is enabled 80 | SELECT pglinter.is_rule_enabled('B002') AS b002_enabled; 81 | 82 | -- Disable B002 temporarily and test 83 | SELECT pglinter.disable_rule('B002') AS b002_disabled; 84 | SELECT pglinter.check(); 85 | 86 | DROP TABLE orders_table CASCADE; 87 | DROP TABLE orders_table_with_constraint CASCADE; 88 | DROP TABLE test_table_with_redundant_indexes CASCADE; 89 | 90 | DROP EXTENSION pglinter CASCADE; 91 | -------------------------------------------------------------------------------- /docker/oci/Dockerfile.local: -------------------------------------------------------------------------------- 1 | # CloudNative-PG Extension Image for pglinter (Local Build) 2 | # Follows the Image Specifications from: 3 | # https://cloudnative-pg.io/documentation/preview/imagevolume_extensions/#image-specifications 4 | 5 | ARG PG_VERSION=18 6 | ARG DISTRO=bookworm 7 | ARG PGLINTER_VERSION=1.0.0 8 | ARG TIMESTAMP 9 | ARG EXT_VERSION 10 | 11 | # Build stage - extract extension files from local .deb package 12 | FROM ghcr.io/cloudnative-pg/postgresql:${PG_VERSION}-minimal-${DISTRO} AS builder 13 | 14 | ARG PG_VERSION 15 | ARG DISTRO 16 | ARG PGLINTER_VERSION 17 | ARG TIMESTAMP 18 | ARG EXT_VERSION 19 | 20 | USER 0 21 | 22 | # Copy local .deb package and extract 23 | COPY packages/pglinter.deb /tmp/pglinter.deb 24 | 25 | RUN set -eux; \ 26 | mkdir -p /extension-build && \ 27 | dpkg-deb -x /tmp/pglinter.deb /tmp/extracted && \ 28 | echo "Contents of extracted package:" && \ 29 | find /tmp/extracted -type f && \ 30 | # Create CloudNative-PG compliant directory structure 31 | mkdir -p /extension-build/lib /extension-build/share/extension && \ 32 | # Copy shared libraries (.so files) 33 | if [ -d /tmp/extracted/usr/lib/postgresql/${PG_VERSION}/lib ]; then \ 34 | echo "Copying libraries from /usr/lib/postgresql/${PG_VERSION}/lib/"; \ 35 | find /tmp/extracted/usr/lib/postgresql/${PG_VERSION}/lib/ -name "*.so" -exec cp {} /extension-build/lib/ \; ; \ 36 | fi && \ 37 | # Copy extension control and SQL files 38 | if [ -d /tmp/extracted/usr/share/postgresql/${PG_VERSION}/extension ]; then \ 39 | echo "Copying extension files from /usr/share/postgresql/${PG_VERSION}/extension/"; \ 40 | cp /tmp/extracted/usr/share/postgresql/${PG_VERSION}/extension/* /extension-build/share/extension/ ; \ 41 | fi && \ 42 | echo "Final extension structure:" && \ 43 | find /extension-build -type f 44 | 45 | # Final image - scratch base following CloudNative-PG specifications 46 | FROM scratch 47 | 48 | # Import build arguments for labeling 49 | ARG PG_VERSION 50 | ARG DISTRO 51 | ARG PGLINTER_VERSION 52 | ARG TIMESTAMP 53 | ARG EXT_VERSION 54 | 55 | # CloudNative-PG compatible labels 56 | LABEL org.opencontainers.image.title="pglinter" 57 | LABEL org.opencontainers.image.description="PostgreSQL Linter Extension for CloudNative-PG" 58 | LABEL org.opencontainers.image.version="${EXT_VERSION:-${PGLINTER_VERSION}}" 59 | LABEL org.opencontainers.image.url="https://github.com/pmpetit/pglinter" 60 | LABEL org.opencontainers.image.source="https://github.com/pmpetit/pglinter" 61 | LABEL org.opencontainers.image.documentation="https://github.com/pmpetit/pglinter/blob/main/README.md" 62 | 63 | # Extension metadata labels 64 | LABEL extension.name="pglinter" 65 | LABEL extension.version="${EXT_VERSION:-${PGLINTER_VERSION}}" 66 | LABEL extension.timestamp="${TIMESTAMP}" 67 | LABEL extension.pg_version="${PG_VERSION}" 68 | LABEL extension.distro="${DISTRO}" 69 | LABEL extension.tag="pglinter:${EXT_VERSION:-${PGLINTER_VERSION}}-${TIMESTAMP}-${PG_VERSION}-${DISTRO}" 70 | 71 | # CloudNative-PG Image Specifications compliance: 72 | # - /lib/ contains shared libraries (.so files) 73 | # - /share/ contains extension subdirectory with control files and SQL scripts 74 | COPY --from=builder /extension-build/lib/ /lib/ 75 | COPY --from=builder /extension-build/share/ /share/ 76 | -------------------------------------------------------------------------------- /docker/ci/Dockerfile.pg-nodeb: -------------------------------------------------------------------------------- 1 | FROM rockylinux/rockylinux:10-ubi 2 | 3 | # Build arguments 4 | ARG PG_MAJOR_VERSION=13 5 | ARG PACKAGE_PATH 6 | ARG PACKAGE_NAME 7 | ARG ARCH 8 | 9 | # Install PostgreSQL ${PG_MAJOR_VERSION} from PGDG repository 10 | # Install basic packages and EPEL repository 11 | RUN dnf install -y epel-release && \ 12 | dnf update -y --allowerasing --nobest && \ 13 | dnf install -y --allowerasing --nobest \ 14 | wget curl sudo vim gcc make rpm-build 15 | 16 | # Pre-create PostgreSQL directories and user to avoid pgbackrest installation issues 17 | RUN groupadd -r postgres --gid=26 || echo "Group postgres already exists" && \ 18 | useradd -r -g postgres --uid=26 --home-dir=/var/lib/pgsql --shell=/bin/bash postgres || echo "User postgres already exists" && \ 19 | mkdir -p /var/lib/pgsql /var/run/postgresql && \ 20 | chown -R postgres:postgres /var/lib/pgsql /var/run/postgresql 21 | 22 | # Install Perl dependencies that might be needed for postgresql-devel 23 | RUN dnf install -y --allowerasing \ 24 | perl-IPC-Run \ 25 | perl-Test-Simple \ 26 | perl-Test-More || echo "Perl packages not available, continuing without them" 27 | 28 | RUN PGDG_REPO_URL="https://download.postgresql.org/pub/repos/yum/reporpms/EL-10-${ARCH}/pgdg-redhat-repo-latest.noarch.rpm"; \ 29 | wget --no-check-certificate -O /tmp/pgdg-repo.rpm "${PGDG_REPO_URL}"; \ 30 | dnf install -y --nogpgcheck /tmp/pgdg-repo.rpm; 31 | 32 | # Install PostgreSQL packages for the specified version 33 | RUN dnf install -y --allowerasing \ 34 | --setopt=sslverify=0 \ 35 | postgresql${PG_MAJOR_VERSION}-server \ 36 | postgresql${PG_MAJOR_VERSION} \ 37 | postgresql${PG_MAJOR_VERSION}-contrib; \ 38 | dnf clean all 39 | 40 | 41 | # Setup directories (postgres user already exists from package installation) 42 | RUN mkdir -p /var/lib/pgsql/${PG_MAJOR_VERSION}/data /var/run/postgresql && \ 43 | chown -R postgres:postgres /var/lib/pgsql /var/run/postgresql && \ 44 | chmod 0700 /var/lib/pgsql/${PG_MAJOR_VERSION}/data 45 | 46 | # Initialize and configure PostgreSQL as postgres user 47 | USER postgres 48 | RUN /usr/pgsql-${PG_MAJOR_VERSION}/bin/initdb -D /var/lib/pgsql/${PG_MAJOR_VERSION}/data && \ 49 | echo "host all all 0.0.0.0/0 md5" >> /var/lib/pgsql/${PG_MAJOR_VERSION}/data/pg_hba.conf && \ 50 | echo "listen_addresses = '*'" >> /var/lib/pgsql/${PG_MAJOR_VERSION}/data/postgresql.conf && \ 51 | echo "port = 5432" >> /var/lib/pgsql/${PG_MAJOR_VERSION}/data/postgresql.conf 52 | 53 | # Switch back to root 54 | USER root 55 | 56 | # Create extension installation directory 57 | RUN mkdir -p /tmp/extensions && chmod 755 /tmp/extensions 58 | 59 | # Download and install pglinter extension 60 | COPY ${PACKAGE_PATH}/${PACKAGE_NAME} /tmp 61 | RUN dnf localinstall -y /tmp/${PACKAGE_NAME} && \ 62 | rm -f /tmp/${PACKAGE_NAME}.rpm 63 | 64 | # Set environment variable for the script to use at runtime 65 | ENV PG_MAJOR_VERSION=${PG_MAJOR_VERSION} 66 | 67 | # Copy startup script 68 | COPY docker/ci/nodeb-start-with-pglinter.sh /usr/local/bin/start-with-pglinter.sh 69 | RUN chmod +x /usr/local/bin/start-with-pglinter.sh 70 | 71 | # Start PostgreSQL, install pglinter extension, run tests, and keep container running 72 | CMD ["/usr/local/bin/start-with-pglinter.sh"] 73 | -------------------------------------------------------------------------------- /.github/actions/reusable-package-action/action.yml: -------------------------------------------------------------------------------- 1 | # .github/actions/reusable-package-action/action.yml 2 | name: 'Build package and upload' 3 | 4 | description: 'Builds rpm/deb package and upload.' 5 | 6 | inputs: 7 | pgver: 8 | description: 'The PostgreSQL major version with the "pg" prefix (e.g. `pg13`, `pg16`, etc.)' 9 | required: true 10 | tag: 11 | description: "The tag version" 12 | required: true 13 | arch: 14 | description: 'Target architecture (amd64 or arm64)' 15 | required: false 16 | default: amd64 17 | pkgtype: 18 | description: 'Package type to build (rpm or deb)' 19 | required: true 20 | default: deb 21 | 22 | runs: 23 | using: "composite" 24 | steps: 25 | - name: Setup workspace for postgres user 26 | run: | 27 | chown -R postgres:postgres $GITHUB_WORKSPACE 28 | chmod -R 755 $GITHUB_WORKSPACE 29 | shell: bash 30 | 31 | - name: Extract version from Cargo.toml 32 | id: get-version 33 | run: | 34 | # VERSION=$(grep '^version = ' Cargo.toml | awk -F'"' '{print $2}') 35 | VERSION=${{ inputs.tag }} 36 | echo "Version: $VERSION" 37 | echo "PGLINTER_MINOR_VERSION=$VERSION" >> $GITHUB_ENV 38 | echo "PACKAGE_ARCH=${{ inputs.arch }}" >> $GITHUB_ENV 39 | 40 | # Set architecture-specific names for artifacts 41 | case "${{ inputs.arch }}" in 42 | amd64) 43 | echo "RPM_ARCH=x86_64" >> $GITHUB_ENV 44 | echo "DEB_ARCH=amd64" >> $GITHUB_ENV 45 | ;; 46 | arm64) 47 | echo "RPM_ARCH=aarch64" >> $GITHUB_ENV 48 | echo "DEB_ARCH=arm64" >> $GITHUB_ENV 49 | ;; 50 | *) 51 | echo "RPM_ARCH=${{ inputs.arch }}" >> $GITHUB_ENV 52 | echo "DEB_ARCH=${{ inputs.arch }}" >> $GITHUB_ENV 53 | ;; 54 | esac 55 | echo "PG_VERSION=$(echo '${{ inputs.pgver }}' | sed 's/^pg//')" >> $GITHUB_ENV 56 | shell: bash 57 | 58 | - name: Build selected package 59 | run: | 60 | export PACKAGE_ARCH=${{ inputs.arch }} 61 | if [ "${{ inputs.pkgtype }}" = "deb" ]; then 62 | su - postgres -c "cd $GITHUB_WORKSPACE && PGVER=${{ inputs.pgver }} PACKAGE_ARCH=${{ env.DEB_ARCH }} make deb" 63 | elif [ "${{ inputs.pkgtype }}" = "rpm" ]; then 64 | su - postgres -c "cd $GITHUB_WORKSPACE && PGVER=${{ inputs.pgver }} PACKAGE_ARCH=${{ env.RPM_ARCH }} make rpm" 65 | else 66 | echo "Unknown package type: ${{ inputs.pkgtype }}" 67 | exit 1 68 | fi 69 | shell: bash 70 | 71 | - name: 'RPM package for ${{ inputs.pgver }} (${{ inputs.arch }})' 72 | if: ${{ inputs.pkgtype == 'rpm' }} 73 | uses: actions/upload-artifact@v4 74 | with: 75 | name: postgresql_pglinter_${{ env.PG_VERSION }}-${{ env.PGLINTER_MINOR_VERSION }}-1.${{ env.RPM_ARCH }}.rpm 76 | path: target/release/pglinter-${{ inputs.pgver }}/*.rpm 77 | 78 | - name: 'DEB package for ${{ inputs.pgver }} (${{ inputs.arch }})' 79 | if: ${{ inputs.pkgtype == 'deb' }} 80 | uses: actions/upload-artifact@v4 81 | with: 82 | name: postgresql_pglinter_${{ env.PG_VERSION }}-${{ env.PGLINTER_MINOR_VERSION }}_${{ env.DEB_ARCH }}.deb 83 | path: target/release/pglinter-${{ inputs.pgver }}/*.deb 84 | -------------------------------------------------------------------------------- /docker/pgrx/Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | # We can't use the official rust docker image because we need to build the 3 | # extension against the oldest glibc version available. 4 | # So we use Rocky8 as the base image, which ships glibc 2.28 5 | # Our hope is that an extension built against an old version of glibc will 6 | # continue to work against newer versions of glibc. 7 | #FROM rust:1 8 | 9 | FROM rockylinux:8 AS rhel8_devtools 10 | 11 | ARG PGRX_VERSION=0.16.1 12 | 13 | COPY docker/pgrx/goreleaser.repo /etc/yum.repos.d/goreleaser.repo 14 | 15 | 16 | # Fix ARM64 repository configuration (following pglinter PostgreSQL compatibility requirements) 17 | RUN dnf update -y ca-certificates curl \ 18 | && dnf install -y 'dnf-command(config-manager)' \ 19 | && if [ "$(uname -m)" = "aarch64" ]; then \ 20 | # For ARM64: Use CRB instead of PowerTools and skip problematic repos 21 | dnf config-manager -y --set-enabled crb || \ 22 | dnf config-manager -y --set-enabled powertools || true; \ 23 | else \ 24 | # For AMD64: Use standard PowerTools 25 | dnf config-manager -y --set-enabled powertools; \ 26 | fi \ 27 | && dnf groupinstall -y 'Development Tools' \ 28 | && dnf install -y epel-release 29 | 30 | 31 | # https://github.com/pgcentralfoundation/pgrx?tab=readme-ov-file#system-requirements 32 | RUN dnf install -y 'dnf-command(config-manager)' \ 33 | && dnf groupinstall -y 'Development Tools' \ 34 | && dnf install -y epel-release \ 35 | && dnf install -y \ 36 | cmake \ 37 | git \ 38 | clang \ 39 | nfpm \ 40 | bison-devel \ 41 | libicu-devel \ 42 | readline-devel \ 43 | zlib-devel \ 44 | openssl-devel \ 45 | ccache \ 46 | wget \ 47 | && dnf clean all \ 48 | && rm -rf /var/cache/yum 49 | 50 | 51 | FROM rhel8_devtools AS rhel8_rust 52 | 53 | # Create the postgres user with the given uid/gid 54 | # If you're not using Docker 55 | # You can fix that by rebuilding the image locally with: 56 | #Desktop and your UID / GID is not 1000 then 57 | # you'll get permission errors with volumes. 58 | # 59 | # docker compose build --build-arg UID=`id -u` --build-arg GID=`id- g` 60 | # 61 | 62 | ARG UID=1000 63 | ARG GID=1000 64 | RUN groupadd -g "${GID}" postgres \ 65 | && useradd --create-home --no-log-init -u "${UID}" -g "${GID}" postgres 66 | 67 | # GitHub Actions compatibility: install sudo and setup directories 68 | RUN dnf install -y sudo \ 69 | && echo "postgres ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers \ 70 | && mkdir -p /__w/_temp/_runner_file_commands /__w/_actions /github/workspacem \ 71 | && chown -R postgres:postgres /__w /github \ 72 | && chmod -R 777 /__w \ 73 | && chmod -R 755 /github 74 | 75 | USER postgres 76 | ENV USER=postgres 77 | 78 | # Install Rust 79 | 80 | RUN wget https://sh.rustup.rs -O /tmp/install-rust.sh \ 81 | && chmod +x /tmp/install-rust.sh \ 82 | && /tmp/install-rust.sh -y 83 | 84 | ENV PATH="/home/postgres/.cargo/bin/:${PATH}" 85 | 86 | 87 | FROM rhel8_rust AS rhel8_pgrx 88 | 89 | RUN rustup self update \ 90 | && rustup component add clippy llvm-tools-preview \ 91 | && cargo install grcov \ 92 | && cargo install --locked --version "${PGRX_VERSION}" cargo-pgrx \ 93 | && cargo pgrx init 94 | 95 | ENV CARGO_HOME=~postgres/.cargo/ 96 | 97 | 98 | WORKDIR /pgrx 99 | VOLUME /pgrx 100 | -------------------------------------------------------------------------------- /tests/expected/b002_non_redundant_idx.out: -------------------------------------------------------------------------------- 1 | -- Test for pglinter B002 rule: No Redundant indexes exists, no warning should be raised. 2 | CREATE EXTENSION pglinter; 3 | -- Create test tables with redundant indexes 4 | CREATE TABLE test_table_without_redundant_indexes ( 5 | id INT PRIMARY KEY, 6 | name TEXT, 7 | email VARCHAR(255), 8 | status VARCHAR(50), 9 | created_at TIMESTAMP DEFAULT NOW() 10 | ); 11 | -- Create table with one index and a unique constrainte on the same column 12 | CREATE TABLE orders_table_with_constraint ( 13 | order_id SERIAL PRIMARY KEY, 14 | customer_id INT UNIQUE, 15 | product_name VARCHAR(255), 16 | order_date DATE, 17 | amount DECIMAL(10, 2) 18 | ); 19 | -- Create another table for more redundant index scenarios 20 | CREATE TABLE orders_table ( 21 | order_id SERIAL PRIMARY KEY, 22 | customer_id INT, 23 | product_name VARCHAR(255), 24 | order_date DATE, 25 | amount DECIMAL(10, 2) 26 | ); 27 | -- First, disable all rules to isolate B001 testing 28 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 29 | NOTICE: 🔴 Disabled 20 rule(s) 30 | all_rules_disabled 31 | -------------------- 32 | 20 33 | (1 row) 34 | 35 | -- Enable only B002 for focused testing 36 | SELECT pglinter.enable_rule('B002') AS b001_enabled; 37 | NOTICE: ✅ Rule B002 has been enabled 38 | b001_enabled 39 | -------------- 40 | t 41 | (1 row) 42 | 43 | -- Test with file output 44 | SELECT pglinter.check('/tmp/pglinter_b002_results.sarif'); 45 | check 46 | ------- 47 | t 48 | (1 row) 49 | 50 | -- Test if file exists and show checksum 51 | \! md5sum /tmp/pglinter_b002_results.sarif 52 | 7cd6073b52ee9a1173c05c016e2458f4 /tmp/pglinter_b002_results.sarif 53 | -- Test with no output file (should output to prompt) 54 | SELECT pglinter.check(); 55 | NOTICE: ✅ No issues found - database schema looks good! 56 | check 57 | ------- 58 | t 59 | (1 row) 60 | 61 | -- Test rule management for B002 62 | SELECT pglinter.explain_rule('B002'); 63 | NOTICE: 📖 Rule Explanation for B002 64 | ============================================================ 65 | 66 | 🎯 Rule Name: HowManyRedudantIndex 67 | 📋 Scope: BASE 68 | 69 | 📝 Description: 70 | Count number of redundant index vs nb index. 71 | 72 | ⚠️ Message Template: 73 | {0}/{1} redundant(s) index exceed the {2} threshold: {3}%. Object list:\n{4} 74 | 75 | 🔧 How to Fix: 76 | 1. remove duplicated index or check if a constraint does not create a redundant index, or change warning/error threshold 77 | ============================================================ 78 | explain_rule 79 | -------------- 80 | t 81 | (1 row) 82 | 83 | -- Show that B002 is enabled 84 | SELECT pglinter.is_rule_enabled('B002') AS b002_enabled; 85 | b002_enabled 86 | -------------- 87 | t 88 | (1 row) 89 | 90 | -- Disable B002 temporarily and test 91 | SELECT pglinter.disable_rule('B002') AS b002_disabled; 92 | NOTICE: 🔴 Rule B002 has been disabled 93 | b002_disabled 94 | --------------- 95 | t 96 | (1 row) 97 | 98 | SELECT pglinter.check(); 99 | NOTICE: ✅ No issues found - database schema looks good! 100 | check 101 | ------- 102 | t 103 | (1 row) 104 | 105 | DROP TABLE orders_table CASCADE; 106 | DROP TABLE orders_table_with_constraint CASCADE; 107 | DROP TABLE test_table_without_redundant_indexes CASCADE; 108 | DROP EXTENSION pglinter CASCADE; 109 | -------------------------------------------------------------------------------- /docker/oci/Dockerfile.pg-deb: -------------------------------------------------------------------------------- 1 | # CloudNative-PG Extension Image for pglinter 2 | # Follows the Image Specifications from: 3 | # https://cloudnative-pg.io/documentation/preview/imagevolume_extensions/#image-specifications 4 | 5 | ARG PG_VERSION=18 6 | ARG DISTRO 7 | ARG PGLINTER_VERSION=1.0.0 8 | ARG EXT_VERSION 9 | 10 | # Build stage - extract extension files from .deb package 11 | FROM ghcr.io/cloudnative-pg/postgresql:${PG_VERSION}-minimal-${DISTRO} AS builder 12 | 13 | ARG PG_VERSION 14 | ARG DISTRO 15 | ARG PGLINTER_VERSION 16 | ARG EXT_VERSION 17 | 18 | USER 0 19 | 20 | # Download and extract pglinter .deb package 21 | # Support multi-arch by detecting architecture 22 | RUN set -eux; \ 23 | ARCH=$(dpkg --print-architecture); \ 24 | mkdir -p /extension-build && \ 25 | apt-get update && \ 26 | apt-get install -y --no-install-recommends wget ca-certificates && \ 27 | cd /tmp && \ 28 | DOWNLOAD_URL="https://github.com/pmpetit/pglinter/releases/download/${PGLINTER_VERSION}/postgresql_pglinter_${PG_VERSION}_${PGLINTER_VERSION}_${ARCH}.deb" && \ 29 | echo "Downloading pglinter from: $DOWNLOAD_URL" && \ 30 | wget -O pglinter.deb "$DOWNLOAD_URL" && \ 31 | dpkg-deb -x pglinter.deb /tmp/extracted && \ 32 | echo "Contents of extracted package:" && \ 33 | find /tmp/extracted -type f && \ 34 | # Create CloudNative-PG compliant directory structure 35 | mkdir -p /extension-build/lib /extension-build/share/extension && \ 36 | # Copy shared libraries (.so files) 37 | if [ -d /tmp/extracted/usr/lib/postgresql/${PG_VERSION}/lib ]; then \ 38 | echo "Copying libraries from /usr/lib/postgresql/${PG_VERSION}/lib/"; \ 39 | find /tmp/extracted/usr/lib/postgresql/${PG_VERSION}/lib/ -name "*.so" -exec cp {} /extension-build/lib/ \; ; \ 40 | fi && \ 41 | # Copy extension control and SQL files 42 | if [ -d /tmp/extracted/usr/share/postgresql/${PG_VERSION}/extension ]; then \ 43 | echo "Copying extension files from /usr/share/postgresql/${PG_VERSION}/extension/"; \ 44 | cp /tmp/extracted/usr/share/postgresql/${PG_VERSION}/extension/* /extension-build/share/extension/ ; \ 45 | fi && \ 46 | echo "Final extension structure:" && \ 47 | find /extension-build -type f 48 | 49 | # Final image - scratch base following CloudNative-PG specifications 50 | FROM scratch 51 | 52 | # Import build arguments for labeling 53 | ARG PG_VERSION 54 | ARG DISTRO 55 | ARG PGLINTER_VERSION 56 | ARG EXT_VERSION 57 | 58 | # CloudNative-PG compatible labels 59 | LABEL org.opencontainers.image.title="pglinter" 60 | LABEL org.opencontainers.image.description="PostgreSQL Linter Extension for CloudNative-PG" 61 | LABEL org.opencontainers.image.version="${EXT_VERSION:-${PGLINTER_VERSION}}" 62 | LABEL org.opencontainers.image.url="https://github.com/pmpetit/pglinter" 63 | LABEL org.opencontainers.image.source="https://github.com/pmpetit/pglinter" 64 | LABEL org.opencontainers.image.documentation="https://github.com/pmpetit/pglinter/blob/main/README.md" 65 | 66 | # Extension metadata labels 67 | LABEL extension.name="pglinter" 68 | LABEL extension.version="${EXT_VERSION:-${PGLINTER_VERSION}}" 69 | LABEL extension.pg_version="${PG_VERSION}" 70 | LABEL extension.distro="${DISTRO}" 71 | LABEL extension.tag="pglinter:${EXT_VERSION:-${PGLINTER_VERSION}}-${PG_VERSION}-${DISTRO}" 72 | 73 | # CloudNative-PG Image Specifications compliance: 74 | # - /lib/ contains shared libraries (.so files) 75 | # - /share/ contains extension subdirectory with control files and SQL scripts 76 | COPY --from=builder /extension-build/lib/ /lib/ 77 | COPY --from=builder /extension-build/share/ /share/ 78 | -------------------------------------------------------------------------------- /tests/sql/integration_test.sql: -------------------------------------------------------------------------------- 1 | -- Comprehensive integration test for pglinter 2 | -- Tests multiple rules across all categories (B, C, T, S series) 3 | CREATE EXTENSION pglinter; 4 | 5 | -- Create various test scenarios to trigger multiple rules 6 | 7 | -- 1. Tables without primary keys (B001, T001) 8 | CREATE TABLE users_no_pk ( 9 | id INT, 10 | username TEXT, 11 | email TEXT 12 | ); 13 | 14 | -- 2. Redundant indexes (B002, T003) 15 | CREATE TABLE products ( 16 | id SERIAL PRIMARY KEY, 17 | name TEXT, 18 | category TEXT, 19 | price NUMERIC 20 | ); 21 | 22 | CREATE INDEX idx_name_1 ON products (name); 23 | CREATE INDEX idx_name_2 ON products (name); -- redundant 24 | CREATE INDEX idx_composite_1 ON products (category, price); 25 | CREATE INDEX idx_composite_2 ON products (category, price); -- redundant 26 | 27 | -- 3. Foreign keys without indexes (B003, T004) 28 | CREATE TABLE orders ( 29 | id SERIAL PRIMARY KEY, 30 | user_id INT, 31 | product_id INT, 32 | total NUMERIC 33 | ); 34 | 35 | -- Add foreign keys without creating indexes 36 | ALTER TABLE orders ADD CONSTRAINT fk_user 37 | FOREIGN KEY (user_id) REFERENCES users_no_pk (id); 38 | ALTER TABLE orders ADD CONSTRAINT fk_product 39 | FOREIGN KEY (product_id) REFERENCES products (id); 40 | 41 | -- 4. Tables with uppercase names/columns (B006, T011) 42 | CREATE TABLE "UPPERCASE_ISSUES" ( 43 | id SERIAL PRIMARY KEY, 44 | "UPPER_NAME" TEXT, 45 | "DESCRIPTION" TEXT 46 | ); 47 | 48 | -- 5. Reserved keywords (T010) 49 | CREATE TABLE items ( 50 | id SERIAL PRIMARY KEY, 51 | "SELECT" TEXT, -- reserved keyword 52 | "FROM" TEXT, -- reserved keyword 53 | description TEXT 54 | ); 55 | 56 | -- 6. Environment-prefixed schemas (S002) 57 | CREATE SCHEMA prod_analytics; 58 | CREATE SCHEMA dev_reports; 59 | CREATE SCHEMA staging_data; 60 | 61 | -- Add some tables to the schemas 62 | CREATE TABLE prod_analytics.metrics ( 63 | id SERIAL PRIMARY KEY, 64 | metric_name TEXT 65 | ); 66 | 67 | CREATE TABLE dev_reports.summaries ( 68 | id SERIAL PRIMARY KEY, 69 | report_data TEXT 70 | ); 71 | 72 | -- Create extension 73 | 74 | 75 | -- Run comprehensive analysis 76 | SELECT '=== COMPREHENSIVE pglinter ANALYSIS ===' AS info; 77 | 78 | -- Test all rule categories 79 | SELECT 'RULES:' AS category; 80 | SELECT pglinter.check(); 81 | 82 | -- Test rule management features 83 | SELECT '=== RULE MANAGEMENT ===' AS info; 84 | 85 | -- Show all rules 86 | SELECT pglinter.show_rules(); 87 | 88 | -- Test some explanations 89 | SELECT pglinter.explain_rule('B001'); 90 | SELECT pglinter.explain_rule('S002'); 91 | 92 | -- Test output to file functionality 93 | SELECT '=== OUTPUT TO FILE TEST ===' AS info; 94 | SELECT pglinter.check('/tmp/integration_base.sarif'); 95 | 96 | -- Test rule disable/enable functionality 97 | SELECT '=== RULE TOGGLE TEST ===' AS info; 98 | 99 | -- Disable some rules 100 | SELECT pglinter.disable_rule('B001'); 101 | 102 | -- Run checks (should skip disabled rules) 103 | SELECT pglinter.check(); 104 | 105 | -- Re-enable rules 106 | SELECT pglinter.enable_rule('B001'); 107 | 108 | -- Final comprehensive check 109 | SELECT pglinter.check(); 110 | 111 | -- Clean up 112 | DROP SCHEMA prod_analytics CASCADE; 113 | DROP SCHEMA dev_reports CASCADE; 114 | DROP SCHEMA staging_data CASCADE; 115 | DROP TABLE items CASCADE; 116 | DROP TABLE "UPPERCASE_ISSUES" CASCADE; 117 | DROP TABLE orders CASCADE; 118 | DROP TABLE products CASCADE; 119 | DROP TABLE users_no_pk CASCADE; 120 | 121 | 122 | DROP EXTENSION pglinter CASCADE; 123 | -------------------------------------------------------------------------------- /tests/sql/import_rules_from_file.sql: -------------------------------------------------------------------------------- 1 | -- Test import_rules_from_file function 2 | -- This test validates the file-based YAML import functionality 3 | 4 | -- Create a temporary test file with YAML content using echo with proper escaping 5 | \! echo "metadata:" > /tmp/test_rules_import.yaml 6 | \! echo " export_timestamp: \"2024-01-01T12:00:00Z\"" >> /tmp/test_rules_import.yaml 7 | \! echo " total_rules: 2" >> /tmp/test_rules_import.yaml 8 | \! echo " format_version: \"1.0\"" >> /tmp/test_rules_import.yaml 9 | \! echo "rules:" >> /tmp/test_rules_import.yaml 10 | \! echo " - id: 9010" >> /tmp/test_rules_import.yaml 11 | \! echo " name: \"Test File Import Rule 1\"" >> /tmp/test_rules_import.yaml 12 | \! echo " code: \"TEST_FILE_001\"" >> /tmp/test_rules_import.yaml 13 | \! echo " enable: true" >> /tmp/test_rules_import.yaml 14 | \! echo " warning_level: 35" >> /tmp/test_rules_import.yaml 15 | \! echo " error_level: 75" >> /tmp/test_rules_import.yaml 16 | \! echo " scope: \"FILE_TEST\"" >> /tmp/test_rules_import.yaml 17 | \! echo " description: \"Test rule imported from file\"" >> /tmp/test_rules_import.yaml 18 | \! echo " message: \"File import test found {0} issues\"" >> /tmp/test_rules_import.yaml 19 | \! echo " fixes:" >> /tmp/test_rules_import.yaml 20 | \! echo " - \"File-based fix suggestion\"" >> /tmp/test_rules_import.yaml 21 | \! echo " q1: \"SELECT 'file_test' as result\"" >> /tmp/test_rules_import.yaml 22 | \! echo " q2: null" >> /tmp/test_rules_import.yaml 23 | \! echo " - id: 9011" >> /tmp/test_rules_import.yaml 24 | \! echo " name: \"Test File Import Rule 2\"" >> /tmp/test_rules_import.yaml 25 | \! echo " code: \"TEST_FILE_002\"" >> /tmp/test_rules_import.yaml 26 | \! echo " enable: false" >> /tmp/test_rules_import.yaml 27 | \! echo " warning_level: 20" >> /tmp/test_rules_import.yaml 28 | \! echo " error_level: 60" >> /tmp/test_rules_import.yaml 29 | \! echo " scope: \"BASE\"" >> /tmp/test_rules_import.yaml 30 | \! echo " description: \"Second file import test rule\"" >> /tmp/test_rules_import.yaml 31 | \! echo " message: \"Second file test message\"" >> /tmp/test_rules_import.yaml 32 | \! echo " fixes:" >> /tmp/test_rules_import.yaml 33 | \! echo " - \"Another file fix\"" >> /tmp/test_rules_import.yaml 34 | \! echo " - \"Second file fix\"" >> /tmp/test_rules_import.yaml 35 | \! echo " q1: \"SELECT 2 as count\"" >> /tmp/test_rules_import.yaml 36 | \! echo " q2: \"SELECT 1 as problems\"" >> /tmp/test_rules_import.yaml 37 | CREATE EXTENSION pglinter; 38 | 39 | -- Test 1: Import rules from file 40 | SELECT pglinter.import_rules_from_file('/tmp/test_rules_import.yaml') AS file_import_result; 41 | 42 | -- Verify imported rules 43 | SELECT 44 | code, 45 | name, 46 | enable, 47 | warning_level, 48 | error_level, 49 | scope 50 | FROM pglinter.rules 51 | WHERE code LIKE 'TEST_FILE_%' 52 | ORDER BY code; 53 | 54 | -- Test 2: Import from non-existent file (should return error) 55 | SELECT pglinter.import_rules_from_file('/tmp/non_existent_file.yaml') AS nonexistent_file_result; 56 | 57 | SELECT pglinter.import_rules_from_file('/tmp/invalid_rules.yaml') AS invalid_file_result; 58 | 59 | -- Test 4: Test with empty file 60 | \! touch /tmp/empty_rules.yaml 61 | 62 | SELECT pglinter.import_rules_from_file('/tmp/empty_rules.yaml') AS empty_file_result; 63 | 64 | -- Verify final state - should still have our valid imported rules 65 | SELECT 66 | COUNT(*) AS imported_rules_count 67 | FROM pglinter.rules 68 | WHERE code LIKE 'TEST_FILE_%'; 69 | 70 | -- Clean up test data and files 71 | DELETE FROM pglinter.rules WHERE code LIKE 'TEST_FILE_%'; 72 | 73 | \! rm -f /tmp/test_rules_import.yaml /tmp/invalid_rules.yaml /tmp/empty_rules.yaml 74 | 75 | -- Final verification - no test rules should remain 76 | SELECT COUNT(*) AS remaining_file_test_rules 77 | FROM pglinter.rules 78 | WHERE code LIKE 'TEST_FILE_%'; 79 | 80 | DROP EXTENSION pglinter CASCADE; 81 | -------------------------------------------------------------------------------- /docker/oci/Dockerfile.nodeb: -------------------------------------------------------------------------------- 1 | # CloudNative-PG Extension Image for pglinter (RPM-based) 2 | # Follows the Image Specifications from: 3 | # https://cloudnative-pg.io/documentation/preview/imagevolume_extensions/#image-specifications 4 | 5 | ARG PG_VERSION=18 6 | ARG DISTRO=ubi9 7 | ARG PGLINTER_VERSION=1.0.0 8 | ARG TIMESTAMP 9 | ARG EXT_VERSION 10 | 11 | # Build stage - extract extension files from .rpm package 12 | FROM rockylinux/rockylinux:10-ubi AS builder 13 | 14 | ARG PG_VERSION 15 | ARG DISTRO 16 | ARG PGLINTER_VERSION 17 | ARG TIMESTAMP 18 | ARG EXT_VERSION 19 | 20 | USER 0 21 | 22 | # Download and extract pglinter .rpm package 23 | # Support multi-arch by detecting architecture 24 | RUN set -eux; \ 25 | ARCH=$(uname -m); \ 26 | case "$ARCH" in \ 27 | x86_64) RPM_ARCH=x86_64 ;; \ 28 | aarch64) RPM_ARCH=aarch64 ;; \ 29 | *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ 30 | esac && \ 31 | mkdir -p /extension-build && \ 32 | dnf install -y wget findutils rpm2cpio cpio && \ 33 | cd /tmp && \ 34 | DOWNLOAD_URL="https://github.com/pmpetit/pglinter/releases/download/${PGLINTER_VERSION}/postgresql_pglinter_${PG_VERSION}-${PGLINTER_VERSION}-1.${RPM_ARCH}.rpm" && \ 35 | echo "Downloading pglinter RPM from: $DOWNLOAD_URL" && \ 36 | wget -O pglinter.rpm "$DOWNLOAD_URL" && \ 37 | # Extract RPM package using rpm2cpio and cpio 38 | rpm2cpio pglinter.rpm | cpio -idmv && \ 39 | echo "Contents of extracted package:" && \ 40 | find . -type f && \ 41 | # Create CloudNative-PG compliant directory structure 42 | mkdir -p /extension-build/lib /extension-build/share/extension && \ 43 | # Copy shared libraries (.so files) 44 | if [ -d ./usr/pgsql-${PG_VERSION}/lib ]; then \ 45 | echo "Copying libraries from ./usr/pgsql-${PG_VERSION}/lib/"; \ 46 | find ./usr/pgsql-${PG_VERSION}/lib/ -name "*.so" -exec cp {} /extension-build/lib/ \; ; \ 47 | elif [ -d ./usr/lib64/pgsql ]; then \ 48 | echo "Copying libraries from ./usr/lib64/pgsql/"; \ 49 | find ./usr/lib64/pgsql/ -name "*.so" -exec cp {} /extension-build/lib/ \; ; \ 50 | fi && \ 51 | # Copy extension control and SQL files 52 | if [ -d ./usr/pgsql-${PG_VERSION}/share/extension ]; then \ 53 | echo "Copying extension files from ./usr/pgsql-${PG_VERSION}/share/extension/"; \ 54 | cp ./usr/pgsql-${PG_VERSION}/share/extension/* /extension-build/share/extension/ ; \ 55 | elif [ -d ./usr/share/pgsql/extension ]; then \ 56 | echo "Copying extension files from ./usr/share/pgsql/extension/"; \ 57 | cp ./usr/share/pgsql/extension/* /extension-build/share/extension/ ; \ 58 | fi && \ 59 | echo "Final extension structure:" && \ 60 | find /extension-build -type f 61 | 62 | # Final image - scratch base following CloudNative-PG specifications 63 | FROM scratch 64 | 65 | # Import build arguments for labeling 66 | ARG PG_VERSION 67 | ARG DISTRO 68 | ARG PGLINTER_VERSION 69 | ARG TIMESTAMP 70 | ARG EXT_VERSION 71 | 72 | # CloudNative-PG compatible labels 73 | LABEL org.opencontainers.image.title="pglinter" 74 | LABEL org.opencontainers.image.description="PostgreSQL Linter Extension for CloudNative-PG (RPM-based)" 75 | LABEL org.opencontainers.image.version="${EXT_VERSION:-${PGLINTER_VERSION}}" 76 | LABEL org.opencontainers.image.url="https://github.com/pmpetit/pglinter" 77 | LABEL org.opencontainers.image.source="https://github.com/pmpetit/pglinter" 78 | LABEL org.opencontainers.image.documentation="https://github.com/pmpetit/pglinter/blob/main/README.md" 79 | 80 | # Extension metadata labels 81 | LABEL extension.name="pglinter" 82 | LABEL extension.version="${EXT_VERSION:-${PGLINTER_VERSION}}" 83 | LABEL extension.timestamp="${TIMESTAMP}" 84 | LABEL extension.pg_version="${PG_VERSION}" 85 | LABEL extension.distro="${DISTRO}" 86 | LABEL extension.package_type="rpm" 87 | LABEL extension.tag="pglinter:${EXT_VERSION:-${PGLINTER_VERSION}}-${TIMESTAMP}-${PG_VERSION}-${DISTRO}" 88 | 89 | # CloudNative-PG Image Specifications compliance: 90 | # - /lib/ contains shared libraries (.so files) 91 | # - /share/ contains extension subdirectory with control files and SQL scripts 92 | COPY --from=builder /extension-build/lib/ /lib/ 93 | COPY --from=builder /extension-build/share/ /share/ 94 | -------------------------------------------------------------------------------- /tests/sql/s003_public_schema.sql: -------------------------------------------------------------------------------- 1 | -- Regression test for S003 rule: Schemas with public CREATE privileges 2 | -- Tests the percentage-based detection of schemas allowing CREATE for public role 3 | -- versus total schemas in the database 4 | CREATE EXTENSION pglinter; 5 | 6 | SELECT 'S003 Regression Test: Schemas with public CREATE privileges' AS test_header; 7 | 8 | -- Setup S003 rule for testing 9 | SELECT pglinter.disable_all_rules(); 10 | SELECT pglinter.enable_rule('S003'); 11 | 12 | -- PART 1: Test with LOW percentage of insecure schemas (should NOT trigger) 13 | SELECT 'PART 1: Testing with LOW percentage of insecure schemas (should NOT trigger)' AS test_part; 14 | 15 | -- Create one secure schema (no public CREATE) 16 | CREATE SCHEMA test_secure_schema; 17 | REVOKE CREATE ON SCHEMA test_secure_schema FROM public; 18 | 19 | -- Create one secure schema 20 | CREATE SCHEMA test_secure_schema_1; 21 | REVOKE CREATE ON SCHEMA test_secure_schema_1 FROM public; 22 | 23 | -- Create one secure schema 24 | CREATE SCHEMA test_secure_schema_2; 25 | REVOKE CREATE ON SCHEMA test_secure_schema_2 FROM public; 26 | 27 | -- Create one secure schema 28 | CREATE SCHEMA test_secure_schema_3; 29 | REVOKE CREATE ON SCHEMA test_secure_schema_3 FROM public; 30 | 31 | -- Create one secure schema 32 | CREATE SCHEMA test_secure_schema_4; 33 | REVOKE CREATE ON SCHEMA test_secure_schema_4 FROM public; 34 | 35 | -- Create one secure schema 36 | CREATE SCHEMA test_secure_schema_5; 37 | REVOKE CREATE ON SCHEMA test_secure_schema_5 FROM public; 38 | 39 | -- Create one secure schema 40 | CREATE SCHEMA test_secure_schema_6; 41 | REVOKE CREATE ON SCHEMA test_secure_schema_6 FROM public; 42 | 43 | -- Create one secure schema 44 | CREATE SCHEMA test_secure_schema_7; 45 | REVOKE CREATE ON SCHEMA test_secure_schema_7 FROM public; 46 | 47 | -- Create one secure schema 48 | CREATE SCHEMA test_secure_schema_8; 49 | REVOKE CREATE ON SCHEMA test_secure_schema_8 FROM public; 50 | 51 | 52 | -- Create one insecure schema 53 | CREATE SCHEMA test_insecure_schema_1; 54 | GRANT CREATE ON SCHEMA test_insecure_schema_1 TO public; 55 | 56 | 57 | -- Test S003 with low percentage (should not trigger with default thresholds) 58 | SELECT 'Running S003 check with LOW percentage of insecure schemas (should not trigger with default thresholds):' AS test_1; 59 | SELECT pglinter.perform_schema_check(); 60 | 61 | CREATE SCHEMA test_insecure_schema_2; 62 | GRANT CREATE ON SCHEMA test_insecure_schema_2 TO public; 63 | 64 | -- Test S003 with high percentage (should trigger) 65 | SELECT 'Running S003 check with HIGH percentage of insecure schemas (should trigger):' AS test_2; 66 | SELECT pglinter.perform_schema_check(); 67 | -- Test with file output 68 | SELECT pglinter.perform_schema_check('/tmp/pglinter_s003_results.sarif'); 69 | -- Test if file exists and show checksum 70 | \! md5sum /tmp/pglinter_s003_results.sarif 71 | 72 | 73 | -- PART 3: Test threshold adjustment 74 | SELECT 'PART 3: Testing S003 threshold adjustments (should not trigger)' AS test_part; 75 | 76 | -- Lower the warning threshold to ensure detection 77 | SELECT pglinter.update_rule_levels('S003', 50, 80); 78 | 79 | SELECT 'S003 thresholds updated to warning=50%, error=80%' AS threshold_info; 80 | 81 | -- Test with adjusted thresholds 82 | SELECT 'Running S003 check with adjusted thresholds (warning=50%):' AS test_3; 83 | SELECT pglinter.perform_schema_check(); 84 | 85 | -- PART 6: Verification of SQL queries used by S003 86 | SELECT 'PART 6: Direct verification of S003 SQL queries' AS sql_verification; 87 | 88 | -- Test rule explanation 89 | SELECT 'S003 rule explanation:' AS rule_explanation; 90 | SELECT pglinter.explain_rule('S003'); 91 | 92 | DROP SCHEMA test_secure_schema CASCADE; 93 | DROP SCHEMA test_secure_schema_1 CASCADE; 94 | DROP SCHEMA test_secure_schema_2 CASCADE; 95 | DROP SCHEMA test_secure_schema_3 CASCADE; 96 | DROP SCHEMA test_secure_schema_4 CASCADE; 97 | DROP SCHEMA test_secure_schema_5 CASCADE; 98 | DROP SCHEMA test_secure_schema_6 CASCADE; 99 | DROP SCHEMA test_secure_schema_7 CASCADE; 100 | DROP SCHEMA test_secure_schema_8 CASCADE; 101 | DROP SCHEMA test_insecure_schema_1 CASCADE; 102 | DROP SCHEMA test_insecure_schema_2 CASCADE; 103 | 104 | DROP EXTENSION pglinter CASCADE; 105 | -------------------------------------------------------------------------------- /tests/sql/b005_uppercase_test.sql: -------------------------------------------------------------------------------- 1 | -- Test for pglinter B005 rule: Database objects with uppercase names 2 | -- This script creates various database objects with uppercase names to test 3 | -- the comprehensive B005 rule detection across all PostgreSQL object types 4 | CREATE EXTENSION pglinter; 5 | 6 | -- Create a test schema for our objects 7 | CREATE SCHEMA test_B005_schema; 8 | 9 | -- Create test objects with UPPERCASE names (should trigger B005) 10 | -- Using quoted identifiers to force case-sensitive storage 11 | 12 | -- 1. Table with uppercase name (quoted for case sensitivity) 13 | CREATE TABLE test_B005_schema."CUSTOMERS_TABLE" ( 14 | customer_id SERIAL PRIMARY KEY, 15 | "FIRST_NAME" VARCHAR(50), -- Column with uppercase (quoted) 16 | last_name VARCHAR(50), -- Column with lowercase (unquoted - should not trigger) 17 | "EMAIL_ADDRESS" VARCHAR(100), -- Column with uppercase (quoted) 18 | phone_number VARCHAR(20) -- Column with lowercase (unquoted) 19 | ); 20 | 21 | -- 2. View with uppercase name (quoted for case sensitivity) 22 | CREATE VIEW test_B005_schema."ACTIVE_CUSTOMERS" AS 23 | SELECT 24 | customer_id, 25 | "FIRST_NAME", 26 | last_name 27 | FROM test_B005_schema."CUSTOMERS_TABLE" 28 | WHERE customer_id > 0; 29 | 30 | -- 3. Index with uppercase name (quoted for case sensitivity) 31 | CREATE INDEX "IDX_CUSTOMERS_EMAIL" ON test_B005_schema."CUSTOMERS_TABLE" ("EMAIL_ADDRESS"); 32 | CREATE INDEX idx_customers_phone ON test_B005_schema."CUSTOMERS_TABLE" (phone_number); -- lowercase, should not trigger 33 | 34 | -- 4. Sequence with uppercase name (quoted for case sensitivity) 35 | CREATE SEQUENCE test_B005_schema."CUSTOMER_ID_SEQ"; 36 | 37 | -- 5. Function with uppercase name (quoted for case sensitivity) 38 | CREATE OR REPLACE FUNCTION test_B005_schema."GET_CUSTOMER_COUNT"() 39 | RETURNS INTEGER AS $$ 40 | BEGIN 41 | RETURN (SELECT COUNT(*) FROM test_B005_schema."CUSTOMERS_TABLE"); 42 | END; 43 | $$ LANGUAGE plpgsql; 44 | 45 | -- 6. Function with lowercase name (should not trigger) 46 | CREATE OR REPLACE FUNCTION test_B005_schema.get_customer_by_id(p_id INTEGER) 47 | RETURNS TEXT AS $$ 48 | BEGIN 49 | RETURN (SELECT "FIRST_NAME" FROM test_B005_schema."CUSTOMERS_TABLE" WHERE customer_id = p_id); 50 | END; 51 | $$ LANGUAGE plpgsql; 52 | 53 | -- 7. Trigger with uppercase name 54 | CREATE OR REPLACE FUNCTION test_B005_schema.update_timestamp() 55 | RETURNS TRIGGER AS $$ 56 | BEGIN 57 | NEW.updated_at = NOW(); 58 | RETURN NEW; 59 | END; 60 | $$ LANGUAGE plpgsql; 61 | 62 | -- Add timestamp column for trigger 63 | ALTER TABLE test_B005_schema."CUSTOMERS_TABLE" ADD COLUMN updated_at TIMESTAMP DEFAULT NOW(); 64 | 65 | CREATE TRIGGER "UPDATE_TIMESTAMP_TRIGGER" 66 | BEFORE UPDATE ON test_B005_schema."CUSTOMERS_TABLE" 67 | FOR EACH ROW EXECUTE FUNCTION test_B005_schema.update_timestamp(); 68 | 69 | -- 8. Constraint with uppercase name (user-defined, not auto-generated) 70 | ALTER TABLE test_B005_schema."CUSTOMERS_TABLE" 71 | ADD CONSTRAINT "EMAIL_UNIQUE_CONSTRAINT" UNIQUE ("EMAIL_ADDRESS"); 72 | 73 | -- 9. User-defined type with uppercase name 74 | CREATE TYPE test_B005_schema."CUSTOMER_STATUS" AS ENUM ('active', 'inactive', 'pending'); 75 | 76 | -- 10. Domain with uppercase name 77 | CREATE DOMAIN test_B005_schema."EMAIL_DOMAIN" AS VARCHAR(100) 78 | CHECK (value ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'); 79 | 80 | -- 11. Create another schema with uppercase name 81 | CREATE SCHEMA "TEST_UPPERCASE_SCHEMA"; 82 | 83 | -- Create some data for testing 84 | INSERT INTO test_B005_schema."CUSTOMERS_TABLE" ("FIRST_NAME", last_name, "EMAIL_ADDRESS", phone_number) VALUES 85 | ('John', 'Doe', 'john.doe@example.com', '555-1234'), 86 | ('Jane', 'Smith', 'jane.smith@example.com', '555-5678'), 87 | ('Bob', 'Johnson', 'bob.johnson@example.com', '555-9012'); 88 | 89 | -- Test B005 rule 90 | 91 | SELECT 'Testing B005 rule - Comprehensive uppercase object detection...' AS test_info; 92 | 93 | -- First, disable all rules to isolate B005 testing 94 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 95 | 96 | -- Enable only B005 for focused testing 97 | SELECT pglinter.enable_rule('B005') AS B005_enabled; 98 | 99 | -- Verify B005 is enabled 100 | SELECT pglinter.is_rule_enabled('B005') AS B005_status; 101 | 102 | -- Run B005 check to detect uppercase violations 103 | -- Expected result: Should detect multiple uppercase objects we created 104 | SELECT 'Running B005 check to detect comprehensive uppercase violations...' AS status; 105 | SELECT pglinter.check(); 106 | 107 | -- Test with file output 108 | SELECT pglinter.check('/tmp/pglinter_B005_results.sarif'); 109 | -- Test if file exists and show checksum 110 | \! md5sum /tmp/pglinter_B005_results.sarif 111 | 112 | -- Test rule management for B005 113 | SELECT 'Testing B005 rule management...' AS test_section; 114 | SELECT pglinter.explain_rule('B005'); 115 | 116 | DROP SCHEMA test_B005_schema CASCADE; 117 | DROP SCHEMA "TEST_UPPERCASE_SCHEMA" CASCADE; 118 | 119 | DROP EXTENSION pglinter CASCADE; 120 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | PGLinter Logo 3 |

4 | 5 | # PG Linter Documentation 6 | 7 | In recent years, DBAs were more involved with the database engine itself: creating instances, configuring backups, and monitoring systems and also, overseeing developers' activities. 8 | Today, in the DBRE world where databases are cloud-managed, developers and operations teams often work independently, without a dedicated DBA. 9 | 10 | So databases objects lives their own life, created by persons that do their best. It can be usefull to be able to detect some wrong design creation (for example foreign keys created accross differents schemas...). That's what pglinter was created for. 11 | 12 | This extension is not designed for DBAs who often have their own tools and scripts to detect those potential problems, but for developers, operations teams, or CI pipelines that do not have deep knowledge on database best practices. 13 | 14 | Database linting and analysis for PostgreSQL 15 | =============================================================================== 16 | 17 | `pglinter` is a PostgreSQL extension that analyzes your database for potential issues, performance problems, and best practice violations. Written in Rust using pgrx, it provides deep integration with PostgreSQL for efficient database analysis. 18 | 19 | The project has a **rule-based approach** to database analysis. This means you can enable or disable specific rules and configure thresholds to match your organization's standards and requirements. 20 | 21 | The main goal of this extension is to offer **database quality by design**. We believe that database analysis should be integrated into your development workflow, allowing teams to catch potential issues early in the development cycle. 22 | 23 | ## Key Features 24 | 25 | * **Performance Analysis**: Detect unused indexes, missing indexes. 26 | * **Schema Validation**: Check for proper primary keys, foreign key indexing, and schema design 27 | * **Security Auditing**: Identify potential security risks and configuration issues 28 | * **SARIF Output**: Industry-standard reporting format compatible with modern CI/CD tools 29 | * **Configurable Rules**: Enable/disable rules and adjust thresholds based on your needs 30 | 31 | ## Rule Categories 32 | 33 | PG Linter organizes its analysis rules into four main categories: 34 | 35 | ### Base Rules (B-series) 36 | 37 | Database-wide checks that analyze overall database health and structure. This type of overview is intended more for non-developers and provides a comprehensive perspective on the database as a whole. 38 | Individual object names are not important in this context. 39 | 40 | * **B001**: Tables without primary keys 41 | * **B002**: Redundant indexes 42 | * **B003**: Tables without indexes on foreign keys 43 | * **B004**: Unused indexes 44 | * **B005**: Unsecured public schema 45 | * **B006**: Tables with uppercase names/columns 46 | 47 | ### Cluster Rules (C-series) 48 | 49 | PostgreSQL cluster configuration checks: 50 | 51 | * **C001**: Memory configuration issues (max_connections * work_mem > available RAM) 52 | * **C002**: Insecure pg_hba.conf entries 53 | * **C003**: MD5 password encryption (deprecated, blocks PG18+ upgrades) 54 | 55 | ### Schema Rules (S-series) 56 | 57 | Schema-level checks: 58 | 59 | * **S001**: Schemas without proper privileges 60 | * **S002**: Schemas with public privileges 61 | 62 | ## Quick Start 63 | 64 | ### **Installation** 65 | 66 | ```sql 67 | CREATE EXTENSION pglinter; 68 | ``` 69 | 70 | ### **Run Analysis** 71 | 72 | ```sql 73 | -- Analyze entire database 74 | SELECT pglinter.perform_base_check(); 75 | 76 | -- Save results to file 77 | SELECT pglinter.perform_base_check('/path/to/results.sarif'); 78 | ``` 79 | 80 | ### **Manage Rules** 81 | 82 | ```sql 83 | -- Show all rules 84 | SELECT pglinter.show_rules(); 85 | 86 | -- Disable a specific rule 87 | SELECT pglinter.disable_rule('B001'); 88 | 89 | -- Get rule explanation 90 | SELECT pglinter.explain_rule('B002'); 91 | ``` 92 | 93 | ## Documentation Structure 94 | 95 | * **[Configuration](configure.md)**: How to configure rules and thresholds 96 | * **[Functions Reference](functions/README.md)**: Complete function reference 97 | * **[Rule Reference](rules/README.md)**: Detailed description of all rules 98 | * **[How-To Guides](how-to/README.md)**: Practical guides for common scenarios 99 | 100 | ## Integration 101 | 102 | pglinter is designed to integrate seamlessly into your development workflow: 103 | 104 | * **CI/CD Pipelines**: Use SARIF output with GitHub Actions, GitLab CI, or other tools 105 | * **Database Migrations**: Run checks after schema changes 106 | * **Monitoring**: Schedule regular database health checks 107 | * **Code Reviews**: Include database analysis in your review process 108 | 109 | ## Support 110 | 111 | * **Issues**: [GitHub Issues](https://github.com/pmpetit/pglinter/issues) 112 | * **Discussions**: [GitHub Discussions](https://github.com/pmpetit/pglinter/discussions) 113 | -------------------------------------------------------------------------------- /tests/sql/b004_idx_not_used.sql: -------------------------------------------------------------------------------- 1 | -- Test for pglinter B004 rule: Unused indexes detection 2 | -- This script creates tables with both used and unused indexes 3 | -- to demonstrate the B004 rule detection of indexes that are never scanned 4 | CREATE EXTENSION pglinter; 5 | 6 | -- Table : table_with_mixed_index_usage 7 | 8 | DROP TABLE IF EXISTS customer_analytics; 9 | 10 | CREATE TABLE customer_analytics ( 11 | id SERIAL PRIMARY KEY, 12 | customer_id INTEGER NOT NULL, 13 | page_views INTEGER DEFAULT 0, 14 | session_duration INTEGER DEFAULT 0, 15 | last_login TIMESTAMP, 16 | device_type VARCHAR(50), 17 | browser VARCHAR(50), 18 | ip_address INET, 19 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 20 | ); 21 | 22 | -- Mix of used and unused indexes 23 | CREATE INDEX idx_analytics_customer_id ON customer_analytics (customer_id); -- Will be used 24 | CREATE INDEX idx_analytics_last_login ON customer_analytics (last_login); -- Will be used 25 | CREATE INDEX idx_analytics_device_type ON customer_analytics (device_type); -- Will NOT be used 26 | CREATE INDEX idx_analytics_browser ON customer_analytics (browser); -- Will NOT be used 27 | CREATE INDEX idx_analytics_ip_address ON customer_analytics (ip_address); -- Will NOT be used 28 | 29 | -- Insert large amount of analytics data 30 | INSERT INTO customer_analytics (customer_id, page_views, session_duration, last_login, device_type, browser) 31 | SELECT 32 | (i % 5) + 1, -- customer_id (1-5) 33 | i % 100 + 10, -- page_views (10-109) 34 | (i % 3600) + 300, -- session_duration (300-3899 seconds) 35 | '2024-01-01'::TIMESTAMP + (i || ' seconds')::INTERVAL, -- varying dates 36 | CASE (i % 4) 37 | WHEN 0 THEN 'desktop' 38 | WHEN 1 THEN 'mobile' 39 | WHEN 2 THEN 'tablet' 40 | ELSE 'laptop' 41 | END, 42 | CASE (i % 3) 43 | WHEN 0 THEN 'chrome' 44 | WHEN 1 THEN 'firefox' 45 | ELSE 'safari' 46 | END 47 | FROM GENERATE_SERIES(1, 22000) AS i; 48 | 49 | -- Reset statistics to start fresh 50 | SELECT PG_STAT_RESET(); 51 | 52 | -- Update table statistics 53 | ANALYZE customer_analytics; 54 | 55 | -- Use some indexes on customer_analytics (mixed usage) 56 | SELECT COUNT(*) FROM customer_analytics 57 | WHERE customer_id = 1; 58 | SELECT COUNT(*) FROM customer_analytics 59 | WHERE customer_id = 2; 60 | SELECT COUNT(*) FROM customer_analytics 61 | WHERE customer_id IN (1, 2, 3); 62 | SELECT 63 | id, 64 | customer_id, 65 | page_views, 66 | session_duration 67 | FROM customer_analytics 68 | WHERE customer_id = 1 69 | ORDER BY id LIMIT 10; 70 | 71 | -- Do not use indexes 72 | SELECT COUNT(*) FROM customer_analytics 73 | WHERE last_login > '2024-01-01'; 74 | SELECT COUNT(*) FROM customer_analytics 75 | WHERE last_login > '2024-01-15'; 76 | SELECT COUNT(*) FROM customer_analytics 77 | WHERE last_login BETWEEN '2024-01-01' AND '2024-01-20'; 78 | SELECT 79 | id, 80 | customer_id, 81 | page_views, 82 | session_duration 83 | FROM customer_analytics 84 | WHERE last_login > '2024-01-01' 85 | ORDER BY last_login LIMIT 10; 86 | 87 | -- Update statistics after usage 88 | -- Update table statistics 89 | ANALYZE customer_analytics; 90 | 91 | -- Give some time.... 92 | SELECT PG_SLEEP(2); 93 | 94 | SELECT 'Testing B004 rule - Unused indexes detection...' AS test_info; 95 | 96 | -- First, disable all rules to isolate B004 testing 97 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 98 | 99 | -- Enable only B004 for focused testing 100 | SELECT pglinter.enable_rule('B004') AS b004_enabled; 101 | 102 | -- Verify B004 is enabled 103 | SELECT pglinter.is_rule_enabled('B004') AS b004_status; 104 | 105 | -- Run base check to detect B004 violations 106 | -- Expected result: Should detect unused indexes with idx_scan = 0 107 | SELECT 'Running base check to detect B004 violations...' AS status; 108 | SELECT pglinter.check(); 109 | 110 | -- Test rule management for B004 111 | SELECT 'Testing B004 rule management...' AS test_section; 112 | SELECT pglinter.explain_rule('B004'); 113 | 114 | -- Drop some unused indexes to show improvement 115 | DROP INDEX idx_analytics_device_type; 116 | 117 | -- Update table statistics 118 | ANALYZE customer_analytics; 119 | -- Give some time.... 120 | SELECT PG_SLEEP(2); 121 | 122 | -- Run B004 check again (should show fewer violations) 123 | SELECT 'Running B004 check after dropping some unused indexes (should show fewer violations):' AS test_info; 124 | SELECT pglinter.check(); 125 | 126 | -- Test with file output 127 | SELECT pglinter.check('/tmp/pglinter_b004_results.sarif'); 128 | -- Test if file exists and show checksum 129 | \! md5sum /tmp/pglinter_b004_results.sarif 130 | 131 | 132 | -- Update B004 thresholds to demonstrate message formatting 133 | SELECT pglinter.update_rule_levels('B004', 60, 90); 134 | 135 | -- Final demonstration with current state 136 | SELECT 'Final B004 (base check) - Shows percentage-based unused index analysis:' AS b004_demo; 137 | SELECT pglinter.check(); 138 | 139 | DROP TABLE customer_analytics; 140 | 141 | DROP EXTENSION pglinter CASCADE; 142 | -------------------------------------------------------------------------------- /tests/sql/import_rules_from_yaml.sql: -------------------------------------------------------------------------------- 1 | -- Test import_rules_from_yaml function 2 | -- This test validates the YAML import functionality 3 | CREATE EXTENSION pglinter; 4 | 5 | \pset pager off 6 | 7 | -- Clean up any existing test rules 8 | DELETE FROM pglinter.rules WHERE code IN ('TEST_YAML_001', 'TEST_YAML_002'); 9 | 10 | -- Test 1: Valid YAML import with new rules 11 | SELECT pglinter.import_rules_from_yaml(' 12 | metadata: 13 | export_timestamp: "2024-01-01T00:00:00Z" 14 | total_rules: 2 15 | format_version: "1.0" 16 | rules: 17 | - id: 9001 18 | name: "Test YAML Rule 1" 19 | code: "TEST_YAML_001" 20 | enable: true 21 | warning_level: 30 22 | error_level: 70 23 | scope: "TEST" 24 | description: "Test rule for YAML import validation" 25 | message: "Test rule found {0} issues" 26 | fixes: 27 | - "Fix suggestion 1" 28 | - "Fix suggestion 2" 29 | q1: "SELECT ''test'' as result" 30 | q2: null 31 | - id: 9002 32 | name: "Test YAML Rule 2" 33 | code: "TEST_YAML_002" 34 | enable: false 35 | warning_level: 25 36 | error_level: 80 37 | scope: "BASE" 38 | description: "Second test rule for YAML import" 39 | message: "Second test rule message" 40 | fixes: 41 | - "Another fix suggestion" 42 | q1: "SELECT 1 as count" 43 | q2: "SELECT 0 as problems" 44 | ') AS import_result; 45 | 46 | -- Verify imported rules exist and have correct values 47 | SELECT 48 | code, 49 | name, 50 | enable, 51 | warning_level, 52 | error_level, 53 | scope, 54 | description, 55 | message, 56 | fixes, 57 | q1, 58 | q2 59 | FROM pglinter.rules 60 | WHERE code IN ('TEST_YAML_001', 'TEST_YAML_002') 61 | ORDER BY code; 62 | 63 | -- Test 2: Update existing rule via YAML import 64 | SELECT pglinter.import_rules_from_yaml(' 65 | metadata: 66 | export_timestamp: "2024-01-02T00:00:00Z" 67 | total_rules: 1 68 | format_version: "1.0" 69 | rules: 70 | - id: 9001 71 | name: "Updated Test YAML Rule 1" 72 | code: "TEST_YAML_001" 73 | enable: false 74 | warning_level: 40 75 | error_level: 85 76 | scope: "UPDATED_TEST" 77 | description: "Updated test rule description" 78 | message: "Updated test message with {0} items" 79 | fixes: 80 | - "Updated fix suggestion" 81 | - "Additional fix" 82 | - "Third fix option" 83 | q1: "SELECT ''updated'' as result" 84 | q2: "SELECT 5 as count" 85 | ') AS update_result; 86 | 87 | -- Verify the rule was updated 88 | SELECT 89 | code, 90 | name, 91 | enable, 92 | warning_level, 93 | error_level, 94 | scope, 95 | description, 96 | message, 97 | array_length(fixes, 1) as fixes_count, 98 | q1, 99 | q2 100 | FROM pglinter.rules 101 | WHERE code = 'TEST_YAML_001'; 102 | 103 | -- Test 3: Import with null values 104 | SELECT pglinter.import_rules_from_yaml(' 105 | metadata: 106 | export_timestamp: "2024-01-03T00:00:00Z" 107 | total_rules: 1 108 | format_version: "1.0" 109 | rules: 110 | - id: 9003 111 | name: "Test Null Values" 112 | code: "TEST_YAML_003" 113 | enable: true 114 | warning_level: 50 115 | error_level: 90 116 | scope: "BASE" 117 | description: "Rule with null values" 118 | message: "Test message" 119 | fixes: [] 120 | q1: null 121 | q2: null 122 | ') AS null_values_result; 123 | 124 | -- Verify null handling 125 | SELECT 126 | code, 127 | name, 128 | q1 IS NULL as q1_is_null, 129 | q2 IS NULL as q2_is_null, 130 | array_length(fixes, 1) as fixes_count 131 | FROM pglinter.rules 132 | WHERE code = 'TEST_YAML_003'; 133 | 134 | -- Test 4: Invalid YAML should return error 135 | SELECT pglinter.import_rules_from_yaml(' 136 | invalid_yaml: [ 137 | this is not valid yaml 138 | - missing proper structure 139 | ') AS invalid_yaml_result; 140 | 141 | -- Test 5: Empty rules array 142 | SELECT pglinter.import_rules_from_yaml(' 143 | metadata: 144 | export_timestamp: "2024-01-04T00:00:00Z" 145 | total_rules: 0 146 | format_version: "1.0" 147 | rules: [] 148 | ') AS empty_rules_result; 149 | 150 | -- Test 6: YAML with minimal required fields 151 | SELECT pglinter.import_rules_from_yaml(' 152 | metadata: 153 | export_timestamp: "2024-01-05T00:00:00Z" 154 | total_rules: 1 155 | format_version: "1.0" 156 | rules: 157 | - id: 9004 158 | name: "Minimal Rule" 159 | code: "TEST_YAML_MIN" 160 | enable: true 161 | warning_level: 10 162 | error_level: 20 163 | scope: "BASE" 164 | description: "Minimal rule" 165 | message: "Simple message" 166 | fixes: [] 167 | q1: "SELECT 1" 168 | q2: null 169 | ') AS minimal_rule_result; 170 | 171 | -- Verify minimal rule 172 | SELECT code, name, scope FROM pglinter.rules WHERE code = 'TEST_YAML_MIN'; 173 | 174 | -- Clean up test data 175 | DELETE FROM pglinter.rules WHERE code IN ('TEST_YAML_001', 'TEST_YAML_002', 'TEST_YAML_003', 'TEST_YAML_MIN'); 176 | 177 | -- Test count verification 178 | SELECT COUNT(*) as remaining_test_rules 179 | FROM pglinter.rules 180 | WHERE code LIKE 'TEST_YAML_%'; 181 | 182 | DROP EXTENSION pglinter CASCADE; 183 | -------------------------------------------------------------------------------- /tests/expected/schema_rules.out: -------------------------------------------------------------------------------- 1 | -- Test for pglinter schema-level rules 2 | -- This script demonstrates schema-level checks and rule management 3 | CREATE EXTENSION pglinter; 4 | -- Create test schemas that should trigger S002 (environment prefixes/suffixes) 5 | CREATE SCHEMA prod_sales; 6 | CREATE SCHEMA dev_analytics; 7 | CREATE SCHEMA testing_data; 8 | CREATE SCHEMA reports_staging; 9 | -- Create a clean schema that should not trigger rules 10 | CREATE SCHEMA business_logic; 11 | -- Create some objects in the schemas to make them more realistic 12 | CREATE TABLE prod_sales.customers ( 13 | id SERIAL PRIMARY KEY, 14 | name TEXT NOT NULL 15 | ); 16 | CREATE TABLE dev_analytics.metrics ( 17 | id SERIAL PRIMARY KEY, 18 | metric_name TEXT NOT NULL, 19 | value NUMERIC 20 | ); 21 | CREATE TABLE business_logic.rules ( 22 | id SERIAL PRIMARY KEY, 23 | rule_name TEXT NOT NULL 24 | ); 25 | -- Enable only S002 26 | SELECT pglinter.disable_all_rules(); 27 | NOTICE: 🔴 Disabled 20 rule(s) 28 | disable_all_rules 29 | ------------------- 30 | 20 31 | (1 row) 32 | 33 | SELECT pglinter.enable_rule('S002'); 34 | NOTICE: ✅ Rule S002 has been enabled 35 | enable_rule 36 | ------------- 37 | t 38 | (1 row) 39 | 40 | -- Test the schema rules 41 | SELECT 'Testing schema rules S002...' as test_info; 42 | test_info 43 | ------------------------------ 44 | Testing schema rules S002... 45 | (1 row) 46 | 47 | -- Run schema check to detect environment-named schemas and default privilege issues 48 | SELECT pglinter.perform_schema_check(); 49 | ERROR: function pglinter.perform_schema_check() does not exist 50 | LINE 1: SELECT pglinter.perform_schema_check(); 51 | ^ 52 | HINT: No function matches the given name and argument types. You might need to add explicit type casts. 53 | -- Test individual schema rules 54 | SELECT pglinter.explain_rule('S002'); 55 | NOTICE: 📖 Rule Explanation for S002 56 | ============================================================ 57 | 58 | 🎯 Rule Name: SchemaPrefixedOrSuffixedWithEnvt 59 | 📋 Scope: SCHEMA 60 | 61 | 📝 Description: 62 | The schema is prefixed with one of staging,stg,preprod,prod,sandbox,sbox string. Means that when you refresh your preprod, staging environments from production, you have to rename the target schema from prod_ to stg_ or something like. It is possible, but it is never easy. 63 | 64 | ⚠️ Message Template: 65 | {0}/{1} schemas are prefixed or suffixed with environment names. It exceed the {2} threshold: {3}%. Prefer prefix or suffix the database name instead. Object list:\n{4} 66 | 67 | 🔧 How to Fix: 68 | 1. Keep the same schema name across environments. Prefer prefix or suffix the database name 69 | ============================================================ 70 | explain_rule 71 | -------------- 72 | t 73 | (1 row) 74 | 75 | -- Test rule management for schema rules 76 | SELECT pglinter.is_rule_enabled('S002') AS s002_enabled; 77 | s002_enabled 78 | -------------- 79 | t 80 | (1 row) 81 | 82 | -- Test disabling S002 (environment prefixes) 83 | SELECT pglinter.disable_rule('S002') AS s002_disabled; 84 | NOTICE: 🔴 Rule S002 has been disabled 85 | s002_disabled 86 | --------------- 87 | t 88 | (1 row) 89 | 90 | SELECT pglinter.perform_schema_check(); -- Should skip S002 91 | ERROR: function pglinter.perform_schema_check() does not exist 92 | LINE 1: SELECT pglinter.perform_schema_check(); 93 | ^ 94 | HINT: No function matches the given name and argument types. You might need to add explicit type casts. 95 | -- Re-enable S002 96 | SELECT pglinter.enable_rule('S002') AS s002_reenabled; 97 | NOTICE: ✅ Rule S002 has been enabled 98 | s002_reenabled 99 | ---------------- 100 | t 101 | (1 row) 102 | 103 | SELECT pglinter.perform_schema_check(); -- Should include S002 again 104 | ERROR: function pglinter.perform_schema_check() does not exist 105 | LINE 1: SELECT pglinter.perform_schema_check(); 106 | ^ 107 | HINT: No function matches the given name and argument types. You might need to add explicit type casts. 108 | -- Test the comprehensive check including schemas 109 | SELECT pglinter.check(); 110 | NOTICE: 🔍 pglinter found 1 issue(s): 111 | NOTICE: ================================================== 112 | NOTICE: ❌ [S002] ERROR: 3/6 schemas are prefixed or suffixed with environment names. It exceed the error threshold: 50%. Prefer prefix or suffix the database name instead. Object list: 113 | dev_analytics 114 | prod_sales 115 | reports_staging 116 | 117 | NOTICE: ================================================== 118 | NOTICE: 📊 Summary: 1 error(s), 0 warning(s), 0 info 119 | NOTICE: 🔴 Critical issues found - please review and fix errors 120 | check 121 | ------- 122 | t 123 | (1 row) 124 | 125 | DROP SCHEMA prod_sales CASCADE; 126 | NOTICE: drop cascades to table prod_sales.customers 127 | DROP SCHEMA dev_analytics CASCADE; 128 | NOTICE: drop cascades to table dev_analytics.metrics 129 | DROP SCHEMA testing_data CASCADE; 130 | DROP SCHEMA reports_staging CASCADE; 131 | DROP SCHEMA business_logic CASCADE; 132 | NOTICE: drop cascades to table business_logic.rules 133 | DROP EXTENSION pglinter CASCADE; 134 | -------------------------------------------------------------------------------- /tests/expected/demo_rule_levels.out: -------------------------------------------------------------------------------- 1 | -- Demo script for the new rule level management functions 2 | -- This script demonstrates how to get and update warning_level and error_level for rules 3 | CREATE EXTENSION pglinter; 4 | ERROR: extension "pglinter" already exists 5 | \echo 'Testing rule level management functions...' 6 | Testing rule level management functions... 7 | -- First, let's see the current levels for T005 8 | \echo 'Current T005 rule levels:' 9 | Current T005 rule levels: 10 | SELECT pglinter.get_rule_levels('T005') as current_levels; 11 | current_levels 12 | ---------------------------------- 13 | warning_level=50, error_level=90 14 | (1 row) 15 | 16 | -- Let's also check a few other rules 17 | \echo 'Current levels for some rules:' 18 | Current levels for some rules: 19 | SELECT 20 | code, 21 | pglinter.get_rule_levels(code) as levels 22 | FROM pglinter.list_rules() 23 | WHERE code IN ('B001', 'T001', 'T005', 'C002') 24 | ORDER BY code; 25 | ERROR: column "code" does not exist 26 | LINE 2: code, 27 | ^ 28 | -- Update T005 to have different thresholds 29 | \echo 'Updating T005 warning level to 25 and error level to 75:' 30 | Updating T005 warning level to 25 and error level to 75: 31 | SELECT pglinter.update_rule_levels('T005', 25, 75) as update_success; 32 | WARNING: ⚠️ Rule T005 not found 33 | update_success 34 | ---------------- 35 | f 36 | (1 row) 37 | 38 | -- Verify the update 39 | \echo 'T005 levels after update:' 40 | T005 levels after update: 41 | SELECT pglinter.get_rule_levels('T005') as updated_levels; 42 | updated_levels 43 | ---------------------------------- 44 | warning_level=50, error_level=90 45 | (1 row) 46 | 47 | -- Update only the warning level of B001 48 | \echo 'Updating only B001 warning level to 5:' 49 | Updating only B001 warning level to 5: 50 | SELECT pglinter.update_rule_levels('B001', 5, NULL) as update_success; 51 | NOTICE: ✅ Updated rule B001 levels: warning=5, error=80 52 | update_success 53 | ---------------- 54 | t 55 | (1 row) 56 | 57 | -- Verify B001 update 58 | \echo 'B001 levels after warning level update:' 59 | B001 levels after warning level update: 60 | SELECT pglinter.get_rule_levels('B001') as updated_levels; 61 | updated_levels 62 | --------------------------------- 63 | warning_level=5, error_level=80 64 | (1 row) 65 | 66 | -- Update only the error level of T001 67 | \echo 'Updating only T001 error level to 3:' 68 | Updating only T001 error level to 3: 69 | SELECT pglinter.update_rule_levels('T001', NULL, 3) as update_success; 70 | WARNING: ⚠️ Rule T001 not found 71 | update_success 72 | ---------------- 73 | f 74 | (1 row) 75 | 76 | -- Verify T001 update 77 | \echo 'T001 levels after error level update:' 78 | T001 levels after error level update: 79 | SELECT pglinter.get_rule_levels('T001') as updated_levels; 80 | updated_levels 81 | ---------------------------------- 82 | warning_level=50, error_level=90 83 | (1 row) 84 | 85 | -- Try to update a non-existent rule 86 | \echo 'Trying to update non-existent rule (should return false):' 87 | Trying to update non-existent rule (should return false): 88 | SELECT pglinter.update_rule_levels('NONEXISTENT', 10, 20) as should_be_false; 89 | WARNING: ⚠️ Rule NONEXISTENT not found 90 | should_be_false 91 | ----------------- 92 | f 93 | (1 row) 94 | 95 | -- You can also query the rules table directly to see the raw values 96 | \echo 'Raw warning_level and error_level from rules table:' 97 | Raw warning_level and error_level from rules table: 98 | SELECT code, name, warning_level, error_level, enable 99 | FROM pglinter.rules 100 | WHERE code IN ('B001', 'T001', 'T005', 'C002') 101 | ORDER BY code; 102 | code | name | warning_level | error_level | enable 103 | ------+------------------------------------------------------+---------------+-------------+-------- 104 | B001 | HowManyTableWithoutPrimaryKey | 5 | 80 | f 105 | C002 | PgHbaEntriesWithMethodTrustOrPasswordShouldNotExists | 20 | 80 | f 106 | (2 rows) 107 | 108 | \echo 'Rule level management demo completed!' 109 | Rule level management demo completed! 110 | \echo '' 111 | 112 | \echo 'Usage Summary:' 113 | Usage Summary: 114 | \echo ' - Get levels: SELECT pglinter.get_rule_levels(''RULE_CODE'');' 115 | - Get levels: SELECT pglinter.get_rule_levels('RULE_CODE'); 116 | \echo ' - Update both: SELECT pglinter.update_rule_levels(''RULE_CODE'', warning, error);' 117 | - Update both: SELECT pglinter.update_rule_levels('RULE_CODE', warning, error); 118 | \echo ' - Update warning only: SELECT pglinter.update_rule_levels(''RULE_CODE'', warning, NULL);' 119 | - Update warning only: SELECT pglinter.update_rule_levels('RULE_CODE', warning, NULL); 120 | \echo ' - Update error only: SELECT pglinter.update_rule_levels(''RULE_CODE'', NULL, error);' 121 | - Update error only: SELECT pglinter.update_rule_levels('RULE_CODE', NULL, error); 122 | \echo '' 123 | 124 | \echo 'Note: Changes affect rule behavior immediately. Higher values mean more permissive thresholds.' 125 | Note: Changes affect rule behavior immediately. Higher values mean more permissive thresholds. 126 | DROP EXTENSION pglinter CASCADE; 127 | -------------------------------------------------------------------------------- /.github/workflows/build-pgrx-image.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/build-pgrx-image.yml 2 | name: Build and Push pgrx Image 3 | 4 | on: 5 | # # Trigger on tag pushes (following pglinter release workflow) 6 | # push: 7 | # tags: 8 | # - '*' 9 | 10 | # Manual dispatch for testing (following pglinter CI/CD flexibility) 11 | workflow_dispatch: 12 | inputs: 13 | image_tag: 14 | description: 'Docker image tag' 15 | required: false 16 | default: 'latest' 17 | push_image: 18 | description: 'Push image to registry' 19 | type: boolean 20 | required: false 21 | default: true 22 | 23 | env: 24 | # Following pglinter's registry configuration 25 | REGISTRY: ghcr.io 26 | IMAGE_NAME: pmpetit/postgresql_pglinter 27 | 28 | jobs: 29 | build-pgrx-image: 30 | name: Build pgrx multi-platform image 31 | runs-on: ubuntu-latest 32 | 33 | # Following pglinter security guidelines 34 | permissions: 35 | contents: read 36 | packages: write 37 | 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v4 41 | 42 | - name: Set up Docker Buildx 43 | uses: docker/setup-buildx-action@v3 44 | with: 45 | platforms: linux/amd64,linux/arm64 46 | 47 | - name: Log in to Container Registry 48 | uses: docker/login-action@v3 49 | with: 50 | registry: ${{ env.REGISTRY }} 51 | username: ${{ github.actor }} 52 | password: ${{ secrets.GITHUB_TOKEN }} 53 | 54 | - name: Extract metadata 55 | id: meta 56 | uses: docker/metadata-action@v5 57 | with: 58 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 59 | tags: | 60 | type=ref,event=tag 61 | type=raw,value=pgrx 62 | type=raw,value=${{ inputs.image_tag || 'latest' }} 63 | 64 | - name: Set up Make environment 65 | run: | 66 | # Following pglinter's build configuration 67 | echo "PGRX_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pgrx" >> $GITHUB_ENV 68 | echo "DOCKER_TAG=${{ inputs.image_tag || github.ref_name || 'latest' }}" >> $GITHUB_ENV 69 | 70 | - name: Verify Dockerfile exists 71 | run: | 72 | if [ ! -f docker/pgrx/Dockerfile ]; then 73 | echo "❌ docker/pgrx/Dockerfile not found" 74 | exit 1 75 | fi 76 | echo "✅ Dockerfile found" 77 | 78 | - name: Build pgrx image 79 | run: | 80 | # Following pglinter's error handling guidelines 81 | echo "Building pglinter pgrx image..." 82 | echo "Image: $PGRX_IMAGE" 83 | echo "Platform: linux/amd64,linux/arm64" 84 | 85 | # Use make target with proper error handling 86 | if make pgrx_image; then 87 | echo "✅ pgrx image built successfully" 88 | else 89 | echo "❌ pgrx image build failed" 90 | exit 1 91 | fi 92 | 93 | - name: Verify image was pushed 94 | if: ${{ inputs.push_image != false }} 95 | run: | 96 | echo "Verifying image was pushed to registry..." 97 | docker buildx imagetools inspect $PGRX_IMAGE || { 98 | echo "❌ Failed to verify image in registry" 99 | exit 1 100 | } 101 | echo "✅ Image verified in registry" 102 | 103 | - name: Clean up buildx builder 104 | if: always() 105 | run: | 106 | # Following pglinter's cleanup guidelines 107 | docker buildx rm pglinter-builder 2>/dev/null || true 108 | echo "✅ Build environment cleaned up" 109 | 110 | test-pgrx-image: 111 | name: Test pgrx image 112 | needs: build-pgrx-image 113 | runs-on: ubuntu-latest 114 | if: ${{ inputs.push_image != false }} 115 | 116 | strategy: 117 | matrix: 118 | arch: [amd64, arm64] 119 | fail-fast: false 120 | 121 | steps: 122 | - name: Set up Docker Buildx 123 | uses: docker/setup-buildx-action@v3 124 | 125 | - name: Log in to Container Registry 126 | uses: docker/login-action@v3 127 | with: 128 | registry: ${{ env.REGISTRY }} 129 | username: ${{ github.actor }} 130 | password: ${{ secrets.GITHUB_TOKEN }} 131 | 132 | - name: Test pgrx image (${{ matrix.arch }}) 133 | run: | 134 | # Following pglinter's testing guidelines 135 | IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pgrx" 136 | 137 | echo "Testing pgrx image for ${{ matrix.arch }}..." 138 | 139 | # Test that the image can run basic commands 140 | docker run --rm --platform linux/${{ matrix.arch }} $IMAGE which cargo || { 141 | echo "❌ Cargo not found in image" 142 | exit 1 143 | } 144 | 145 | docker run --rm --platform linux/${{ matrix.arch }} $IMAGE which nfpm || { 146 | echo "❌ nfpm not found in image" 147 | exit 1 148 | } 149 | 150 | docker run --rm --platform linux/${{ matrix.arch }} $IMAGE cargo pgrx --version || { 151 | echo "❌ pgrx not working in image" 152 | exit 1 153 | } 154 | 155 | echo "✅ pgrx image test passed for ${{ matrix.arch }}" 156 | -------------------------------------------------------------------------------- /src/fixtures.rs: -------------------------------------------------------------------------------- 1 | // Test fixtures for pglinter unit tests 2 | // This module contains common SQL setup and teardown functions for tests 3 | 4 | use pgrx::prelude::*; 5 | 6 | /// Generic setup function for creating a test rule 7 | pub fn setup_test_rule( 8 | code: &str, 9 | id: i32, 10 | name: &str, 11 | enabled: bool, 12 | warning_level: i32, 13 | error_level: i32, 14 | ) { 15 | let _ = Spi::run(&format!( 16 | "DELETE FROM pglinter.rules WHERE code = '{}'", 17 | code 18 | )); 19 | let _ = Spi::run(&format!( 20 | "INSERT INTO pglinter.rules (id, code, name, enable, warning_level, error_level) VALUES ({}, '{}', '{}', {}, {}, {})", 21 | id, code, name, enabled, warning_level, error_level 22 | )); 23 | } 24 | 25 | /// Generic cleanup function for removing test rules 26 | pub fn cleanup_test_rule(code: &str) { 27 | let _ = Spi::run(&format!( 28 | "DELETE FROM pglinter.rules WHERE code = '{}'", 29 | code 30 | )); 31 | } 32 | 33 | /// Create test tables for rule testing 34 | pub fn setup_test_tables() { 35 | // Table without primary key (for B001 testing) 36 | let _ = Spi::run("DROP TABLE IF EXISTS test_table_no_pk CASCADE"); 37 | let _ = Spi::run("CREATE TABLE test_table_no_pk (id INTEGER, name TEXT)"); 38 | let _ = Spi::run("INSERT INTO test_table_no_pk VALUES (1, 'test')"); 39 | 40 | // Table with primary key 41 | let _ = Spi::run("DROP TABLE IF EXISTS test_table_with_pk CASCADE"); 42 | let _ = Spi::run("CREATE TABLE test_table_with_pk (id INTEGER PRIMARY KEY, name TEXT)"); 43 | let _ = Spi::run("INSERT INTO test_table_with_pk VALUES (1, 'test')"); 44 | 45 | // Update statistics 46 | let _ = Spi::run("ANALYZE test_table_no_pk"); 47 | let _ = Spi::run("ANALYZE test_table_with_pk"); 48 | } 49 | 50 | /// Cleanup test tables 51 | pub fn cleanup_test_tables() { 52 | let _ = Spi::run("DROP TABLE IF EXISTS test_table_no_pk CASCADE"); 53 | let _ = Spi::run("DROP TABLE IF EXISTS test_table_with_pk CASCADE"); 54 | } 55 | 56 | /// Get rule boolean property from database 57 | pub fn get_rule_bool_property(code: &str, property: &str) -> Option { 58 | let query = format!( 59 | "SELECT {} FROM pglinter.rules WHERE code = '{}'", 60 | property, code 61 | ); 62 | Spi::get_one::(&query).unwrap_or(Some(false)) 63 | } 64 | 65 | /// Get test YAML content for import testing 66 | pub fn get_valid_yaml_content() -> &'static str { 67 | r#" 68 | metadata: 69 | export_timestamp: "2024-01-01T00:00:00Z" 70 | total_rules: 2 71 | format_version: "1.0" 72 | rules: 73 | - id: 9998 74 | name: "Test Import Rule 1" 75 | code: "TEST_IMPORT_1" 76 | enable: true 77 | warning_level: 30 78 | error_level: 70 79 | scope: "TEST" 80 | description: "First test rule for import testing" 81 | message: "Test message for rule 1" 82 | fixes: ["Fix 1", "Fix 2"] 83 | q1: "SELECT 1 as test_query" 84 | q2: "SELECT 2 as test_q2" 85 | - id: 9999 86 | name: "Test Import Rule 2" 87 | code: "TEST_IMPORT_2" 88 | enable: false 89 | warning_level: 40 90 | error_level: 80 91 | scope: "TEST" 92 | description: "Second test rule for import testing" 93 | message: "Test message for rule 2" 94 | fixes: ["Fix A", "Fix B", "Fix C"] 95 | q1: null 96 | q2: "SELECT 3 as another_test_query" 97 | "# 98 | } 99 | 100 | pub fn get_invalid_yaml_content() -> &'static str { 101 | r#" 102 | metadata: 103 | export_timestamp: "invalid-timestamp" 104 | invalid_yaml_structure: { 105 | rules: 106 | - id: "not_a_number" 107 | name: Missing required fields 108 | "# 109 | } 110 | 111 | /// Get invalid rule YAML content (valid YAML structure but invalid rule data) 112 | pub fn get_invalid_rule_yaml_content() -> &'static str { 113 | r#" 114 | metadata: 115 | export_timestamp: "2024-01-01T00:00:00Z" 116 | total_rules: 1 117 | format_version: "1.0" 118 | rules: 119 | - id: 9999 120 | name: "Invalid Rule Test" 121 | code: "INVALID_TEST" 122 | enable: true 123 | warning_level: -10 124 | error_level: 200 125 | scope: "INVALID" 126 | description: "Test rule with potentially invalid data" 127 | message: "Test message" 128 | fixes: [] 129 | q1: "SELECT 'invalid sql syntax FROM" 130 | q2: null 131 | "# 132 | } 133 | 134 | /// Get minimal valid YAML content 135 | pub fn get_minimal_yaml_content() -> &'static str { 136 | r#" 137 | metadata: 138 | export_timestamp: "2024-01-01T00:00:00Z" 139 | total_rules: 0 140 | format_version: "1.0" 141 | rules: [] 142 | "# 143 | } 144 | 145 | /// Get special characters YAML content 146 | pub fn get_special_chars_yaml_content() -> &'static str { 147 | r#" 148 | metadata: 149 | export_timestamp: "2024-01-01T00:00:00Z" 150 | total_rules: 1 151 | format_version: "1.0" 152 | rules: 153 | - id: 9993 154 | name: "Special Characters Test: <>&\"'`" 155 | code: "SPECIAL_TEST" 156 | enable: true 157 | warning_level: 50 158 | error_level: 90 159 | scope: "SPECIAL" 160 | description: "Test rule with special characters: àáâãäå çñü €£¥" 161 | message: "Message with quotes: \"double\" and 'single' and `backticks`" 162 | fixes: ["Fix with ", "Fix with & ampersand"] 163 | q1: "SELECT 'string with '' embedded quotes' as test" 164 | q2: "SELECT 'another test' WHERE column = 'value with \"quotes\"'" 165 | "# 166 | } 167 | -------------------------------------------------------------------------------- /tests/expected/rule_management.out: -------------------------------------------------------------------------------- 1 | -- Test for pglinter rule management functionality 2 | CREATE EXTENSION pglinter; 3 | CREATE TABLE test_table_for_rules ( 4 | id INT, 5 | name TEXT 6 | ); 7 | -- Show initial rule status 8 | SELECT pglinter.show_rules(); 9 | NOTICE: 📋 pglinter Rule Status: 10 | NOTICE: ============================================================ 11 | NOTICE: Code Status Name 12 | NOTICE: ------------------------------------------------------------ 13 | NOTICE: B001 ✅ ON HowManyTableWithoutPrimaryKey 14 | NOTICE: B002 ✅ ON HowManyRedudantIndex 15 | NOTICE: B003 ✅ ON HowManyTableWithoutIndexOnFk 16 | NOTICE: B004 ✅ ON HowManyUnusedIndex 17 | NOTICE: B005 ✅ ON HowManyObjectsWithUppercase 18 | NOTICE: B006 ✅ ON HowManyTablesNeverSelected 19 | NOTICE: B007 ✅ ON HowManyTablesWithFkOutsideSchema 20 | NOTICE: B008 ✅ ON HowManyTablesWithFkMismatch 21 | NOTICE: B009 ✅ ON HowManyTablesWithSameTrigger 22 | NOTICE: B010 ✅ ON HowManyTablesWithReservedKeywords 23 | NOTICE: B011 ✅ ON SeveralTableOwnerInSchema 24 | NOTICE: C001 ✅ ON PgHbaEntriesWithMethodTrustShouldNotExists 25 | NOTICE: C002 ✅ ON PgHbaEntriesWithMethodTrustOrPasswordShouldNotExists 26 | NOTICE: C003 ✅ ON PasswordEncryptionIsMd5 27 | NOTICE: S001 ✅ ON SchemaWithDefaultRoleNotGranted 28 | NOTICE: S002 ✅ ON SchemaPrefixedOrSuffixedWithEnvt 29 | NOTICE: S003 ✅ ON UnsecuredPublicSchema 30 | NOTICE: S004 ✅ ON OwnerSchemaIsInternalRole 31 | NOTICE: S005 ✅ ON SchemaOwnerDoNotMatchTableOwner 32 | NOTICE: ============================================================ 33 | NOTICE: 📊 Summary: 19 enabled, 0 disabled 34 | show_rules 35 | ------------ 36 | t 37 | (1 row) 38 | 39 | -- Test enabling and disabling a specific rule 40 | SELECT pglinter.is_rule_enabled('B001') AS b001_initially_enabled; 41 | b001_initially_enabled 42 | ------------------------ 43 | t 44 | (1 row) 45 | 46 | -- Disable B001 rule 47 | SELECT pglinter.disable_rule('B001') AS b001_disabled; 48 | NOTICE: 🔴 Rule B001 has been disabled 49 | b001_disabled 50 | --------------- 51 | t 52 | (1 row) 53 | 54 | -- Check if it's disabled 55 | SELECT pglinter.is_rule_enabled('B001') AS b001_after_disable; 56 | b001_after_disable 57 | -------------------- 58 | f 59 | (1 row) 60 | 61 | -- Run base check (should skip B001) 62 | SELECT pglinter.perform_base_check(); 63 | WARNING: pglinter base check failed: Failed to execute q2 rule B011: Database error: SpiTupleTable positioned before the start or after the end 64 | perform_base_check 65 | -------------------- 66 | f 67 | (1 row) 68 | 69 | -- Re-enable B001 rule 70 | SELECT pglinter.enable_rule('B001') AS b001_enabled; 71 | NOTICE: ✅ Rule B001 has been enabled 72 | b001_enabled 73 | -------------- 74 | t 75 | (1 row) 76 | 77 | -- Check if it's enabled again 78 | SELECT pglinter.is_rule_enabled('B001') AS b001_after_enable; 79 | b001_after_enable 80 | ------------------- 81 | t 82 | (1 row) 83 | 84 | -- Test with non-existent rule 85 | SELECT pglinter.disable_rule('NONEXISTENT') AS nonexistent_disable; 86 | WARNING: ⚠️ Rule NONEXISTENT not found 87 | nonexistent_disable 88 | --------------------- 89 | f 90 | (1 row) 91 | 92 | -- Show final rule status 93 | SELECT pglinter.show_rules(); 94 | NOTICE: 📋 pglinter Rule Status: 95 | NOTICE: ============================================================ 96 | NOTICE: Code Status Name 97 | NOTICE: ------------------------------------------------------------ 98 | NOTICE: B001 ✅ ON HowManyTableWithoutPrimaryKey 99 | NOTICE: B002 ✅ ON HowManyRedudantIndex 100 | NOTICE: B003 ✅ ON HowManyTableWithoutIndexOnFk 101 | NOTICE: B004 ✅ ON HowManyUnusedIndex 102 | NOTICE: B005 ✅ ON HowManyObjectsWithUppercase 103 | NOTICE: B006 ✅ ON HowManyTablesNeverSelected 104 | NOTICE: B007 ✅ ON HowManyTablesWithFkOutsideSchema 105 | NOTICE: B008 ✅ ON HowManyTablesWithFkMismatch 106 | NOTICE: B009 ✅ ON HowManyTablesWithSameTrigger 107 | NOTICE: B010 ✅ ON HowManyTablesWithReservedKeywords 108 | NOTICE: B011 ✅ ON SeveralTableOwnerInSchema 109 | NOTICE: C001 ✅ ON PgHbaEntriesWithMethodTrustShouldNotExists 110 | NOTICE: C002 ✅ ON PgHbaEntriesWithMethodTrustOrPasswordShouldNotExists 111 | NOTICE: C003 ✅ ON PasswordEncryptionIsMd5 112 | NOTICE: S001 ✅ ON SchemaWithDefaultRoleNotGranted 113 | NOTICE: S002 ✅ ON SchemaPrefixedOrSuffixedWithEnvt 114 | NOTICE: S003 ✅ ON UnsecuredPublicSchema 115 | NOTICE: S004 ✅ ON OwnerSchemaIsInternalRole 116 | NOTICE: S005 ✅ ON SchemaOwnerDoNotMatchTableOwner 117 | NOTICE: ============================================================ 118 | NOTICE: 📊 Summary: 19 enabled, 0 disabled 119 | show_rules 120 | ------------ 121 | t 122 | (1 row) 123 | 124 | DROP TABLE test_table_for_rules CASCADE; 125 | DROP EXTENSION pglinter CASCADE; 126 | -------------------------------------------------------------------------------- /.github/actions/build-oci-image/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Build OCI Extension Image' 2 | description: 'Build and optionally push CloudNative-PG compatible OCI extension image for pglinter' 3 | 4 | inputs: 5 | postgresql-version: 6 | description: 'PostgreSQL major version (e.g., 18)' 7 | required: true 8 | default: '18' 9 | 10 | distro: 11 | description: 'Linux distribution (e.g., bookworm for deb, el9 for rpm)' 12 | required: true 13 | default: 'bookworm' 14 | 15 | registry: 16 | description: 'Container registry (e.g., ghcr.io/pmpetit)' 17 | required: true 18 | default: 'ghcr.io/pmpetit' 19 | 20 | image-name: 21 | description: 'Image name (e.g., pglinter)' 22 | required: true 23 | default: 'pglinter' 24 | 25 | push: 26 | description: 'Whether to push the image to registry' 27 | required: false 28 | default: 'false' 29 | 30 | platforms: 31 | description: 'Target platforms for multi-arch build' 32 | required: false 33 | default: 'linux/amd64,linux/arm64' 34 | 35 | build-local: 36 | description: 'Whether to build locally (affects Dockerfile selection and build optimizations)' 37 | required: false 38 | default: 'false' 39 | 40 | outputs: 41 | image-tags: 42 | description: 'Generated image tags' 43 | value: ${{ steps.build.outputs.image-tags }} 44 | 45 | image-digest: 46 | description: 'Image digest' 47 | value: ${{ steps.build.outputs.digest }} 48 | 49 | runs: 50 | using: 'composite' 51 | steps: 52 | - name: Set up Docker Buildx 53 | uses: docker/setup-buildx-action@v3 54 | with: 55 | driver-opts: network=host 56 | 57 | - name: Prepare build context 58 | shell: bash 59 | run: | 60 | # Generate timestamp for tagging 61 | echo "TIMESTAMP=$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV 62 | 63 | # Extract version from Cargo.toml 64 | PGLINTER_VERSION=$(grep '^version *= *' Cargo.toml | sed 's/^version *= *//' | tr -d '"' | tr -d ' ') 65 | echo "PGLINTER_VERSION=$PGLINTER_VERSION" >> $GITHUB_ENV 66 | 67 | # Determine package type and Dockerfile based on distro and build-local setting 68 | if [[ "${{ inputs.build-local }}" == "true" ]]; then 69 | # Use local Dockerfile for development/testing builds 70 | PKGTYPE="local" 71 | echo "DOCKERFILE=docker/oci/Dockerfile.local" >> $GITHUB_ENV 72 | echo "Using local Dockerfile for development build" 73 | else 74 | # Use distribution-specific Dockerfiles for production builds 75 | case "${{ inputs.distro }}" in 76 | bookworm|bullseye|buster|jammy|focal|noble) 77 | PKGTYPE="deb" 78 | echo "DOCKERFILE=docker/oci/Dockerfile.pg-deb" >> $GITHUB_ENV 79 | echo "Using Debian/Ubuntu Dockerfile for .deb packages (distro: ${{ inputs.distro }})" 80 | ;; 81 | el8|el9|fedora*|centos*|rocky*|alma*) 82 | PKGTYPE="rpm" 83 | echo "DOCKERFILE=docker/oci/Dockerfile.nodeb" >> $GITHUB_ENV 84 | echo "Using RPM-based Dockerfile for .rpm packages (distro: ${{ inputs.distro }})" 85 | ;; 86 | *) 87 | echo "❌ Unsupported distro: ${{ inputs.distro }}" 88 | echo "Supported deb distros: bookworm, bullseye, buster, jammy, focal, noble" 89 | echo "Supported rpm distros: el8, el9, fedora*, centos*, rocky*, alma*" 90 | exit 1 91 | ;; 92 | esac 93 | fi 94 | 95 | echo "PKGTYPE=$PKGTYPE" >> $GITHUB_ENV 96 | 97 | # Set image tags 98 | BASE_TAG="$PGLINTER_VERSION-$PKGTYPE" 99 | FULL_TAG="$PGLINTER_VERSION-$(date +%Y%m%d%H%M%S)-pg${{ inputs.postgresql-version }}-${{ inputs.distro }}-$PKGTYPE" 100 | 101 | echo "BASE_TAG=$BASE_TAG" >> $GITHUB_ENV 102 | echo "FULL_TAG=$FULL_TAG" >> $GITHUB_ENV 103 | 104 | - name: Build and push OCI image 105 | id: build 106 | uses: docker/build-push-action@v5 107 | with: 108 | context: . 109 | file: ${{ env.DOCKERFILE }} 110 | platforms: ${{ inputs.platforms }} 111 | push: ${{ inputs.push }} 112 | build-args: | 113 | PG_VERSION=${{ inputs.postgresql-version }} 114 | DISTRO=${{ inputs.distro }} 115 | PGLINTER_VERSION=${{ env.PGLINTER_VERSION }} 116 | TIMESTAMP=${{ env.TIMESTAMP }} 117 | EXT_VERSION=${{ env.PGLINTER_VERSION }} 118 | tags: | 119 | ${{ inputs.registry }}/${{ inputs.image-name }}:${{ env.FULL_TAG }} 120 | ${{ inputs.registry }}/${{ inputs.image-name }}:latest 121 | ${{ inputs.registry }}/${{ inputs.image-name }}:${{ env.BASE_TAG }} 122 | cache-from: type=gha 123 | cache-to: type=gha,mode=max 124 | 125 | - name: Output image information 126 | shell: bash 127 | run: | 128 | echo "image-tags=${{ inputs.registry }}/${{ inputs.image-name }}:${{ env.FULL_TAG }},${{ inputs.registry }}/${{ inputs.image-name }}:latest,${{ inputs.registry }}/${{ inputs.image-name }}:${{ env.BASE_TAG }}" >> $GITHUB_OUTPUT 129 | echo "Built OCI image with tags:" 130 | echo " - ${{ inputs.registry }}/${{ inputs.image-name }}:${{ env.FULL_TAG }}" 131 | echo " - ${{ inputs.registry }}/${{ inputs.image-name }}:latest" 132 | echo " - ${{ inputs.registry }}/${{ inputs.image-name }}:${{ env.BASE_TAG }}" 133 | -------------------------------------------------------------------------------- /tests/expected/import_rules_from_file.out: -------------------------------------------------------------------------------- 1 | -- Test import_rules_from_file function 2 | -- This test validates the file-based YAML import functionality 3 | -- Create a temporary test file with YAML content using echo with proper escaping 4 | \! echo "metadata:" > /tmp/test_rules_import.yaml 5 | \! echo " export_timestamp: \"2024-01-01T12:00:00Z\"" >> /tmp/test_rules_import.yaml 6 | \! echo " total_rules: 2" >> /tmp/test_rules_import.yaml 7 | \! echo " format_version: \"1.0\"" >> /tmp/test_rules_import.yaml 8 | \! echo "rules:" >> /tmp/test_rules_import.yaml 9 | \! echo " - id: 9010" >> /tmp/test_rules_import.yaml 10 | \! echo " name: \"Test File Import Rule 1\"" >> /tmp/test_rules_import.yaml 11 | \! echo " code: \"TEST_FILE_001\"" >> /tmp/test_rules_import.yaml 12 | \! echo " enable: true" >> /tmp/test_rules_import.yaml 13 | \! echo " warning_level: 35" >> /tmp/test_rules_import.yaml 14 | \! echo " error_level: 75" >> /tmp/test_rules_import.yaml 15 | \! echo " scope: \"FILE_TEST\"" >> /tmp/test_rules_import.yaml 16 | \! echo " description: \"Test rule imported from file\"" >> /tmp/test_rules_import.yaml 17 | \! echo " message: \"File import test found {0} issues\"" >> /tmp/test_rules_import.yaml 18 | \! echo " fixes:" >> /tmp/test_rules_import.yaml 19 | \! echo " - \"File-based fix suggestion\"" >> /tmp/test_rules_import.yaml 20 | \! echo " q1: \"SELECT 'file_test' as result\"" >> /tmp/test_rules_import.yaml 21 | \! echo " q2: null" >> /tmp/test_rules_import.yaml 22 | \! echo " - id: 9011" >> /tmp/test_rules_import.yaml 23 | \! echo " name: \"Test File Import Rule 2\"" >> /tmp/test_rules_import.yaml 24 | \! echo " code: \"TEST_FILE_002\"" >> /tmp/test_rules_import.yaml 25 | \! echo " enable: false" >> /tmp/test_rules_import.yaml 26 | \! echo " warning_level: 20" >> /tmp/test_rules_import.yaml 27 | \! echo " error_level: 60" >> /tmp/test_rules_import.yaml 28 | \! echo " scope: \"BASE\"" >> /tmp/test_rules_import.yaml 29 | \! echo " description: \"Second file import test rule\"" >> /tmp/test_rules_import.yaml 30 | \! echo " message: \"Second file test message\"" >> /tmp/test_rules_import.yaml 31 | \! echo " fixes:" >> /tmp/test_rules_import.yaml 32 | \! echo " - \"Another file fix\"" >> /tmp/test_rules_import.yaml 33 | \! echo " - \"Second file fix\"" >> /tmp/test_rules_import.yaml 34 | \! echo " q1: \"SELECT 2 as count\"" >> /tmp/test_rules_import.yaml 35 | \! echo " q2: \"SELECT 1 as problems\"" >> /tmp/test_rules_import.yaml 36 | CREATE EXTENSION pglinter; 37 | -- Test 1: Import rules from file 38 | SELECT pglinter.import_rules_from_file('/tmp/test_rules_import.yaml') AS file_import_result; 39 | NOTICE: 📂 Reading rules from: /tmp/test_rules_import.yaml 40 | NOTICE: 📥 Importing 2 rules from YAML (format v1.0) 41 | file_import_result 42 | -------------------------------------------------- 43 | ✅ Import completed: 2 new rules, 0 updated rules 44 | (1 row) 45 | 46 | -- Verify imported rules 47 | SELECT 48 | code, 49 | name, 50 | enable, 51 | warning_level, 52 | error_level, 53 | scope 54 | FROM pglinter.rules 55 | WHERE code LIKE 'TEST_FILE_%' 56 | ORDER BY code; 57 | code | name | enable | warning_level | error_level | scope 58 | ---------------+-------------------------+--------+---------------+-------------+----------- 59 | TEST_FILE_001 | Test File Import Rule 1 | t | 35 | 75 | FILE_TEST 60 | TEST_FILE_002 | Test File Import Rule 2 | f | 20 | 60 | BASE 61 | (2 rows) 62 | 63 | -- Test 2: Import from non-existent file (should return error) 64 | SELECT pglinter.import_rules_from_file('/tmp/non_existent_file.yaml') AS nonexistent_file_result; 65 | WARNING: Failed to import: File read error: No such file or directory (os error 2) 66 | nonexistent_file_result 67 | --------------------------------------------------------- 68 | File read error: No such file or directory (os error 2) 69 | (1 row) 70 | 71 | SELECT pglinter.import_rules_from_file('/tmp/invalid_rules.yaml') AS invalid_file_result; 72 | WARNING: Failed to import: File read error: No such file or directory (os error 2) 73 | invalid_file_result 74 | --------------------------------------------------------- 75 | File read error: No such file or directory (os error 2) 76 | (1 row) 77 | 78 | -- Test 4: Test with empty file 79 | \! touch /tmp/empty_rules.yaml 80 | SELECT pglinter.import_rules_from_file('/tmp/empty_rules.yaml') AS empty_file_result; 81 | NOTICE: 📂 Reading rules from: /tmp/empty_rules.yaml 82 | WARNING: Failed to import: YAML parsing error: missing field `metadata` 83 | empty_file_result 84 | ---------------------------------------------- 85 | YAML parsing error: missing field `metadata` 86 | (1 row) 87 | 88 | -- Verify final state - should still have our valid imported rules 89 | SELECT 90 | COUNT(*) AS imported_rules_count 91 | FROM pglinter.rules 92 | WHERE code LIKE 'TEST_FILE_%'; 93 | imported_rules_count 94 | ---------------------- 95 | 2 96 | (1 row) 97 | 98 | -- Clean up test data and files 99 | DELETE FROM pglinter.rules WHERE code LIKE 'TEST_FILE_%'; 100 | \! rm -f /tmp/test_rules_import.yaml /tmp/invalid_rules.yaml /tmp/empty_rules.yaml 101 | -- Final verification - no test rules should remain 102 | SELECT COUNT(*) AS remaining_file_test_rules 103 | FROM pglinter.rules 104 | WHERE code LIKE 'TEST_FILE_%'; 105 | remaining_file_test_rules 106 | --------------------------- 107 | 0 108 | (1 row) 109 | 110 | DROP EXTENSION pglinter CASCADE; 111 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # 🐳 pglinter Docker Setup 2 | 3 | This directory contains Docker configuration for running pglinter PostgreSQL extension in containers. 4 | 5 | ## 🚀 Quick Start 6 | 7 | ### Pull Pre-built Images 8 | 9 | ```bash 10 | # Pull specific PostgreSQL version 11 | docker pull ghcr.io/pmpetit/pglinter:pg17-latest 12 | 13 | # Or pull all versions 14 | docker pull ghcr.io/pmpetit/pglinter:pg13-latest 15 | docker pull ghcr.io/pmpetit/pglinter:pg14-latest 16 | docker pull ghcr.io/pmpetit/pglinter:pg15-latest 17 | docker pull ghcr.io/pmpetit/pglinter:pg16-latest 18 | docker pull ghcr.io/pmpetit/pglinter:pg17-latest 19 | ``` 20 | 21 | ### Run Container 22 | 23 | ```bash 24 | # Run PostgreSQL 17 with pglinter 25 | docker run -d \ 26 | --name pglinter-pg17 \ 27 | -p 5432:5432 \ 28 | -e POSTGRES_PASSWORD=postgres \ 29 | -e POSTGRES_DB=pglinter_test \ 30 | ghcr.io/pmpetit/pglinter:pg17-latest 31 | 32 | # Connect and test 33 | docker exec -it pglinter-pg17 psql -U postgres -d pglinter_test 34 | ``` 35 | 36 | ### Test Extension 37 | 38 | ```sql 39 | -- In psql 40 | SELECT pglinter.hello_pglinter(); 41 | SELECT pglinter.list_rules(); 42 | SELECT pglinter.check(); 43 | SELECT pglinter.check_rule('B001'); 44 | ``` 45 | 46 | ## 🛠️ Build Your Own Images 47 | 48 | ### Build Single Version 49 | 50 | ```bash 51 | # Build PostgreSQL 17 52 | make docker-build-pg17 53 | 54 | # Or use docker directly 55 | docker build --build-arg PG_MAJOR_VERSION=17 -t pglinter:pg17 . 56 | ``` 57 | 58 | ### Build All Versions 59 | 60 | ```bash 61 | make docker-build-all 62 | ``` 63 | 64 | ## 🐙 Docker Compose 65 | 66 | ### Start All Versions 67 | 68 | ```bash 69 | cd docker 70 | docker-compose up -d 71 | ``` 72 | 73 | This starts containers for all PostgreSQL versions on different ports: 74 | - PostgreSQL 13: `localhost:5413` 75 | - PostgreSQL 14: `localhost:5414` 76 | - PostgreSQL 15: `localhost:5415` 77 | - PostgreSQL 16: `localhost:5416` 78 | - PostgreSQL 17: `localhost:5417` 79 | 80 | ### Connect to Specific Version 81 | 82 | ```bash 83 | # PostgreSQL 17 84 | psql -h localhost -p 5417 -U postgres -d pglinter_test 85 | 86 | # PostgreSQL 14 87 | psql -h localhost -p 5414 -U postgres -d pglinter_test 88 | ``` 89 | 90 | ### View Logs 91 | 92 | ```bash 93 | cd docker 94 | docker-compose logs -f pglinter-pg17 95 | ``` 96 | 97 | ### Stop All 98 | 99 | ```bash 100 | cd docker 101 | docker-compose down 102 | ``` 103 | 104 | ## 🧪 Running Tests 105 | 106 | ### Makefile Targets 107 | 108 | ```bash 109 | # Start container and run tests 110 | make docker-run-pg17 111 | make docker-test 112 | make docker-clean 113 | ``` 114 | 115 | ### Manual Testing 116 | 117 | ```bash 118 | # Start container 119 | docker run -d --name test-pglinter -p 5432:5432 \ 120 | -e POSTGRES_PASSWORD=postgres \ 121 | ghcr.io/pmpetit/pglinter:pg17-latest 122 | 123 | # Wait for startup 124 | sleep 10 125 | 126 | # Run tests 127 | docker exec test-pglinter psql -U postgres -d pglinter_test -c " 128 | SELECT pglinter.hello_pglinter(); 129 | SELECT pglinter.check(); 130 | SELECT pglinter.check_rule('B001'); 131 | " 132 | 133 | # Cleanup 134 | docker stop test-pglinter 135 | docker rm test-pglinter 136 | ``` 137 | 138 | ## 📦 Available Images 139 | 140 | | PostgreSQL Version | Image Tag | Size | Status | 141 | |-------------------|-----------|------|--------| 142 | | 13 | `ghcr.io/pmpetit/pglinter:pg13-latest` | ~400MB | ✅ | 143 | | 14 | `ghcr.io/pmpetit/pglinter:pg14-latest` | ~400MB | ✅ | 144 | | 15 | `ghcr.io/pmpetit/pglinter:pg15-latest` | ~400MB | ✅ | 145 | | 16 | `ghcr.io/pmpetit/pglinter:pg16-latest` | ~400MB | ✅ | 146 | | 17 | `ghcr.io/pmpetit/pglinter:pg17-latest` | ~400MB | ✅ | 147 | 148 | ## 🔧 Environment Variables 149 | 150 | | Variable | Default | Description | 151 | |----------|---------|-------------| 152 | | `POSTGRES_DB` | `pglinter_test` | Database name | 153 | | `POSTGRES_USER` | `postgres` | Database user | 154 | | `POSTGRES_PASSWORD` | `postgres` | Database password | 155 | 156 | ## 🐛 Troubleshooting 157 | 158 | ### Container Won't Start 159 | 160 | ```bash 161 | # Check logs 162 | docker logs pglinter-pg17 163 | 164 | # Check if port is in use 165 | sudo netstat -tulpn | grep 5432 166 | ``` 167 | 168 | ### Extension Not Found 169 | 170 | ```bash 171 | # Verify extension files 172 | docker exec pglinter-pg17 ls -la /usr/share/postgresql/17/extension/pglinter* 173 | docker exec pglinter-pg17 ls -la /usr/lib/postgresql/17/lib/pglinter.so 174 | ``` 175 | 176 | ### Connection Issues 177 | 178 | ```bash 179 | # Test connection 180 | docker exec pglinter-pg17 pg_isready -U postgres 181 | 182 | # Check PostgreSQL config 183 | docker exec pglinter-pg17 cat /var/lib/postgresql/data/postgresql.conf | grep listen 184 | ``` 185 | 186 | ## 📚 Integration Examples 187 | 188 | ### GitHub Actions 189 | 190 | ```yaml 191 | services: 192 | postgres: 193 | image: ghcr.io/pmpetit/pglinter:pg17-latest 194 | env: 195 | POSTGRES_PASSWORD: postgres 196 | POSTGRES_DB: test_db 197 | ports: 198 | - 5432:5432 199 | options: >- 200 | --health-cmd pg_isready 201 | --health-interval 10s 202 | --health-timeout 5s 203 | --health-retries 5 204 | ``` 205 | 206 | ### Docker Compose for Development 207 | 208 | ```yaml 209 | version: '3.8' 210 | services: 211 | db: 212 | image: ghcr.io/pmpetit/pglinter:pg17-latest 213 | environment: 214 | POSTGRES_DB: myapp 215 | POSTGRES_USER: developer 216 | POSTGRES_PASSWORD: secret 217 | ports: 218 | - "5432:5432" 219 | volumes: 220 | - ./sql:/docker-entrypoint-initdb.d 221 | ``` 222 | 223 | ## 🏗️ Build Process 224 | 225 | The images are built using a multi-stage Dockerfile: 226 | 227 | 1. **Build Stage**: Compile pglinter extension with Rust and cargo-pgrx 228 | 2. **Runtime Stage**: Install extension into PostgreSQL official image 229 | 3. **Test Stage**: Verify extension functionality 230 | 231 | Images are automatically built and pushed to GitHub Container Registry via GitHub Actions. 232 | -------------------------------------------------------------------------------- /tests/expected/b002_redundant_idx.out: -------------------------------------------------------------------------------- 1 | -- Test for pglinter B002 rule: Redundant indexes 2 | CREATE EXTENSION pglinter; 3 | -- Create test tables with redundant indexes 4 | CREATE TABLE test_table_with_redundant_indexes ( 5 | id INT PRIMARY KEY, 6 | name TEXT, 7 | email VARCHAR(255), 8 | status VARCHAR(50), 9 | created_at TIMESTAMP DEFAULT NOW() 10 | ); 11 | -- Create table with one index and a unique constrainte on the same column 12 | CREATE TABLE orders_table_with_constraint ( 13 | order_id SERIAL PRIMARY KEY, 14 | customer_id INT UNIQUE, 15 | product_name VARCHAR(255), 16 | order_date DATE, 17 | amount DECIMAL(10, 2) 18 | ); 19 | -- Create an index that is redundant with the unique constraint 20 | CREATE INDEX my_idx_customer ON orders_table_with_constraint (customer_id); 21 | -- Create another table for more redundant index scenarios 22 | CREATE TABLE orders_table ( 23 | order_id SERIAL PRIMARY KEY, 24 | customer_id INT, 25 | product_name VARCHAR(255), 26 | order_date DATE, 27 | amount DECIMAL(10, 2) 28 | ); 29 | -- Create redundant indexes to trigger B002 rule 30 | -- Case 1: Exact duplicate indexes on same columns 31 | CREATE INDEX idx_name_1 ON test_table_with_redundant_indexes (name); 32 | CREATE INDEX idx_name_2 ON test_table_with_redundant_indexes (name, created_at); 33 | CREATE INDEX idx_name_3 ON test_table_with_redundant_indexes ( 34 | name, created_at, email 35 | ); 36 | -- Case 2: Multiple indexes on same composite key 37 | CREATE INDEX idx_email_status_1 ON test_table_with_redundant_indexes ( 38 | email, status 39 | ); 40 | CREATE INDEX idx_email_status_2 ON test_table_with_redundant_indexes ( 41 | email, status, created_at 42 | ); 43 | -- Case 3: Redundant indexes on the orders table 44 | CREATE INDEX idx_customer_1 ON orders_table (order_id); 45 | -- Case 3-bis: Non Redundant indexes on the orders table 46 | CREATE INDEX idx_customer_2 ON orders_table (customer_id, order_id); 47 | -- Case 4: Composite index redundancy 48 | CREATE INDEX idx_customer_date_1 ON orders_table (product_name, order_date); 49 | CREATE INDEX idx_customer_date_2 ON orders_table (product_name, order_date); 50 | -- First, disable all rules to isolate B001 testing 51 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 52 | NOTICE: 🔴 Disabled 20 rule(s) 53 | all_rules_disabled 54 | -------------------- 55 | 20 56 | (1 row) 57 | 58 | -- Enable only B002 for focused testing 59 | SELECT pglinter.enable_rule('B002') AS b001_enabled; 60 | NOTICE: ✅ Rule B002 has been enabled 61 | b001_enabled 62 | -------------- 63 | t 64 | (1 row) 65 | 66 | -- Test with file output 67 | SELECT pglinter.check('/tmp/pglinter_b002_results.sarif'); 68 | check 69 | ------- 70 | t 71 | (1 row) 72 | 73 | -- Test if file exists and show checksum 74 | \! md5sum /tmp/pglinter_b002_results.sarif 75 | d33fa0746f8eacf2f90c32f1a9957845 /tmp/pglinter_b002_results.sarif 76 | -- Test with no output file (should output to prompt) 77 | SELECT pglinter.check(); 78 | NOTICE: 🔍 pglinter found 1 issue(s): 79 | NOTICE: ================================================== 80 | NOTICE: ⚠️ [B002] WARNING: 6/14 redundant(s) index exceed the warning threshold: 42%. Object list: 81 | public.orders_table.idx_customer_date_2(product_name,order_date) is redundant with idx_customer_date_1(product_name,order_date) 82 | public.orders_table.orders_table_pkey(order_id) is redundant with idx_customer_1(order_id) 83 | public.orders_table.idx_customer_date_1(product_name,order_date) is redundant with idx_customer_date_2(product_name,order_date) 84 | public.orders_table.idx_customer_1(order_id) is redundant with orders_table_pkey(order_id) 85 | public.orders_table_with_constraint.my_idx_customer(customer_id) is redundant with orders_table_with_constraint_customer_id_key(customer_id) 86 | public.orders_table_with_constraint.orders_table_with_constraint_customer_id_key(customer_id) is redundant with my_idx_customer(customer_id) 87 | public.test_table_with_redundant_indexes.idx_name_1(name) is redundant with idx_name_3(name,created_at,email) 88 | public.test_table_with_redundant_indexes.idx_email_status_1(email,status) is redundant with idx_email_status_2(email,status,created_at) 89 | public.test_table_with_redundant_indexes.idx_name_2(name,created_at) is redundant with idx_name_3(name,created_at,email) 90 | public.test_table_with_redundant_indexes.idx_name_1(name) is redundant with idx_name_2(name,created_at) 91 | 92 | NOTICE: ================================================== 93 | NOTICE: 📊 Summary: 0 error(s), 1 warning(s), 0 info 94 | NOTICE: 🟡 Some warnings found - consider reviewing for optimization 95 | check 96 | ------- 97 | t 98 | (1 row) 99 | 100 | -- Test rule management for B002 101 | SELECT pglinter.explain_rule('B002'); 102 | NOTICE: 📖 Rule Explanation for B002 103 | ============================================================ 104 | 105 | 🎯 Rule Name: HowManyRedudantIndex 106 | 📋 Scope: BASE 107 | 108 | 📝 Description: 109 | Count number of redundant index vs nb index. 110 | 111 | ⚠️ Message Template: 112 | {0}/{1} redundant(s) index exceed the {2} threshold: {3}%. Object list:\n{4} 113 | 114 | 🔧 How to Fix: 115 | 1. remove duplicated index or check if a constraint does not create a redundant index, or change warning/error threshold 116 | ============================================================ 117 | explain_rule 118 | -------------- 119 | t 120 | (1 row) 121 | 122 | -- Show that B002 is enabled 123 | SELECT pglinter.is_rule_enabled('B002') AS b002_enabled; 124 | b002_enabled 125 | -------------- 126 | t 127 | (1 row) 128 | 129 | -- Disable B002 temporarily and test 130 | SELECT pglinter.disable_rule('B002') AS b002_disabled; 131 | NOTICE: 🔴 Rule B002 has been disabled 132 | b002_disabled 133 | --------------- 134 | t 135 | (1 row) 136 | 137 | SELECT pglinter.check(); 138 | NOTICE: ✅ No issues found - database schema looks good! 139 | check 140 | ------- 141 | t 142 | (1 row) 143 | 144 | DROP TABLE orders_table CASCADE; 145 | DROP TABLE orders_table_with_constraint CASCADE; 146 | DROP TABLE test_table_with_redundant_indexes CASCADE; 147 | DROP EXTENSION pglinter CASCADE; 148 | -------------------------------------------------------------------------------- /sql/install_for_users.sql: -------------------------------------------------------------------------------- 1 | -- ============================================================================= 2 | -- pglinter Installation Helper for Non-Superusers 3 | -- ============================================================================= 4 | -- 5 | -- This file provides functions to help install and configure pglinter for 6 | -- regular (non-superuser) database users. It must be executed by a superuser 7 | -- to grant necessary permissions and set up the extension for target users. 8 | -- 9 | -- Main Functions: 10 | -- - pglinter_install_for_user(): Install extension for a specific user 11 | -- - Grant necessary permissions for pglinter schema and functions 12 | -- - Handle extension dependencies and setup 13 | -- 14 | -- Usage: 15 | -- 1. Connect as superuser (postgres) 16 | -- 2. Execute this file: \i sql/install_for_users.sql 17 | -- 3. Install for specific user: SELECT pglinter_install_for_user('username'); 18 | -- 4. Or install for current user: SELECT pglinter_install_for_user(); 19 | -- 20 | -- Security Note: 21 | -- This creates functions that require superuser privileges to execute 22 | -- properly, but allows delegation of pglinter access to regular users. 23 | -- 24 | -- ============================================================================= 25 | 26 | -- Installation functions for non-superuser pglinter usage 27 | -- This file should be run by a superuser to set up pglinter for regular users 28 | 29 | -- Function to install pglinter extension for regular users 30 | CREATE OR REPLACE FUNCTION pglinter_install_for_user( 31 | target_user text DEFAULT current_user 32 | ) 33 | RETURNS text AS $$ 34 | DECLARE 35 | result_msg text; 36 | BEGIN 37 | -- Check if extension already exists 38 | IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pglinter') THEN 39 | result_msg := 'pglinter extension already installed'; 40 | ELSE 41 | -- Install the extension 42 | CREATE EXTENSION pglinter; 43 | result_msg := 'pglinter extension installed successfully'; 44 | END IF; 45 | 46 | -- Grant schema usage 47 | EXECUTE format('GRANT USAGE ON SCHEMA pglinter TO %I', target_user); 48 | 49 | -- Grant table permissions 50 | EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA pglinter TO %I', target_user); 51 | 52 | -- Grant function execution permissions 53 | EXECUTE format('GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pglinter TO %I', target_user); 54 | 55 | -- Grant permissions for future objects 56 | EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA pglinter GRANT SELECT ON TABLES TO %I', target_user); 57 | EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA pglinter GRANT EXECUTE ON FUNCTIONS TO %I', target_user); 58 | 59 | RETURN result_msg || format(' and permissions granted to user %s', target_user); 60 | EXCEPTION 61 | WHEN OTHERS THEN 62 | RETURN 'Error: ' || SQLERRM; 63 | END; 64 | $$ LANGUAGE plpgsql SECURITY DEFINER; 65 | 66 | -- Function to uninstall pglinter 67 | CREATE OR REPLACE FUNCTION pglinter_uninstall() 68 | RETURNS text AS $$ 69 | BEGIN 70 | DROP EXTENSION IF EXISTS pglinter CASCADE; 71 | RETURN 'pglinter extension uninstalled successfully'; 72 | EXCEPTION 73 | WHEN OTHERS THEN 74 | RETURN 'Error uninstalling pglinter: ' || SQLERRM; 75 | END; 76 | $$ LANGUAGE plpgsql SECURITY DEFINER; 77 | 78 | -- Function to check pglinter status 79 | CREATE OR REPLACE FUNCTION pglinter_status() 80 | RETURNS TABLE ( 81 | extension_installed boolean, 82 | extension_version text, 83 | schema_accessible boolean, 84 | functions_count bigint 85 | ) AS $$ 86 | BEGIN 87 | RETURN QUERY 88 | SELECT 89 | EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pglinter') as extension_installed, 90 | COALESCE( 91 | (SELECT extversion FROM pg_extension WHERE extname = 'pglinter'), 92 | 'not installed' 93 | ) as extension_version, 94 | has_schema_privilege('pglinter', 'USAGE') as schema_accessible, 95 | COALESCE( 96 | (SELECT count(*) FROM information_schema.routines 97 | WHERE routine_schema = 'pglinter'), 98 | 0 99 | ) as functions_count; 100 | END; 101 | $$ LANGUAGE plpgsql SECURITY DEFINER; 102 | 103 | -- Function to grant pglinter permissions to a user 104 | CREATE OR REPLACE FUNCTION pglinter_grant_to_user(target_user text) 105 | RETURNS text AS $$ 106 | BEGIN 107 | -- Validate user exists 108 | IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = target_user) THEN 109 | RETURN format('Error: User %s does not exist', target_user); 110 | END IF; 111 | 112 | -- Grant permissions 113 | EXECUTE format('GRANT USAGE ON SCHEMA pglinter TO %I', target_user); 114 | EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA pglinter TO %I', target_user); 115 | EXECUTE format('GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pglinter TO %I', target_user); 116 | 117 | RETURN format('pglinter permissions granted to user %s', target_user); 118 | EXCEPTION 119 | WHEN OTHERS THEN 120 | RETURN 'Error granting permissions: ' || SQLERRM; 121 | END; 122 | $$ LANGUAGE plpgsql SECURITY DEFINER; 123 | 124 | -- Grant execute permissions to public (or specific roles) 125 | GRANT EXECUTE ON FUNCTION pglinter_install_for_user(text) TO public; 126 | GRANT EXECUTE ON FUNCTION pglinter_uninstall() TO public; 127 | GRANT EXECUTE ON FUNCTION pglinter_status() TO public; 128 | GRANT EXECUTE ON FUNCTION pglinter_grant_to_user(text) TO public; 129 | 130 | -- Usage instructions 131 | DO $$ 132 | BEGIN 133 | RAISE NOTICE 'pglinter installation functions created successfully!'; 134 | RAISE NOTICE 'Usage for regular users:'; 135 | RAISE NOTICE ' SELECT pglinter_install_for_user(); -- Install for current user'; 136 | RAISE NOTICE ' SELECT pglinter_install_for_user(''username''); -- Install for specific user'; 137 | RAISE NOTICE ' SELECT pglinter_status(); -- Check installation status'; 138 | RAISE NOTICE ' SELECT pglinter_grant_to_user(''username''); -- Grant permissions to user'; 139 | RAISE NOTICE ' SELECT pglinter_uninstall(); -- Uninstall extension'; 140 | END; 141 | $$; 142 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions for pglinter 2 | 3 | ## Project Overview 4 | 5 | **pglinter** is a PostgreSQL extension written in Rust using pgrx that analyzes databases for potential issues, performance problems, and best practice violations. It's a conversion of the original Python dblinter to provide better performance and deeper PostgreSQL integration. 6 | 7 | ## Architecture & Key Components 8 | 9 | ### Core Technologies 10 | - **Language**: Rust 1.88.0+ 11 | - **Framework**: pgrx 0.16.1 (PostgreSQL extension framework) 12 | - **PostgreSQL Support**: Versions 13-18 (including 18beta2) 13 | - **Output Format**: SARIF (Static Analysis Results Interchange Format) 14 | 15 | ### Project Structure 16 | - `src/lib.rs`: Main entry point and PostgreSQL function definitions 17 | - `src/execute_rules.rs`: Rule execution engine and SARIF output generation 18 | - `src/manage_rules.rs`: Rule configuration management 19 | - `src/fixtures.rs`: Test data and fixtures 20 | - `tests/sql/`: SQL test files for rule validation 21 | - `tests/expected/`: Expected output files for regression testing 22 | 23 | ### Rule Categories 24 | - **B (Base/Database)**: Database-wide checks including tables, indexes, constraints, and general database analysis 25 | - **C (Cluster)**: PostgreSQL cluster configuration checks (authentication, security) 26 | - **S (Schema)**: Schema-level validation 27 | 28 | ## Coding Standards & Best Practices 29 | 30 | ### Rust Code Guidelines 31 | 1. **Error Handling**: Use `Result` types extensively, prefer `?` operator 32 | 2. **Memory Safety**: Leverage Rust's ownership system, avoid unnecessary clones 33 | 3. **Performance**: Prefer iterators over loops, use `Vec::with_capacity` for known sizes 34 | 4. **pgrx Integration**: Use pgrx macros properly (`#[pg_extern]`, `#[pg_schema]`) 35 | 5. **Serialization**: Use serde for JSON/YAML with proper derives 36 | 37 | ### PostgreSQL Integration 38 | 1. **SQL Queries**: Use parameterized queries, avoid SQL injection risks 39 | 2. **Transactions**: Be explicit about transaction boundaries in rules 40 | 3. **Performance**: Consider query performance impact, use EXPLAIN when needed 41 | 4. **Compatibility**: Ensure code works across PostgreSQL 13-18 42 | 43 | ### Rule Development Guidelines 44 | 1. **Rule Structure**: Each rule should implement consistent interface 45 | 2. **Configuration**: Rules should be configurable via database settings 46 | 3. **Documentation**: Include clear descriptions and examples 47 | 4. **Testing**: Add comprehensive SQL tests in `tests/sql/` 48 | 5. **SARIF Output**: Ensure proper SARIF format with locations and severity 49 | 50 | ### Code Review Focus Areas 51 | 52 | #### Security 53 | - Validate all SQL inputs to prevent injection attacks 54 | - Check privilege escalation in rule execution 55 | - Review authentication and authorization logic (C-category rules) 56 | - Ensure secure handling of configuration data 57 | 58 | #### Performance 59 | - Review query efficiency, especially for large databases 60 | - Check for potential N+1 query problems 61 | - Validate memory usage in rule execution loops 62 | - Consider impact on production databases 63 | 64 | #### Correctness 65 | - Verify rule logic against PostgreSQL documentation 66 | - Test edge cases and boundary conditions 67 | - Validate SARIF output format compliance 68 | - Check PostgreSQL version compatibility 69 | 70 | #### Code Quality 71 | - Ensure proper error handling and propagation 72 | - Review panic-free code (use `Result` types) 73 | - Check for proper resource cleanup 74 | - Validate test coverage completeness 75 | 76 | ### Testing Standards 77 | 1. **SQL Tests**: Create `.sql` files in `tests/sql/` with descriptive names 78 | 2. **Expected Outputs**: Maintain `.out` files in `tests/expected/` 79 | 3. **Rule Testing**: Test both positive and negative cases 80 | 4. **Integration Tests**: Test rule combinations and configurations 81 | 5. **Performance Tests**: Include benchmarks for critical paths 82 | 83 | ### Documentation Requirements 84 | 1. **Function Documentation**: Document all public functions with examples 85 | 2. **Rule Documentation**: Include purpose, configuration, and examples 86 | 3. **SARIF Schema**: Maintain documentation for output format 87 | 4. **Configuration**: Document all configurable parameters 88 | 89 | ### Git & PR Guidelines 90 | 1. **Commit Messages**: Use conventional commits format 91 | 2. **Branch Names**: Use descriptive names (feature/rule-name, fix/issue-123) 92 | 3. **PR Description**: Include rule testing results and performance impact 93 | 4. **Breaking Changes**: Clearly document any breaking changes 94 | 95 | ### Dependencies & Versions 96 | - Keep pgrx version aligned with project requirements 97 | - Update Rust toolchain carefully (currently requires 1.88.0+) 98 | - Maintain PostgreSQL compatibility matrix 99 | - Use stable versions for production dependencies 100 | 101 | ## Common Patterns to Follow 102 | 103 | ### Rule Implementation 104 | ```rust 105 | pub fn check_rule_name() -> Result, String> { 106 | // Rule logic here 107 | // Return SARIF-compatible results 108 | } 109 | ``` 110 | 111 | ### Error Handling 112 | ```rust 113 | // Prefer Result types over panics 114 | match query_result { 115 | Ok(data) => process_data(data), 116 | Err(e) => return Err(format!("Query failed: {}", e)), 117 | } 118 | ``` 119 | 120 | ### PostgreSQL Function Export 121 | ```rust 122 | #[pg_extern] 123 | fn pglinter_function_name() -> Result> { 124 | // Implementation 125 | } 126 | ``` 127 | 128 | ## Anti-Patterns to Avoid 129 | 1. **Hardcoded Values**: Use configuration parameters instead 130 | 2. **Unwrap/Panic**: Always handle errors gracefully 131 | 3. **SQL String Concatenation**: Use parameterized queries 132 | 4. **Blocking Operations**: Be mindful of database locks 133 | 5. **Inconsistent Naming**: Follow PostgreSQL naming conventions 134 | 135 | ## Performance Considerations 136 | - Rules should be efficient on large databases 137 | - Consider query optimization and indexing requirements 138 | - Be mindful of memory usage during rule execution 139 | - Test performance impact on production-like datasets 140 | 141 | When reviewing or generating code for this project, prioritize database safety, performance, and maintainability. Always consider the impact on production PostgreSQL environments. 142 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # pglinter Documentation Makefile 2 | 3 | # Variables 4 | PANDOC_CONTAINER_ID?=pglinter-pandoc-1 5 | PANDOC=docker exec $(PANDOC_CONTAINER_ID) pandoc 6 | 7 | PG_CONTAINER_ID?=pglinter-pg-1 8 | 9 | TUTORIALS?=$(sort $(wildcard tutorials/*.md)) 10 | EXAMPLES?=$(sort $(wildcard examples/*.md)) 11 | 12 | # Docker compose commands 13 | up: 14 | docker compose --project-directory docs up --detach 15 | 16 | down: 17 | docker compose --project-directory docs down 18 | 19 | clean: 20 | docker compose --project-directory docs rm 21 | 22 | psql: 23 | docker exec -it $(PG_CONTAINER_ID) psql 24 | 25 | # Documentation generation 26 | .PHONY: tutorials examples html pdf mkdocs-serve mkdocs-build mkdocs-install 27 | tutorials: $(TUTORIALS) 28 | examples: $(EXAMPLES) 29 | 30 | tutorials/%.md: tutorials/%.md 31 | @echo "Processing tutorial: $<" 32 | @$(PANDOC) $< \ 33 | --to markdown-grid_tables-simple_tables-multiline_tables \ 34 | --filter=pandoc-run-postgres \ 35 | --output=$@ 36 | @grep -vqz '{class="warning"}' $@ || echo '⚠️ Warning(s) in $@' 37 | 38 | examples/%.md: examples/%.md 39 | @echo "Processing example: $<" 40 | @$(PANDOC) $< \ 41 | --to markdown-grid_tables-simple_tables-multiline_tables \ 42 | --output=$@ 43 | 44 | # MkDocs commands 45 | mkdocs-install: 46 | @echo "Installing MkDocs dependencies..." 47 | pip3 install -r requirements.txt 48 | 49 | mkdocs-serve: 50 | @echo "Starting MkDocs development server..." 51 | @cd .. && mkdocs serve 52 | 53 | mkdocs-build: 54 | @echo "Building MkDocs documentation..." 55 | @cd .. && mkdocs build 56 | 57 | mkdocs-clean: 58 | @echo "Cleaning MkDocs build directory..." 59 | @cd .. && rm -rf site/ 60 | 61 | # Generate HTML documentation 62 | html: 63 | @echo "Generating HTML documentation..." 64 | @mkdir -p build/html 65 | @for file in *.md */*.md; do \ 66 | if [ -f "$$file" ]; then \ 67 | output="build/html/$$(echo $$file | sed 's/\.md$$/\.html/')"; \ 68 | mkdir -p "$$(dirname $$output)"; \ 69 | pandoc "$$file" -o "$$output" \ 70 | --standalone \ 71 | --css=../assets/style.css \ 72 | --template=assets/template.html \ 73 | --toc \ 74 | --toc-depth=3; \ 75 | echo "Generated $$output"; \ 76 | fi \ 77 | done 78 | 79 | # Generate PDF documentation 80 | pdf: 81 | @echo "Generating PDF documentation..." 82 | @mkdir -p build/pdf 83 | @pandoc index.md \ 84 | INSTALL.md \ 85 | configure.md \ 86 | SECURITY.md \ 87 | api/README.md \ 88 | tutorials/README.md \ 89 | how-to/README.md \ 90 | examples/README.md \ 91 | -o build/pdf/pglinter-documentation.pdf \ 92 | --pdf-engine=xelatex \ 93 | --toc \ 94 | --toc-depth=2 \ 95 | --number-sections 96 | 97 | # Check documentation links 98 | check-links: 99 | @echo "Checking documentation links..." 100 | @find . -name "*.md" -exec grep -l "http" {} \; | \ 101 | xargs -I {} sh -c 'echo "Checking links in {}"; \ 102 | grep -o "https\?://[^)]*" {} | sort -u | \ 103 | while read url; do \ 104 | if ! curl -s --head "$$url" > /dev/null; then \ 105 | echo "❌ Broken link in {}: $$url"; \ 106 | fi; \ 107 | done' 108 | 109 | # Validate markdown syntax 110 | lint: 111 | @echo "Linting markdown files..." 112 | @if command -v markdownlint > /dev/null; then \ 113 | markdownlint *.md */*.md; \ 114 | else \ 115 | echo "markdownlint not found. Install with: npm install -g markdownlint-cli"; \ 116 | fi 117 | 118 | # Spell check 119 | spell-check: 120 | @echo "Checking spelling..." 121 | @if command -v aspell > /dev/null; then \ 122 | find . -name "*.md" -exec aspell --mode=markdown check {} \;; \ 123 | else \ 124 | echo "aspell not found. Install with your package manager"; \ 125 | fi 126 | 127 | # Generate table of contents 128 | toc: 129 | @echo "Generating table of contents..." 130 | @if command -v doctoc > /dev/null; then \ 131 | doctoc *.md */*.md; \ 132 | else \ 133 | echo "doctoc not found. Install with: npm install -g doctoc"; \ 134 | fi 135 | 136 | # Development server 137 | serve: 138 | @echo "Starting development server..." 139 | @if command -v python3 > /dev/null; then \ 140 | cd build/html && python3 -m http.server 8000; \ 141 | else \ 142 | echo "Python 3 not found"; \ 143 | fi 144 | 145 | # Test documentation examples 146 | test-examples: 147 | @echo "Testing documentation examples..." 148 | @if [ -f "test_examples.sh" ]; then \ 149 | ./test_examples.sh; \ 150 | else \ 151 | echo "test_examples.sh not found"; \ 152 | fi 153 | 154 | # Clean build artifacts 155 | clean-build: 156 | @echo "Cleaning build artifacts..." 157 | @rm -rf build/ 158 | 159 | # Install dependencies 160 | install-deps: 161 | @echo "Installing documentation dependencies..." 162 | @if command -v npm > /dev/null; then \ 163 | npm install -g markdownlint-cli doctoc; \ 164 | else \ 165 | echo "npm not found. Please install Node.js"; \ 166 | fi 167 | 168 | # Setup development environment 169 | setup: 170 | @echo "Setting up documentation development environment..." 171 | @make install-deps 172 | @mkdir -p build/html build/pdf 173 | @mkdir -p assets 174 | @echo "Setup complete!" 175 | 176 | # Deploy documentation (customize for your deployment) 177 | deploy: 178 | @echo "Deploying documentation..." 179 | @make html 180 | @echo "Documentation built. Deploy build/html/ to your web server." 181 | 182 | # Help 183 | help: 184 | @echo "pglinter Documentation Makefile" 185 | @echo "" 186 | @echo "Available targets:" 187 | @echo " up - Start documentation containers" 188 | @echo " down - Stop documentation containers" 189 | @echo " clean - Remove documentation containers" 190 | @echo " psql - Connect to PostgreSQL container" 191 | @echo " tutorials - Process tutorial files" 192 | @echo " examples - Process example files" 193 | @echo " html - Generate HTML documentation" 194 | @echo " pdf - Generate PDF documentation" 195 | @echo " check-links - Check for broken links" 196 | @echo " lint - Lint markdown files" 197 | @echo " spell-check - Check spelling" 198 | @echo " toc - Generate table of contents" 199 | @echo " serve - Start development server" 200 | @echo " test-examples - Test documentation examples" 201 | @echo " clean-build - Clean build artifacts" 202 | @echo " install-deps - Install documentation dependencies" 203 | @echo " setup - Setup development environment" 204 | @echo " deploy - Deploy documentation" 205 | @echo " help - Show this help message" 206 | 207 | .DEFAULT_GOAL := help 208 | -------------------------------------------------------------------------------- /.github/actions/reusable-test-nodeb-package/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Reusable Test PostgreSQL NODEB Image' 2 | description: 'Builds and tests pglinter PostgreSQL NODEB Docker image' 3 | 4 | inputs: 5 | pg_version: 6 | description: 'PostgreSQL major version (13-18)' 7 | required: true 8 | default: '17' 9 | pglinter_version: 10 | description: 'pglinter version' 11 | required: true 12 | default: '0.0.19' 13 | platform: 14 | description: 'Target platform' 15 | required: false 16 | default: 'linux/amd64' 17 | timeout: 18 | description: 'Container timeout in seconds' 19 | required: false 20 | default: '120' 21 | 22 | outputs: 23 | image_name: 24 | description: 'Built Docker image name' 25 | value: ${{ steps.build.outputs.image_name }} 26 | test_result: 27 | description: 'Test execution result (success/failure)' 28 | value: ${{ steps.test.outputs.result }} 29 | 30 | runs: 31 | using: 'composite' 32 | steps: 33 | - name: Validate PostgreSQL version 34 | shell: bash 35 | run: | 36 | if [[ ! "${{ inputs.pg_version }}" =~ ^(13|14|15|16|17|18)$ ]]; then 37 | echo "❌ ERROR: Unsupported PostgreSQL version: ${{ inputs.pg_version }}" 38 | exit 1 39 | fi 40 | echo "✅ PostgreSQL version ${{ inputs.pg_version }} is supported" 41 | 42 | - name: Set architecture variables 43 | id: setup-arch 44 | shell: bash 45 | run: | 46 | case "${{ inputs.platform }}" in 47 | "linux/amd64") 48 | echo "RPM_ARCH=x86_64" >> $GITHUB_OUTPUT 49 | ;; 50 | "linux/arm64") 51 | echo "RPM_ARCH=aarch64" >> $GITHUB_OUTPUT 52 | ;; 53 | *) 54 | echo "Unknown architecture: ${{ inputs.platform }}" 55 | exit 1 56 | ;; 57 | esac 58 | 59 | - name: Set up Docker Buildx 60 | uses: docker/setup-buildx-action@v3 61 | with: 62 | platforms: ${{ inputs.platform }} 63 | 64 | - name: Download package artifact 65 | uses: actions/download-artifact@v4 66 | with: 67 | name: postgresql_pglinter_${{ inputs.pg_version }}-${{ inputs.pglinter_version }}-1.${{ steps.setup-arch.outputs.RPM_ARCH }}.rpm 68 | path: ./artifacts 69 | 70 | - name: List downloaded artifact files 71 | run: ls -l ./artifacts 72 | shell: bash 73 | 74 | - name: Build PostgreSQL NODEB image 75 | id: build 76 | shell: bash 77 | run: | 78 | IMAGE_NAME="pglinter:pg${{ inputs.pg_version }}-nodeb-test" 79 | echo "🔨 Building pglinter PostgreSQL ${{ inputs.pg_version }} NODEB image..." 80 | echo " Image: ${IMAGE_NAME}" 81 | echo " Platform: ${{ inputs.platform }}" 82 | echo " pglinter version: ${{ inputs.pglinter_version }}" 83 | if [[ ! -f "docker/ci/Dockerfile.pg-nodeb" ]]; then 84 | echo "❌ ERROR: Dockerfile not found: docker/ci/Dockerfile.pg-nodeb" 85 | exit 1 86 | fi 87 | if [[ ! -f "docker/ci/nodeb-start-with-pglinter.sh" ]]; then 88 | echo "❌ ERROR: Entrypoint script not found: docker/ci/nodeb-start-with-pglinter.sh" 89 | exit 1 90 | fi 91 | 92 | echo "docker buildx build --load \ 93 | --platform \"${{ inputs.platform }}\" \ 94 | --build-arg PG_MAJOR_VERSION=\"${{ inputs.pg_version }}\" \ 95 | --build-arg PGLINTER_VERSION=\"${{ inputs.pglinter_version }}\" \ 96 | --build-arg ARCH=\"${{ steps.setup-arch.outputs.RPM_ARCH }}\" \ 97 | --build-arg PACKAGE_PATH=./artifacts \ 98 | --build-arg PACKAGE_NAME=postgresql_pglinter_${{ inputs.pg_version }}-${{ inputs.pglinter_version }}-1.${{ steps.setup-arch.outputs.RPM_ARCH }}.rpm \ 99 | -f docker/ci/Dockerfile.pg-nodeb \ 100 | -t \"${IMAGE_NAME}\" . " 101 | 102 | if docker buildx build --load \ 103 | --platform "${{ inputs.platform }}" \ 104 | --build-arg PG_MAJOR_VERSION="${{ inputs.pg_version }}" \ 105 | --build-arg PGLINTER_VERSION="${{ inputs.pglinter_version }}" \ 106 | --build-arg ARCH="${{ steps.setup-arch.outputs.RPM_ARCH }}" \ 107 | --build-arg PACKAGE_PATH=./artifacts \ 108 | --build-arg PACKAGE_NAME=postgresql_pglinter_${{ inputs.pg_version }}-${{ inputs.pglinter_version }}-1.${{ steps.setup-arch.outputs.RPM_ARCH }}.rpm \ 109 | -f docker/ci/Dockerfile.pg-nodeb \ 110 | -t "${IMAGE_NAME}" \ 111 | .; then 112 | echo "✅ Successfully built image: ${IMAGE_NAME}" 113 | echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT 114 | else 115 | echo "❌ Failed to build PostgreSQL ${{ inputs.pg_version }} NODEB image" 116 | exit 1 117 | fi 118 | 119 | - name: Test pglinter NODEB image 120 | id: test 121 | shell: bash 122 | run: | 123 | IMAGE_NAME="${{ steps.build.outputs.image_name }}" 124 | TIMEOUT="${{ inputs.timeout }}" 125 | CONTAINER_NAME="pglinter-test-pg${{ inputs.pg_version }}-nodeb-$(date +%s)" 126 | echo "🧪 Testing pglinter PostgreSQL ${{ inputs.pg_version }} NODEB image..." 127 | set +e 128 | timeout "${TIMEOUT}" docker run \ 129 | --name "${CONTAINER_NAME}" \ 130 | --platform "${{ inputs.platform }}" \ 131 | --rm \ 132 | "${IMAGE_NAME}" 133 | EXIT_CODE=$? 134 | set -e 135 | case $EXIT_CODE in 136 | 0) 137 | echo "✅ pglinter PostgreSQL ${{ inputs.pg_version }} NODEB test PASSED" 138 | echo "result=success" >> $GITHUB_OUTPUT 139 | ;; 140 | 124) 141 | echo "❌ pglinter PostgreSQL ${{ inputs.pg_version }} NODEB test TIMEOUT" 142 | echo "result=timeout" >> $GITHUB_OUTPUT 143 | exit 1 144 | ;; 145 | *) 146 | echo "❌ pglinter PostgreSQL ${{ inputs.pg_version }} NODEB test FAILED" 147 | echo "result=failure" >> $GITHUB_OUTPUT 148 | exit 1 149 | ;; 150 | esac 151 | 152 | - name: Cleanup test resources 153 | if: always() 154 | shell: bash 155 | run: | 156 | echo "🧹 Cleaning up test resources..." 157 | LEFTOVER_CONTAINERS=$(docker ps -aq --filter "name=pglinter-test-pg${{ inputs.pg_version }}-nodeb" 2>/dev/null || true) 158 | if [[ -n "$LEFTOVER_CONTAINERS" ]]; then 159 | docker rm -f $LEFTOVER_CONTAINERS 2>/dev/null || true 160 | fi 161 | docker rmi "${{ steps.build.outputs.image_name }}" 2>/dev/null || true 162 | echo "✅ Cleanup completed" 163 | -------------------------------------------------------------------------------- /tests/sql/c003_scram_PG17-.sql: -------------------------------------------------------------------------------- 1 | -- Comprehensive test for pglinter C003 rule: SCRAM-SHA-256 encrypted passwords 2 | -- This script tests the C003 rule when password encryption is set to secure SCRAM-SHA-256 3 | -- Note: MD5 password encryption was removed in PostgreSQL 18 4 | 5 | CREATE EXTENSION pglinter; 6 | 7 | \pset pager off 8 | 9 | SELECT 'Testing C003 rule with SCRAM-SHA-256 password encryption...' AS test_info; 10 | 11 | -- Store original password_encryption setting 12 | CREATE TEMP TABLE original_settings AS 13 | SELECT name, setting as original_value 14 | FROM pg_settings 15 | WHERE name = 'password_encryption'; 16 | 17 | SELECT 'Original password_encryption setting: ' || setting AS original_setting 18 | FROM pg_settings 19 | WHERE name = 'password_encryption'; 20 | 21 | -- Change password_encryption to scram-sha-256 (secure setting) 22 | -- Note: This requires superuser privileges and may require session restart in some cases 23 | SELECT 'Attempting to set password_encryption to scram-sha-256...' AS action_info; 24 | 25 | -- Try to change the setting (may fail if not superuser or restart required) 26 | DO $$ 27 | DECLARE 28 | current_user_is_superuser boolean; 29 | can_change_setting boolean := false; 30 | BEGIN 31 | -- Check if current user is superuser 32 | SELECT usesuper INTO current_user_is_superuser 33 | FROM pg_user 34 | WHERE usename = current_user; 35 | 36 | IF current_user_is_superuser THEN 37 | BEGIN 38 | -- Attempt to change password_encryption 39 | PERFORM set_config('password_encryption', 'scram-sha-256', false); 40 | can_change_setting := true; 41 | RAISE NOTICE 'Successfully changed password_encryption to scram-sha-256'; 42 | EXCEPTION WHEN OTHERS THEN 43 | RAISE NOTICE 'Could not change password_encryption (may require restart): %', SQLERRM; 44 | can_change_setting := false; 45 | END; 46 | ELSE 47 | RAISE NOTICE 'Current user is not superuser - cannot change password_encryption setting'; 48 | can_change_setting := false; 49 | END IF; 50 | 51 | -- Store whether we could change the setting 52 | CREATE TEMP TABLE setting_change_status AS 53 | SELECT can_change_setting as was_changed; 54 | END 55 | $$; 56 | 57 | -- Check current password_encryption setting after attempted change 58 | SELECT '=== Current Password Encryption Setting ===' AS test_section; 59 | SELECT 60 | name, 61 | setting as current_value, 62 | CASE 63 | WHEN setting = 'scram-sha-256' THEN '✅ SECURE: Using recommended SCRAM-SHA-256' 64 | WHEN setting = 'md5' THEN '❌ INSECURE: Using deprecated MD5' 65 | ELSE '❓ OTHER: Using ' || setting 66 | END as security_status 67 | FROM pg_settings 68 | WHERE name = 'password_encryption'; 69 | 70 | -- First, disable all rules to isolate C003 testing 71 | SELECT pglinter.disable_all_rules() AS all_rules_disabled; 72 | 73 | -- Enable only C003 for focused testing 74 | SELECT pglinter.enable_rule('C003') AS c003_enabled; 75 | 76 | -- Verify C003 is enabled 77 | SELECT pglinter.is_rule_enabled('C003') AS c003_status; 78 | 79 | -- Test 1: Check if C003 detects any issues with current setting 80 | SELECT '=== Test 1: C003 Rule Execution with Current Setting ===' AS test_section; 81 | SELECT pglinter.perform_cluster_check(); 82 | 83 | -- Test 2: Manual execution of C003 query 84 | SELECT '=== Test 2: Manual C003 Query Execution ===' AS test_section; 85 | SELECT 86 | count(*) as md5_password_count, 87 | CASE 88 | WHEN count(*) = 0 THEN '✅ PASS: No MD5 password encryption detected' 89 | ELSE '❌ FAIL: ' || count(*) || ' MD5 configuration(s) found' 90 | END as test_result 91 | FROM pg_catalog.pg_settings 92 | WHERE name='password_encryption' AND setting='md5'; 93 | 94 | -- Test 3: Show what C003 is actually checking 95 | SELECT '=== Test 3: C003 Query and Logic ===' AS test_section; 96 | SELECT 97 | 'C003 checks for: password_encryption = ''md5''' as what_c003_checks, 98 | 'Current setting: ' || setting as current_setting, 99 | 'Expected result: ' || CASE 100 | WHEN setting = 'md5' THEN 'FAIL (MD5 detected)' 101 | ELSE 'PASS (No MD5)' 102 | END as expected_result 103 | FROM pg_settings 104 | WHERE name = 'password_encryption'; 105 | 106 | -- Test 4: Rule explanation 107 | SELECT '=== Test 4: C003 Rule Details ===' AS test_section; 108 | SELECT pglinter.explain_rule('C003') AS rule_explanation; 109 | 110 | -- Test 5: Show rule configuration 111 | SELECT '=== Test 5: C003 Rule Configuration ===' AS test_section; 112 | SELECT code, name, description, warning_level, error_level, fixes 113 | FROM pglinter.rules 114 | WHERE code = 'C003'; 115 | 116 | -- Test 6: Demonstrate secure configuration benefits 117 | SELECT '=== Test 6: Security Assessment ===' AS test_section; 118 | SELECT 119 | CASE 120 | WHEN setting = 'scram-sha-256' THEN 121 | 'SECURE: SCRAM-SHA-256 provides strong password hashing and is PostgreSQL 18+ compatible' 122 | WHEN setting = 'md5' THEN 123 | 'INSECURE: MD5 is deprecated, weak, and prevents upgrade to PostgreSQL 18+' 124 | ELSE 125 | 'UNKNOWN: Setting ' || setting || ' - check PostgreSQL documentation' 126 | END as security_assessment, 127 | 'Recommendation: Use scram-sha-256 for new installations' as recommendation 128 | FROM pg_settings 129 | WHERE name = 'password_encryption'; 130 | 131 | -- Test 7: Export results to SARIF format 132 | SELECT '=== Test 7: Export to SARIF ===' AS test_section; 133 | SELECT pglinter.check('/tmp/pglinter_c003_scram_results.sarif'); 134 | 135 | -- Show checksum of generated file if it exists 136 | \! test -f /tmp/pglinter_c003_scram_results.sarif && md5sum /tmp/pglinter_c003_scram_results.sarif || echo "SARIF file not generated" 137 | 138 | -- Restore original setting if we changed it (attempt) 139 | DO $$ 140 | DECLARE 141 | is_changed boolean; 142 | original_val text; 143 | BEGIN 144 | SELECT was_changed INTO is_changed FROM setting_change_status; 145 | SELECT original_value INTO original_val FROM original_settings WHERE name = 'password_encryption'; 146 | 147 | IF is_changed THEN 148 | BEGIN 149 | PERFORM set_config('password_encryption', original_val, false); 150 | RAISE NOTICE 'Restored password_encryption to original value: %', original_val; 151 | EXCEPTION WHEN OTHERS THEN 152 | RAISE NOTICE 'Could not restore password_encryption (may require restart): %', SQLERRM; 153 | END; 154 | END IF; 155 | END 156 | $$; 157 | 158 | DROP TABLE original_settings; 159 | DROP TABLE setting_change_status; 160 | 161 | DROP EXTENSION pglinter CASCADE; 162 | --------------------------------------------------------------------------------