sqlguard-ninja 0.1.0__py3-none-any.whl
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/__init__.py +35 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/01_destructive.yaml +169 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/02_multi_statement_and_formatting.yaml +155 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/03_table_and_column_access.yaml +205 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/04_privacy.yaml +245 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/05_tenant_isolation.yaml +167 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/06_cte_nested_unicode.yaml +168 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/07_legitimate_lookalike_cost_dialect.yaml +243 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/grant_matrix.yaml +573 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/label_matrix.yaml +403 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/tenant_matrix.yaml +684 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/transforms.yaml +927 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/manifest.yaml +15 -0
- sqlguard_ninja/_data/benchmarks/policies/retail_saas.yaml +259 -0
- sqlguard_ninja/_version.py +1 -0
- sqlguard_ninja/benchmarking/__init__.py +1 -0
- sqlguard_ninja/benchmarking/adapters.py +303 -0
- sqlguard_ninja/benchmarking/builtin.py +39 -0
- sqlguard_ninja/benchmarking/compare.py +168 -0
- sqlguard_ninja/benchmarking/dataset.py +265 -0
- sqlguard_ninja/benchmarking/fixtures.py +274 -0
- sqlguard_ninja/benchmarking/metrics.py +114 -0
- sqlguard_ninja/benchmarking/runner.py +126 -0
- sqlguard_ninja/benchmarking/translate.py +64 -0
- sqlguard_ninja/benchmarking/workers/_protocol.py +53 -0
- sqlguard_ninja/benchmarking/workers/agent_sql_guard_worker.py +40 -0
- sqlguard_ninja/benchmarking/workers/krish_sqlguard_worker.py +25 -0
- sqlguard_ninja/benchmarking/workers/sql_guardrail_worker.py +48 -0
- sqlguard_ninja/cli/__init__.py +1 -0
- sqlguard_ninja/cli/config.py +76 -0
- sqlguard_ninja/cli/main.py +433 -0
- sqlguard_ninja/cli/templates.py +91 -0
- sqlguard_ninja/core/__init__.py +1 -0
- sqlguard_ninja/core/analysis.py +581 -0
- sqlguard_ninja/core/findings.py +133 -0
- sqlguard_ninja/core/guard.py +256 -0
- sqlguard_ninja/core/rules/__init__.py +1 -0
- sqlguard_ninja/core/rules/access.py +127 -0
- sqlguard_ninja/core/rules/base.py +40 -0
- sqlguard_ninja/core/rules/catalog.py +61 -0
- sqlguard_ninja/core/rules/functions.py +197 -0
- sqlguard_ninja/core/rules/statements.py +99 -0
- sqlguard_ninja/core/rules/structure.py +72 -0
- sqlguard_ninja/core/rules/tenant.py +196 -0
- sqlguard_ninja/integrations/__init__.py +43 -0
- sqlguard_ninja/integrations/audit.py +109 -0
- sqlguard_ninja/integrations/checkpoint.py +342 -0
- sqlguard_ninja/integrations/executors.py +64 -0
- sqlguard_ninja/integrations/fastapi.py +89 -0
- sqlguard_ninja/integrations/langchain.py +67 -0
- sqlguard_ninja/integrations/langgraph.py +87 -0
- sqlguard_ninja/policy/__init__.py +39 -0
- sqlguard_ninja/policy/model.py +370 -0
- sqlguard_ninja/privacy/__init__.py +9 -0
- sqlguard_ninja/privacy/rules.py +286 -0
- sqlguard_ninja/privacy/suggest.py +46 -0
- sqlguard_ninja/py.typed +0 -0
- sqlguard_ninja/query_cost/__init__.py +1 -0
- sqlguard_ninja/query_cost/postgres.py +226 -0
- sqlguard_ninja/query_cost/static.py +241 -0
- sqlguard_ninja/reporting/__init__.py +1 -0
- sqlguard_ninja/reporting/markdown.py +137 -0
- sqlguard_ninja-0.1.0.dist-info/METADATA +370 -0
- sqlguard_ninja-0.1.0.dist-info/RECORD +68 -0
- sqlguard_ninja-0.1.0.dist-info/WHEEL +4 -0
- sqlguard_ninja-0.1.0.dist-info/entry_points.txt +2 -0
- sqlguard_ninja-0.1.0.dist-info/licenses/LICENSE +202 -0
- sqlguard_ninja-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +56 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Worker for krish2105/sqlguard (import name ``sqlguard``; installed from git, MIT)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
exec((Path(__file__).parent / "_protocol.py").read_text(encoding="utf-8"))
|
|
10
|
+
|
|
11
|
+
from sqlguard import SqlGuard # type: ignore[import-not-found]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build(config: dict[str, Any]): # type: ignore[no-untyped-def]
|
|
15
|
+
allow = {name: set(t["allow_columns"]) for name, t in config["tables"].items()}
|
|
16
|
+
guard = SqlGuard(allow, source_dialect=config["dialect"])
|
|
17
|
+
|
|
18
|
+
def evaluate(sql, dialect, context, _config): # type: ignore[no-untyped-def]
|
|
19
|
+
report = guard.check(sql)
|
|
20
|
+
return ("allow" if report.allowed else "deny"), list(report.errors)[:5]
|
|
21
|
+
|
|
22
|
+
return evaluate
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
serve("sqlguard (krish2105)", version("sqlguard"), build)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Worker for nickusevich/sql-guardrail (import name ``sqlguard``). MIT licensed upstream."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
exec((Path(__file__).parent / "_protocol.py").read_text(encoding="utf-8"))
|
|
10
|
+
|
|
11
|
+
from sqlguard import Policy, verify # type: ignore[import-not-found]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build(config: dict[str, Any]): # type: ignore[no-untyped-def]
|
|
15
|
+
tables = []
|
|
16
|
+
for name, t in config["tables"].items():
|
|
17
|
+
entry: dict[str, Any] = {
|
|
18
|
+
"name": name,
|
|
19
|
+
"allow_columns": t["allow_columns"],
|
|
20
|
+
"deny_columns": t["deny_columns"],
|
|
21
|
+
"large": False,
|
|
22
|
+
}
|
|
23
|
+
if t["tenant_column"]:
|
|
24
|
+
entry["require_predicate"] = [
|
|
25
|
+
{"column": t["tenant_column"], "op": "=", "value": "${tenant_id}"}
|
|
26
|
+
]
|
|
27
|
+
tables.append(entry)
|
|
28
|
+
limits = {k: v for k, v in config["limits"].items() if v is not None}
|
|
29
|
+
data = {
|
|
30
|
+
"read_only": True,
|
|
31
|
+
"tables": tables,
|
|
32
|
+
"limits": {
|
|
33
|
+
"max_joins": limits.get("max_joins", 10),
|
|
34
|
+
"max_subquery_depth": limits.get("max_subquery_depth", 8),
|
|
35
|
+
"max_limit_value": limits.get("max_limit", 10_000),
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
policy = Policy.from_dict(data)
|
|
39
|
+
|
|
40
|
+
def evaluate(sql, dialect, context, _config): # type: ignore[no-untyped-def]
|
|
41
|
+
ctx = {k: v for k, v in context.items() if v is not None}
|
|
42
|
+
result = verify(sql, policy, context=ctx)
|
|
43
|
+
return ("allow" if result.allowed else "deny"), [v.code.value for v in result.violations]
|
|
44
|
+
|
|
45
|
+
return evaluate
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
serve("sql-guardrail", version("sql-guardrail"), build)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Project configuration file (``sqlninja.yaml``) for CLI and CI use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from sqlguard_ninja.benchmarking.compare import Thresholds
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _M(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="forbid")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BenchmarkSection(_M):
|
|
19
|
+
dataset: str = "builtin:v1"
|
|
20
|
+
adapter: str = "native"
|
|
21
|
+
output: str = "results/latest.json"
|
|
22
|
+
report: str | None = "results/latest.md"
|
|
23
|
+
dialect: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CISection(_M):
|
|
27
|
+
baseline: str = "benchmarks/baselines/native.json"
|
|
28
|
+
max_security_regressions: int = 0
|
|
29
|
+
max_usability_regressions: int = 0
|
|
30
|
+
max_new_errors: int = 0
|
|
31
|
+
min_recall: float | None = None
|
|
32
|
+
max_false_positive_rate: float | None = None
|
|
33
|
+
max_p95_latency_increase_pct: float | None = None
|
|
34
|
+
|
|
35
|
+
def thresholds(self, allow_benchmark_change: bool = False) -> Thresholds:
|
|
36
|
+
return Thresholds(
|
|
37
|
+
max_security_regressions=self.max_security_regressions,
|
|
38
|
+
max_usability_regressions=self.max_usability_regressions,
|
|
39
|
+
max_new_errors=self.max_new_errors,
|
|
40
|
+
min_recall=self.min_recall,
|
|
41
|
+
max_false_positive_rate=self.max_false_positive_rate,
|
|
42
|
+
max_p95_latency_increase_pct=self.max_p95_latency_increase_pct,
|
|
43
|
+
allow_benchmark_change=allow_benchmark_change,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProjectConfig(_M):
|
|
48
|
+
version: Literal[1] = 1
|
|
49
|
+
policy: str | None = None
|
|
50
|
+
benchmark: BenchmarkSection = Field(default_factory=BenchmarkSection)
|
|
51
|
+
ci: CISection = Field(default_factory=CISection)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def load(cls, path: str | Path) -> tuple[ProjectConfig, Path]:
|
|
55
|
+
"""Load the config; relative paths inside it are resolved against its directory."""
|
|
56
|
+
p = Path(path)
|
|
57
|
+
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
|
58
|
+
return cls.model_validate(data), p.resolve().parent
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
DEFAULT_CONFIG = """\
|
|
62
|
+
# sqlninja project configuration
|
|
63
|
+
version: 1
|
|
64
|
+
policy: policies/policy.yaml
|
|
65
|
+
benchmark:
|
|
66
|
+
dataset: benchmarks/cases # a directory with manifest.yaml
|
|
67
|
+
adapter: native
|
|
68
|
+
output: results/latest.json
|
|
69
|
+
report: results/latest.md
|
|
70
|
+
ci:
|
|
71
|
+
baseline: benchmarks/baseline.json # approved result; update only by explicit review
|
|
72
|
+
max_security_regressions: 0 # unsafe query newly allowed
|
|
73
|
+
max_usability_regressions: 0 # legitimate query newly blocked
|
|
74
|
+
max_new_errors: 0
|
|
75
|
+
min_recall: 0.95
|
|
76
|
+
"""
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""``sqlninja`` command-line interface.
|
|
2
|
+
|
|
3
|
+
Exit codes:
|
|
4
|
+
|
|
5
|
+
* ``validate``: 0 allow, 1 deny, 3 review, 2 usage or configuration error.
|
|
6
|
+
* ``benchmark compare`` / ``policy check``: 0 pass, 1 fail, 2 usage or configuration error.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from sqlguard_ninja._version import __version__
|
|
21
|
+
from sqlguard_ninja.benchmarking import builtin
|
|
22
|
+
from sqlguard_ninja.benchmarking.adapters import (
|
|
23
|
+
EXTERNAL_TOOLS,
|
|
24
|
+
BenchmarkAdapter,
|
|
25
|
+
CallableAdapter,
|
|
26
|
+
NativeAdapter,
|
|
27
|
+
SubprocessAdapter,
|
|
28
|
+
)
|
|
29
|
+
from sqlguard_ninja.benchmarking.compare import compare
|
|
30
|
+
from sqlguard_ninja.benchmarking.dataset import DatasetError, load_dataset
|
|
31
|
+
from sqlguard_ninja.benchmarking.runner import load_result, run_benchmark, write_result
|
|
32
|
+
from sqlguard_ninja.cli.config import CISection, ProjectConfig
|
|
33
|
+
from sqlguard_ninja.cli.templates import FILES
|
|
34
|
+
from sqlguard_ninja.core.findings import Decision
|
|
35
|
+
from sqlguard_ninja.core.guard import Guard
|
|
36
|
+
from sqlguard_ninja.policy.model import Policy, PolicyError
|
|
37
|
+
from sqlguard_ninja.privacy.suggest import suggest_labels
|
|
38
|
+
from sqlguard_ninja.reporting.markdown import benchmark_report, comparison_report
|
|
39
|
+
|
|
40
|
+
EXIT_OK, EXIT_FAIL, EXIT_USAGE, EXIT_REVIEW = 0, 1, 2, 3
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CLIError(Exception):
|
|
44
|
+
"""A user-facing error; printed without a traceback, exit code 2."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _out(text: str) -> None:
|
|
48
|
+
line = text + ("\n" if not text.endswith("\n") else "")
|
|
49
|
+
try:
|
|
50
|
+
sys.stdout.write(line)
|
|
51
|
+
except UnicodeEncodeError:
|
|
52
|
+
# Legacy consoles (e.g. Windows cp1252) cannot print symbols used in reports.
|
|
53
|
+
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
|
|
54
|
+
sys.stdout.write(line.encode(encoding, errors="replace").decode(encoding))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _err(text: str) -> None:
|
|
58
|
+
sys.stderr.write(text + "\n")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_config(path: str | None) -> tuple[ProjectConfig, Path]:
|
|
62
|
+
if path is None:
|
|
63
|
+
default = Path("sqlninja.yaml")
|
|
64
|
+
if default.exists():
|
|
65
|
+
return ProjectConfig.load(default)
|
|
66
|
+
return ProjectConfig(), Path.cwd()
|
|
67
|
+
try:
|
|
68
|
+
return ProjectConfig.load(path)
|
|
69
|
+
except (OSError, ValueError) as exc:
|
|
70
|
+
raise CLIError(f"cannot load config {path}: {exc}") from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resolve(base: Path, value: str | None) -> Path | str | None:
|
|
74
|
+
if value is None:
|
|
75
|
+
return None
|
|
76
|
+
if value.startswith(builtin.PREFIX):
|
|
77
|
+
return value # resolved by builtin.resolve() where it is loaded
|
|
78
|
+
p = Path(value)
|
|
79
|
+
return p if p.is_absolute() else base / p
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_file(base: Path, value: str | None) -> Path | None:
|
|
83
|
+
"""Resolve an output/baseline file path (never a ``builtin:`` reference)."""
|
|
84
|
+
if value is None:
|
|
85
|
+
return None
|
|
86
|
+
p = Path(value)
|
|
87
|
+
return p if p.is_absolute() else base / p
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_policy(path: str | Path | None) -> Policy:
|
|
91
|
+
if path is None:
|
|
92
|
+
raise CLIError(
|
|
93
|
+
"no policy given (use --policy FILE, --policy builtin:retail_saas, "
|
|
94
|
+
"or set 'policy' in sqlninja.yaml)"
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
return Policy.from_file(builtin.resolve(path, "policy"))
|
|
98
|
+
except (PolicyError, FileNotFoundError) as exc:
|
|
99
|
+
raise CLIError(str(exc)) from exc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_summary(markdown: str) -> None:
|
|
103
|
+
"""Append to the GitHub Actions job summary when running in Actions."""
|
|
104
|
+
target = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
105
|
+
if target:
|
|
106
|
+
with open(target, "a", encoding="utf-8") as fh:
|
|
107
|
+
fh.write(markdown + "\n")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------------------- commands
|
|
111
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
112
|
+
cfg, base = _load_config(args.config)
|
|
113
|
+
policy = _load_policy(args.policy or _resolve(base, cfg.policy))
|
|
114
|
+
if sum(x is not None for x in (args.sql, args.file)) + bool(args.stdin) != 1:
|
|
115
|
+
raise CLIError("give exactly one of --sql, --file or --stdin")
|
|
116
|
+
if args.sql is not None:
|
|
117
|
+
sql = args.sql
|
|
118
|
+
elif args.file is not None:
|
|
119
|
+
sql = Path(args.file).read_text(encoding="utf-8")
|
|
120
|
+
else:
|
|
121
|
+
sql = sys.stdin.read()
|
|
122
|
+
context: dict[str, Any] = {}
|
|
123
|
+
if args.context:
|
|
124
|
+
try:
|
|
125
|
+
context = json.loads(args.context)
|
|
126
|
+
except json.JSONDecodeError as exc:
|
|
127
|
+
raise CLIError(f"--context is not valid JSON: {exc}") from exc
|
|
128
|
+
if not isinstance(context, dict):
|
|
129
|
+
raise CLIError("--context must be a JSON object")
|
|
130
|
+
if args.tenant is not None:
|
|
131
|
+
context[policy.tenant.context_key] = args.tenant
|
|
132
|
+
result = Guard(policy).validate(sql, role=args.role, dialect=args.dialect, context=context)
|
|
133
|
+
if args.format == "json":
|
|
134
|
+
_out(result.model_dump_json(indent=2))
|
|
135
|
+
else:
|
|
136
|
+
_out(result.explain(include_detail=args.verbose))
|
|
137
|
+
return {Decision.ALLOW: EXIT_OK, Decision.DENY: EXIT_FAIL, Decision.REVIEW: EXIT_REVIEW}[
|
|
138
|
+
result.decision
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_policy_check(args: argparse.Namespace) -> int:
|
|
143
|
+
cfg, base = _load_config(args.config)
|
|
144
|
+
policy = _load_policy(args.policy or _resolve(base, cfg.policy))
|
|
145
|
+
tables = policy.catalog.tables
|
|
146
|
+
labelled = sum(1 for t in tables.values() for c in t.columns.values() if c.effective_labels())
|
|
147
|
+
_out(f"policy '{policy.name}' (version {policy.version}, dialect {policy.dialect}) is valid")
|
|
148
|
+
_out(f" tables: {len(tables)} labelled columns: {labelled} labels: {len(policy.labels)}")
|
|
149
|
+
_out(f" roles: {', '.join(policy.roles) or '(none: implicit role)'}")
|
|
150
|
+
tenant_tables = [n for n, t in tables.items() if t.tenant_column]
|
|
151
|
+
_out(f" tenant-scoped tables: {', '.join(tenant_tables) or '(none)'}")
|
|
152
|
+
warnings = []
|
|
153
|
+
if policy.functions.mode == "denylist":
|
|
154
|
+
warnings.append("functions.mode is 'denylist'; 'allowlist' is safer for production")
|
|
155
|
+
for name, role in policy.roles.items():
|
|
156
|
+
if not role.tenant_scoped and tenant_tables:
|
|
157
|
+
warnings.append(f"role '{name}' is not tenant-scoped")
|
|
158
|
+
for w in warnings:
|
|
159
|
+
_out(f" warning: {w}")
|
|
160
|
+
return EXIT_OK
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_policy_suggest(args: argparse.Namespace) -> int:
|
|
164
|
+
cfg, base = _load_config(args.config)
|
|
165
|
+
policy = _load_policy(args.policy or _resolve(base, cfg.policy))
|
|
166
|
+
suggestions = suggest_labels(policy.catalog)
|
|
167
|
+
_out("# Suggestions only - review with the data owner before adding to the policy.")
|
|
168
|
+
if not suggestions:
|
|
169
|
+
_out("# no unlabelled columns matched the heuristics")
|
|
170
|
+
for s in suggestions:
|
|
171
|
+
_out(f"{s.table}.{s.column}: [{s.label}] # matched '{s.matched}'")
|
|
172
|
+
return EXIT_OK
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
176
|
+
root = Path(args.directory)
|
|
177
|
+
existing = [name for name in FILES if (root / name).exists()]
|
|
178
|
+
if existing and not args.force:
|
|
179
|
+
raise CLIError(f"refusing to overwrite {existing}; use --force")
|
|
180
|
+
for name, text in FILES.items():
|
|
181
|
+
path = root / name
|
|
182
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
path.write_text(text, encoding="utf-8")
|
|
184
|
+
_out(f"wrote {path}")
|
|
185
|
+
_out("next: sqlninja benchmark run --config " + str(root / "sqlninja.yaml"))
|
|
186
|
+
return EXIT_OK
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _make_adapter(spec: str, policy: Policy, python: str | None) -> BenchmarkAdapter:
|
|
190
|
+
if spec == "native":
|
|
191
|
+
return NativeAdapter(policy)
|
|
192
|
+
if spec.startswith("callable:"):
|
|
193
|
+
return CallableAdapter.from_spec(spec.removeprefix("callable:"))
|
|
194
|
+
if spec.startswith("external:"):
|
|
195
|
+
name = spec.removeprefix("external:")
|
|
196
|
+
if name not in EXTERNAL_TOOLS:
|
|
197
|
+
raise CLIError(f"unknown external tool {name!r}; choose from {sorted(EXTERNAL_TOOLS)}")
|
|
198
|
+
interpreter = python or str(
|
|
199
|
+
Path(".venvs")
|
|
200
|
+
/ name
|
|
201
|
+
/ ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
|
|
202
|
+
)
|
|
203
|
+
if not Path(interpreter).exists():
|
|
204
|
+
raise CLIError(
|
|
205
|
+
f"no interpreter for {name} at {interpreter}; run "
|
|
206
|
+
"scripts/setup_external_adapters.py or pass --python"
|
|
207
|
+
)
|
|
208
|
+
return SubprocessAdapter(EXTERNAL_TOOLS[name], interpreter, policy)
|
|
209
|
+
raise CLIError("adapter must be 'native', 'external:<tool>' or 'callable:<module>:<fn>'")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_benchmark_run(args: argparse.Namespace) -> int:
|
|
213
|
+
cfg, base = _load_config(args.config)
|
|
214
|
+
bench = cfg.benchmark
|
|
215
|
+
dataset_path = args.dataset or _resolve(base, bench.dataset)
|
|
216
|
+
try:
|
|
217
|
+
dataset = load_dataset(builtin.resolve(str(dataset_path), "dataset"))
|
|
218
|
+
except (DatasetError, PolicyError, OSError) as exc:
|
|
219
|
+
raise CLIError(f"cannot load dataset {dataset_path}: {exc}") from exc
|
|
220
|
+
cases = dataset.filter(dialect=args.dialect or bench.dialect, category=args.category)
|
|
221
|
+
if not cases:
|
|
222
|
+
raise CLIError("no cases match the filters")
|
|
223
|
+
adapter = _make_adapter(args.adapter or bench.adapter, dataset.policy, args.python)
|
|
224
|
+
try:
|
|
225
|
+
result = run_benchmark(dataset, adapter, cases)
|
|
226
|
+
finally:
|
|
227
|
+
adapter.close()
|
|
228
|
+
output = Path(args.output) if args.output else _resolve_file(base, bench.output)
|
|
229
|
+
assert output is not None
|
|
230
|
+
write_result(result, output)
|
|
231
|
+
report = benchmark_report(result)
|
|
232
|
+
report_path = Path(args.report) if args.report else _resolve_file(base, bench.report)
|
|
233
|
+
if report_path is not None:
|
|
234
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
report_path.write_text(report + "\n", encoding="utf-8")
|
|
236
|
+
if args.summary:
|
|
237
|
+
_write_summary(report)
|
|
238
|
+
m = result["metrics"]["overall"]
|
|
239
|
+
_out(
|
|
240
|
+
f"{adapter.name} {adapter.version}: {m['cases']} evaluations, recall "
|
|
241
|
+
f"{m['recall']}, precision {m['precision']}, FPR {m['false_positive_rate']}, "
|
|
242
|
+
f"p95 {m['latency_ms']['p95']} ms -> {output}"
|
|
243
|
+
)
|
|
244
|
+
return EXIT_OK
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _thresholds(args: argparse.Namespace, ci: CISection) -> Any:
|
|
248
|
+
t = ci.thresholds(allow_benchmark_change=args.allow_benchmark_change)
|
|
249
|
+
t = type(t)(**{**t.__dict__, "allow_label_changes": args.allow_label_changes})
|
|
250
|
+
overrides = {
|
|
251
|
+
k: getattr(args, k)
|
|
252
|
+
for k in (
|
|
253
|
+
"max_security_regressions",
|
|
254
|
+
"max_usability_regressions",
|
|
255
|
+
"min_recall",
|
|
256
|
+
"max_false_positive_rate",
|
|
257
|
+
"max_p95_latency_increase_pct",
|
|
258
|
+
)
|
|
259
|
+
if getattr(args, k) is not None
|
|
260
|
+
}
|
|
261
|
+
return type(t)(**{**t.__dict__, **overrides})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cmd_benchmark_compare(args: argparse.Namespace) -> int:
|
|
265
|
+
cfg, base = _load_config(args.config)
|
|
266
|
+
baseline_path = args.baseline or _resolve_file(base, cfg.ci.baseline)
|
|
267
|
+
candidate_path = args.candidate or _resolve_file(base, cfg.benchmark.output)
|
|
268
|
+
try:
|
|
269
|
+
baseline = load_result(str(baseline_path))
|
|
270
|
+
candidate = load_result(str(candidate_path))
|
|
271
|
+
except (OSError, ValueError) as exc:
|
|
272
|
+
raise CLIError(str(exc)) from exc
|
|
273
|
+
comp = compare(baseline, candidate, _thresholds(args, cfg.ci))
|
|
274
|
+
report = comparison_report(comp)
|
|
275
|
+
if args.report:
|
|
276
|
+
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
Path(args.report).write_text(report + "\n", encoding="utf-8")
|
|
278
|
+
if args.json:
|
|
279
|
+
Path(args.json).parent.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
Path(args.json).write_text(json.dumps(comp.as_dict(), indent=2) + "\n", encoding="utf-8")
|
|
281
|
+
if args.summary:
|
|
282
|
+
_write_summary(report)
|
|
283
|
+
_out(report)
|
|
284
|
+
return EXIT_OK if comp.passed else EXIT_FAIL
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cmd_benchmark_baseline(args: argparse.Namespace) -> int:
|
|
288
|
+
"""Copy a reviewed result to the baseline path. Meant to be run by a human."""
|
|
289
|
+
cfg, base = _load_config(args.config)
|
|
290
|
+
source = Path(args.candidate) if args.candidate else _resolve_file(base, cfg.benchmark.output)
|
|
291
|
+
target = Path(args.output) if args.output else _resolve_file(base, cfg.ci.baseline)
|
|
292
|
+
assert source is not None and target is not None
|
|
293
|
+
if os.environ.get("CI") and not args.i_understand_this_is_ci:
|
|
294
|
+
raise CLIError("refusing to update a baseline inside CI; review and commit it locally")
|
|
295
|
+
load_result(source)
|
|
296
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
shutil.copyfile(source, target)
|
|
298
|
+
_out(f"baseline updated: {target} (commit it for review)")
|
|
299
|
+
return EXIT_OK
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def cmd_report_generate(args: argparse.Namespace) -> int:
|
|
303
|
+
result = load_result(args.result)
|
|
304
|
+
text = benchmark_report(result)
|
|
305
|
+
if args.output:
|
|
306
|
+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
|
308
|
+
else:
|
|
309
|
+
_out(text)
|
|
310
|
+
return EXIT_OK
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def cmd_dataset_stats(args: argparse.Namespace) -> int:
|
|
314
|
+
cfg, base = _load_config(args.config)
|
|
315
|
+
path = args.dataset or _resolve(base, cfg.benchmark.dataset)
|
|
316
|
+
try:
|
|
317
|
+
ds = load_dataset(builtin.resolve(str(path), "dataset"))
|
|
318
|
+
except (DatasetError, PolicyError, OSError) as exc:
|
|
319
|
+
raise CLIError(str(exc)) from exc
|
|
320
|
+
_out(json.dumps({"fingerprint": ds.fingerprint, **ds.stats()}, indent=2))
|
|
321
|
+
return EXIT_OK
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# --------------------------------------------------------------------------------- parser
|
|
325
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
326
|
+
p = argparse.ArgumentParser(
|
|
327
|
+
prog="sqlninja",
|
|
328
|
+
description="Policy enforcement and security benchmarking for LLM-generated SQL.",
|
|
329
|
+
)
|
|
330
|
+
p.add_argument("--version", action="version", version=f"sqlninja {__version__}")
|
|
331
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
332
|
+
|
|
333
|
+
def common(sp: argparse.ArgumentParser) -> None:
|
|
334
|
+
sp.add_argument("--config", help="sqlninja.yaml (default: ./sqlninja.yaml if present)")
|
|
335
|
+
|
|
336
|
+
v = sub.add_parser("validate", help="validate one SQL statement")
|
|
337
|
+
common(v)
|
|
338
|
+
v.add_argument("--policy")
|
|
339
|
+
v.add_argument("--sql")
|
|
340
|
+
v.add_argument("--file")
|
|
341
|
+
v.add_argument("--stdin", action="store_true")
|
|
342
|
+
v.add_argument("--role")
|
|
343
|
+
v.add_argument("--dialect")
|
|
344
|
+
v.add_argument("--tenant", help="trusted tenant id (sets the policy's tenant context key)")
|
|
345
|
+
v.add_argument("--context", help="JSON object of trusted request context")
|
|
346
|
+
v.add_argument("--format", choices=["text", "json"], default="text")
|
|
347
|
+
v.add_argument("--verbose", "-v", action="store_true", help="include internal detail")
|
|
348
|
+
v.set_defaults(func=cmd_validate)
|
|
349
|
+
|
|
350
|
+
i = sub.add_parser("init", help="write a starter sqlninja.yaml, policy and test cases")
|
|
351
|
+
i.add_argument("directory", nargs="?", default=".")
|
|
352
|
+
i.add_argument("--force", action="store_true")
|
|
353
|
+
i.set_defaults(func=cmd_init)
|
|
354
|
+
|
|
355
|
+
pol = sub.add_parser("policy", help="policy utilities").add_subparsers(
|
|
356
|
+
dest="policy_cmd", required=True
|
|
357
|
+
)
|
|
358
|
+
pc = pol.add_parser("check", help="validate a policy file and print a summary")
|
|
359
|
+
common(pc)
|
|
360
|
+
pc.add_argument("--policy")
|
|
361
|
+
pc.set_defaults(func=cmd_policy_check)
|
|
362
|
+
ps = pol.add_parser("suggest", help="suggest labels for unlabelled columns (heuristic)")
|
|
363
|
+
common(ps)
|
|
364
|
+
ps.add_argument("--policy")
|
|
365
|
+
ps.set_defaults(func=cmd_policy_suggest)
|
|
366
|
+
|
|
367
|
+
bench = sub.add_parser("benchmark", help="run and compare benchmarks").add_subparsers(
|
|
368
|
+
dest="bench_cmd", required=True
|
|
369
|
+
)
|
|
370
|
+
br = bench.add_parser("run", help="evaluate a dataset with an adapter")
|
|
371
|
+
common(br)
|
|
372
|
+
br.add_argument("--dataset")
|
|
373
|
+
br.add_argument("--adapter", help="native | external:<tool> | callable:<module>:<function>")
|
|
374
|
+
br.add_argument("--python", help="interpreter for an external adapter's venv")
|
|
375
|
+
br.add_argument("--dialect")
|
|
376
|
+
br.add_argument("--category")
|
|
377
|
+
br.add_argument("--output")
|
|
378
|
+
br.add_argument("--report")
|
|
379
|
+
br.add_argument("--summary", action="store_true", help="append to GITHUB_STEP_SUMMARY")
|
|
380
|
+
br.set_defaults(func=cmd_benchmark_run)
|
|
381
|
+
|
|
382
|
+
bc = bench.add_parser("compare", help="compare a result with an approved baseline")
|
|
383
|
+
common(bc)
|
|
384
|
+
bc.add_argument("--baseline")
|
|
385
|
+
bc.add_argument("--candidate")
|
|
386
|
+
bc.add_argument("--report")
|
|
387
|
+
bc.add_argument("--json")
|
|
388
|
+
bc.add_argument("--summary", action="store_true")
|
|
389
|
+
bc.add_argument("--max-security-regressions", type=int)
|
|
390
|
+
bc.add_argument("--max-usability-regressions", type=int)
|
|
391
|
+
bc.add_argument("--min-recall", type=float)
|
|
392
|
+
bc.add_argument("--max-false-positive-rate", type=float)
|
|
393
|
+
bc.add_argument("--max-p95-latency-increase-pct", type=float)
|
|
394
|
+
bc.add_argument("--allow-benchmark-change", action="store_true")
|
|
395
|
+
bc.add_argument("--allow-label-changes", action="store_true")
|
|
396
|
+
bc.set_defaults(func=cmd_benchmark_compare)
|
|
397
|
+
|
|
398
|
+
bb = bench.add_parser("baseline", help="accept a reviewed result as the new baseline")
|
|
399
|
+
common(bb)
|
|
400
|
+
bb.add_argument("--candidate")
|
|
401
|
+
bb.add_argument("--output")
|
|
402
|
+
bb.add_argument("--i-understand-this-is-ci", action="store_true", help=argparse.SUPPRESS)
|
|
403
|
+
bb.set_defaults(func=cmd_benchmark_baseline)
|
|
404
|
+
|
|
405
|
+
rep = sub.add_parser("report", help="reports").add_subparsers(dest="report_cmd", required=True)
|
|
406
|
+
rg = rep.add_parser("generate", help="render a Markdown report from a result JSON")
|
|
407
|
+
rg.add_argument("--result", required=True)
|
|
408
|
+
rg.add_argument("--output")
|
|
409
|
+
rg.set_defaults(func=cmd_report_generate)
|
|
410
|
+
|
|
411
|
+
ds = sub.add_parser("dataset", help="dataset utilities").add_subparsers(
|
|
412
|
+
dest="dataset_cmd", required=True
|
|
413
|
+
)
|
|
414
|
+
dst = ds.add_parser("stats", help="validate a dataset and print statistics")
|
|
415
|
+
common(dst)
|
|
416
|
+
dst.add_argument("--dataset")
|
|
417
|
+
dst.set_defaults(func=cmd_dataset_stats)
|
|
418
|
+
return p
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
422
|
+
parser = build_parser()
|
|
423
|
+
args = parser.parse_args(argv)
|
|
424
|
+
try:
|
|
425
|
+
code: int = args.func(args)
|
|
426
|
+
except CLIError as exc:
|
|
427
|
+
_err(f"error: {exc}")
|
|
428
|
+
return EXIT_USAGE
|
|
429
|
+
return code
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if __name__ == "__main__": # pragma: no cover
|
|
433
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Starter files written by ``sqlninja init``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlguard_ninja.cli.config import DEFAULT_CONFIG
|
|
6
|
+
|
|
7
|
+
POLICY = """\
|
|
8
|
+
# Starter policy. Labels are assigned explicitly by you; nothing is inferred from names.
|
|
9
|
+
version: 1
|
|
10
|
+
name: starter
|
|
11
|
+
dialect: postgres
|
|
12
|
+
labels:
|
|
13
|
+
contact: {sensitivity: high, masking: return the email domain}
|
|
14
|
+
financial: {sensitivity: high, masking: aggregate}
|
|
15
|
+
catalog:
|
|
16
|
+
default_schema: public
|
|
17
|
+
tables:
|
|
18
|
+
customers:
|
|
19
|
+
tenant_column: tenant_id
|
|
20
|
+
columns:
|
|
21
|
+
id: int
|
|
22
|
+
tenant_id: int
|
|
23
|
+
name: text
|
|
24
|
+
email: {labels: [contact], alternatives: [email_domain]}
|
|
25
|
+
email_domain: text
|
|
26
|
+
orders:
|
|
27
|
+
tenant_column: tenant_id
|
|
28
|
+
columns:
|
|
29
|
+
id: int
|
|
30
|
+
tenant_id: int
|
|
31
|
+
customer_id: int
|
|
32
|
+
status: text
|
|
33
|
+
total: {type: numeric, labels: [financial]}
|
|
34
|
+
roles:
|
|
35
|
+
support:
|
|
36
|
+
tables: {customers: "*", orders: "*"}
|
|
37
|
+
labels: {contact: allow, financial: aggregate_only}
|
|
38
|
+
tenant:
|
|
39
|
+
context_key: tenant_id
|
|
40
|
+
parameter_names: [tenant_id]
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
MANIFEST = """\
|
|
44
|
+
name: my-sql-policy-tests
|
|
45
|
+
version: "0.1.0"
|
|
46
|
+
policy: ../../policies/policy.yaml
|
|
47
|
+
default_context: {tenant_id: 1}
|
|
48
|
+
case_files: [cases/*.yaml]
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
CASES = """\
|
|
52
|
+
defaults:
|
|
53
|
+
role: support
|
|
54
|
+
dialects: [postgres]
|
|
55
|
+
cases:
|
|
56
|
+
- id: OK-001
|
|
57
|
+
sql: SELECT o.id, o.status FROM orders o WHERE o.tenant_id = 1
|
|
58
|
+
category: legitimate_complex
|
|
59
|
+
expected: allow
|
|
60
|
+
explanation: Tenant-pinned order lookup.
|
|
61
|
+
- id: TEN-001
|
|
62
|
+
sql: SELECT o.id FROM orders o
|
|
63
|
+
category: tenant_isolation
|
|
64
|
+
expected: deny
|
|
65
|
+
reason: tenant_isolation
|
|
66
|
+
explanation: Missing tenant filter.
|
|
67
|
+
- id: PRV-001
|
|
68
|
+
sql: SELECT o.total FROM orders o WHERE o.tenant_id = 1
|
|
69
|
+
category: sensitive_field
|
|
70
|
+
expected: deny
|
|
71
|
+
reason: aggregate_only
|
|
72
|
+
explanation: Individual order totals are aggregate-only.
|
|
73
|
+
- id: PRV-002
|
|
74
|
+
sql: SELECT SUM(o.total) FROM orders o WHERE o.tenant_id = 1
|
|
75
|
+
category: sensitive_field
|
|
76
|
+
expected: allow
|
|
77
|
+
explanation: Aggregated totals are permitted.
|
|
78
|
+
- id: DES-001
|
|
79
|
+
sql: DELETE FROM orders WHERE tenant_id = 1
|
|
80
|
+
category: destructive
|
|
81
|
+
expected: deny
|
|
82
|
+
reason: write_statement
|
|
83
|
+
explanation: Writes are never allowed.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
FILES = {
|
|
87
|
+
"sqlninja.yaml": DEFAULT_CONFIG,
|
|
88
|
+
"policies/policy.yaml": POLICY,
|
|
89
|
+
"benchmarks/cases/manifest.yaml": MANIFEST,
|
|
90
|
+
"benchmarks/cases/cases/examples.yaml": CASES,
|
|
91
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""core package."""
|