queryapigate 0.35.0__tar.gz → 0.35.1__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.
Files changed (217) hide show
  1. {queryapigate-0.35.0/queryapigate.egg-info → queryapigate-0.35.1}/PKG-INFO +3 -1
  2. {queryapigate-0.35.0 → queryapigate-0.35.1}/README.md +2 -0
  3. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/__init__.py +1 -1
  4. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/apikeys.py +4 -2
  5. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/auditstream.py +1 -0
  6. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console.py +6 -1
  7. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/logging_setup.py +15 -1
  8. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/runners.py +4 -0
  9. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/sqltools.py +9 -1
  10. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/store.py +4 -1
  11. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/tableguard.py +44 -8
  12. {queryapigate-0.35.0 → queryapigate-0.35.1/queryapigate.egg-info}/PKG-INFO +3 -1
  13. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_api_keys.py +6 -1
  14. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_console.py +7 -0
  15. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_observability.py +16 -0
  16. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_queryapigate.py +10 -0
  17. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_release_check.py +12 -0
  18. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_sql_guard_fuzz.py +19 -0
  19. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_sql_guard_writes.py +15 -0
  20. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_supply_chain.py +41 -0
  21. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_tableguard.py +30 -0
  22. {queryapigate-0.35.0 → queryapigate-0.35.1}/LICENSE +0 -0
  23. {queryapigate-0.35.0 → queryapigate-0.35.1}/pyproject.toml +0 -0
  24. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/__main__.py +0 -0
  25. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/accesslog.py +0 -0
  26. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/adminroles.py +0 -0
  27. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/admins.py +0 -0
  28. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/agentwrites.py +0 -0
  29. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/alerts.py +0 -0
  30. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/app.py +0 -0
  31. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/auditchain.py +0 -0
  32. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/aws.py +0 -0
  33. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/broadcast.py +0 -0
  34. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/bundle.py +0 -0
  35. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/cache.py +0 -0
  36. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/cli.py +0 -0
  37. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/collection_admin.py +0 -0
  38. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/config.py +0 -0
  39. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/configcode.py +0 -0
  40. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/AccessMapPage-BVQ6wRwp.js +0 -0
  41. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/AdministratorsPage-BiUGhISO.js +0 -0
  42. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/AgentsPage-CSA-qCUv.js +0 -0
  43. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/AlertsPage-MYZoziNZ.js +0 -0
  44. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/ApiKeysPage-BZISE5oJ.js +0 -0
  45. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/ApprovalsPage-CUiEkHq3.js +0 -0
  46. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/AuditLogPage-D9vrSHU7.js +0 -0
  47. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/CachingPage-2hjur6KQ.js +0 -0
  48. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/CodeBox-BZY9RBv5.js +0 -0
  49. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/ConnectionsPage-D26k-98S.js +0 -0
  50. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/DesignerPage-B_os91xI.js +0 -0
  51. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/DestinationsPage--bjoNE0y.js +0 -0
  52. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/ExportsPage-5ai6Ojbl.js +0 -0
  53. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/HelpPage-DNaGeh-8.js +0 -0
  54. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/HomePage-DINN_eBD.js +0 -0
  55. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/Managed-CVE0layM.js +0 -0
  56. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/MetricsPage-D6UAGhYP.js +0 -0
  57. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/RepositoryPage-BvsZyKvp.js +0 -0
  58. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/Results-D8fDmPdY.js +0 -0
  59. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/RolesPage-BdEQdLFD.js +0 -0
  60. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/SchemaBrowser-Bu0NvoUy.js +0 -0
  61. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/SettingsPage-BQhT4xlX.js +0 -0
  62. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/StatTile-DEmSFOkX.js +0 -0
  63. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/Time-MA05YtZP.js +0 -0
  64. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/forms-BSqwVHNJ.js +0 -0
  65. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/forms-C6RLe1F3.js +0 -0
  66. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/icons-DQ8_pv9M.js +0 -0
  67. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/index-BLd0t6vZ.css +0 -0
  68. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/index-sn5NUET1.js +0 -0
  69. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/maskRules-BGQ7mS0j.js +0 -0
  70. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/reach-2gDU11-l.js +0 -0
  71. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/shared-D5ownoD-.js +0 -0
  72. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
  73. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/assets/useMutation-BF7AWw3p.js +0 -0
  74. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/favicon.svg +0 -0
  75. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/console_dist/index.html +0 -0
  76. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/cors.py +0 -0
  77. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/databases.py +0 -0
  78. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/db.py +0 -0
  79. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/definitions.py +0 -0
  80. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/deprecations.py +0 -0
  81. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/descriptions.py +0 -0
  82. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/destinations.py +0 -0
  83. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/duckfiles.py +0 -0
  84. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/engine.py +0 -0
  85. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/errors.py +0 -0
  86. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/events.py +0 -0
  87. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/examples.py +0 -0
  88. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/experimental.py +0 -0
  89. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/exporting.py +0 -0
  90. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/exports.py +0 -0
  91. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/formats.py +0 -0
  92. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/governance.py +0 -0
  93. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/gunicorn_conf.py +0 -0
  94. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/history.py +0 -0
  95. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/instances.py +0 -0
  96. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/jwtauth.py +0 -0
  97. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/lib/h2-2.2.224.jar +0 -0
  98. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/limits.py +0 -0
  99. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/masking.py +0 -0
  100. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/mcp_server.py +0 -0
  101. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/metrics.py +0 -0
  102. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/mongotools.py +0 -0
  103. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/oidc.py +0 -0
  104. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/openapi.py +0 -0
  105. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/params.py +0 -0
  106. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/parquet.py +0 -0
  107. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/pool.py +0 -0
  108. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/postman.py +0 -0
  109. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/quotas.py +0 -0
  110. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/ratelimit.py +0 -0
  111. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/rediscache.py +0 -0
  112. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/rekey.py +0 -0
  113. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/schema.py +0 -0
  114. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/secheaders.py +0 -0
  115. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/secretrefs.py +0 -0
  116. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/__init__.py +0 -0
  117. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/access.py +0 -0
  118. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/administrators.py +0 -0
  119. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/audit.py +0 -0
  120. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/collections.py +0 -0
  121. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/connections.py +0 -0
  122. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/descriptions.py +0 -0
  123. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/destinations.py +0 -0
  124. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/exports.py +0 -0
  125. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/mcp.py +0 -0
  126. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/services/queries.py +0 -0
  127. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/sqlflow.py +0 -0
  128. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/sso.py +0 -0
  129. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/tracing.py +0 -0
  130. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/usage.py +0 -0
  131. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/v1.py +0 -0
  132. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/v1_spec.py +0 -0
  133. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate/vault.py +0 -0
  134. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate.egg-info/SOURCES.txt +0 -0
  135. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate.egg-info/dependency_links.txt +0 -0
  136. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate.egg-info/entry_points.txt +0 -0
  137. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate.egg-info/requires.txt +0 -0
  138. {queryapigate-0.35.0 → queryapigate-0.35.1}/queryapigate.egg-info/top_level.txt +0 -0
  139. {queryapigate-0.35.0 → queryapigate-0.35.1}/setup.cfg +0 -0
  140. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_adhoc_history.py +0 -0
  141. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_adminroles.py +0 -0
  142. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_admins.py +0 -0
  143. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_agent_writes.py +0 -0
  144. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_alerts.py +0 -0
  145. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_api_administrators.py +0 -0
  146. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_api_v1.py +0 -0
  147. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_audit_chain.py +0 -0
  148. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_audit_log.py +0 -0
  149. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_audit_siem.py +0 -0
  150. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_audit_stream.py +0 -0
  151. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_aws.py +0 -0
  152. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_backup_restore.py +0 -0
  153. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_broadcast.py +0 -0
  154. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_cli.py +0 -0
  155. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_collection_bundle.py +0 -0
  156. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_collection_postman.py +0 -0
  157. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_collections.py +0 -0
  158. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_configcode.py +0 -0
  159. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_connection_encryption.py +0 -0
  160. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_cors_and_rate_limit.py +0 -0
  161. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_data_protection.py +0 -0
  162. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_databases.py +0 -0
  163. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_db.py +0 -0
  164. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_dependency_floors.py +0 -0
  165. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_deprecations.py +0 -0
  166. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_descriptions.py +0 -0
  167. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_destinations.py +0 -0
  168. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_docs_site.py +0 -0
  169. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_duckdb.py +0 -0
  170. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_duckdb_files.py +0 -0
  171. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_errors.py +0 -0
  172. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_events.py +0 -0
  173. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_events_server.py +0 -0
  174. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_examples.py +0 -0
  175. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_experimental.py +0 -0
  176. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_export_security.py +0 -0
  177. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_exporting.py +0 -0
  178. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_exports.py +0 -0
  179. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_fips.py +0 -0
  180. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_helm.py +0 -0
  181. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_history.py +0 -0
  182. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_howto.py +0 -0
  183. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_instances.py +0 -0
  184. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_integration.py +0 -0
  185. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_jdbc.py +0 -0
  186. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_jwt.py +0 -0
  187. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_key_rotation.py +0 -0
  188. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_limits.py +0 -0
  189. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_masking.py +0 -0
  190. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_masking_requests.py +0 -0
  191. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_mcp.py +0 -0
  192. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_mcp_keycloak.py +0 -0
  193. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_mcp_oauth.py +0 -0
  194. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_mongotools.py +0 -0
  195. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_multinode.py +0 -0
  196. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_oidc.py +0 -0
  197. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_params.py +0 -0
  198. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_parquet.py +0 -0
  199. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_pool.py +0 -0
  200. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_publishing.py +0 -0
  201. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_query_window.py +0 -0
  202. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_quotas.py +0 -0
  203. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_rekey.py +0 -0
  204. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_response_cache.py +0 -0
  205. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_roles.py +0 -0
  206. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_rolling_upgrade.py +0 -0
  207. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_secretrefs.py +0 -0
  208. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_security_headers.py +0 -0
  209. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_settings.py +0 -0
  210. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_shared_rate_limit.py +0 -0
  211. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_sqlflow.py +0 -0
  212. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_sso.py +0 -0
  213. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_sso_keycloak.py +0 -0
  214. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_tracing.py +0 -0
  215. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_upgrades.py +0 -0
  216. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_usage.py +0 -0
  217. {queryapigate-0.35.0 → queryapigate-0.35.1}/tests/test_vault.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queryapigate
3
- Version: 0.35.0
3
+ Version: 0.35.1
4
4
  Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
5
5
  Author-email: Anantha Raju C <arcswdev@gmail.com>
6
6
  License-Expression: FSL-1.1-MIT
@@ -84,6 +84,8 @@ Dynamic: license-file
84
84
  [![PyPI downloads](https://img.shields.io/pypi/dm/queryapigate.svg)](https://pypi.org/project/queryapigate/)
85
85
  [![License: FSL-1.1-MIT](https://img.shields.io/badge/license-FSL--1.1--MIT-blue.svg)](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/LICENSE)
86
86
  ![Python](https://img.shields.io/badge/python-3.11%2B-blue)
87
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/AnanthaRajuC/QueryAPIGate/badge)](https://scorecard.dev/viewer/?uri=github.com/AnanthaRajuC/QueryAPIGate)
88
+ [![SLSA 2](https://slsa.dev/images/gh-badge-level2.svg)](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/SECURITY.md#slsa-level)
87
89
 
88
90
  ### Turn SQL queries into secure, governed REST APIs.
89
91
 
@@ -6,6 +6,8 @@
6
6
  [![PyPI downloads](https://img.shields.io/pypi/dm/queryapigate.svg)](https://pypi.org/project/queryapigate/)
7
7
  [![License: FSL-1.1-MIT](https://img.shields.io/badge/license-FSL--1.1--MIT-blue.svg)](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/LICENSE)
8
8
  ![Python](https://img.shields.io/badge/python-3.11%2B-blue)
9
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/AnanthaRajuC/QueryAPIGate/badge)](https://scorecard.dev/viewer/?uri=github.com/AnanthaRajuC/QueryAPIGate)
10
+ [![SLSA 2](https://slsa.dev/images/gh-badge-level2.svg)](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/SECURITY.md#slsa-level)
9
11
 
10
12
  ### Turn SQL queries into secure, governed REST APIs.
11
13
 
@@ -1,5 +1,5 @@
1
1
  """QueryAPIGate - expose SQL databases as a REST API."""
2
- __version__ = "0.35.0"
2
+ __version__ = "0.35.1"
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -450,8 +450,10 @@ def is_expired(entry):
450
450
  return False
451
451
  try:
452
452
  deadline = datetime.strptime(expires_at, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
453
- except ValueError:
454
- return False # a malformed stored value should not itself lock the key out
453
+ except (TypeError, ValueError):
454
+ # Every way in validates the date (_validate_expiry()), so this is a store edited by hand or damaged: an expiry
455
+ # nobody can read is treated as passed - fail closed - not as none (review, 2026-10-15)
456
+ return True
455
457
  return datetime.now() > deadline
456
458
 
457
459
 
@@ -191,6 +191,7 @@ class _Syslog:
191
191
  if parts.scheme != 'tls':
192
192
  return raw
193
193
  context = ssl.create_default_context(cafile=os.environ.get('QUERYAPIGATE_AUDIT_SYSLOG_CA', '').strip() or None)
194
+ context.minimum_version = ssl.TLSVersion.TLSv1_2 # the default; said, so nothing lowers it
194
195
  cert = os.environ.get('QUERYAPIGATE_AUDIT_SYSLOG_CERT', '').strip()
195
196
  if cert:
196
197
  context.load_cert_chain(cert, os.environ.get('QUERYAPIGATE_AUDIT_SYSLOG_KEY', '').strip())
@@ -8,9 +8,11 @@ built it gets a short "not built" page instead of an error - backend work never
8
8
  The page itself is public and holds no data: everything it shows comes from the JSON API, called with the API key the
9
9
  user enters (kept in sessionStorage under the same name /docs uses).
10
10
  """
11
+ import os
11
12
  from pathlib import Path
12
13
 
13
14
  from flask import Response, send_from_directory
15
+ from werkzeug.security import safe_join
14
16
 
15
17
  DIST = Path(__file__).parent / 'console_dist'
16
18
 
@@ -59,7 +61,10 @@ def serve(path=''):
59
61
  asset reference fails visibly instead of loading HTML as a script."""
60
62
  if not built():
61
63
  return _secure(Response(NOT_BUILT_HTML, mimetype='text/html'))
62
- if path and (DIST / path).is_file():
64
+ # Only what's inside the build is looked at: checking the raw path told a caller whether a file existed anywhere
65
+ # on the server - 404 for one that did, the index for one that didn't (CodeQL py/path-injection).
66
+ inside = safe_join(str(DIST), path) if path else None
67
+ if inside is not None and os.path.isfile(inside):
63
68
  response = send_from_directory(DIST, path, max_age=0)
64
69
  if path.startswith('assets/'): # content-hashed file names: safe to cache forever
65
70
  response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
@@ -39,6 +39,20 @@ class _RequestContextFilter(logging.Filter):
39
39
  return True
40
40
 
41
41
 
42
+ class _PlainFormatter(logging.Formatter):
43
+ """One event, one line: a line break in the message - a caller's SQL, a request path with %0A in it - is written
44
+ as \\n, so it can't start what reads as a log line of its own (CodeQL py/log-injection). A traceback keeps its
45
+ lines: it comes after the message, from the server, not a caller."""
46
+
47
+ def formatMessage(self, record):
48
+ message = record.message
49
+ record.message = message.replace('\\', '\\\\').replace('\r', '\\r').replace('\n', '\\n')
50
+ try:
51
+ return super().formatMessage(record)
52
+ finally:
53
+ record.message = message
54
+
55
+
42
56
  class _JsonFormatter(logging.Formatter):
43
57
  def format(self, record):
44
58
  payload = {
@@ -72,7 +86,7 @@ def configure(logger):
72
86
  handler = logging.StreamHandler()
73
87
  handler._queryapigate_managed = True
74
88
  handler.addFilter(_RequestContextFilter())
75
- handler.setFormatter(_JsonFormatter() if config.json_logs() else logging.Formatter(_PLAIN_FORMAT))
89
+ handler.setFormatter(_JsonFormatter() if config.json_logs() else _PlainFormatter(_PLAIN_FORMAT))
76
90
  logger.addHandler(handler)
77
91
  logger.setLevel(logging.INFO)
78
92
  logger.propagate = False
@@ -546,6 +546,10 @@ class _H2(_Driver):
546
546
 
547
547
  def connect(self, details, read_only):
548
548
  import jaydebeapi
549
+ if not os.path.isfile(config.h2_jar()): # a source checkout: the driver is fetched, not committed
550
+ raise ApiError(f"H2's driver isn't installed ({config.h2_jar()}): in a source checkout run "
551
+ 'python .github/scripts/fetch_h2_jar.py, or set QUERYAPIGATE_H2_JAR', 500,
552
+ code='driver_missing')
549
553
  _attach_thread_as_daemon()
550
554
  host = details.get('host') or 'localhost'
551
555
  if details.get('port') and ':' not in host:
@@ -96,6 +96,10 @@ def write_operations(sql, dialect=None):
96
96
  if keyword == 'explain':
97
97
  inner = _EXPLAIN_RE.sub('', masked, count=1)
98
98
  inner_keyword = first_keyword(inner, dialect)
99
+ if inner_keyword == 'explain':
100
+ # No database runs EXPLAIN EXPLAIN: refused as a write (fails closed) rather than looked into again - each
101
+ # level was a recursion re-scanning the whole text, so thousands of them raised RecursionError (fuzzing).
102
+ return {'explain'}
99
103
  if inner_keyword in READ_ONLY_STATEMENTS:
100
104
  return write_operations(inner, dialect)
101
105
  return {inner_keyword} if inner_keyword in _WRITE_KEYWORDS else set()
@@ -144,7 +148,11 @@ def validate_sql(sql, dialect=None, allow_writes=None, allowed_write_ops=None, a
144
148
  """
145
149
  if not isinstance(sql, str) or not sql.strip():
146
150
  raise ApiError('SQL query is missing', code='sql_required')
147
- sql = re.sub(r'[\s;]+$', '', sql.strip())
151
+ sql = sql.strip()
152
+ end = len(sql) # trailing whitespace and semicolons, from the end: `[\s;]+$` was quadratic in a long inner run
153
+ while end and (sql[end - 1] == ';' or sql[end - 1].isspace()):
154
+ end -= 1
155
+ sql = sql[:end]
148
156
  if ';' in _literals_re(dialect).sub(' ', sql):
149
157
  raise ApiError('Only a single SQL statement can be executed at a time', code='multiple_statements')
150
158
  # Every write the statement performs - including one hidden behind a read-only first keyword (a writable CTE,
@@ -8,6 +8,7 @@ filesystem path.
8
8
  """
9
9
  import contextlib
10
10
  import json
11
+ import logging
11
12
  import os
12
13
  import re
13
14
  import threading
@@ -119,7 +120,9 @@ def record_audit(actor, action, target, changes=None, via=None):
119
120
  # several instances still make one chain
120
121
  link = auditchain.append(conn, entry, config.audit_log_limit())
121
122
  except (OSError, *db.Error):
122
- pass
123
+ # The action stands, but it has no entry: said loudly, never silently (review, 2026-10-15)
124
+ logging.getLogger('queryapigate').error('Audit log: %s on %s by %s happened, but its entry could not be '
125
+ 'written', action, target, actor, exc_info=True)
123
126
 
124
127
  def deliver():
125
128
  from . import auditstream
@@ -3,12 +3,13 @@ dialects sqlglot can parse (see ``_DIALECT_MAP``). Unlike sqlflow.py's best-effo
3
3
  extraction for the Access tab's visualization, this one is a security check: a query whose tables can't be
4
4
  verified - an unsupported dialect, a parse failure - must be rejected, not silently let through.
5
5
 
6
- Extraction is ``parsed.find_all(exp.Table)`` minus any CTE names defined in the same query, not
7
- ``sqlglot.optimizer.scope.build_scope()`` - the latter correctly excludes CTE names for SELECT/WITH but
8
- returns None entirely for DELETE/UPDATE/INSERT (verified directly against a real sqlglot install), which
9
- would silently miss a write statement's own target table. The simpler find_all()-minus-CTE-names approach
10
- was verified to give correct results uniformly across CTEs, subqueries, UNION, self-joins, and bare or
11
- CTE'd DELETE/UPDATE/INSERT - one code path for every statement type this guard needs to check, not two.
6
+ Extraction walks the parsed statement keeping track of which CTEs are visible where (``_real_tables``), not
7
+ ``sqlglot.optimizer.scope.build_scope()`` - that returns None entirely for DELETE/UPDATE/INSERT (verified against a
8
+ real sqlglot install), which would silently miss a write statement's own target table. A bare name refers to a CTE
9
+ only where SQL says it does: in the statement after its WITH, in the CTEs defined after it - and in its own body
10
+ only under WITH RECURSIVE. Anywhere else, and always when qualified (``public.secret``), it's the table: dropping
11
+ every table that merely shared a CTE's name let `WITH secret AS (SELECT * FROM secret) SELECT * FROM secret` past a
12
+ key allowed only `orders` (fixed 2026-10-15).
12
13
 
13
14
  Known, accepted limitations: a table name is extracted bare (``information_schema.tables`` -> ``'tables'``),
14
15
  not schema-qualified - matches allowed_write_ops's own bare-keyword-list style, but can't disambiguate two
@@ -48,8 +49,43 @@ def extract_tables(sql, dialect):
48
49
  name = function.sql_name().lower() if not isinstance(function, exp.Anonymous) else function.name.lower()
49
50
  raise ApiError(f'This API key may only query its allowed tables - a table function such as {name}() '
50
51
  "reads data the allow-list can't see, so it isn't allowed", 403, code='table_not_allowed')
51
- cte_names = {cte.alias_or_name for cte in parsed.find_all(exp.CTE)}
52
- return {t.name.lower() for t in parsed.find_all(exp.Table) if t.name and t.name not in cte_names}
52
+ try:
53
+ return _real_tables(parsed, frozenset())
54
+ except RecursionError: # nested deeper than can be walked: refused, never let through
55
+ raise ApiError('This query could not be analyzed to enforce its table access restrictions', 403,
56
+ code='table_check_failed') from None
57
+
58
+
59
+ def _real_tables(node, visible):
60
+ """The tables under ``node`` that aren't references to a CTE in ``visible`` (lower-case names) - see the
61
+ module docstring for the scoping rule."""
62
+ from sqlglot import exp
63
+ if isinstance(node, exp.Table):
64
+ name = node.name.lower()
65
+ if not name or (name in visible and not node.args.get('db') and not node.args.get('catalog')):
66
+ return set()
67
+ return {name} | _children(node, visible)
68
+ found = set()
69
+ with_ = node.args.get('with') if isinstance(node, exp.Expression) else None
70
+ if isinstance(with_, exp.With):
71
+ ctes = [(cte.alias_or_name.lower(), cte.this) for cte in with_.expressions]
72
+ if with_.args.get('recursive'):
73
+ visible = visible | {name for name, _ in ctes}
74
+ for _, body in ctes:
75
+ found |= _real_tables(body, visible)
76
+ else:
77
+ for name, body in ctes: # each body sees the CTEs before it, not itself or those after
78
+ found |= _real_tables(body, visible)
79
+ visible = visible | {name}
80
+ return found | _children(node, visible, skip=with_)
81
+
82
+
83
+ def _children(node, visible, skip=None):
84
+ found = set()
85
+ for child in node.iter_expressions():
86
+ if child is not skip:
87
+ found |= _real_tables(child, visible)
88
+ return found
53
89
 
54
90
 
55
91
  # Table functions that only generate values - no data to restrict - and so stay usable with allowed_tables.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queryapigate
3
- Version: 0.35.0
3
+ Version: 0.35.1
4
4
  Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
5
5
  Author-email: Anantha Raju C <arcswdev@gmail.com>
6
6
  License-Expression: FSL-1.1-MIT
@@ -84,6 +84,8 @@ Dynamic: license-file
84
84
  [![PyPI downloads](https://img.shields.io/pypi/dm/queryapigate.svg)](https://pypi.org/project/queryapigate/)
85
85
  [![License: FSL-1.1-MIT](https://img.shields.io/badge/license-FSL--1.1--MIT-blue.svg)](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/LICENSE)
86
86
  ![Python](https://img.shields.io/badge/python-3.11%2B-blue)
87
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/AnanthaRajuC/QueryAPIGate/badge)](https://scorecard.dev/viewer/?uri=github.com/AnanthaRajuC/QueryAPIGate)
88
+ [![SLSA 2](https://slsa.dev/images/gh-badge-level2.svg)](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/SECURITY.md#slsa-level)
87
89
 
88
90
  ### Turn SQL queries into secure, governed REST APIs.
89
91
 
@@ -949,6 +949,11 @@ class ApiKeysModuleTests(unittest.TestCase):
949
949
  rate_limit=None, allowed_write_ops=None)
950
950
  self.assertTrue(apikeys.can_write_query(admin, 'anything'))
951
951
 
952
+ def test_an_expiry_nobody_can_read_counts_as_passed(self):
953
+ for bad in ('not-a-date', '2026-13-40', 20261015):
954
+ with self.subTest(bad=bad):
955
+ self.assertTrue(apikeys.is_expired({'expires_at': bad}))
956
+
952
957
  def test_is_expired(self):
953
958
  yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
954
959
  tomorrow = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
@@ -1042,4 +1047,4 @@ class CatalogTests(AppTestCase):
1042
1047
  def test_requires_authentication_like_any_other_functional_endpoint(self):
1043
1048
  self.assertEqual(self.client.get('/catalog').status_code, 401)
1044
1049
  self.assertEqual(self.client.get('/catalog', headers={'X-API-Key': 'wrong'}).status_code, 401)
1045
- self.assertFalse(apikeys.is_expired({'expires_at': 'garbage'})) # malformed shouldn't lock the key out
1050
+ self.assertTrue(apikeys.is_expired({'expires_at': 'garbage'})) # unreadable: fails closed (2026-10-15)
@@ -84,6 +84,13 @@ class BuiltTests(ConsoleTestCase):
84
84
  res = self.get(path)
85
85
  self.assertNotIn('do not serve', res.get_data(as_text=True), path)
86
86
 
87
+ def test_whether_a_file_outside_exists_isnt_told_either(self):
88
+ # CodeQL py/path-injection: the check before send_from_directory() looked outside the build, so a file that
89
+ # existed there answered 404 and one that didn't the Console's index - which file is on the server, told.
90
+ (Path(self.tmp.name) / 'LICENSE').write_text('outside')
91
+ there, missing = self.get('/console/%2e%2e/LICENSE'), self.get('/console/%2e%2e/NO_SUCH_FILE')
92
+ self.assertEqual((there.status_code, there.get_data()), (missing.status_code, missing.get_data()))
93
+
87
94
  def test_every_response_carries_a_strict_content_security_policy(self):
88
95
  for path in ('/console/', '/console/queries', '/console/assets/index-abc123.js',
89
96
  '/console/assets/missing-999.js'):
@@ -153,6 +153,22 @@ class LoggingFormatTests(unittest.TestCase):
153
153
  _, handler = self._handler_for('queryapigate_test_plain', json_logs=False)
154
154
  self.assertNotIsInstance(handler.formatter, logging_setup._JsonFormatter)
155
155
 
156
+ def test_a_line_break_in_a_message_cant_forge_a_line_of_its_own(self):
157
+ # CodeQL py/log-injection: a caller's SQL (or a request path with %0A) is in the message; in plain text a line
158
+ # break there could start what reads as another event. A traceback keeps its own lines.
159
+ logger, handler = self._handler_for('queryapigate_test_inject', json_logs=False)
160
+ buf = io.StringIO()
161
+ handler.stream = buf
162
+ logger.warning('Executing: %s', "SELECT 1\n2026-10-15 07:00:00 INFO queryapigate [- key=admin]: forged\\n")
163
+ try:
164
+ raise ValueError('boom')
165
+ except ValueError:
166
+ logger.exception('failed')
167
+ lines = buf.getvalue().splitlines()
168
+ self.assertTrue(lines[0].endswith(r"SELECT 1\n2026-10-15 07:00:00 INFO queryapigate [- key=admin]: forged\\n"))
169
+ self.assertFalse(any(line.startswith('2026-10-15 07:00:00 INFO') for line in lines))
170
+ self.assertIn('Traceback (most recent call last):', lines) # the server's own lines, as they were
171
+
156
172
  def test_json_when_enabled(self):
157
173
  logger, handler = self._handler_for('queryapigate_test_json', json_logs=True)
158
174
  self.assertIsInstance(handler.formatter, logging_setup._JsonFormatter)
@@ -794,6 +794,7 @@ class DriverWiringTests(unittest.TestCase):
794
794
 
795
795
  def test_h2_sets_query_timeout_and_maps_cancellation(self):
796
796
  import jaydebeapi
797
+ self.enterContext(mock.patch.object(config, 'h2_jar', return_value=__file__)) # any file: the JVM is mocked
797
798
  cursor = mock.MagicMock()
798
799
  cursor.description = [('C',)]
799
800
  cursor.execute.side_effect = [None, jaydebeapi.DatabaseError('Statement was canceled or the session timed out')]
@@ -1038,7 +1039,16 @@ class PooledDriverTests(unittest.TestCase):
1038
1039
  with mock.patch('jpype.isJVMStarted', return_value=False): # nothing to attach to before the JVM starts
1039
1040
  runners._attach_thread_as_daemon()
1040
1041
 
1042
+ def test_h2_without_its_driver_says_how_to_get_it(self):
1043
+ with mock.patch.object(config, 'h2_jar', return_value='/nowhere/h2.jar'), \
1044
+ mock.patch('jaydebeapi.connect') as connect, self.assertRaises(ApiError) as caught:
1045
+ runners._H2().connect({'host': 'h', 'database': 'd'}, True)
1046
+ self.assertEqual((caught.exception.status, caught.exception.code), (500, 'driver_missing'))
1047
+ self.assertIn('fetch_h2_jar.py', caught.exception.message)
1048
+ self.assertFalse(connect.called)
1049
+
1041
1050
  def test_h2_dead_connection_is_replaced(self):
1051
+ self.enterContext(mock.patch.object(config, 'h2_jar', return_value=__file__)) # any file: the JVM is mocked
1042
1052
  first, second = mock.MagicMock(), mock.MagicMock()
1043
1053
  for conn in (first, second):
1044
1054
  conn.cursor.return_value.description = [('C',)]
@@ -64,6 +64,18 @@ class ReleaseCheckTests(unittest.TestCase):
64
64
  (self.root / 'CHANGELOG.md').unlink()
65
65
  self.assertEqual(len(check_release.problems('v0.2.0', self.root)), 2)
66
66
 
67
+ def test_the_release_notes_are_the_versions_changelog_section(self):
68
+ (self.root / 'CHANGELOG.md').write_text(
69
+ '## [Unreleased]\n\n## [0.3.0] - 2026-10-14\n\nNewer.\n\n## [0.2.0] - 2026-09-21\n\nThe summary.\n\n'
70
+ '### Fixed\n- a thing\n\n[Unreleased]: https://example.com/compare\n')
71
+ self.assertEqual(check_release.notes('v0.2.0', self.root), 'The summary.\n\n### Fixed\n- a thing\n')
72
+ self.assertEqual(check_release.notes('v0.3.0', self.root), 'Newer.\n')
73
+ with self.assertRaises(SystemExit):
74
+ check_release.notes('v9.9.9', self.root)
75
+ out = subprocess.run(['python3', str(SCRIPT), '--notes', 'v0.3.0'], cwd=self.root, capture_output=True,
76
+ text=True)
77
+ self.assertEqual((out.returncode, out.stdout), (0, 'Newer.\n'))
78
+
67
79
  def test_command_line_exit_codes(self):
68
80
  run = lambda tag: subprocess.run(['python3', str(SCRIPT), tag], cwd=self.root, # noqa: E731
69
81
  capture_output=True, text=True)
@@ -385,3 +385,22 @@ class PaginateFuzzTests(unittest.TestCase):
385
385
 
386
386
  if __name__ == '__main__':
387
387
  unittest.main()
388
+
389
+
390
+ class LinearTimeTests(unittest.TestCase):
391
+ """CodeQL (py/polynomial-redos, 2026-10-15): validate_sql stripped trailing whitespace and semicolons with
392
+ `[\\s;]+$`, which a regex engine retries from every position of a long run that isn't at the end - 40,000 spaces
393
+ took three seconds, a few hundred thousand a minute, in any caller's ad-hoc SQL."""
394
+
395
+ def test_a_long_run_of_spaces_is_linear(self):
396
+ import time
397
+ started = time.monotonic()
398
+ sqltools.validate_sql('SELECT 1' + ' ' * 200_000 + 'AS x', 'postgres', allow_writes=False)
399
+ self.assertLess(time.monotonic() - started, 0.5)
400
+
401
+ def test_trailing_whitespace_and_semicolons_still_go(self):
402
+ for sql, expected in (('SELECT 1;', 'SELECT 1'), ('SELECT 1 ; ;\n\t ', 'SELECT 1'),
403
+ ('SELECT 1 ;', 'SELECT 1'), (' SELECT 1', 'SELECT 1'),
404
+ ("SELECT ';'", "SELECT ';'")):
405
+ with self.subTest(sql=sql):
406
+ self.assertEqual(sqltools.validate_sql(sql, 'postgres', allow_writes=False), expected)
@@ -62,6 +62,21 @@ class RuleTests(unittest.TestCase):
62
62
  with self.subTest(sql=sql):
63
63
  self.assertEqual(sqltools.write_operations(sql, dialect), set())
64
64
 
65
+ def test_explain_inside_explain_is_refused_not_recursed_into(self):
66
+ # Found by fuzzing (fuzz/fuzz_sql_guard.py): each EXPLAIN was one more level of recursion, re-scanning the
67
+ # whole text - a few thousand in one statement raised RecursionError (a 500) after quadratic work. No database
68
+ # runs EXPLAIN EXPLAIN, so it's refused as a write, failing closed, and nothing deeper is looked at.
69
+ import time
70
+ started = time.monotonic()
71
+ for sql in ('EXPLAIN EXPLAIN SELECT 1', 'explain ' * 5000 + 'SELECT 1', 'EXPLAIN ANALYZE EXPLAIN SELECT 1'):
72
+ with self.subTest(sql=sql[:40]):
73
+ self.assertEqual(sqltools.write_operations(sql, 'postgres'), {'explain'})
74
+ with self.assertRaises(Exception) as caught:
75
+ sqltools.validate_sql(sql, 'postgres', allow_writes=False)
76
+ self.assertEqual(caught.exception.code, 'read_only')
77
+ self.assertLess(time.monotonic() - started, 2)
78
+ self.assertEqual(sqltools.write_operations('EXPLAIN SELECT 1', 'postgres'), set()) # one level still reads
79
+
65
80
  def test_allowed_write_ops_cover_hidden_writes_too(self):
66
81
  sql = 'WITH d AS (DELETE FROM t RETURNING *) SELECT * FROM d'
67
82
  with self.assertRaises(Exception) as caught:
@@ -102,3 +102,44 @@ class SignedTests(unittest.TestCase):
102
102
 
103
103
  if __name__ == '__main__':
104
104
  unittest.main()
105
+
106
+
107
+ class H2DriverTests(unittest.TestCase):
108
+ """The H2 driver the package bundles isn't committed (OpenSSF Scorecard: Binary-Artifacts): the fetch script and
109
+ the Dockerfile each download it from Maven Central, checked against a pinned SHA-256 - the same one."""
110
+
111
+ def setUp(self):
112
+ import importlib.util
113
+ spec = importlib.util.spec_from_file_location(
114
+ 'fetch_h2_jar', os.path.join(ROOT, '.github', 'scripts', 'fetch_h2_jar.py'))
115
+ self.fetch = importlib.util.module_from_spec(spec)
116
+ spec.loader.exec_module(self.fetch)
117
+
118
+ def test_the_dockerfile_fetches_the_same_file_with_the_same_hash(self):
119
+ with open(os.path.join(ROOT, 'Dockerfile')) as f:
120
+ dockerfile = f.read()
121
+ added = re.search(r'ADD --chmod=644 --checksum=sha256:([0-9a-f]{64}) \\\n\s+(\S+) (\S+)', dockerfile)
122
+ self.assertIsNotNone(added, 'the Dockerfile fetches the H2 driver with ADD --checksum')
123
+ self.assertEqual(added.groups(), (self.fetch.SHA256, self.fetch.URL,
124
+ f'./queryapigate/lib/h2-{self.fetch.VERSION}.jar'))
125
+
126
+ def test_the_server_looks_for_the_version_fetched(self):
127
+ from queryapigate import config
128
+ self.assertEqual(config.BUNDLED_H2_JAR.resolve(), self.fetch.TARGET)
129
+
130
+ def test_a_download_with_another_hash_is_never_written(self):
131
+ import io
132
+ import tempfile
133
+ from contextlib import redirect_stderr
134
+ from pathlib import Path
135
+ from unittest import mock
136
+ with tempfile.TemporaryDirectory() as tmp:
137
+ target = Path(tmp) / 'h2.jar'
138
+ response = mock.MagicMock()
139
+ response.__enter__.return_value.read.return_value = b'not the driver'
140
+ with mock.patch.object(self.fetch, 'TARGET', target), \
141
+ mock.patch.object(self.fetch.urllib.request, 'urlopen', return_value=response), \
142
+ redirect_stderr(io.StringIO()) as err:
143
+ self.assertEqual(self.fetch.main(), 1)
144
+ self.assertFalse(target.exists())
145
+ self.assertIn('not written', err.getvalue())
@@ -26,6 +26,36 @@ class ExtractTablesTests(unittest.TestCase):
26
26
  'WHERE r.id IN (SELECT order_id FROM refunds)')
27
27
  self.assertEqual(tableguard.extract_tables(sql, 'postgres'), {'orders', 'customers', 'refunds'})
28
28
 
29
+ def test_a_cte_named_after_the_table_it_reads_still_counts_that_table(self):
30
+ # Found by review, 2026-10-15: every table sharing a name with a CTE was dropped, the one the CTE itself reads
31
+ # included - so `WITH secret AS (SELECT * FROM secret) SELECT * FROM secret` named no table at all, and a key
32
+ # allowed only `orders` read `secret`. Inside its own definition a (non-recursive) CTE's name is the table.
33
+ for sql, tables in (
34
+ ('WITH secret AS (SELECT * FROM secret) SELECT * FROM secret', {'secret'}),
35
+ ('WITH Secret AS (SELECT * FROM secret) SELECT * FROM SECRET', {'secret'}),
36
+ ('WITH a AS (SELECT * FROM b), b AS (SELECT 1 AS n) SELECT * FROM a, b', {'b'}), # b: not yet
37
+ ('WITH b AS (SELECT * FROM orders), a AS (SELECT * FROM b) SELECT * FROM a', {'orders'}),
38
+ ('WITH secret AS (SELECT 1 AS n) SELECT * FROM public.secret', {'secret'}), # qualified: the table
39
+ ('SELECT * FROM (WITH secret AS (SELECT * FROM secret) SELECT * FROM secret) x', {'secret'}),
40
+ ('WITH x AS (SELECT 1 AS n) SELECT * FROM (WITH y AS (SELECT * FROM x) SELECT * FROM y) z', set()),
41
+ ('WITH RECURSIVE n AS (SELECT 1 AS i UNION ALL SELECT i + 1 FROM n WHERE i < 3) SELECT * FROM n',
42
+ set())): # a recursive CTE reads itself, not a table
43
+ with self.subTest(sql=sql):
44
+ self.assertEqual(tableguard.extract_tables(sql, 'postgres'), tables)
45
+ with self.assertRaises(ApiError) as caught:
46
+ sqltools.validate_sql('WITH secret AS (SELECT * FROM secret) SELECT * FROM secret', 'postgres',
47
+ allow_writes=False, allowed_tables={'orders'})
48
+ self.assertEqual(caught.exception.code, 'table_not_allowed')
49
+
50
+ def test_a_statement_nested_too_deep_to_walk_is_refused_not_let_through(self):
51
+ for depth in (50, 400, 3000):
52
+ sql = 'SELECT * FROM ' + '(SELECT * FROM ' * depth + 'secret' + ') x' * depth
53
+ with self.subTest(depth=depth):
54
+ try:
55
+ self.assertEqual(tableguard.extract_tables(sql, 'postgres'), {'secret'})
56
+ except ApiError as error:
57
+ self.assertEqual(error.code, 'table_check_failed')
58
+
29
59
  def test_a_derived_table_subquery_resolves_to_its_real_table(self):
30
60
  sql = 'SELECT * FROM (SELECT * FROM secret_table) x'
31
61
  self.assertEqual(tableguard.extract_tables(sql, 'postgres'), {'secret_table'})
File without changes