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,168 @@
|
|
|
1
|
+
"""Compare a candidate benchmark result with an approved baseline.
|
|
2
|
+
|
|
3
|
+
A comparison never updates the baseline. Regressions are classified so CI can gate on the
|
|
4
|
+
ones that matter most:
|
|
5
|
+
|
|
6
|
+
* ``security_regressions`` — an unsafe case the baseline flagged is now ALLOWED.
|
|
7
|
+
* ``sensitive_regressions`` / ``tenant_regressions`` — subsets in privacy and tenant categories.
|
|
8
|
+
* ``usability_regressions`` — a safe case the baseline allowed is now blocked.
|
|
9
|
+
* ``decision_changes`` — any other change of decision (e.g. deny → review).
|
|
10
|
+
* ``new_errors`` — validator crashes or new parse failures not present in the baseline.
|
|
11
|
+
* ``latency`` — p95 change in percent.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
PRIVACY_CATEGORIES = frozenset({"sensitive_field", "json_fields"})
|
|
20
|
+
TENANT_CATEGORIES = frozenset({"tenant_isolation"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Thresholds:
|
|
25
|
+
max_security_regressions: int = 0
|
|
26
|
+
max_usability_regressions: int = 0
|
|
27
|
+
max_new_errors: int = 0
|
|
28
|
+
min_recall: float | None = None
|
|
29
|
+
max_false_positive_rate: float | None = None
|
|
30
|
+
max_p95_latency_increase_pct: float | None = None
|
|
31
|
+
"""None disables the latency gate (CI hardware varies)."""
|
|
32
|
+
allow_benchmark_change: bool = False
|
|
33
|
+
allow_label_changes: bool = False
|
|
34
|
+
"""Expected outcomes of existing cases changed. Off by default: relabelling a case is
|
|
35
|
+
exactly how a regression could be hidden, so it needs explicit human approval."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Comparison:
|
|
40
|
+
baseline: dict[str, Any]
|
|
41
|
+
candidate: dict[str, Any]
|
|
42
|
+
security_regressions: list[str] = field(default_factory=list)
|
|
43
|
+
sensitive_regressions: list[str] = field(default_factory=list)
|
|
44
|
+
tenant_regressions: list[str] = field(default_factory=list)
|
|
45
|
+
usability_regressions: list[str] = field(default_factory=list)
|
|
46
|
+
improvements: list[str] = field(default_factory=list)
|
|
47
|
+
decision_changes: list[str] = field(default_factory=list)
|
|
48
|
+
new_errors: list[str] = field(default_factory=list)
|
|
49
|
+
new_parse_failures: list[str] = field(default_factory=list)
|
|
50
|
+
missing_cases: list[str] = field(default_factory=list)
|
|
51
|
+
label_changes: list[str] = field(default_factory=list)
|
|
52
|
+
added_cases: list[str] = field(default_factory=list)
|
|
53
|
+
p95_change_pct: float | None = None
|
|
54
|
+
failures: list[str] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def passed(self) -> bool:
|
|
58
|
+
return not self.failures
|
|
59
|
+
|
|
60
|
+
def as_dict(self) -> dict[str, Any]:
|
|
61
|
+
keys = [
|
|
62
|
+
"security_regressions",
|
|
63
|
+
"sensitive_regressions",
|
|
64
|
+
"tenant_regressions",
|
|
65
|
+
"usability_regressions",
|
|
66
|
+
"improvements",
|
|
67
|
+
"decision_changes",
|
|
68
|
+
"new_errors",
|
|
69
|
+
"new_parse_failures",
|
|
70
|
+
"missing_cases",
|
|
71
|
+
"label_changes",
|
|
72
|
+
"added_cases",
|
|
73
|
+
"p95_change_pct",
|
|
74
|
+
"failures",
|
|
75
|
+
]
|
|
76
|
+
return {"passed": self.passed, **{k: getattr(self, k) for k in keys}}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_parse_failure(record: dict[str, Any]) -> bool:
|
|
80
|
+
return any(str(r).startswith("parse.") for r in record.get("reasons", ()))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compare(
|
|
84
|
+
baseline: dict[str, Any], candidate: dict[str, Any], thresholds: Thresholds | None = None
|
|
85
|
+
) -> Comparison:
|
|
86
|
+
t = thresholds or Thresholds()
|
|
87
|
+
c = Comparison(baseline, candidate)
|
|
88
|
+
base = {r["id"]: r for r in baseline["cases"]}
|
|
89
|
+
cand = {r["id"]: r for r in candidate["cases"]}
|
|
90
|
+
c.missing_cases = sorted(set(base) - set(cand))
|
|
91
|
+
c.added_cases = sorted(set(cand) - set(base))
|
|
92
|
+
|
|
93
|
+
for cid in sorted(set(base) & set(cand)):
|
|
94
|
+
b, n = base[cid], cand[cid]
|
|
95
|
+
if b["expected"] != n["expected"]:
|
|
96
|
+
c.label_changes.append(cid)
|
|
97
|
+
if n.get("error") and not b.get("error"):
|
|
98
|
+
c.new_errors.append(cid)
|
|
99
|
+
if _is_parse_failure(n) and not _is_parse_failure(b) and n["expected"] == "allow":
|
|
100
|
+
c.new_parse_failures.append(cid)
|
|
101
|
+
if b["actual"] == n["actual"]:
|
|
102
|
+
continue
|
|
103
|
+
unsafe = n["expected"] != "allow"
|
|
104
|
+
if unsafe and b["actual"] != "allow" and n["actual"] == "allow":
|
|
105
|
+
c.security_regressions.append(cid)
|
|
106
|
+
if n["category"] in PRIVACY_CATEGORIES:
|
|
107
|
+
c.sensitive_regressions.append(cid)
|
|
108
|
+
if n["category"] in TENANT_CATEGORIES:
|
|
109
|
+
c.tenant_regressions.append(cid)
|
|
110
|
+
elif not unsafe and b["actual"] == "allow" and n["actual"] != "allow":
|
|
111
|
+
c.usability_regressions.append(cid)
|
|
112
|
+
elif n["actual"] == n["expected"]:
|
|
113
|
+
c.improvements.append(cid)
|
|
114
|
+
else:
|
|
115
|
+
c.decision_changes.append(cid)
|
|
116
|
+
|
|
117
|
+
# Cases that are new in the candidate still count when they are unsafe and allowed:
|
|
118
|
+
# a PR must not be able to add a failing case and the code that fails it together.
|
|
119
|
+
for cid in c.added_cases:
|
|
120
|
+
if cand[cid]["kind"] == "fn":
|
|
121
|
+
c.security_regressions.append(cid)
|
|
122
|
+
|
|
123
|
+
bp95 = baseline["metrics"]["overall"]["latency_ms"]["p95"]
|
|
124
|
+
np95 = candidate["metrics"]["overall"]["latency_ms"]["p95"]
|
|
125
|
+
if bp95 and np95:
|
|
126
|
+
c.p95_change_pct = round((np95 - bp95) / bp95 * 100, 1)
|
|
127
|
+
|
|
128
|
+
bfp = baseline["benchmark"]["fingerprint"]
|
|
129
|
+
nfp = candidate["benchmark"]["fingerprint"]
|
|
130
|
+
if bfp != nfp and not t.allow_benchmark_change:
|
|
131
|
+
c.failures.append(
|
|
132
|
+
f"benchmark fingerprint changed ({bfp} -> {nfp}); regenerate and approve a new "
|
|
133
|
+
"baseline, or pass allow_benchmark_change"
|
|
134
|
+
)
|
|
135
|
+
if c.label_changes and not t.allow_label_changes:
|
|
136
|
+
c.failures.append(
|
|
137
|
+
f"{len(c.label_changes)} case label(s) changed; relabelling requires explicit approval"
|
|
138
|
+
)
|
|
139
|
+
if c.missing_cases and not t.allow_benchmark_change:
|
|
140
|
+
c.failures.append(f"{len(c.missing_cases)} baseline cases are missing from the candidate")
|
|
141
|
+
if len(c.security_regressions) > t.max_security_regressions:
|
|
142
|
+
c.failures.append(f"{len(c.security_regressions)} security regression(s)")
|
|
143
|
+
if len(c.usability_regressions) > t.max_usability_regressions:
|
|
144
|
+
c.failures.append(f"{len(c.usability_regressions)} newly blocked legitimate case(s)")
|
|
145
|
+
if len(c.new_errors) + len(c.new_parse_failures) > t.max_new_errors:
|
|
146
|
+
c.failures.append(
|
|
147
|
+
f"{len(c.new_errors)} new validator error(s), "
|
|
148
|
+
f"{len(c.new_parse_failures)} new parse failure(s)"
|
|
149
|
+
)
|
|
150
|
+
overall = candidate["metrics"]["overall"]
|
|
151
|
+
if t.min_recall is not None and (overall["recall"] or 0) < t.min_recall:
|
|
152
|
+
c.failures.append(f"recall {overall['recall']} below {t.min_recall}")
|
|
153
|
+
fpr = overall["false_positive_rate"]
|
|
154
|
+
if (
|
|
155
|
+
t.max_false_positive_rate is not None
|
|
156
|
+
and fpr is not None
|
|
157
|
+
and fpr > t.max_false_positive_rate
|
|
158
|
+
):
|
|
159
|
+
c.failures.append(f"false-positive rate {fpr} above {t.max_false_positive_rate}")
|
|
160
|
+
if (
|
|
161
|
+
t.max_p95_latency_increase_pct is not None
|
|
162
|
+
and c.p95_change_pct is not None
|
|
163
|
+
and c.p95_change_pct > t.max_p95_latency_increase_pct
|
|
164
|
+
):
|
|
165
|
+
c.failures.append(
|
|
166
|
+
f"p95 latency +{c.p95_change_pct}% exceeds {t.max_p95_latency_increase_pct}%"
|
|
167
|
+
)
|
|
168
|
+
return c
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Versioned benchmark corpus: case model, file format and loader.
|
|
2
|
+
|
|
3
|
+
A dataset directory contains ``manifest.yaml`` and one or more case files. Each case file has
|
|
4
|
+
optional ``defaults`` merged into every case. A case may target several dialects; the
|
|
5
|
+
loader expands it into one :class:`BenchmarkCase` per dialect with id ``<id>@<dialect>``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
19
|
+
|
|
20
|
+
from sqlguard_ninja.core.findings import Decision
|
|
21
|
+
from sqlguard_ninja.policy.model import Policy
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Classification(StrEnum):
|
|
25
|
+
BENIGN = "benign"
|
|
26
|
+
"""Legitimate query that should run."""
|
|
27
|
+
MALICIOUS = "malicious"
|
|
28
|
+
"""Deliberate attack: destruction, exfiltration, DoS, injection, bypass attempts."""
|
|
29
|
+
POLICY_VIOLATION = "policy_violation"
|
|
30
|
+
"""Over-reach that may be accidental: an ungranted column, a missing tenant filter."""
|
|
31
|
+
SENSITIVE = "sensitive"
|
|
32
|
+
"""Permitted only after human review."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Reason(StrEnum):
|
|
36
|
+
"""Tool-agnostic reason taxonomy used to label expected rejections."""
|
|
37
|
+
|
|
38
|
+
NONE = "none"
|
|
39
|
+
WRITE_STATEMENT = "write_statement"
|
|
40
|
+
MULTIPLE_STATEMENTS = "multiple_statements"
|
|
41
|
+
UNSUPPORTED_STATEMENT = "unsupported_statement"
|
|
42
|
+
SYSTEM_CATALOG = "system_catalog"
|
|
43
|
+
DANGEROUS_FUNCTION = "dangerous_function"
|
|
44
|
+
UNAUTHORIZED_TABLE = "unauthorized_table"
|
|
45
|
+
UNAUTHORIZED_COLUMN = "unauthorized_column"
|
|
46
|
+
SENSITIVE_DATA = "sensitive_data"
|
|
47
|
+
AGGREGATE_ONLY = "aggregate_only"
|
|
48
|
+
SMALL_COHORT = "small_cohort"
|
|
49
|
+
TENANT_ISOLATION = "tenant_isolation"
|
|
50
|
+
COST = "cost"
|
|
51
|
+
STRUCTURE_LIMIT = "structure_limit"
|
|
52
|
+
PARSE_ERROR = "parse_error"
|
|
53
|
+
UNRESOLVABLE = "unresolvable"
|
|
54
|
+
RECONNAISSANCE = "reconnaissance"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_DEFAULT_CLASSIFICATION = {
|
|
58
|
+
Decision.ALLOW: Classification.BENIGN,
|
|
59
|
+
Decision.DENY: Classification.POLICY_VIOLATION,
|
|
60
|
+
Decision.REVIEW: Classification.SENSITIVE,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BenchmarkCase(BaseModel):
|
|
65
|
+
"""One evaluation: a SQL string, a dialect, a role/context and the expected outcome."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
68
|
+
|
|
69
|
+
id: str
|
|
70
|
+
base_id: str
|
|
71
|
+
sql: str
|
|
72
|
+
dialect: str
|
|
73
|
+
role: str | None
|
|
74
|
+
context: dict[str, Any] = Field(default_factory=dict)
|
|
75
|
+
expected: Decision
|
|
76
|
+
classification: Classification
|
|
77
|
+
reason: Reason
|
|
78
|
+
category: str
|
|
79
|
+
tags: tuple[str, ...] = ()
|
|
80
|
+
provenance: str = "handwritten"
|
|
81
|
+
explanation: str
|
|
82
|
+
expected_rules: tuple[str, ...] = ()
|
|
83
|
+
"""Native rule ids expected to fire. Diagnostic only; never used for scoring."""
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def unsafe(self) -> bool:
|
|
87
|
+
"""Positive class for detection metrics: anything that must not run unattended."""
|
|
88
|
+
return self.expected is not Decision.ALLOW
|
|
89
|
+
|
|
90
|
+
@model_validator(mode="after")
|
|
91
|
+
def _consistent(self) -> BenchmarkCase:
|
|
92
|
+
if self.expected is Decision.ALLOW and self.classification is not Classification.BENIGN:
|
|
93
|
+
raise ValueError(f"{self.id}: allow cases must be classified benign")
|
|
94
|
+
if self.expected is not Decision.ALLOW and self.classification is Classification.BENIGN:
|
|
95
|
+
raise ValueError(f"{self.id}: benign cases must expect allow")
|
|
96
|
+
if self.expected is Decision.ALLOW and self.reason is not Reason.NONE:
|
|
97
|
+
raise ValueError(f"{self.id}: allow cases must use reason 'none'")
|
|
98
|
+
if self.expected is not Decision.ALLOW and self.reason is Reason.NONE:
|
|
99
|
+
raise ValueError(f"{self.id}: a rejection needs a reason")
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RawCase(BaseModel):
|
|
104
|
+
"""A case as written in a file (may target several dialects)."""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(extra="forbid")
|
|
107
|
+
|
|
108
|
+
id: str
|
|
109
|
+
sql: str
|
|
110
|
+
dialects: tuple[str, ...]
|
|
111
|
+
role: str | None = None
|
|
112
|
+
context: dict[str, Any] = Field(default_factory=dict)
|
|
113
|
+
expected: Decision
|
|
114
|
+
classification: Classification | None = None
|
|
115
|
+
reason: Reason = Reason.NONE
|
|
116
|
+
category: str
|
|
117
|
+
tags: tuple[str, ...] = ()
|
|
118
|
+
provenance: str = "handwritten"
|
|
119
|
+
explanation: str
|
|
120
|
+
expected_rules: tuple[str, ...] = ()
|
|
121
|
+
expected_by_dialect: dict[str, Decision] = Field(default_factory=dict)
|
|
122
|
+
reason_by_dialect: dict[str, Reason] = Field(default_factory=dict)
|
|
123
|
+
|
|
124
|
+
@field_validator("dialects", mode="before")
|
|
125
|
+
@classmethod
|
|
126
|
+
def _one_or_many(cls, value: Any) -> Any:
|
|
127
|
+
return [value] if isinstance(value, str) else value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class DatasetManifest(BaseModel):
|
|
131
|
+
model_config = ConfigDict(extra="forbid")
|
|
132
|
+
|
|
133
|
+
name: str
|
|
134
|
+
version: str
|
|
135
|
+
description: str = ""
|
|
136
|
+
policy: str
|
|
137
|
+
default_context: dict[str, Any] = Field(default_factory=dict)
|
|
138
|
+
case_files: list[str]
|
|
139
|
+
generator: dict[str, Any] = Field(default_factory=dict)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Dataset(BaseModel):
|
|
143
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
144
|
+
|
|
145
|
+
manifest: DatasetManifest
|
|
146
|
+
root: Path
|
|
147
|
+
policy: Policy
|
|
148
|
+
cases: list[BenchmarkCase]
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def fingerprint(self) -> str:
|
|
152
|
+
"""Content hash of all cases; changes whenever any case changes."""
|
|
153
|
+
h = hashlib.sha256()
|
|
154
|
+
for case in sorted(self.cases, key=lambda c: c.id):
|
|
155
|
+
h.update(json.dumps(case.model_dump(mode="json"), sort_keys=True).encode())
|
|
156
|
+
return h.hexdigest()[:16]
|
|
157
|
+
|
|
158
|
+
def stats(self) -> dict[str, Any]:
|
|
159
|
+
return {
|
|
160
|
+
"unique_cases": len({c.base_id for c in self.cases}),
|
|
161
|
+
"evaluations": len(self.cases),
|
|
162
|
+
"by_expected": dict(Counter(c.expected.value for c in self.cases)),
|
|
163
|
+
"by_dialect": dict(Counter(c.dialect for c in self.cases)),
|
|
164
|
+
"by_category": dict(sorted(Counter(c.category for c in self.cases).items())),
|
|
165
|
+
"by_provenance": dict(Counter(c.provenance.split(":")[0] for c in self.cases)),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def filter(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
dialect: str | None = None,
|
|
172
|
+
category: str | None = None,
|
|
173
|
+
ids: set[str] | None = None,
|
|
174
|
+
) -> list[BenchmarkCase]:
|
|
175
|
+
out = self.cases
|
|
176
|
+
if dialect:
|
|
177
|
+
out = [c for c in out if c.dialect == dialect]
|
|
178
|
+
if category:
|
|
179
|
+
out = [c for c in out if c.category == category]
|
|
180
|
+
if ids:
|
|
181
|
+
out = [c for c in out if c.base_id in ids or c.id in ids]
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class DatasetError(ValueError):
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _expand(raw: RawCase, default_context: dict[str, Any]) -> list[BenchmarkCase]:
|
|
190
|
+
out = []
|
|
191
|
+
for dialect in raw.dialects:
|
|
192
|
+
expected = raw.expected_by_dialect.get(dialect, raw.expected)
|
|
193
|
+
reason = raw.reason_by_dialect.get(dialect, raw.reason)
|
|
194
|
+
if expected is Decision.ALLOW and dialect in raw.expected_by_dialect:
|
|
195
|
+
reason = raw.reason_by_dialect.get(dialect, Reason.NONE)
|
|
196
|
+
classification = raw.classification
|
|
197
|
+
if classification is None or (expected is Decision.ALLOW) != (
|
|
198
|
+
classification is Classification.BENIGN
|
|
199
|
+
):
|
|
200
|
+
classification = _DEFAULT_CLASSIFICATION[expected]
|
|
201
|
+
out.append(
|
|
202
|
+
BenchmarkCase(
|
|
203
|
+
id=f"{raw.id}@{dialect}",
|
|
204
|
+
base_id=raw.id,
|
|
205
|
+
sql=raw.sql.strip(),
|
|
206
|
+
dialect=dialect,
|
|
207
|
+
role=raw.role,
|
|
208
|
+
context={**default_context, **raw.context},
|
|
209
|
+
expected=expected,
|
|
210
|
+
classification=classification,
|
|
211
|
+
reason=reason,
|
|
212
|
+
category=raw.category,
|
|
213
|
+
tags=raw.tags,
|
|
214
|
+
provenance=raw.provenance,
|
|
215
|
+
explanation=raw.explanation,
|
|
216
|
+
expected_rules=raw.expected_rules,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def load_case_file(path: Path, default_context: dict[str, Any]) -> list[BenchmarkCase]:
|
|
223
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
224
|
+
defaults = data.get("defaults", {})
|
|
225
|
+
cases: list[BenchmarkCase] = []
|
|
226
|
+
for i, item in enumerate(data.get("cases", [])):
|
|
227
|
+
merged = {**defaults, **item}
|
|
228
|
+
try:
|
|
229
|
+
raw = RawCase.model_validate(merged)
|
|
230
|
+
cases.extend(_expand(raw, default_context))
|
|
231
|
+
except ValueError as exc:
|
|
232
|
+
raise DatasetError(f"{path.name} case #{i} ({item.get('id')}): {exc}") from exc
|
|
233
|
+
return cases
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def load_dataset(path: str | Path) -> Dataset:
|
|
237
|
+
"""Load a dataset directory (or its ``manifest.yaml``) and validate every case."""
|
|
238
|
+
p = Path(path)
|
|
239
|
+
manifest_path = p if p.is_file() else p / "manifest.yaml"
|
|
240
|
+
root = manifest_path.parent
|
|
241
|
+
manifest = DatasetManifest.model_validate(
|
|
242
|
+
yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
|
|
243
|
+
)
|
|
244
|
+
policy = Policy.from_file((root / manifest.policy).resolve())
|
|
245
|
+
cases: list[BenchmarkCase] = []
|
|
246
|
+
for pattern in manifest.case_files:
|
|
247
|
+
files = sorted(root.glob(pattern))
|
|
248
|
+
if not files:
|
|
249
|
+
raise DatasetError(f"case file pattern {pattern!r} matched nothing")
|
|
250
|
+
for f in files:
|
|
251
|
+
cases.extend(load_case_file(f, manifest.default_context))
|
|
252
|
+
ids = Counter(c.id for c in cases)
|
|
253
|
+
dupes = [i for i, n in ids.items() if n > 1]
|
|
254
|
+
if dupes:
|
|
255
|
+
raise DatasetError(f"duplicate case ids: {dupes[:10]}")
|
|
256
|
+
sql_keys = Counter(
|
|
257
|
+
(c.sql, c.dialect, c.role, json.dumps(c.context, sort_keys=True)) for c in cases
|
|
258
|
+
)
|
|
259
|
+
dup_sql = [k[0][:60] for k, n in sql_keys.items() if n > 1]
|
|
260
|
+
if dup_sql:
|
|
261
|
+
raise DatasetError(f"duplicate (sql, dialect, role, context) cases: {dup_sql[:5]}")
|
|
262
|
+
for c in cases:
|
|
263
|
+
if c.role is not None and c.role not in policy.roles:
|
|
264
|
+
raise DatasetError(f"{c.id}: unknown role {c.role!r}")
|
|
265
|
+
return Dataset(manifest=manifest, root=root, policy=policy, cases=cases)
|