sqlguard-ninja 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/CHANGELOG.md +21 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/PKG-INFO +19 -1
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/README.md +18 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/architecture/ARCHITECTURE_DECISIONS.md +19 -0
- sqlguard_ninja-0.3.0/docs/development/IMPLEMENTATION_PROGRESS.md +11 -0
- sqlguard_ninja-0.3.0/docs/development/NEXT_ACTIONS.md +5 -0
- sqlguard_ninja-0.3.0/docs/development/TEST_EVIDENCE.md +12 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/development/progress.json +10 -6
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/guides/AGENT_INTEGRATION.md +3 -0
- sqlguard_ninja-0.3.0/docs/guides/POLICY_AS_CODE.md +97 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/guides/POLICY_REFERENCE.md +5 -0
- sqlguard_ninja-0.3.0/docs/releases/v0.3.0.md +29 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/security/THREAT_MODEL.md +2 -0
- sqlguard_ninja-0.3.0/src/sqlguard_ninja/_version.py +1 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/cli/main.py +128 -1
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/findings.py +4 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/guard.py +4 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/integrations/audit.py +8 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/integrations/checkpoint.py +37 -4
- sqlguard_ninja-0.3.0/src/sqlguard_ninja/policy/inheritance.py +118 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/policy/model.py +47 -3
- sqlguard_ninja-0.3.0/src/sqlguard_ninja/policy/templates/analytics_readonly.yaml +31 -0
- sqlguard_ninja-0.3.0/src/sqlguard_ninja/policy/templates/minimal.yaml +17 -0
- sqlguard_ninja-0.3.0/src/sqlguard_ninja/policy/templates/multi_tenant_saas.yaml +53 -0
- sqlguard_ninja-0.3.0/src/sqlguard_ninja/policy/tools.py +410 -0
- sqlguard_ninja-0.3.0/src/sqlguard_ninja/tenancy.py +160 -0
- sqlguard_ninja-0.3.0/tests/security/test_tenant_rewrite.py +116 -0
- sqlguard_ninja-0.3.0/tests/unit/test_policy_inheritance.py +162 -0
- sqlguard_ninja-0.3.0/tests/unit/test_policy_tools.py +254 -0
- sqlguard_ninja-0.2.0/docs/development/IMPLEMENTATION_PROGRESS.md +0 -7
- sqlguard_ninja-0.2.0/docs/development/NEXT_ACTIONS.md +0 -5
- sqlguard_ninja-0.2.0/docs/development/TEST_EVIDENCE.md +0 -7
- sqlguard_ninja-0.2.0/src/sqlguard_ninja/_version.py +0 -1
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/.gitignore +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/LICENSE +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/THIRD_PARTY_NOTICES.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/baselines/native.json +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/external/README.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/LABEL_CHANGES.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/cases/01_destructive.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/cases/02_multi_statement_and_formatting.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/cases/03_table_and_column_access.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/cases/04_privacy.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/cases/05_tenant_isolation.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/cases/06_cte_nested_unicode.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/cases/07_legitimate_lookalike_cost_dialect.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/cases/08_lineage.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/generated/grant_matrix.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/generated/label_matrix.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/generated/tenant_matrix.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/generated/transforms.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/datasets/v1/manifest.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/policies/retail_saas.yaml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.0.0/agent-sql-guard.json +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.0.0/agent-sql-guard.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.0.0/krish2105-sqlguard.json +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.0.0/krish2105-sqlguard.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.0.0/native.json +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.0.0/native.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.0.0/sql-guardrail.json +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.0.0/sql-guardrail.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/agent-sql-guard.csv +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/agent-sql-guard.json +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/agent-sql-guard.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/krish2105-sqlguard.csv +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/krish2105-sqlguard.json +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/krish2105-sqlguard.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/native.csv +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/native.json +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/native.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/sql-guardrail.csv +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/sql-guardrail.json +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/benchmarks/results/v1.1.0/sql-guardrail.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/benchmarks/RESULTS_v1.1.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/benchmarks/RESULTS_v1.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/development/ARCHITECTURE_MAP.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/development/DECISION_LOG.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/development/UPGRADE_MASTER_PLAN.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/guides/BENCHMARK_BIAS_REVIEW.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/guides/BENCHMARK_METHODOLOGY.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/guides/CI_INTEGRATION.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/guides/QUERY_COST.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/release/FINAL_TECHNICAL_AUDIT.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/releases/v0.1.0.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/releases/v0.1.1.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/releases/v0.2.0.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/research/COMPETITIVE_ANALYSIS.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/docs/security/STATIC_ANALYSIS_LIMITS.md +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/examples/_shared.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/examples/fastapi/app.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/examples/github-actions/sql-security.yml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/examples/langchain/guarded_tool_demo.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/examples/langgraph/real_llm_agent.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/examples/langgraph/sql_agent_demo.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/examples/postgresql/plan_inspection_demo.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/examples/sdk/quickstart.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/examples/sqlite/text_to_sql_demo.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/pyproject.toml +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/adapters.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/builtin.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/compare.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/dataset.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/fixtures.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/metrics.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/runner.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/translate.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/workers/_protocol.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/workers/agent_sql_guard_worker.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/workers/krish_sqlguard_worker.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/benchmarking/workers/sql_guardrail_worker.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/cli/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/cli/config.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/cli/templates.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/analysis.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/lineage.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/rules/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/rules/access.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/rules/base.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/rules/catalog.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/rules/functions.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/rules/statements.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/rules/structure.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/core/rules/tenant.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/integrations/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/integrations/executors.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/integrations/fastapi.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/integrations/langchain.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/integrations/langgraph.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/policy/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/privacy/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/privacy/rules.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/privacy/suggest.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/py.typed +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/query_cost/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/query_cost/postgres.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/query_cost/static.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/reporting/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/src/sqlguard_ninja/reporting/markdown.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/conftest.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/integration/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/integration/test_corpus_sqlite_validation.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/integration/test_external_adapters.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/integration/test_frameworks.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/integration/test_postgres.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/pg_support.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/property/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/property/test_guard_properties.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/security/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/security/test_agent_boundaries.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/security/test_corpus_native.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/unit/__init__.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/unit/helpers.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/unit/test_benchmark_engine.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/unit/test_cli.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/unit/test_dataset.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/unit/test_docs.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/unit/test_function_policy_mechanics.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/unit/test_lineage.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/unit/test_policy.py +0 -0
- {sqlguard_ninja-0.2.0 → sqlguard_ninja-0.3.0}/tests/unit/test_rules.py +0 -0
|
@@ -3,6 +3,27 @@
|
|
|
3
3
|
All notable changes are documented here. The project follows
|
|
4
4
|
[Semantic Versioning](https://semver.org/); before 1.0, minor versions may change the API.
|
|
5
5
|
|
|
6
|
+
## [0.3.0] — 2026-10-24
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
* **Role inheritance** (`extends`): grants merge, explicit denies are sticky, and conflicts,
|
|
10
|
+
unknown parents and cycles are load errors.
|
|
11
|
+
* **Policy metadata and identity:** `metadata.revision/owner/change_ticket`, a policy
|
|
12
|
+
fingerprint, and a per-decision `decision_id`. Audit events add `policy_revision`,
|
|
13
|
+
`policy_fingerprint`, `decision_id`, `correlation_id`, a literal-insensitive
|
|
14
|
+
`query_fingerprint`, and `tenant_filter_rewrite`.
|
|
15
|
+
* **Policy CLI:** `policy template`, `policy diff` (effect classification,
|
|
16
|
+
`--fail-on-relaxation`), `policy simulate` (`--against`, `--fail-on-change`),
|
|
17
|
+
`policy explain` (decision or column access, with inheritance source).
|
|
18
|
+
* **Templates:** `minimal`, `multi_tenant_saas`, `analytics_readonly`.
|
|
19
|
+
* **Opt-in AST tenant-filter rewrite** (`sqlguard_ninja.tenancy`,
|
|
20
|
+
`SQLCheckpoint(rewrite_tenant_filters=True)`) with re-validation and refusal rules (ADR-011).
|
|
21
|
+
* Docs: `docs/guides/POLICY_AS_CODE.md`; ADR-011, ADR-012; threat model T6a/T6b.
|
|
22
|
+
|
|
23
|
+
### Compatibility
|
|
24
|
+
* Policy files without the new fields behave exactly as before. `ValidationResult` gains
|
|
25
|
+
fields with defaults.
|
|
26
|
+
|
|
6
27
|
## [0.2.0] — 2026-10-24
|
|
7
28
|
|
|
8
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sqlguard-ninja
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Security testing, policy enforcement, agent integration, privacy analysis and CI benchmarking for LLM-generated SQL.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Pratik653/SQLGuard_Ninjapy
|
|
6
6
|
Project-URL: Documentation, https://github.com/Pratik653/SQLGuard_Ninjapy/tree/main/docs
|
|
@@ -246,6 +246,23 @@ Labels also apply to paths inside JSON columns (`profile->'health'->>'allergies'
|
|
|
246
246
|
suggestions are never enforced. This is a technical control, not a certification of GDPR
|
|
247
247
|
compliance. Full semantics: [POLICY_REFERENCE.md](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/POLICY_REFERENCE.md).
|
|
248
248
|
|
|
249
|
+
## Policy as code
|
|
250
|
+
|
|
251
|
+
Roles can inherit (`extends`), and explicit denies cannot be relaxed by a child. Every decision
|
|
252
|
+
carries the policy revision, the policy fingerprint and a decision id. Review changes before
|
|
253
|
+
activating them:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
sqlninja policy template multi_tenant_saas --output policy.yaml
|
|
257
|
+
sqlninja policy diff old.yaml policy.yaml --fail-on-relaxation
|
|
258
|
+
sqlninja policy simulate --policy old.yaml --against policy.yaml --cases cases.yaml
|
|
259
|
+
sqlninja policy explain --policy policy.yaml --role support --column customers.email
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Optional: `SQLCheckpoint(..., rewrite_tenant_filters=True)` adds missing tenant filters
|
|
263
|
+
through the AST and executes only the re-validated rewrite. See
|
|
264
|
+
[POLICY_AS_CODE.md](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/POLICY_AS_CODE.md).
|
|
265
|
+
|
|
249
266
|
## Query cost analysis
|
|
250
267
|
|
|
251
268
|
Every validation includes an explainable static score that uses the catalog's
|
|
@@ -340,6 +357,7 @@ writes, more tested dialects, and SARIF output. Details in [ROADMAP.md](https://
|
|
|
340
357
|
## Documentation
|
|
341
358
|
|
|
342
359
|
* [Policy reference](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/POLICY_REFERENCE.md)
|
|
360
|
+
* [Policy as code](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/POLICY_AS_CODE.md)
|
|
343
361
|
* [Agent integration](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/AGENT_INTEGRATION.md)
|
|
344
362
|
* [Query cost](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/QUERY_COST.md)
|
|
345
363
|
* [CI integration](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/CI_INTEGRATION.md)
|
|
@@ -190,6 +190,23 @@ Labels also apply to paths inside JSON columns (`profile->'health'->>'allergies'
|
|
|
190
190
|
suggestions are never enforced. This is a technical control, not a certification of GDPR
|
|
191
191
|
compliance. Full semantics: [POLICY_REFERENCE.md](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/POLICY_REFERENCE.md).
|
|
192
192
|
|
|
193
|
+
## Policy as code
|
|
194
|
+
|
|
195
|
+
Roles can inherit (`extends`), and explicit denies cannot be relaxed by a child. Every decision
|
|
196
|
+
carries the policy revision, the policy fingerprint and a decision id. Review changes before
|
|
197
|
+
activating them:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
sqlninja policy template multi_tenant_saas --output policy.yaml
|
|
201
|
+
sqlninja policy diff old.yaml policy.yaml --fail-on-relaxation
|
|
202
|
+
sqlninja policy simulate --policy old.yaml --against policy.yaml --cases cases.yaml
|
|
203
|
+
sqlninja policy explain --policy policy.yaml --role support --column customers.email
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Optional: `SQLCheckpoint(..., rewrite_tenant_filters=True)` adds missing tenant filters
|
|
207
|
+
through the AST and executes only the re-validated rewrite. See
|
|
208
|
+
[POLICY_AS_CODE.md](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/POLICY_AS_CODE.md).
|
|
209
|
+
|
|
193
210
|
## Query cost analysis
|
|
194
211
|
|
|
195
212
|
Every validation includes an explainable static score that uses the catalog's
|
|
@@ -284,6 +301,7 @@ writes, more tested dialects, and SARIF output. Details in [ROADMAP.md](https://
|
|
|
284
301
|
## Documentation
|
|
285
302
|
|
|
286
303
|
* [Policy reference](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/POLICY_REFERENCE.md)
|
|
304
|
+
* [Policy as code](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/POLICY_AS_CODE.md)
|
|
287
305
|
* [Agent integration](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/AGENT_INTEGRATION.md)
|
|
288
306
|
* [Query cost](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/QUERY_COST.md)
|
|
289
307
|
* [CI integration](https://github.com/Pratik653/SQLGuard_Ninjapy/blob/main/docs/guides/CI_INTEGRATION.md)
|
|
@@ -122,6 +122,25 @@ Extras: `langchain`, `langgraph`, `fastapi`, `postgres` (`psycopg`), `dev`.
|
|
|
122
122
|
Apache-2.0 provides an explicit patent grant, which matters to enterprise adopters. It is
|
|
123
123
|
compatible with all dependencies we use (MIT, BSD, Apache-2.0). No third-party code is vendored.
|
|
124
124
|
|
|
125
|
+
## ADR-011 — Opt-in tenant-filter rewrite (refines ADR-005)
|
|
126
|
+
|
|
127
|
+
**Context.** Enterprise users want missing tenant filters added rather than rejected.
|
|
128
|
+
|
|
129
|
+
**Decision.** `sqlguard_ninja.tenancy.apply_tenant_filters` may add tenant predicates through
|
|
130
|
+
the AST. It is opt-in (`rewrite_tenant_filters=True`), uses bind parameters only, refuses on any
|
|
131
|
+
non-tenant problem or ambiguous ownership, and **re-validates** the rewritten SQL, which is the
|
|
132
|
+
exact string executed. ADR-005's invariant still holds: the validated string and the executed
|
|
133
|
+
string are identical.
|
|
134
|
+
|
|
135
|
+
**Consequences.** The rewritten SQL is the qualified form produced by sqlglot. It is equivalent
|
|
136
|
+
but may look different from the input. Database RLS remains the authoritative control.
|
|
137
|
+
|
|
138
|
+
## ADR-012 — Role inheritance with sticky explicit denies
|
|
139
|
+
|
|
140
|
+
**Decision.** `extends` merges grants. A child cannot relax an inherited explicit deny, and an
|
|
141
|
+
attempt is a load error. `policy diff` works on the effective (inherited) policy and classifies
|
|
142
|
+
relaxations so reviewers and CI can gate them.
|
|
143
|
+
|
|
125
144
|
## ADR-010 — Read-only scope for v0.1
|
|
126
145
|
|
|
127
146
|
**Decision.** v0.1 validates read-only workloads only. All writes are denied. Supporting governed
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Implementation progress
|
|
2
|
+
|
|
3
|
+
| Phase | Status | Commit | Tests | Notes |
|
|
4
|
+
|---|---|---|---|---|
|
|
5
|
+
| 0 Audit and checkpoint files | IMPLEMENTED | 375c52f^ | — | baseline v0.1.1 verified: clean tree, main@5206e96, PyPI 0.1.1 |
|
|
6
|
+
| 1a Benchmark tiers, basis, F1, Wilson CIs, throughput, CSV, hashes | RELEASED v0.2.0 | 375c52f | focused + full gate | |
|
|
7
|
+
| 1b Lineage: aggregate-only through CTEs, tenant push-down, small-cohort through derived scopes | RELEASED v0.2.0 | 8b59a38 | corpus 1.1.0 (21 boundary cases), 11 unit tests | LIN-012 found a real bypass of the small-cohort rule before release; fixed |
|
|
8
|
+
| 2a Role inheritance (sticky denies), revision/fingerprint, decision ids, audit fields | VERIFIED (local) | 2f1919c | test_policy_inheritance (11) | |
|
|
9
|
+
| 2b Policy diff/simulate/explain/templates CLI | VERIFIED (local) | e64859a | test_policy_tools (21) | |
|
|
10
|
+
| 2c Opt-in AST tenant-filter rewrite + checkpoint | VERIFIED (local) | b0b5ade | test_tenant_rewrite (32, executes on SQLite, tenants 1-3) | |
|
|
11
|
+
| 2d Docs, ADR-011/012, v0.3.0 bump | VERIFIED (local) | see PR | full gate 1,804 passed, 94% | |
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Next actions (development program)
|
|
2
|
+
|
|
3
|
+
1. Merge the v0.3.0 PR, tag v0.3.0, release, verify PyPI and a fresh install.
|
|
4
|
+
2. Phase 3 (v0.3.x or v0.4.0): MCP stdio server with validation-only tools (`validate_sql`, `explain_policy_decision`, `inspect_query_cost`, `get_policy_capabilities`), trusted role and tenant from server config, tested with a real MCP client (the official Python SDK client over stdio).
|
|
5
|
+
3. Workstream 1 open items: independent dataset (#6), function/catalog cases (#5).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Test evidence
|
|
2
|
+
|
|
3
|
+
| Date | Scope | Command | Result |
|
|
4
|
+
|---|---|---|---|
|
|
5
|
+
| 2026-10-24 | Baseline (v0.1.1) | `pytest --cov` | 1,655 passed, 95% (local, Python 3.11) |
|
|
6
|
+
| 2026-10-24 | v0.2.0 candidate | full gate (`ruff`, `mypy`, corpus `--check`, `pytest --cov`, build, `twine check`) | 1,738 passed, 94% branch coverage; twine PASSED |
|
|
7
|
+
| 2026-10-24 | Benchmark, corpus 1.1.0 | `sqlninja benchmark run --dataset builtin:v1` (4 adapters) | see docs/benchmarks/RESULTS_v1.1.md |
|
|
8
|
+
| 2026-10-24 | v0.2.0 PR #17 | CI run on PR | 12/12 jobs green; gate: baseline from main, 4 approved relabels, 0 regressions |
|
|
9
|
+
| 2026-10-24 | v0.2.0 main | CI run 40107938223 on 8b59a38 | success |
|
|
10
|
+
| 2026-10-24 | v0.2.0 publish | release.yml run 40108113801 | build + publish success; wheel and sdist with PEP 740 provenance; 0.1.0 and 0.1.1 unchanged |
|
|
11
|
+
| 2026-10-24 | v0.2.0 fresh install | `pip install --no-cache-dir --index-url https://pypi.org/simple sqlguard-ninja==0.2.0` (Python 3.13, outside repo) | import from site-packages; lineage allow/deny and tenant push-down via CLI; bundled benchmark 1,044 evaluations with CSV, tiers, 14 file hashes |
|
|
12
|
+
| 2026-10-24 | v0.3.0 candidate | full gate | 1,804 passed, 94%; build + twine PASSED; 3 templates in wheel |
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
],
|
|
14
14
|
"evidence": [
|
|
15
15
|
"tiers, basis, F1, Wilson CIs, CSV, hashes: VERIFIED locally",
|
|
16
|
-
"no independent dataset yet (#6); function/catalog cases absent (#5)"
|
|
16
|
+
"no independent dataset yet (#6); function/catalog cases absent (#5)",
|
|
17
|
+
"v0.2.0 released: tiers/CIs/CSV/hashes; still no independent data"
|
|
17
18
|
],
|
|
18
19
|
"blockers": [
|
|
19
20
|
"external dataset needs licensed independent source (#6)"
|
|
@@ -22,24 +23,27 @@
|
|
|
22
23
|
{
|
|
23
24
|
"id": 2,
|
|
24
25
|
"name": "Lineage-aware privacy",
|
|
25
|
-
"status": "
|
|
26
|
+
"status": "RELEASED",
|
|
26
27
|
"release": "v0.2.0",
|
|
27
28
|
"issues": [
|
|
28
29
|
8,
|
|
29
30
|
9
|
|
30
31
|
],
|
|
31
32
|
"evidence": [
|
|
32
|
-
"corpus 1.1.0 lineage cases + tests/unit/test_lineage.py; full gate 1,738 passed"
|
|
33
|
+
"corpus 1.1.0 lineage cases + tests/unit/test_lineage.py; full gate 1,738 passed",
|
|
34
|
+
"v0.2.0 on PyPI; fresh install verified"
|
|
33
35
|
],
|
|
34
36
|
"blockers": []
|
|
35
37
|
},
|
|
36
38
|
{
|
|
37
39
|
"id": 3,
|
|
38
40
|
"name": "Policy-as-code and multitenancy",
|
|
39
|
-
"status": "
|
|
41
|
+
"status": "VERIFIED",
|
|
40
42
|
"release": "v0.3.0",
|
|
41
43
|
"issues": [],
|
|
42
|
-
"evidence": [
|
|
44
|
+
"evidence": [
|
|
45
|
+
"inheritance, diff/simulate/explain/templates, tenant rewrite, audit metadata; 1,804 tests pass locally"
|
|
46
|
+
],
|
|
43
47
|
"blockers": []
|
|
44
48
|
},
|
|
45
49
|
{
|
|
@@ -103,4 +107,4 @@
|
|
|
103
107
|
]
|
|
104
108
|
}
|
|
105
109
|
]
|
|
106
|
-
}
|
|
110
|
+
}
|
|
@@ -19,6 +19,9 @@ LLM ──SQL──▶ SQLCheckpoint.evaluate(sql, RequestContext) ──▶ aut
|
|
|
19
19
|
| `review` | not run | run only with an approval bound to the SQL hash, trace and principal (single use, TTL, no self-approval) | run |
|
|
20
20
|
| `audit` | run, recorded `enforced=False` | run, recorded | run |
|
|
21
21
|
|
|
22
|
+
`rewrite_tenant_filters=True` (off by default) adds missing tenant filters through the AST and
|
|
23
|
+
executes the re-validated rewrite. See [POLICY_AS_CODE.md](POLICY_AS_CODE.md).
|
|
24
|
+
|
|
22
25
|
`max_denied_attempts` (default 5) locks a trace after repeated denials, so a retry loop
|
|
23
26
|
cannot grind through rephrasings.
|
|
24
27
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Policy as code
|
|
2
|
+
|
|
3
|
+
Policies are versioned files reviewed like code. This guide covers inheritance, metadata,
|
|
4
|
+
templates and the review workflow. Field reference:
|
|
5
|
+
[POLICY_REFERENCE.md](POLICY_REFERENCE.md).
|
|
6
|
+
|
|
7
|
+
## Metadata and identity
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
version: 1 # schema version
|
|
11
|
+
name: shop
|
|
12
|
+
metadata:
|
|
13
|
+
revision: "2026.10.2" # your revision; recorded in every decision and audit event
|
|
14
|
+
owner: data-governance
|
|
15
|
+
change_ticket: SEC-1234
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Every `ValidationResult` and audit event records:
|
|
19
|
+
- `decision_id`: unique per decision
|
|
20
|
+
- `policy_revision`
|
|
21
|
+
- `policy_fingerprint`: a 16-hex-character content hash that changes with any semantic change
|
|
22
|
+
Audit events also record the `correlation_id` (the request trace id) and a `query_fingerprint`,
|
|
23
|
+
which is the SHA-256 of the literal-redacted SQL. Queries that differ only in literal values
|
|
24
|
+
share a fingerprint, and literal values themselves are never logged.
|
|
25
|
+
|
|
26
|
+
## Role inheritance
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
roles:
|
|
30
|
+
tenant_base:
|
|
31
|
+
tenant_scoped: true
|
|
32
|
+
labels: {payment_card: deny}
|
|
33
|
+
support:
|
|
34
|
+
extends: [tenant_base]
|
|
35
|
+
tables: {customers: "*", orders: [id, tenant_id, status]}
|
|
36
|
+
labels: {contact: allow}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
* Grants are merged. Column lists are unioned, and `"*"` wins.
|
|
40
|
+
* Label access and `column_access` override per key, and `limits` come from the nearest role
|
|
41
|
+
that sets them.
|
|
42
|
+
* **Explicit denies are sticky.** A child cannot relax an inherited deny label, deny column
|
|
43
|
+
rule, `deny_columns` entry, or `tenant_scoped: true`. The attempt is a load error (a
|
|
44
|
+
*conflict*), not a silent override.
|
|
45
|
+
* Unknown parents and cycles are load errors. Grants inherited from parents are validated
|
|
46
|
+
against the catalog just like direct grants.
|
|
47
|
+
|
|
48
|
+
`sqlninja policy explain --policy P --role R --column table.column` shows the effective access
|
|
49
|
+
and which role in the chain set each label.
|
|
50
|
+
|
|
51
|
+
## Templates
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
sqlninja policy template # list: minimal, multi_tenant_saas, analytics_readonly
|
|
55
|
+
sqlninja policy template multi_tenant_saas --output policies/policy.yaml
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Reviewing a change before activation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
sqlninja policy diff policies/old.yaml policies/new.yaml # every change, classified
|
|
62
|
+
sqlninja policy diff old.yaml new.yaml --fail-on-relaxation # CI: block silent relaxations
|
|
63
|
+
sqlninja policy simulate --policy old.yaml --against new.yaml --cases examples.yaml
|
|
64
|
+
sqlninja policy explain --policy new.yaml --role support --tenant 1 --sql "SELECT ..."
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
* `diff` compares the **effective** policies, after inheritance. It classifies each change as
|
|
68
|
+
`relaxes` (can only allow more), `restricts` or `neutral`. Covered areas: roles, table and
|
|
69
|
+
column grants, `deny_columns`, column rules, label access, `tenant_scoped`, catalog labels
|
|
70
|
+
and tenant columns, function policy, limits, defaults, cost thresholds and disclosure.
|
|
71
|
+
* `simulate` evaluates example queries (`{id, sql, role, context, expect}`) under the current
|
|
72
|
+
policy and, optionally, a candidate. It reports decision changes and expectation mismatches,
|
|
73
|
+
and exits 1 on mismatch, or with `--fail-on-change` when any decision changes.
|
|
74
|
+
* `explain` prints the structured decision: rule ids, messages, internal detail and lineage.
|
|
75
|
+
Use it for operators. Agent-facing messages remain the redacted `agent_message`.
|
|
76
|
+
|
|
77
|
+
Suggested CI step for policy repositories:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
- run: sqlninja policy diff "$BASE_POLICY" policies/policy.yaml --fail-on-relaxation
|
|
81
|
+
- run: sqlninja policy simulate --policy policies/policy.yaml --cases policies/cases.yaml
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Tenant isolation: inspection, rewrite, and the database
|
|
85
|
+
|
|
86
|
+
1. **Inspection (default).** Every scope reading a tenant table must pin it to the context
|
|
87
|
+
tenant. Otherwise the query is denied (see the methodology for the exact semantics).
|
|
88
|
+
2. **Opt-in rewrite.** `SQLCheckpoint(..., rewrite_tenant_filters=True)` or
|
|
89
|
+
`sqlguard_ninja.tenancy.apply_tenant_filters()` adds the missing
|
|
90
|
+
`alias.tenant_column = <bind parameter>` predicates through the AST, then **re-validates**.
|
|
91
|
+
The rewritten string is what gets executed. It refuses when the query has any other
|
|
92
|
+
problem (a forbidden column, another tenant's id, a write), when ownership is ambiguous
|
|
93
|
+
(the preserved side of a RIGHT/FULL join), or when the rewritten SQL fails validation. The
|
|
94
|
+
tenant value is always a bind parameter, never inlined.
|
|
95
|
+
3. **The database (authoritative).** Use PostgreSQL row-level security, or per-tenant
|
|
96
|
+
credentials, as the real boundary. SQL inspection and rewriting are defence in depth. They
|
|
97
|
+
cannot see views, functions or data an attacker reaches by other means.
|
|
@@ -9,6 +9,7 @@ start-up instead of silently changing behaviour. Run `sqlninja policy check --po
|
|
|
9
9
|
```yaml
|
|
10
10
|
version: 1
|
|
11
11
|
name: shop
|
|
12
|
+
metadata: {revision: "2026.10.1", owner: data-governance} # recorded in every decision
|
|
12
13
|
dialect: postgres # default dialect for validate()
|
|
13
14
|
disclosure: names # or "generic": agent messages never name tables/columns
|
|
14
15
|
|
|
@@ -35,6 +36,7 @@ catalog:
|
|
|
35
36
|
|
|
36
37
|
roles:
|
|
37
38
|
support:
|
|
39
|
+
# extends: [base_role] # inherit grants; inherited explicit denies cannot be relaxed
|
|
38
40
|
tables:
|
|
39
41
|
customers: [id, tenant_id, email, email_domain] # or "*", or {columns, deny_columns, column_access}
|
|
40
42
|
labels: {contact: allow, health: deny}
|
|
@@ -83,6 +85,9 @@ cost: {enabled: true, review_score: 50, deny_score: 80, large_table_rows: 100000
|
|
|
83
85
|
|
|
84
86
|
Without roles, an implicit role grants every catalog table and denies every labelled column.
|
|
85
87
|
|
|
88
|
+
Inheritance, templates, `policy diff` / `simulate` / `explain` and the tenant-filter rewrite
|
|
89
|
+
are described in [POLICY_AS_CODE.md](POLICY_AS_CODE.md).
|
|
90
|
+
|
|
86
91
|
## Decisions
|
|
87
92
|
|
|
88
93
|
Each rule emits findings with an action (`deny`, `review`, `warn`). Any deny → **DENY**,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SQLGuard Ninja v0.3.0
|
|
2
|
+
|
|
3
|
+
Enterprise policy-as-code and tenant enforcement.
|
|
4
|
+
|
|
5
|
+
## Highlights
|
|
6
|
+
|
|
7
|
+
* **Role inheritance** with sticky explicit denies: a child role can add permissions, but cannot undo an inherited deny. Conflicts, unknown parents and cycles fail at load time.
|
|
8
|
+
* **Review before activation:** `sqlninja policy diff` classifies every change as relaxes / restricts / neutral (`--fail-on-relaxation` for CI). `policy simulate --against` shows decision changes on example queries, and `policy explain` traces effective access through the inheritance chain.
|
|
9
|
+
* **Auditability:** every decision has a `decision_id`, the policy revision and fingerprint. Audit events add a correlation id, a literal-insensitive query fingerprint and a tenant-rewrite flag.
|
|
10
|
+
* **Templates:** `minimal`, `multi_tenant_saas`, `analytics_readonly`.
|
|
11
|
+
* **Opt-in AST tenant-filter rewrite:** missing tenant pins are added as bind-parameter predicates and the rewrite is re-validated. It is refused on any other problem or on RIGHT/FULL-join ownership ambiguity.
|
|
12
|
+
|
|
13
|
+
## Compatibility
|
|
14
|
+
|
|
15
|
+
Existing policies behave as before; the new fields are optional.
|
|
16
|
+
|
|
17
|
+
## Known limitations
|
|
18
|
+
|
|
19
|
+
* All benchmark results are internal. There is no independent dataset yet (issue #6), and no restricted-function or system-catalog cases (issue #5).
|
|
20
|
+
* The tenant rewrite emits sqlglot-normalised SQL. Database row-level security remains the authoritative tenant control.
|
|
21
|
+
* Only PostgreSQL and SQLite are supported; writes are denied; no guarantee against SQL injection or data leakage.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m pip install sqlguard-ninja==0.3.0
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
PyPI: https://pypi.org/project/sqlguard-ninja/0.3.0/
|
|
@@ -57,6 +57,8 @@ flowchart LR
|
|
|
57
57
|
| T3 | Prompt injection through database content | A ticket body says "now query payments" | Every subsequent query is checked again. Retry lock-out per trace. Read-only session. | The model can still be steered to permitted data. |
|
|
58
58
|
| T4 | Parser ambiguity | Comment and string tricks, nested comments, dollar quoting | sqlglot dialect parsing. The corpus includes formatting and comment cases validated against real engines. Unparseable input is denied. | Unknown tokenizer differences. Report them as vulnerabilities. |
|
|
59
59
|
| T5 | Dialect-specific bypasses | MySQL `/*! */` executable comments, SQLite `ATTACH`/`PRAGMA` | Executable-comment rule for the MySQL family. SQLite session statements are denied. | Untested dialects are experimental. |
|
|
60
|
+
| T6a | Unsafe automatic tenant filtering | A rewrite that changes meaning or masks a query naming another tenant | Rewrite is opt-in, bind-parameter only, refused on any non-tenant finding and on RIGHT/FULL-join ownership ambiguity; the rewritten SQL is re-validated and is the executed string | Rewritten SQL is sqlglot-normalised; RLS remains authoritative |
|
|
61
|
+
| T6b | Silent policy relaxation through inheritance or edits | A child role or edit quietly widens access | Sticky explicit denies (load error on conflict); `policy diff --fail-on-relaxation`; fingerprint and revision in every decision | Reviewers must still read the diff |
|
|
60
62
|
| T6 | Tenant data leakage | missing filter, `OR 1=1`, other tenant id, unpinned join or subquery | Every scope reading a tenant table must pin it with an equality conjunct to the context value or an approved bind parameter. `OR`/`NOT` don't count, and casts of the column don't count. | Apply RLS in the database as the authoritative control. |
|
|
61
63
|
| T7 | Unauthorized columns | `SELECT *`, whole-row references, alias laundering, columns in filters | Schema-based star expansion. Per-scope base-column attribution. Ungranted columns are denied in any clause. | Views and UDFs hide columns. |
|
|
62
64
|
| T8 | Sensitive-data exposure | card numbers, health data in JSON, salary per person | Explicit labels. Deny / aggregate-only / review per role. JSON path labels. Window aggregates count as value-revealing. Small-cohort review. | Inference attacks across queries. |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -36,6 +36,15 @@ from sqlguard_ninja.cli.templates import FILES
|
|
|
36
36
|
from sqlguard_ninja.core.findings import Decision
|
|
37
37
|
from sqlguard_ninja.core.guard import Guard
|
|
38
38
|
from sqlguard_ninja.policy.model import Policy, PolicyError
|
|
39
|
+
from sqlguard_ninja.policy.tools import (
|
|
40
|
+
TEMPLATES,
|
|
41
|
+
diff_policies,
|
|
42
|
+
explain_column,
|
|
43
|
+
explain_result,
|
|
44
|
+
load_sim_cases,
|
|
45
|
+
simulate,
|
|
46
|
+
template_text,
|
|
47
|
+
)
|
|
39
48
|
from sqlguard_ninja.privacy.suggest import suggest_labels
|
|
40
49
|
from sqlguard_ninja.reporting.markdown import benchmark_report, comparison_report
|
|
41
50
|
|
|
@@ -154,7 +163,9 @@ def cmd_policy_check(args: argparse.Namespace) -> int:
|
|
|
154
163
|
warnings = []
|
|
155
164
|
if policy.functions.mode == "denylist":
|
|
156
165
|
warnings.append("functions.mode is 'denylist'; 'allowlist' is safer for production")
|
|
157
|
-
for name
|
|
166
|
+
for name in policy.roles:
|
|
167
|
+
role = policy.effective_role(name)
|
|
168
|
+
assert role is not None
|
|
158
169
|
if not role.tenant_scoped and tenant_tables:
|
|
159
170
|
warnings.append(f"role '{name}' is not tenant-scoped")
|
|
160
171
|
for w in warnings:
|
|
@@ -174,6 +185,94 @@ def cmd_policy_suggest(args: argparse.Namespace) -> int:
|
|
|
174
185
|
return EXIT_OK
|
|
175
186
|
|
|
176
187
|
|
|
188
|
+
def cmd_policy_template(args: argparse.Namespace) -> int:
|
|
189
|
+
if not args.kind:
|
|
190
|
+
_out("available templates: " + ", ".join(TEMPLATES))
|
|
191
|
+
return EXIT_OK
|
|
192
|
+
try:
|
|
193
|
+
text = template_text(args.kind)
|
|
194
|
+
except KeyError as exc:
|
|
195
|
+
raise CLIError(str(exc.args[0])) from exc
|
|
196
|
+
if args.output:
|
|
197
|
+
path = Path(args.output)
|
|
198
|
+
if path.exists() and not args.force:
|
|
199
|
+
raise CLIError(f"refusing to overwrite {path}; use --force")
|
|
200
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
path.write_text(text, encoding="utf-8")
|
|
202
|
+
_out(f"wrote {path}")
|
|
203
|
+
else:
|
|
204
|
+
_out(text)
|
|
205
|
+
return EXIT_OK
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def cmd_policy_diff(args: argparse.Namespace) -> int:
|
|
209
|
+
old, new = _load_policy(args.old), _load_policy(args.new)
|
|
210
|
+
diff = diff_policies(old, new)
|
|
211
|
+
if args.format == "json":
|
|
212
|
+
_out(json.dumps(diff.as_dict(), indent=2, default=str))
|
|
213
|
+
else:
|
|
214
|
+
_out(
|
|
215
|
+
f"policy diff {old.revision} ({old.fingerprint}) -> {new.revision} "
|
|
216
|
+
f"({new.fingerprint}): {len(diff.changes)} change(s), "
|
|
217
|
+
f"{len(diff.relaxations)} relaxation(s)"
|
|
218
|
+
)
|
|
219
|
+
for c in diff.changes:
|
|
220
|
+
_out(f" [{c.effect.value:9}] {c.path}: {c.before!s} -> {c.after!s}")
|
|
221
|
+
if args.fail_on_relaxation and diff.relaxations:
|
|
222
|
+
return EXIT_FAIL
|
|
223
|
+
return EXIT_OK
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cmd_policy_simulate(args: argparse.Namespace) -> int:
|
|
227
|
+
policy = _load_policy(args.policy)
|
|
228
|
+
against = _load_policy(args.against) if args.against else None
|
|
229
|
+
try:
|
|
230
|
+
cases = load_sim_cases(args.cases)
|
|
231
|
+
except (OSError, ValueError, yaml.YAMLError) as exc:
|
|
232
|
+
raise CLIError(f"cannot load cases {args.cases}: {exc}") from exc
|
|
233
|
+
rows = simulate(policy, cases, against)
|
|
234
|
+
if args.format == "json":
|
|
235
|
+
_out(json.dumps(rows, indent=2))
|
|
236
|
+
else:
|
|
237
|
+
for r in rows:
|
|
238
|
+
line = f"{r['id']:>6} {r['role'] or '-':18} {r['decision']:6}"
|
|
239
|
+
if against is not None:
|
|
240
|
+
marker = " CHANGED" if r["changed"] else ""
|
|
241
|
+
line += f" -> {r['candidate_decision']:6}{marker}"
|
|
242
|
+
if r["expect"] is not None:
|
|
243
|
+
line += f" expect={r['expect']}"
|
|
244
|
+
_out(line)
|
|
245
|
+
key = "candidate_matches_expectation" if against is not None else "matches_expectation"
|
|
246
|
+
mismatches = sum(1 for r in rows if r.get(key) is False)
|
|
247
|
+
changed = sum(1 for r in rows if r.get("changed"))
|
|
248
|
+
_out(f"{len(rows)} case(s); {mismatches} expectation mismatch(es); {changed} changed")
|
|
249
|
+
if mismatches or (args.fail_on_change and changed):
|
|
250
|
+
return EXIT_FAIL
|
|
251
|
+
return EXIT_OK
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_policy_explain(args: argparse.Namespace) -> int:
|
|
255
|
+
policy = _load_policy(args.policy)
|
|
256
|
+
if bool(args.sql) == bool(args.column):
|
|
257
|
+
raise CLIError("give exactly one of --sql or --column TABLE.COLUMN")
|
|
258
|
+
if args.column:
|
|
259
|
+
table, _, column = args.column.partition(".")
|
|
260
|
+
try:
|
|
261
|
+
payload = explain_column(policy, args.role, table.lower(), column.lower())
|
|
262
|
+
except KeyError as exc:
|
|
263
|
+
raise CLIError(str(exc.args[0])) from exc
|
|
264
|
+
else:
|
|
265
|
+
context: dict[str, Any] = {}
|
|
266
|
+
if args.tenant is not None:
|
|
267
|
+
context[policy.tenant.context_key] = args.tenant
|
|
268
|
+
result = Guard(policy).validate(
|
|
269
|
+
args.sql, role=args.role, dialect=args.dialect, context=context
|
|
270
|
+
)
|
|
271
|
+
payload = explain_result(result)
|
|
272
|
+
_out(json.dumps(payload, indent=2, default=str))
|
|
273
|
+
return EXIT_OK
|
|
274
|
+
|
|
275
|
+
|
|
177
276
|
def cmd_init(args: argparse.Namespace) -> int:
|
|
178
277
|
root = Path(args.directory)
|
|
179
278
|
existing = [name for name in FILES if (root / name).exists()]
|
|
@@ -390,6 +489,34 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
390
489
|
common(ps)
|
|
391
490
|
ps.add_argument("--policy")
|
|
392
491
|
ps.set_defaults(func=cmd_policy_suggest)
|
|
492
|
+
pt = pol.add_parser("template", help="print or write a policy template")
|
|
493
|
+
pt.add_argument("kind", nargs="?", help="template name (omit to list)")
|
|
494
|
+
pt.add_argument("--output")
|
|
495
|
+
pt.add_argument("--force", action="store_true")
|
|
496
|
+
pt.set_defaults(func=cmd_policy_template)
|
|
497
|
+
pd = pol.add_parser("diff", help="semantic diff of two policies, flagging relaxations")
|
|
498
|
+
pd.add_argument("old")
|
|
499
|
+
pd.add_argument("new")
|
|
500
|
+
pd.add_argument("--format", choices=["text", "json"], default="text")
|
|
501
|
+
pd.add_argument("--fail-on-relaxation", action="store_true")
|
|
502
|
+
pd.set_defaults(func=cmd_policy_diff)
|
|
503
|
+
psim = pol.add_parser(
|
|
504
|
+
"simulate", help="evaluate example queries; optionally against a candidate"
|
|
505
|
+
)
|
|
506
|
+
psim.add_argument("--policy", required=True)
|
|
507
|
+
psim.add_argument("--cases", required=True, help="YAML list of {sql, role, context, expect}")
|
|
508
|
+
psim.add_argument("--against", help="candidate policy to compare with")
|
|
509
|
+
psim.add_argument("--format", choices=["text", "json"], default="text")
|
|
510
|
+
psim.add_argument("--fail-on-change", action="store_true")
|
|
511
|
+
psim.set_defaults(func=cmd_policy_simulate)
|
|
512
|
+
pe = pol.add_parser("explain", help="explain a decision or a column's effective access")
|
|
513
|
+
pe.add_argument("--policy", required=True)
|
|
514
|
+
pe.add_argument("--role", required=True)
|
|
515
|
+
pe.add_argument("--sql")
|
|
516
|
+
pe.add_argument("--column", help="TABLE.COLUMN")
|
|
517
|
+
pe.add_argument("--dialect")
|
|
518
|
+
pe.add_argument("--tenant")
|
|
519
|
+
pe.set_defaults(func=cmd_policy_explain)
|
|
393
520
|
|
|
394
521
|
bench = sub.add_parser("benchmark", help="run and compare benchmarks").add_subparsers(
|
|
395
522
|
dest="bench_cmd", required=True
|
|
@@ -97,6 +97,10 @@ class ValidationResult(BaseModel):
|
|
|
97
97
|
role: str | None
|
|
98
98
|
policy_name: str
|
|
99
99
|
policy_version: int
|
|
100
|
+
policy_revision: str = "unversioned"
|
|
101
|
+
policy_fingerprint: str = ""
|
|
102
|
+
decision_id: str = ""
|
|
103
|
+
"""Unique id of this decision (correlates the result, audit event and logs)."""
|
|
100
104
|
tables: tuple[str, ...] = ()
|
|
101
105
|
columns: tuple[str, ...] = ()
|
|
102
106
|
elapsed_ms: float = 0.0
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import time
|
|
7
|
+
import uuid
|
|
7
8
|
from collections.abc import Iterable, Mapping, Sequence
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
@@ -176,6 +177,9 @@ class Guard:
|
|
|
176
177
|
role=role_name,
|
|
177
178
|
policy_name=self.policy.name,
|
|
178
179
|
policy_version=self.policy.version,
|
|
180
|
+
policy_revision=self.policy.revision,
|
|
181
|
+
policy_fingerprint=self.policy.fingerprint,
|
|
182
|
+
decision_id=uuid.uuid4().hex,
|
|
179
183
|
tables=tuple(analysis.base_table_names) if analysis else (),
|
|
180
184
|
columns=tuple(sorted({f"{c.table}.{c.column}" for c in analysis.columns}))
|
|
181
185
|
if analysis
|
|
@@ -60,8 +60,16 @@ class AuditEvent(BaseModel):
|
|
|
60
60
|
would_block: bool
|
|
61
61
|
sql_sha256: str
|
|
62
62
|
sql_redacted: str | None
|
|
63
|
+
query_fingerprint: str | None = None
|
|
64
|
+
"""SHA-256 of the redacted SQL: equal for queries that differ only in literals."""
|
|
63
65
|
dialect: str
|
|
64
66
|
policy: str
|
|
67
|
+
policy_revision: str = "unversioned"
|
|
68
|
+
policy_fingerprint: str = ""
|
|
69
|
+
decision_id: str = ""
|
|
70
|
+
correlation_id: str | None = None
|
|
71
|
+
tenant_filter_rewrite: bool = False
|
|
72
|
+
"""True when the executed SQL is the AST-rewritten form with tenant filters added."""
|
|
65
73
|
findings: list[dict[str, Any]]
|
|
66
74
|
approval: dict[str, Any] | None = None
|
|
67
75
|
attempt: int = 1
|