velocity-python 0.1.57__tar.gz → 0.1.61__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {velocity_python-0.1.57 → velocity_python-0.1.61}/PKG-INFO +1 -1
- {velocity_python-0.1.57 → velocity_python-0.1.61}/pyproject.toml +1 -1
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/__init__.py +1 -1
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/assets/backfill.py +13 -1
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/lambda_handler.py +10 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/mixins/data_service.py +49 -6
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/tablehelper.py +56 -1
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity_python.egg-info/PKG-INFO +1 -1
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity_python.egg-info/SOURCES.txt +5 -1
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_assets_service.py +37 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_dirty_pipeline_fast_path.py +8 -0
- velocity_python-0.1.61/tests/test_http_handler_rollback.py +82 -0
- velocity_python-0.1.61/tests/test_identifier_injection_guard.py +116 -0
- velocity_python-0.1.61/tests/test_restricted_direct_tables.py +59 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_security_hardening.py +6 -5
- velocity_python-0.1.61/tests/test_write_hook_create_flow.py +138 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/LICENSE +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/README.md +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/setup.cfg +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/amplify.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/amplify_build.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/assets/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/assets/indexing.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/assets/references.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/assets/service.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/assets/usage_index.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/dirty_pipeline.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/base_handler.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/context.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/context_factory.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/exceptions.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/perf.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/response.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/sqs_handler.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/s3.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/ssm_config.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/tests/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/tests/test_response.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/async_support.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/column.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/database.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/decorators.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/engine.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/result.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/row.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/sequence.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/table.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/transaction.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/core/view.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/exceptions.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/migrations.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/base/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/base/initializer.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/base/operators.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/base/sql.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/base/types.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/mysql/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/mysql/operators.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/mysql/reserved.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/mysql/sql.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/mysql/types.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/postgres/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/postgres/operators.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/postgres/reserved.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/postgres/sql.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/postgres/types.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/sqlite/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/sqlite/operators.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/sqlite/reserved.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/sqlite/sql.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/sqlite/types.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/sqlserver/operators.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/sqlserver/sql.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/servers/sqlserver/types.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/common_db_test.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/common.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/conftest.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_column.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_connections.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_database.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_engine.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_imports.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_result.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_row.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_table.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/sql/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/sql/common.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_db_utils.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_postgres.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_result_caching.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_sql_builder.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_tablehelper.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/tests/test_view_helper.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/db/utils.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/logging.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/conv/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/conv/iconv.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/conv/oconv.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/db.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/export.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/format.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/mail.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/merge.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/pdf.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/tests/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/tests/test_db.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/tests/test_fix.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/tests/test_format.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/tests/test_iconv.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/tests/test_merge.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/tests/test_oconv.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/tests/test_original_error.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/tests/test_timer.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/timer.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/misc/tools.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/payment/__init__.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/payment/authorizenet_adapter.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/payment/authorizenet_mirror.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/payment/base_adapter.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/payment/braintree_adapter.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/payment/braintree_mirror.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/payment/charge_rules.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/payment/stripe_adapter.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/payment/stripe_mirror.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity_python.egg-info/dependency_links.txt +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity_python.egg-info/entry_points.txt +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity_python.egg-info/requires.txt +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity_python.egg-info/top_level.txt +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_amplify_build.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_asset_indexing.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_asset_references.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_async_support.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_batch_operations.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_concurrency_safety.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_connection_pool.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_connection_resilience.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_context_job_descriptions.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_db_credentials_ssm_cascade.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_decorators.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_email_processing.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_iconv_money_to_cents.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_jsonb_dict_adapter.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_lambda_handler.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_lambda_handler_auth.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_mixins_import.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_n_plus_one.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_observability.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_payment_authorizenet_adapter.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_payment_braintree_adapter.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_payment_braintree_mirror.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_payment_profile_sorting.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_payment_stripe_adapter.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_pdf.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_prepared_statements.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_psycopg3_upgrade.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_query_cache.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_retry_side_effect_guard.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_return_default_safety.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_row_batch_update.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_row_cache_staleness.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_row_dirty_tracking.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_schema_migrations.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_server_cursor.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_single_autocommit_safety.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_spreadsheet_functions.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_sqs_per_record_transactions.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_ssm_config.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_sys_modified_count_postgres_demo.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_table_alter.py +0 -0
- {velocity_python-0.1.57 → velocity_python-0.1.61}/tests/test_where_clause_validation.py +0 -0
|
@@ -70,7 +70,16 @@ def backfill_asset_records(
|
|
|
70
70
|
bucket: str,
|
|
71
71
|
objects: Iterable[Mapping[str, Any]],
|
|
72
72
|
head_loader: HeadLoader | None = None,
|
|
73
|
+
dry_run: bool = False,
|
|
73
74
|
) -> tuple[list[AssetRecord], AssetBackfillSummary]:
|
|
75
|
+
"""Register missing S3 objects as ``images`` rows.
|
|
76
|
+
|
|
77
|
+
When ``dry_run`` is True no rows are written: the function still scans,
|
|
78
|
+
checks for existing rows, and builds the candidate records, and the returned
|
|
79
|
+
summary's ``inserted`` count reflects how many rows *would* be created. The
|
|
80
|
+
returned asset list holds the normalized would-be records. This lets a
|
|
81
|
+
runbook or the admin button preview a backfill before committing it.
|
|
82
|
+
"""
|
|
74
83
|
inserted_assets: list[AssetRecord] = []
|
|
75
84
|
scanned = 0
|
|
76
85
|
inserted = 0
|
|
@@ -91,8 +100,11 @@ def backfill_asset_records(
|
|
|
91
100
|
if not row:
|
|
92
101
|
skipped_unsupported += 1
|
|
93
102
|
continue
|
|
94
|
-
tx.table("images").upsert(row, {"key": row["key"]})
|
|
95
103
|
inserted += 1
|
|
104
|
+
if dry_run:
|
|
105
|
+
inserted_assets.append(normalize_asset_record(row))
|
|
106
|
+
continue
|
|
107
|
+
tx.table("images").upsert(row, {"key": row["key"]})
|
|
96
108
|
stored = tx.table("images").find({"key": row["key"]})
|
|
97
109
|
inserted_assets.append(normalize_asset_record(stored.to_dict() if stored else row))
|
|
98
110
|
|
{velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/lambda_handler.py
RENAMED
|
@@ -209,6 +209,16 @@ class LambdaHandler(BaseHandler):
|
|
|
209
209
|
try:
|
|
210
210
|
self.execute_actions(tx, local_context, actions)
|
|
211
211
|
except Exception as e:
|
|
212
|
+
# A single HTTP action is atomic: any exception (including a handled
|
|
213
|
+
# AlertError validation rejection) must discard partial writes so the
|
|
214
|
+
# surrounding @engine.transaction does not commit them on the normal
|
|
215
|
+
# return below. Without this, e.g. a write_hook that inserts a @new
|
|
216
|
+
# row and then raises a validation AlertError would leak a committed
|
|
217
|
+
# orphan row. Mirrors SqsHandler._process_record.
|
|
218
|
+
try:
|
|
219
|
+
tx.rollback()
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
212
222
|
self.handle_error(tx, local_context, e)
|
|
213
223
|
local_context.perf.log("execute_actions total (serve)")
|
|
214
224
|
|
{velocity_python-0.1.57 → velocity_python-0.1.61}/src/velocity/aws/handlers/mixins/data_service.py
RENAMED
|
@@ -16,6 +16,7 @@ import xml.etree.ElementTree as ET
|
|
|
16
16
|
from io import BytesIO, StringIO
|
|
17
17
|
|
|
18
18
|
from velocity.aws import dirty_pipeline
|
|
19
|
+
from velocity.aws.handlers.exceptions import AlertError
|
|
19
20
|
from velocity.misc import export
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger(__name__)
|
|
@@ -41,6 +42,15 @@ class DataServiceMixin:
|
|
|
41
42
|
Override read_hook, write_hook, etc. methods to add custom business logic.
|
|
42
43
|
"""
|
|
43
44
|
|
|
45
|
+
# Tables that must NOT be reachable through the hook-bypassing generic
|
|
46
|
+
# actions (query-direct, update-rows, get-table-schema). The generic actions
|
|
47
|
+
# do not run rwx hooks, so any per-table row-scoping or secret-redaction a
|
|
48
|
+
# table relies on would be bypassed. Apps populate this set (business policy
|
|
49
|
+
# lives in the app, not this business-agnostic mixin) with the names of such
|
|
50
|
+
# tables; the normal read-object/find-object/query/write-object actions —
|
|
51
|
+
# which DO run hooks — remain the supported path for them.
|
|
52
|
+
restricted_direct_tables = frozenset()
|
|
53
|
+
|
|
44
54
|
# PostgreSQL type mappings for frontend display
|
|
45
55
|
_pg_types = {
|
|
46
56
|
"bool": "string",
|
|
@@ -58,6 +68,14 @@ class DataServiceMixin:
|
|
|
58
68
|
"timestamp": "string",
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
def _assert_direct_access_allowed(self, table):
|
|
72
|
+
"""Reject hook-bypassing direct access to a restricted table."""
|
|
73
|
+
if table in self.restricted_direct_tables:
|
|
74
|
+
raise AlertError(
|
|
75
|
+
f"Direct access to '{table}' is not permitted; "
|
|
76
|
+
"use the standard data actions."
|
|
77
|
+
)
|
|
78
|
+
|
|
61
79
|
def _get_field_type(self, column_info):
|
|
62
80
|
"""Convert database column type to frontend display type"""
|
|
63
81
|
return (
|
|
@@ -149,20 +167,39 @@ class DataServiceMixin:
|
|
|
149
167
|
self._call_rwx_hook(
|
|
150
168
|
"before_new", table, tx, table, sys_id, incoming, context
|
|
151
169
|
)
|
|
170
|
+
# Run before_write BEFORE inserting the row, passing the "@new"
|
|
171
|
+
# sentinel so table hooks can detect a create, validate required
|
|
172
|
+
# fields, and reject it without leaving an orphan row. (The serve()
|
|
173
|
+
# error path rolls the transaction back, but validating pre-insert
|
|
174
|
+
# avoids ever allocating the row in the first place.)
|
|
175
|
+
self._call_rwx_hook(
|
|
176
|
+
"before_write", "common", tx, table, "@new", incoming, context
|
|
177
|
+
)
|
|
178
|
+
self._call_rwx_hook(
|
|
179
|
+
"before_write", table, tx, table, "@new", incoming, context
|
|
180
|
+
)
|
|
152
181
|
row = tx.table(table).new()
|
|
153
182
|
sys_id = row["sys_id"]
|
|
154
183
|
self._call_rwx_hook("after_new", "common", tx, table, sys_id, row, context)
|
|
155
184
|
self._call_rwx_hook("after_new", table, tx, table, sys_id, row, context)
|
|
156
185
|
elif sys_id:
|
|
157
186
|
sys_id = int(sys_id)
|
|
187
|
+
self._call_rwx_hook(
|
|
188
|
+
"before_write", "common", tx, table, sys_id, incoming, context
|
|
189
|
+
)
|
|
190
|
+
self._call_rwx_hook(
|
|
191
|
+
"before_write", table, tx, table, sys_id, incoming, context
|
|
192
|
+
)
|
|
193
|
+
# Update path: the row must already exist. Use find (not get, which
|
|
194
|
+
# is get-or-create) so a write to a stale/unknown sys_id is a clear
|
|
195
|
+
# error rather than a phantom row inserted with a client-chosen PK.
|
|
196
|
+
row = tx.table(table).find(sys_id)
|
|
197
|
+
if not row:
|
|
198
|
+
raise AlertError(
|
|
199
|
+
f"Cannot update {table}: record {sys_id} no longer exists."
|
|
200
|
+
)
|
|
158
201
|
else:
|
|
159
202
|
raise Exception("Object sys_id was not supplied on write operation.")
|
|
160
|
-
self._call_rwx_hook(
|
|
161
|
-
"before_write", "common", tx, table, sys_id, incoming, context
|
|
162
|
-
)
|
|
163
|
-
self._call_rwx_hook("before_write", table, tx, table, sys_id, incoming, context)
|
|
164
|
-
if not row:
|
|
165
|
-
row = tx.table(table).get(sys_id)
|
|
166
203
|
row.update(incoming)
|
|
167
204
|
self._call_rwx_hook("after_write", "common", tx, table, sys_id, row, context)
|
|
168
205
|
self._call_rwx_hook("after_write", table, tx, table, sys_id, row, context)
|
|
@@ -842,6 +879,8 @@ class DataServiceMixin:
|
|
|
842
879
|
if not rows:
|
|
843
880
|
raise ValueError("Parameter 'updateRows' cannot be empty")
|
|
844
881
|
|
|
882
|
+
self._assert_direct_access_allowed(table)
|
|
883
|
+
|
|
845
884
|
t = tx.table(table)
|
|
846
885
|
count = t.update(data, {"sys_id": rows})
|
|
847
886
|
context.response().toast(f"Updated {count} item(s).", "success")
|
|
@@ -867,6 +906,8 @@ class DataServiceMixin:
|
|
|
867
906
|
if not table_name:
|
|
868
907
|
raise ValueError("Parameter 'obj' cannot be empty")
|
|
869
908
|
|
|
909
|
+
self._assert_direct_access_allowed(table_name)
|
|
910
|
+
|
|
870
911
|
params = payload.get("params", {})
|
|
871
912
|
|
|
872
913
|
if payload.get("result_format") == "excel":
|
|
@@ -932,6 +973,8 @@ class DataServiceMixin:
|
|
|
932
973
|
if not table_name:
|
|
933
974
|
raise ValueError("Parameter 'tableName' cannot be empty")
|
|
934
975
|
|
|
976
|
+
self._assert_direct_access_allowed(table_name)
|
|
977
|
+
|
|
935
978
|
try:
|
|
936
979
|
# Query information_schema to get table schema
|
|
937
980
|
schema_query = """
|
|
@@ -158,6 +158,54 @@ class TableHelper:
|
|
|
158
158
|
self.foreign_keys[key] = data
|
|
159
159
|
return data
|
|
160
160
|
|
|
161
|
+
# Substrings that never legitimately appear inside a column/WHERE-key
|
|
162
|
+
# reference and are the signatures of identifier-level SQL injection. The
|
|
163
|
+
# values side of every predicate is parameterized; only the identifier side
|
|
164
|
+
# flows through here, so a placeholder, statement terminator, or comment in
|
|
165
|
+
# this position is always an attack, never a real column or expression.
|
|
166
|
+
_INJECTION_SIGNATURES: ClassVar[Tuple[str, ...]] = (
|
|
167
|
+
";", # statement terminator / stacked query
|
|
168
|
+
"--", # line comment
|
|
169
|
+
"/*", # block comment open
|
|
170
|
+
"*/", # block comment close
|
|
171
|
+
"%s", # positional bind-parameter smuggling
|
|
172
|
+
"%(", # named bind-parameter smuggling
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _assert_safe_reference(self, key: str) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Reject identifier/expression references carrying SQL-injection signatures.
|
|
178
|
+
|
|
179
|
+
This is a defense-in-depth guard for the *structured* query path (WHERE
|
|
180
|
+
dict keys, ORDER BY / GROUP BY entries, projected columns). Values are
|
|
181
|
+
always parameterized; this guard protects the identifier side, which is
|
|
182
|
+
interpolated. It deliberately does not reject legitimate SQL expressions
|
|
183
|
+
(aggregates, CASE/CAST, window functions, correlated subqueries) — only
|
|
184
|
+
the unambiguous injection markers and unbalanced parentheses. Raw string
|
|
185
|
+
WHERE clauses are intentionally not routed through here and remain
|
|
186
|
+
caller-beware per the documented contract.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ValueError: unconditionally (ignores bypass_on_error) when an
|
|
190
|
+
injection signature is present, because such input is never a
|
|
191
|
+
valid reference.
|
|
192
|
+
"""
|
|
193
|
+
if not isinstance(key, str):
|
|
194
|
+
return
|
|
195
|
+
# Strip a leading operator prefix (e.g. '%', '%%', '>=') so that a LIKE
|
|
196
|
+
# key such as '%status' is not misread as the '%s' placeholder marker.
|
|
197
|
+
body = self.remove_operator(key)
|
|
198
|
+
lowered = body.lower()
|
|
199
|
+
for marker in self._INJECTION_SIGNATURES:
|
|
200
|
+
if marker in lowered:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"Unsafe SQL reference detected (contains {marker!r}): {key!r}"
|
|
203
|
+
)
|
|
204
|
+
if not self.are_parentheses_balanced(body):
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"Unsafe SQL reference detected (unbalanced parentheses): {key!r}"
|
|
207
|
+
)
|
|
208
|
+
|
|
161
209
|
def resolve_references(
|
|
162
210
|
self, key: str, options: Optional[Dict[str, Any]] = None
|
|
163
211
|
) -> str:
|
|
@@ -176,13 +224,20 @@ class TableHelper:
|
|
|
176
224
|
Resolved column reference with appropriate aliasing
|
|
177
225
|
|
|
178
226
|
Raises:
|
|
179
|
-
ValueError: If key is invalid and bypass_on_error is False
|
|
227
|
+
ValueError: If key is invalid and bypass_on_error is False, or if the
|
|
228
|
+
key carries an SQL-injection signature (always, regardless of
|
|
229
|
+
bypass_on_error)
|
|
180
230
|
"""
|
|
181
231
|
if not key or not isinstance(key, str):
|
|
182
232
|
if options and options.get("bypass_on_error"):
|
|
183
233
|
return key or ""
|
|
184
234
|
raise ValueError(f"Invalid key: {key}")
|
|
185
235
|
|
|
236
|
+
# Identifier-level injection guard runs before bypass_on_error so that a
|
|
237
|
+
# poisoned reference is a hard error on every path, not silently passed
|
|
238
|
+
# through.
|
|
239
|
+
self._assert_safe_reference(key)
|
|
240
|
+
|
|
186
241
|
if options is None:
|
|
187
242
|
options = {"alias_column": True, "alias_table": False, "alias_only": False}
|
|
188
243
|
|
|
@@ -162,7 +162,9 @@ tests/test_db_credentials_ssm_cascade.py
|
|
|
162
162
|
tests/test_decorators.py
|
|
163
163
|
tests/test_dirty_pipeline_fast_path.py
|
|
164
164
|
tests/test_email_processing.py
|
|
165
|
+
tests/test_http_handler_rollback.py
|
|
165
166
|
tests/test_iconv_money_to_cents.py
|
|
167
|
+
tests/test_identifier_injection_guard.py
|
|
166
168
|
tests/test_jsonb_dict_adapter.py
|
|
167
169
|
tests/test_lambda_handler.py
|
|
168
170
|
tests/test_lambda_handler_auth.py
|
|
@@ -178,6 +180,7 @@ tests/test_pdf.py
|
|
|
178
180
|
tests/test_prepared_statements.py
|
|
179
181
|
tests/test_psycopg3_upgrade.py
|
|
180
182
|
tests/test_query_cache.py
|
|
183
|
+
tests/test_restricted_direct_tables.py
|
|
181
184
|
tests/test_retry_side_effect_guard.py
|
|
182
185
|
tests/test_return_default_safety.py
|
|
183
186
|
tests/test_row_batch_update.py
|
|
@@ -192,4 +195,5 @@ tests/test_sqs_per_record_transactions.py
|
|
|
192
195
|
tests/test_ssm_config.py
|
|
193
196
|
tests/test_sys_modified_count_postgres_demo.py
|
|
194
197
|
tests/test_table_alter.py
|
|
195
|
-
tests/test_where_clause_validation.py
|
|
198
|
+
tests/test_where_clause_validation.py
|
|
199
|
+
tests/test_write_hook_create_flow.py
|
|
@@ -299,6 +299,43 @@ def test_backfill_asset_records_skips_existing_and_thumbnail_objects():
|
|
|
299
299
|
assert summary.skipped_unsupported == 1
|
|
300
300
|
|
|
301
301
|
|
|
302
|
+
def test_backfill_asset_records_dry_run_reports_without_writing():
|
|
303
|
+
tx = FakeTx(
|
|
304
|
+
[
|
|
305
|
+
{
|
|
306
|
+
"sys_id": "img-1",
|
|
307
|
+
"bucket": "donate.resources",
|
|
308
|
+
"key": "client-a/existing.png",
|
|
309
|
+
"url": "https://donate.resources.s3.amazonaws.com/client-a/existing.png",
|
|
310
|
+
"public": True,
|
|
311
|
+
}
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
objects = [
|
|
315
|
+
{"Key": "client-a/existing.png", "Size": 10, "ETag": '"a"'},
|
|
316
|
+
{"Key": "client-a/new.png", "Size": 11, "ETag": '"b"'},
|
|
317
|
+
{"Key": "client-a/new-thumb.png", "Size": 12, "ETag": '"c"'},
|
|
318
|
+
]
|
|
319
|
+
rows_before = len(tx.images.rows)
|
|
320
|
+
|
|
321
|
+
inserted, summary = backfill_asset_records(
|
|
322
|
+
tx,
|
|
323
|
+
bucket="donate.resources",
|
|
324
|
+
objects=objects,
|
|
325
|
+
dry_run=True,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Same accounting as a real run...
|
|
329
|
+
assert summary.scanned == 3
|
|
330
|
+
assert summary.inserted == 1
|
|
331
|
+
assert summary.skipped_existing == 1
|
|
332
|
+
assert summary.skipped_unsupported == 1
|
|
333
|
+
assert len(inserted) == 1
|
|
334
|
+
assert inserted[0].key == "client-a/new.png"
|
|
335
|
+
# ...but nothing was actually written.
|
|
336
|
+
assert len(tx.images.rows) == rows_before
|
|
337
|
+
|
|
338
|
+
|
|
302
339
|
def _matches(row, where):
|
|
303
340
|
for key, value in where.items():
|
|
304
341
|
if key == "!%key":
|
|
@@ -39,6 +39,14 @@ class FakeTable:
|
|
|
39
39
|
def get(self, sys_id):
|
|
40
40
|
return self.row
|
|
41
41
|
|
|
42
|
+
def find(self, sys_id):
|
|
43
|
+
# Update path now uses find() (not get, which is get-or-create) so a
|
|
44
|
+
# write to a stale sys_id is an error rather than a phantom row.
|
|
45
|
+
return self.row
|
|
46
|
+
|
|
47
|
+
def new(self):
|
|
48
|
+
return self.row
|
|
49
|
+
|
|
42
50
|
|
|
43
51
|
class FakeTx:
|
|
44
52
|
def __init__(self, row):
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""V2 — the HTTP handler must roll back partial writes before handle_error.
|
|
2
|
+
|
|
3
|
+
A single HTTP action is atomic. Because the handler class is wrapped in
|
|
4
|
+
@engine.transaction, a normal return from serve() commits. Without an explicit
|
|
5
|
+
rollback on the error path, an action that writes and then raises — including a
|
|
6
|
+
write_hook that inserts a @new row and then raises a validation AlertError
|
|
7
|
+
(D2) — would leave a committed orphan row. These tests pin the rollback.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest.mock import MagicMock
|
|
12
|
+
|
|
13
|
+
from velocity.aws.handlers.exceptions import AlertError
|
|
14
|
+
from velocity.aws.handlers.lambda_handler import LambdaHandler
|
|
15
|
+
from velocity.aws.handlers.context_factory import ContextFactory
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _StubContext:
|
|
19
|
+
def __init__(self, response):
|
|
20
|
+
self._response = response
|
|
21
|
+
self.perf = MagicMock()
|
|
22
|
+
|
|
23
|
+
def parse_postdata(self):
|
|
24
|
+
return {"action": "do-thing", "payload": {}}
|
|
25
|
+
|
|
26
|
+
def update_postdata(self, postdata):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def configure_perf(self, **kw):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def action(self):
|
|
33
|
+
return "do-thing"
|
|
34
|
+
|
|
35
|
+
def postdata(self, keys=-1, default=None):
|
|
36
|
+
return {"action": "do-thing", "payload": {}}
|
|
37
|
+
|
|
38
|
+
def response(self):
|
|
39
|
+
return self._response
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _StubContextFactory(ContextFactory):
|
|
43
|
+
def create(self, *, aws_event, aws_context, args, postdata, response, session):
|
|
44
|
+
return _StubContext(response)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _make_handler(raise_exc):
|
|
48
|
+
handler = LambdaHandler(
|
|
49
|
+
{"queryStringParameters": {}},
|
|
50
|
+
MagicMock(aws_request_id="req-123"),
|
|
51
|
+
context_factory=_StubContextFactory(),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def _boom(tx, local_context, actions):
|
|
55
|
+
raise raise_exc
|
|
56
|
+
|
|
57
|
+
handler.execute_actions = _boom
|
|
58
|
+
# Keep onError off the DB during the generic-exception path.
|
|
59
|
+
handler.onError = lambda tx, context, exc, tb: None
|
|
60
|
+
return handler
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class HttpHandlerRollbackTest(unittest.TestCase):
|
|
64
|
+
def test_alert_error_rolls_back_before_handle_error(self):
|
|
65
|
+
# D2: a validation AlertError raised after a @new insert must not commit
|
|
66
|
+
# the orphan row.
|
|
67
|
+
handler = _make_handler(AlertError("required field missing"))
|
|
68
|
+
tx = MagicMock()
|
|
69
|
+
handler.serve(tx)
|
|
70
|
+
tx.rollback.assert_called_once()
|
|
71
|
+
|
|
72
|
+
def test_unhandled_exception_rolls_back_and_returns_500(self):
|
|
73
|
+
handler = _make_handler(RuntimeError("kaboom"))
|
|
74
|
+
tx = MagicMock()
|
|
75
|
+
rendered = handler.serve(tx)
|
|
76
|
+
tx.rollback.assert_called_once()
|
|
77
|
+
# _set_unhandled_error_response sets a 500 on the response.
|
|
78
|
+
self.assertIn("500", str(rendered.get("statusCode", "")) + str(rendered))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
unittest.main()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""V1 — identifier-level SQL-injection guard for the structured query path.
|
|
2
|
+
|
|
3
|
+
The value side of every predicate is parameterized; the identifier side (WHERE
|
|
4
|
+
dict keys, ORDER BY / GROUP BY entries, projected columns) is interpolated and
|
|
5
|
+
flows through TableHelper.resolve_references / make_predicate. These tests pin
|
|
6
|
+
down that injection signatures on the identifier side are rejected while
|
|
7
|
+
legitimate column expressions (aggregates, CASE/CAST, window functions, pointer
|
|
8
|
+
syntax, operator prefixes including LIKE/ILIKE) still resolve.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import unittest
|
|
12
|
+
|
|
13
|
+
# Importing the postgres SQL module configures TableHelper.operators / .reserved
|
|
14
|
+
# as a side effect, which make_predicate / resolve_references rely on.
|
|
15
|
+
from velocity.db.servers.postgres.sql import SQL # noqa: F401
|
|
16
|
+
from velocity.db.servers.postgres.operators import OPERATORS
|
|
17
|
+
from velocity.db.servers.postgres.reserved import reserved_words
|
|
18
|
+
from velocity.db.servers.tablehelper import TableHelper
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IdentifierInjectionGuardTest(unittest.TestCase):
|
|
22
|
+
def setUp(self):
|
|
23
|
+
# Pin the postgres operator/reserved tables on the shared class attrs so
|
|
24
|
+
# this suite is independent of any other test that mutates them.
|
|
25
|
+
TableHelper.operators = OPERATORS
|
|
26
|
+
TableHelper.reserved = reserved_words
|
|
27
|
+
# tx is only consulted for pointer (foreign-key) resolution; the cases
|
|
28
|
+
# here are all local columns/expressions, so None is sufficient.
|
|
29
|
+
self.helper = TableHelper(tx=None, table="customers")
|
|
30
|
+
|
|
31
|
+
# --- the confirmed proof-of-concept vectors from the code review ---------
|
|
32
|
+
|
|
33
|
+
def test_where_key_placeholder_injection_rejected(self):
|
|
34
|
+
with self.assertRaises(ValueError):
|
|
35
|
+
self.helper.make_predicate("email = %s) OR (1=1", "x")
|
|
36
|
+
|
|
37
|
+
def test_orderby_style_stacked_query_rejected(self):
|
|
38
|
+
with self.assertRaises(ValueError):
|
|
39
|
+
self.helper.resolve_references(
|
|
40
|
+
"name; DROP TABLE users",
|
|
41
|
+
options={"alias_only": True, "bypass_on_error": True},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def test_column_comment_and_stacking_rejected(self):
|
|
45
|
+
with self.assertRaises(ValueError):
|
|
46
|
+
self.helper.resolve_references(
|
|
47
|
+
"1 FROM users; DELETE FROM users WHERE 1=1 --",
|
|
48
|
+
options={
|
|
49
|
+
"alias_column": True,
|
|
50
|
+
"alias_table": True,
|
|
51
|
+
"bypass_on_error": True,
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# --- bypass_on_error must NOT swallow an injection ------------------------
|
|
56
|
+
|
|
57
|
+
def test_bypass_on_error_does_not_swallow_injection(self):
|
|
58
|
+
for poisoned in (
|
|
59
|
+
"col; SELECT 1",
|
|
60
|
+
"col /* x */",
|
|
61
|
+
"col -- comment",
|
|
62
|
+
"col = %s",
|
|
63
|
+
"col = %(name)s",
|
|
64
|
+
"col) OR (1=1",
|
|
65
|
+
):
|
|
66
|
+
with self.subTest(poisoned=poisoned):
|
|
67
|
+
with self.assertRaises(ValueError):
|
|
68
|
+
self.helper.resolve_references(
|
|
69
|
+
poisoned, options={"bypass_on_error": True}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# --- legitimate references still resolve ---------------------------------
|
|
73
|
+
|
|
74
|
+
def test_plain_column_resolves(self):
|
|
75
|
+
sql, val = self.helper.make_predicate("email_address", "a@example.com")
|
|
76
|
+
self.assertEqual(sql, "email_address = %s")
|
|
77
|
+
self.assertEqual(val, "a@example.com")
|
|
78
|
+
|
|
79
|
+
def test_like_operator_prefix_not_flagged_as_placeholder(self):
|
|
80
|
+
# '%status' is a LIKE on the `status` column; the leading '%' is an
|
|
81
|
+
# operator prefix, not a '%s' bind placeholder.
|
|
82
|
+
sql, val = self.helper.make_predicate("%status", "active%")
|
|
83
|
+
self.assertIn("status", sql)
|
|
84
|
+
self.assertIn("LIKE", sql.upper())
|
|
85
|
+
self.assertEqual(val, "active%")
|
|
86
|
+
|
|
87
|
+
def test_ilike_double_percent_prefix_ok(self):
|
|
88
|
+
sql, _ = self.helper.make_predicate("%%name", "smith")
|
|
89
|
+
self.assertIn("ILIKE", sql.upper())
|
|
90
|
+
|
|
91
|
+
def test_aggregate_expression_column_ok(self):
|
|
92
|
+
result = self.helper.resolve_references(
|
|
93
|
+
"SUM(amount)",
|
|
94
|
+
options={"alias_column": True, "bypass_on_error": True},
|
|
95
|
+
)
|
|
96
|
+
self.assertIn("amount", result)
|
|
97
|
+
|
|
98
|
+
def test_balanced_function_expression_ok(self):
|
|
99
|
+
result = self.helper.resolve_references(
|
|
100
|
+
"COALESCE(amount, 0)",
|
|
101
|
+
options={"alias_column": True, "bypass_on_error": True},
|
|
102
|
+
)
|
|
103
|
+
self.assertIn("amount", result)
|
|
104
|
+
|
|
105
|
+
def test_in_list_predicate_ok(self):
|
|
106
|
+
sql, val = self.helper.make_predicate("sys_id", [1, 2, 3])
|
|
107
|
+
self.assertIn("IN", sql.upper())
|
|
108
|
+
self.assertEqual(tuple(val), (1, 2, 3))
|
|
109
|
+
|
|
110
|
+
def test_not_equal_operator_prefix_ok(self):
|
|
111
|
+
sql, _ = self.helper.make_predicate("!status", "deleted")
|
|
112
|
+
self.assertIn("status", sql)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
unittest.main()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""D6 — hook-bypassing generic actions must refuse restricted tables.
|
|
2
|
+
|
|
3
|
+
query-direct / update-rows / get-table-schema run no rwx hooks, so a table
|
|
4
|
+
that relies on per-row scoping or secret redaction in its hooks must not be
|
|
5
|
+
reachable through them. Apps declare such tables in restricted_direct_tables.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest.mock import MagicMock
|
|
10
|
+
|
|
11
|
+
from velocity.aws.handlers.exceptions import AlertError
|
|
12
|
+
from velocity.aws.handlers.mixins.data_service import DataServiceMixin
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Service(DataServiceMixin):
|
|
16
|
+
restricted_direct_tables = frozenset({"mail_accounts"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ctx(payload):
|
|
20
|
+
ctx = MagicMock()
|
|
21
|
+
ctx.payload.return_value = payload
|
|
22
|
+
return ctx
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RestrictedDirectTablesTest(unittest.TestCase):
|
|
26
|
+
def setUp(self):
|
|
27
|
+
self.service = _Service()
|
|
28
|
+
self.tx = MagicMock()
|
|
29
|
+
|
|
30
|
+
def test_query_direct_blocks_restricted(self):
|
|
31
|
+
ctx = _ctx({"obj": "mail_accounts", "params": {}})
|
|
32
|
+
with self.assertRaises(AlertError):
|
|
33
|
+
self.service.OnActionQueryDirect(self.tx, ctx)
|
|
34
|
+
|
|
35
|
+
def test_update_rows_blocks_restricted(self):
|
|
36
|
+
ctx = _ctx(
|
|
37
|
+
{"table": "mail_accounts", "updateData": {"x": 1}, "updateRows": [1, 2]}
|
|
38
|
+
)
|
|
39
|
+
with self.assertRaises(AlertError):
|
|
40
|
+
self.service.OnActionUpdateRows(self.tx, ctx)
|
|
41
|
+
|
|
42
|
+
def test_get_table_schema_blocks_restricted(self):
|
|
43
|
+
ctx = _ctx({"tableName": "mail_accounts"})
|
|
44
|
+
with self.assertRaises(AlertError):
|
|
45
|
+
self.service.OnActionGetTableSchema(self.tx, ctx)
|
|
46
|
+
|
|
47
|
+
def test_unrestricted_table_not_blocked_by_guard(self):
|
|
48
|
+
# A non-restricted table passes the guard (it then proceeds to touch the
|
|
49
|
+
# mocked tx, which is fine for this assertion — no AlertError from the
|
|
50
|
+
# guard itself).
|
|
51
|
+
ctx = _ctx({"table": "customers", "updateData": {"x": 1}, "updateRows": [1]})
|
|
52
|
+
try:
|
|
53
|
+
self.service.OnActionUpdateRows(self.tx, ctx)
|
|
54
|
+
except AlertError:
|
|
55
|
+
self.fail("guard should not block an unrestricted table")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
unittest.main()
|
|
@@ -93,17 +93,18 @@ class TestAggregateQuoting:
|
|
|
93
93
|
assert 'min("amount")' in sql
|
|
94
94
|
|
|
95
95
|
def test_sum_injection_prevented(self):
|
|
96
|
+
# The identifier-level injection guard (V1) rejects the poisoned column
|
|
97
|
+
# outright rather than relying on quoting to defang it.
|
|
96
98
|
table = _make_table()
|
|
97
99
|
malicious = "balance); DROP TABLE users; --"
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
assert "DROP TABLE" not in sql.split('"')[-1] # not outside quotes
|
|
100
|
+
with pytest.raises(ValueError):
|
|
101
|
+
table.sum(malicious, sql_only=True)
|
|
101
102
|
|
|
102
103
|
def test_max_injection_prevented(self):
|
|
103
104
|
table = _make_table()
|
|
104
105
|
malicious = "x); DELETE FROM accounts; --"
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
with pytest.raises(ValueError):
|
|
107
|
+
table.max(malicious, sql_only=True)
|
|
107
108
|
|
|
108
109
|
|
|
109
110
|
# ──────────────────────────────────────────────────────────────────────
|