incognito-sql 0.0.5a1__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.
- incognito_sql/__init__.py +10 -0
- incognito_sql/__main__.py +5 -0
- incognito_sql/assess/__init__.py +6 -0
- incognito_sql/assess/command.py +47 -0
- incognito_sql/assess/model.py +87 -0
- incognito_sql/assess/report.py +66 -0
- incognito_sql/assess/run.py +146 -0
- incognito_sql/assess/truth.py +104 -0
- incognito_sql/audit_command.py +50 -0
- incognito_sql/check/__init__.py +1 -0
- incognito_sql/check/clients.py +99 -0
- incognito_sql/check/probes.py +106 -0
- incognito_sql/check/run.py +136 -0
- incognito_sql/cli.py +202 -0
- incognito_sql/connect/__init__.py +1 -0
- incognito_sql/connect/clients.py +143 -0
- incognito_sql/connect/release.py +25 -0
- incognito_sql/connect/run.py +172 -0
- incognito_sql/connect/write.py +122 -0
- incognito_sql/core/__init__.py +1 -0
- incognito_sql/core/audit.py +111 -0
- incognito_sql/core/audit_chain.py +149 -0
- incognito_sql/core/audit_verify.py +82 -0
- incognito_sql/core/budget.py +173 -0
- incognito_sql/core/config/__init__.py +28 -0
- incognito_sql/core/config/forms.py +36 -0
- incognito_sql/core/config/load.py +76 -0
- incognito_sql/core/config/model.py +223 -0
- incognito_sql/core/config/protection.py +147 -0
- incognito_sql/core/config/report.py +61 -0
- incognito_sql/core/config/row_rules.py +119 -0
- incognito_sql/core/db.py +137 -0
- incognito_sql/core/deadline.py +53 -0
- incognito_sql/core/describe.py +65 -0
- incognito_sql/core/display.py +128 -0
- incognito_sql/core/errors.py +44 -0
- incognito_sql/core/exec/__init__.py +1 -0
- incognito_sql/core/exec/run.py +88 -0
- incognito_sql/core/output/__init__.py +1 -0
- incognito_sql/core/output/detect.py +139 -0
- incognito_sql/core/output/mask.py +80 -0
- incognito_sql/core/output/secret.py +32 -0
- incognito_sql/core/output/shape.py +84 -0
- incognito_sql/core/output/suspects.py +49 -0
- incognito_sql/core/pipeline.py +194 -0
- incognito_sql/core/rate.py +45 -0
- incognito_sql/core/sql/__init__.py +1 -0
- incognito_sql/core/sql/aggregates.py +103 -0
- incognito_sql/core/sql/allowlist.py +107 -0
- incognito_sql/core/sql/allowlist_table.py +124 -0
- incognito_sql/core/sql/bind.py +120 -0
- incognito_sql/core/sql/bind_domain.py +105 -0
- incognito_sql/core/sql/bind_forms.py +94 -0
- incognito_sql/core/sql/bind_type.py +133 -0
- incognito_sql/core/sql/bind_values.py +52 -0
- incognito_sql/core/sql/binder.py +92 -0
- incognito_sql/core/sql/catalog.py +181 -0
- incognito_sql/core/sql/colref.py +29 -0
- incognito_sql/core/sql/constants.py +69 -0
- incognito_sql/core/sql/expand.py +93 -0
- incognito_sql/core/sql/families.py +61 -0
- incognito_sql/core/sql/flows.py +113 -0
- incognito_sql/core/sql/grouping.py +142 -0
- incognito_sql/core/sql/lineage.py +150 -0
- incognito_sql/core/sql/opaque.py +24 -0
- incognito_sql/core/sql/order_keys.py +80 -0
- incognito_sql/core/sql/origin.py +70 -0
- incognito_sql/core/sql/parse.py +81 -0
- incognito_sql/core/sql/policy.py +89 -0
- incognito_sql/core/sql/policy_places.py +78 -0
- incognito_sql/core/sql/policy_probes.py +94 -0
- incognito_sql/core/sql/policy_rules.py +149 -0
- incognito_sql/core/sql/policy_shape.py +121 -0
- incognito_sql/core/sql/probe_count.py +35 -0
- incognito_sql/core/sql/recheck.py +71 -0
- incognito_sql/core/sql/resolve.py +129 -0
- incognito_sql/core/sql/rewrite.py +153 -0
- incognito_sql/core/sql/row_mask.py +151 -0
- incognito_sql/core/sql/scan_fallible.py +170 -0
- incognito_sql/core/sql/scan_order.py +49 -0
- incognito_sql/core/sql/scan_spellings.py +55 -0
- incognito_sql/core/sql/shadow.py +105 -0
- incognito_sql/core/sql/shape.py +73 -0
- incognito_sql/core/sql/stars.py +27 -0
- incognito_sql/core/sql/strings.py +46 -0
- incognito_sql/core/sql/tiebreak.py +143 -0
- incognito_sql/core/sql/typefamily.py +85 -0
- incognito_sql/core/tokens/__init__.py +1 -0
- incognito_sql/core/tokens/canon.py +6 -0
- incognito_sql/core/tokens/crypto.py +80 -0
- incognito_sql/core/tokens/db.py +59 -0
- incognito_sql/core/tokens/format.py +13 -0
- incognito_sql/core/tokens/schema.sql +20 -0
- incognito_sql/core/tokens/store.py +150 -0
- incognito_sql/demo.py +91 -0
- incognito_sql/diff/__init__.py +1 -0
- incognito_sql/diff/changes.py +168 -0
- incognito_sql/diff/kinds.py +19 -0
- incognito_sql/diff/rules.py +66 -0
- incognito_sql/diff/run.py +49 -0
- incognito_sql/dpo/__init__.py +6 -0
- incognito_sql/dpo/command.py +81 -0
- incognito_sql/dpo/journal.py +95 -0
- incognito_sql/dpo/summary.py +77 -0
- incognito_sql/envfile.py +37 -0
- incognito_sql/init/__init__.py +1 -0
- incognito_sql/init/hints.py +35 -0
- incognito_sql/init/report.py +42 -0
- incognito_sql/init/run.py +142 -0
- incognito_sql/init/sampling.py +42 -0
- incognito_sql/init/scaffold.py +51 -0
- incognito_sql/levels/__init__.py +6 -0
- incognito_sql/levels/asker.py +120 -0
- incognito_sql/levels/chance.py +11 -0
- incognito_sql/levels/cuts.py +60 -0
- incognito_sql/levels/dialects.py +40 -0
- incognito_sql/levels/forms.py +77 -0
- incognito_sql/levels/fragment.py +101 -0
- incognito_sql/levels/infer.py +155 -0
- incognito_sql/levels/oracle.py +98 -0
- incognito_sql/levels/partial.py +68 -0
- incognito_sql/levels/series.py +146 -0
- incognito_sql/levels/target.py +78 -0
- incognito_sql/levels/textforms.py +61 -0
- incognito_sql/levels/verdict.py +40 -0
- incognito_sql/mcp/__init__.py +1 -0
- incognito_sql/mcp/http.py +48 -0
- incognito_sql/mcp/instructions.md +76 -0
- incognito_sql/mcp/refusals.py +188 -0
- incognito_sql/mcp/server.py +69 -0
- incognito_sql/mcp/token.py +19 -0
- incognito_sql/parser.py +33 -0
- incognito_sql/problems.py +14 -0
- incognito_sql/py.typed +0 -0
- incognito_sql/sdk/__init__.py +1 -0
- incognito_sql/serving.py +114 -0
- incognito_sql/setup/__init__.py +1 -0
- incognito_sql/setup/cases.py +16 -0
- incognito_sql/setup/closed.py +158 -0
- incognito_sql/setup/columns.py +76 -0
- incognito_sql/setup/commands.py +49 -0
- incognito_sql/setup/common.py +87 -0
- incognito_sql/setup/env.py +53 -0
- incognito_sql/setup/guide.py +88 -0
- incognito_sql/setup/keep.py +41 -0
- incognito_sql/setup/local.py +163 -0
- incognito_sql/setup/model.py +122 -0
- incognito_sql/setup/projects.py +101 -0
- incognito_sql/setup/secret.py +93 -0
- incognito_sql/setup/setkey.py +61 -0
- incognito_sql/setup/uservar.py +32 -0
- incognito_sql/setup/widen.py +25 -0
- incognito_sql/shell.py +30 -0
- incognito_sql/trial/__init__.py +1 -0
- incognito_sql/trial/run.py +111 -0
- incognito_sql/trial/verdict.py +104 -0
- incognito_sql/web/__init__.py +1 -0
- incognito_sql/web/app.py +92 -0
- incognito_sql/web/cards.py +109 -0
- incognito_sql/web/guard.py +75 -0
- incognito_sql/web/pages.py +75 -0
- incognito_sql/web/run.py +122 -0
- incognito_sql/web/session.py +107 -0
- incognito_sql/web/templates/index.html +2 -0
- incognito_sql/web/templates/layout.html +20 -0
- incognito_sql/web/templates/masked.html +6 -0
- incognito_sql/web/templates/person.html +4 -0
- incognito_sql/web/templates/value.html +3 -0
- incognito_sql/wiring.py +72 -0
- incognito_sql/wizard/__init__.py +1 -0
- incognito_sql/wizard/arrows.py +80 -0
- incognito_sql/wizard/columns.py +73 -0
- incognito_sql/wizard/prompts.py +111 -0
- incognito_sql/wizard/run.py +59 -0
- incognito_sql/wizard/walk.py +124 -0
- incognito_sql-0.0.5a1.dist-info/METADATA +799 -0
- incognito_sql-0.0.5a1.dist-info/RECORD +181 -0
- incognito_sql-0.0.5a1.dist-info/WHEEL +4 -0
- incognito_sql-0.0.5a1.dist-info/entry_points.txt +2 -0
- incognito_sql-0.0.5a1.dist-info/licenses/LICENSE +202 -0
- incognito_sql-0.0.5a1.dist-info/licenses/NOTICE +176 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Pseudonymizing MCP server for LLM access to databases with personal data."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
__version__ = "0.0.0"
|
|
6
|
+
|
|
7
|
+
# sqlglot logs unparsable SQL verbatim (with its literals); keep it off stderr.
|
|
8
|
+
_sqlglot_log = logging.getLogger("sqlglot")
|
|
9
|
+
_sqlglot_log.addHandler(logging.NullHandler())
|
|
10
|
+
_sqlglot_log.propagate = False
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""`assess`: the disclosure levels of the operator's own database, under its profile.
|
|
2
|
+
|
|
3
|
+
The oracles of ``incognito_sql.levels`` ask a gateway opened on a copy of the operator's
|
|
4
|
+
declaration, as a server is asked; the truth they judge by is read from the database
|
|
5
|
+
directly. The report holds counts and column names, never a value.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""`incognito-sql assess`: its arguments, the run, the report and the exit code."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from incognito_sql.assess.model import Source
|
|
8
|
+
from incognito_sql.assess.report import as_json, as_text, broken
|
|
9
|
+
from incognito_sql.assess.run import assess
|
|
10
|
+
from incognito_sql.core.config import Declaration
|
|
11
|
+
|
|
12
|
+
EXIT_BROKEN = 1
|
|
13
|
+
EXIT_FAILED = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def add_assess_parser(commands: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
|
|
17
|
+
"""Add `assess` and its options to the command line."""
|
|
18
|
+
command = commands.add_parser(
|
|
19
|
+
"assess", help="judge the disclosure levels of this database under its profile"
|
|
20
|
+
)
|
|
21
|
+
command.add_argument("--config", type=Path, required=True, help="the service's declaration")
|
|
22
|
+
command.add_argument("--env-file", type=Path, help="KEY=VALUE secrets for unset variables")
|
|
23
|
+
command.add_argument("--targets", type=int, default=3, help="values probed per column")
|
|
24
|
+
command.add_argument(
|
|
25
|
+
"--capacity", action="store_true", help="also measure with the literal budget lifted"
|
|
26
|
+
)
|
|
27
|
+
command.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
28
|
+
command.add_argument(
|
|
29
|
+
"--fail-on", choices=["breaks"], help="exit 1 when a column breaks its profile's claim"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_assess(args: argparse.Namespace, decl: Declaration, dsn: str, key: bytes) -> int:
|
|
34
|
+
"""Assess, print the report on stdout, return the exit code.
|
|
35
|
+
|
|
36
|
+
A failure is named by its type alone: a database error quotes its query's
|
|
37
|
+
parameters, and the series' probes are made from the values.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
result = assess(
|
|
41
|
+
Source(args.config, decl, dsn, key), count=args.targets, capacity=args.capacity
|
|
42
|
+
)
|
|
43
|
+
except Exception as exc: # noqa: BLE001 -- any failure: never its message
|
|
44
|
+
sys.stderr.write(f"incognito-sql: assess failed: {type(exc).__name__}\n")
|
|
45
|
+
return EXIT_FAILED
|
|
46
|
+
sys.stdout.write((as_json(result) if args.json else as_text(result)) + "\n")
|
|
47
|
+
return EXIT_BROKEN if args.fail_on == "breaks" and broken(result) else 0
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""What an assessment reads and what it found, column by column."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from incognito_sql.core.config import Declaration, Limits
|
|
7
|
+
from incognito_sql.levels.infer import Budgets, Inference
|
|
8
|
+
from incognito_sql.levels.verdict import Leaks, verdict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def budgets_of(limits: Limits) -> Budgets:
|
|
12
|
+
"""Return the literal and probe budgets of ``limits`` and their window."""
|
|
13
|
+
return Budgets(limits.literal_budget, limits.probe_budget, limits.literal_window_s)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Source:
|
|
18
|
+
"""The service's settings: its declaration file and what it was read into."""
|
|
19
|
+
|
|
20
|
+
config: Path
|
|
21
|
+
decl: Declaration
|
|
22
|
+
dsn: str
|
|
23
|
+
key: bytes
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ColumnLevels:
|
|
28
|
+
"""What the oracles saw on one column: cuts that showed it, facts the series read."""
|
|
29
|
+
|
|
30
|
+
table: str
|
|
31
|
+
column: str
|
|
32
|
+
profile: str
|
|
33
|
+
opened: frozenset[str] # L3 channels the profile declares open
|
|
34
|
+
cuts: int = 0
|
|
35
|
+
whole: int = 0 # cuts that showed a value whole
|
|
36
|
+
parts: int = 0 # cuts that showed a part of one
|
|
37
|
+
measure: bool = False # the budgets lifted: values read whole are their capacity
|
|
38
|
+
budgets: Budgets = field(default_factory=lambda: budgets_of(Limits())) # the config's own
|
|
39
|
+
inference: Inference = field(default_factory=Inference)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
"""Return ``table.column``."""
|
|
44
|
+
return f"{self.table}.{self.column}"
|
|
45
|
+
|
|
46
|
+
def verdict(self) -> str:
|
|
47
|
+
"""Return whether the column keeps its profile's claim, and what it broke."""
|
|
48
|
+
leaks = Leaks(
|
|
49
|
+
shown=bool(self.whole),
|
|
50
|
+
read_whole=bool(self.inference.whole) and not self.measure,
|
|
51
|
+
parts=bool(self.parts),
|
|
52
|
+
inferred=frozenset(self.inference.open_channels()),
|
|
53
|
+
)
|
|
54
|
+
found = verdict(leaks, "L3", self.opened)
|
|
55
|
+
if found == "holds" and not any(self.inference.targets.values()):
|
|
56
|
+
return "holds cuts; no value to probe by a series"
|
|
57
|
+
return found
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class HiddenColumn:
|
|
62
|
+
"""A column the declaration does not name: every cut of it must be refused."""
|
|
63
|
+
|
|
64
|
+
table: str
|
|
65
|
+
column: str
|
|
66
|
+
cuts: int = 0
|
|
67
|
+
refused: int = 0
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def name(self) -> str:
|
|
71
|
+
"""Return ``table.column``."""
|
|
72
|
+
return f"{self.table}.{self.column}"
|
|
73
|
+
|
|
74
|
+
def verdict(self) -> str:
|
|
75
|
+
"""Return ``holds`` when every cut was refused."""
|
|
76
|
+
return (
|
|
77
|
+
"holds" if self.refused == self.cuts else f"breaks: {self.cuts - self.refused} answered"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Assessment:
|
|
83
|
+
"""Every identifier column under the default budget, and with it lifted if asked."""
|
|
84
|
+
|
|
85
|
+
columns: list[ColumnLevels]
|
|
86
|
+
capacity: list[ColumnLevels]
|
|
87
|
+
hidden: list[HiddenColumn] = field(default_factory=list)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The assessment as the operator reads it: counts per column and level, never a value."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from incognito_sql.assess.model import Assessment, ColumnLevels, HiddenColumn
|
|
6
|
+
from incognito_sql.levels.infer import CHANNELS, HOURS_LEGEND, WHOLE
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _row(c: ColumnLevels) -> dict[str, object]:
|
|
10
|
+
series = c.inference
|
|
11
|
+
row: dict[str, object] = {
|
|
12
|
+
"column": c.name,
|
|
13
|
+
"profile": c.profile,
|
|
14
|
+
"L1 cuts": f"{c.whole}/{c.cuts}",
|
|
15
|
+
"L1 series": f"{series.whole}/{series.targets['prefix']}",
|
|
16
|
+
"L2 cuts": f"{c.parts}/{c.cuts}",
|
|
17
|
+
**{f"L3 {ch}": series.share(ch) for ch in CHANNELS},
|
|
18
|
+
"price whole": series.price(WHOLE),
|
|
19
|
+
}
|
|
20
|
+
if c.measure: # what the config's own budget would make the attacker wait
|
|
21
|
+
row["hours whole"] = series.hours(WHOLE, c.budgets)
|
|
22
|
+
row["verdict"] = c.verdict()
|
|
23
|
+
return row
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _hidden(c: HiddenColumn) -> dict[str, object]:
|
|
27
|
+
return {"column": c.name, "hidden: refused": f"{c.refused}/{c.cuts}", "verdict": c.verdict()}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def as_dict(result: Assessment) -> dict[str, list[dict[str, object]]]:
|
|
31
|
+
"""Return the report as data: identifier columns, hidden ones, then the budget lifted."""
|
|
32
|
+
return {
|
|
33
|
+
"columns": [_row(c) for c in result.columns],
|
|
34
|
+
"hidden": [_hidden(c) for c in result.hidden],
|
|
35
|
+
"capacity": [_row(c) for c in result.capacity],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def as_json(result: Assessment) -> str:
|
|
40
|
+
"""Return the report as JSON."""
|
|
41
|
+
return json.dumps(as_dict(result), ensure_ascii=False, indent=2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def as_text(result: Assessment) -> str:
|
|
45
|
+
"""Return the report as Markdown tables: verdicts first, capacity after."""
|
|
46
|
+
parts = []
|
|
47
|
+
for title, rows in as_dict(result).items():
|
|
48
|
+
if not rows:
|
|
49
|
+
continue
|
|
50
|
+
head = list(rows[0])
|
|
51
|
+
lines = [f"## {title}", "", "| " + " | ".join(head) + " |", "|---" * len(head) + "|"]
|
|
52
|
+
lines += ["| " + " | ".join(str(r[k]) for k in head) + " |" for r in rows]
|
|
53
|
+
if "hours whole" in head:
|
|
54
|
+
lines += ["", HOURS_LEGEND]
|
|
55
|
+
parts.append("\n".join(lines))
|
|
56
|
+
return "\n\n".join(parts) or "no identifier column of an entity table to assess"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def broken(result: Assessment) -> bool:
|
|
60
|
+
"""Tell whether a column breaks its profile's claim, with the budget lifted too.
|
|
61
|
+
|
|
62
|
+
Lifted, a closed channel inferred is a hole of the policy the budget hid.
|
|
63
|
+
"""
|
|
64
|
+
verdicts = [c.verdict() for c in [*result.columns, *result.capacity]]
|
|
65
|
+
verdicts += [c.verdict() for c in result.hidden]
|
|
66
|
+
return any(v.startswith("breaks") for v in verdicts)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Run the oracles on each identifier column against a gateway of its own.
|
|
2
|
+
|
|
3
|
+
The gateway is opened on a copy of the declaration in a temporary directory: its own
|
|
4
|
+
literal budget, journal and tokens, so the assessment never spends the budget live
|
|
5
|
+
agents share nor raises its alarm. Cuts and series ask it as one connection, as one
|
|
6
|
+
agent session would; a second pass with the literal budget lifted measures what each
|
|
7
|
+
channel carries.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import tempfile
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from sqlalchemy.engine import Engine
|
|
19
|
+
|
|
20
|
+
from incognito_sql.assess.model import Assessment, ColumnLevels, HiddenColumn, Source, budgets_of
|
|
21
|
+
from incognito_sql.assess.truth import (
|
|
22
|
+
DbTruth,
|
|
23
|
+
hidden_columns,
|
|
24
|
+
identifier_columns,
|
|
25
|
+
targets,
|
|
26
|
+
values,
|
|
27
|
+
)
|
|
28
|
+
from incognito_sql.core.config import load_declaration
|
|
29
|
+
from incognito_sql.core.config.model import column_operations, column_profile
|
|
30
|
+
from incognito_sql.core.config.protection import opens
|
|
31
|
+
from incognito_sql.core.db import make_engine
|
|
32
|
+
from incognito_sql.core.pipeline import Gateway
|
|
33
|
+
from incognito_sql.levels import asker as budgets
|
|
34
|
+
from incognito_sql.levels.asker import Ask, Asker, Reply
|
|
35
|
+
from incognito_sql.levels.cuts import cuts
|
|
36
|
+
from incognito_sql.levels.fragment import Fragments
|
|
37
|
+
from incognito_sql.levels.infer import CHANNELS
|
|
38
|
+
from incognito_sql.levels.oracle import Oracle
|
|
39
|
+
from incognito_sql.levels.partial import Visible, shown_in_part
|
|
40
|
+
from incognito_sql.levels.series import run_series
|
|
41
|
+
from incognito_sql.wiring import open_gateway
|
|
42
|
+
|
|
43
|
+
VALUES_CAP = 100_000 # distinct values of a column looked for in the answers
|
|
44
|
+
_LIFTED = 1_000_000
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def assess(source: Source, *, count: int, capacity: bool) -> Assessment:
|
|
48
|
+
"""Assess every identifier column of an entity table on ``count`` targets each."""
|
|
49
|
+
decl = source.decl
|
|
50
|
+
engine = make_engine(
|
|
51
|
+
source.dsn, decl.dialect, statement_timeout_ms=decl.limits.statement_timeout_ms
|
|
52
|
+
)
|
|
53
|
+
try:
|
|
54
|
+
hidden = [HiddenColumn(t, c) for t, c in hidden_columns(engine, decl)]
|
|
55
|
+
with _opened(source, lifted=False) as (gateway, connection):
|
|
56
|
+
for closed in hidden:
|
|
57
|
+
for _, sql in cuts(closed.table, closed.column, decl.dialect):
|
|
58
|
+
closed.cuts += 1
|
|
59
|
+
closed.refused += "error" in gateway.query(sql, connection)
|
|
60
|
+
columns = list(identifier_columns(decl))
|
|
61
|
+
held = [_column(source, engine, c, count, lifted=False) for c in columns]
|
|
62
|
+
lifted = (
|
|
63
|
+
[_column(source, engine, c, count, lifted=True) for c in columns] if capacity else []
|
|
64
|
+
)
|
|
65
|
+
finally:
|
|
66
|
+
engine.dispose()
|
|
67
|
+
return Assessment(held, lifted, hidden)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@contextmanager
|
|
71
|
+
def _opened(source: Source, *, lifted: bool) -> Iterator[tuple[Gateway, str]]:
|
|
72
|
+
"""Open a gateway on a copy of the config: its own budget, audit log and tokens."""
|
|
73
|
+
with tempfile.TemporaryDirectory(prefix="incognito-assess-") as tmp:
|
|
74
|
+
copy = _copy(source.config, Path(tmp), lifted=lifted)
|
|
75
|
+
gateway = open_gateway(copy, load_declaration(copy), source.dsn, source.key)
|
|
76
|
+
try:
|
|
77
|
+
yield gateway, uuid.uuid4().hex
|
|
78
|
+
finally:
|
|
79
|
+
gateway.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _column(
|
|
83
|
+
source: Source, engine: Engine, column: tuple[str, str, str], count: int, *, lifted: bool
|
|
84
|
+
) -> ColumnLevels:
|
|
85
|
+
"""Assess one column against a gateway of its own: no column spends another's budget."""
|
|
86
|
+
decl = source.decl
|
|
87
|
+
table, name, key = column
|
|
88
|
+
profile = column_profile(decl, table, name)
|
|
89
|
+
leaks = opens(column_operations(decl, table, name))
|
|
90
|
+
opened = frozenset(str(leak).removeprefix("L3 ") for leak in leaks)
|
|
91
|
+
own = budgets_of(decl.limits)
|
|
92
|
+
levels = ColumnLevels(table, name, profile, opened, measure=lifted, budgets=own)
|
|
93
|
+
tool, series = (
|
|
94
|
+
(budgets.CAPACITY_TOOL, budgets.CAPACITY_SERIES)
|
|
95
|
+
if lifted
|
|
96
|
+
else (budgets.TOOL_BUDGET, budgets.SERIES_BUDGET)
|
|
97
|
+
)
|
|
98
|
+
with _opened(source, lifted=lifted) as (gateway, connection):
|
|
99
|
+
if not lifted:
|
|
100
|
+
_cut(gateway, connection, values(engine, table, name, VALUES_CAP), levels, decl.dialect)
|
|
101
|
+
asker = Asker(_asking(gateway, connection), tool, series, decl.dialect)
|
|
102
|
+
for target in targets(engine, table, name, key, count):
|
|
103
|
+
for channel in CHANNELS:
|
|
104
|
+
levels.inference.add(run_series(asker, DbTruth(engine), target, channel))
|
|
105
|
+
levels.inference.settle()
|
|
106
|
+
return levels
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cut(
|
|
110
|
+
gateway: Gateway, connection: str, hidden: list[object], levels: ColumnLevels, dialect: str
|
|
111
|
+
) -> None:
|
|
112
|
+
oracle, fragments = Oracle(hidden), Fragments(hidden)
|
|
113
|
+
for _, sql in cuts(levels.table, levels.column, dialect):
|
|
114
|
+
answer = gateway.query(sql, connection)
|
|
115
|
+
levels.cuts += 1
|
|
116
|
+
if "error" in answer:
|
|
117
|
+
continue
|
|
118
|
+
text = json.dumps(answer, ensure_ascii=False, default=str)
|
|
119
|
+
whole = oracle.found(text)
|
|
120
|
+
levels.whole += bool(whole)
|
|
121
|
+
levels.parts += bool(shown_in_part(fragments.found(text), whole, (Visible([sql]),)))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _asking(gateway: Gateway, connection: str) -> Ask:
|
|
125
|
+
def ask(sql: str) -> Reply:
|
|
126
|
+
answer, spend = gateway.ask(sql, connection)
|
|
127
|
+
spent = (spend.literals, spend.probes)
|
|
128
|
+
if "error" in answer:
|
|
129
|
+
return Reply(answered=False, spend=spent)
|
|
130
|
+
rows = answer.get("rows")
|
|
131
|
+
text = json.dumps(answer, default=str)
|
|
132
|
+
rows = rows if isinstance(rows, list) else None
|
|
133
|
+
return Reply(answered=True, rows=rows, text=text, spend=spent)
|
|
134
|
+
|
|
135
|
+
return ask
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _copy(config: Path, into: Path, *, lifted: bool) -> Path:
|
|
139
|
+
data = yaml.safe_load(config.read_text(encoding="utf-8"))
|
|
140
|
+
limits = data.setdefault("limits", {})
|
|
141
|
+
limits["query_rate"] = _LIFTED # a brake on a search, not a bound on what it learns
|
|
142
|
+
if lifted: # both: a budget left in place would cut the series and its cost short
|
|
143
|
+
limits["literal_budget"] = limits["probe_budget"] = _LIFTED
|
|
144
|
+
out = into / "assess.yaml"
|
|
145
|
+
out.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
146
|
+
return out
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The database read directly: targets of the series, values to look for, and truth.
|
|
2
|
+
|
|
3
|
+
Read by the service's DSN through a read-only engine, never through the protection.
|
|
4
|
+
Queries are built with SQLAlchemy, which writes them in the database's dialect.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
from sqlalchemy.engine import Engine
|
|
11
|
+
|
|
12
|
+
from incognito_sql.core.config import ColumnClass, Declaration
|
|
13
|
+
from incognito_sql.core.sql.catalog import reflect
|
|
14
|
+
from incognito_sql.levels.cuts import ROWS
|
|
15
|
+
from incognito_sql.levels.target import MIN_TARGET, Target
|
|
16
|
+
|
|
17
|
+
_POOL = 1000 # rows a target is chosen among
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _table(name: str, *columns: str) -> sa.TableClause:
|
|
21
|
+
return sa.table(name, *(sa.column(c) for c in columns))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DbTruth:
|
|
25
|
+
"""The truth of the series: the database compares and finds values itself."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, engine: Engine) -> None:
|
|
28
|
+
"""Read through ``engine``."""
|
|
29
|
+
self._engine = engine
|
|
30
|
+
|
|
31
|
+
def below(self, target: Target, probe: object) -> bool:
|
|
32
|
+
"""Tell whether the target's value sorts below ``probe`` in the database."""
|
|
33
|
+
t = _table(target.table, target.key, target.column)
|
|
34
|
+
query = (
|
|
35
|
+
sa.select(sa.func.count())
|
|
36
|
+
.select_from(t)
|
|
37
|
+
.where(t.c[target.key] == target.row, t.c[target.column] < probe)
|
|
38
|
+
)
|
|
39
|
+
with self._engine.connect() as con:
|
|
40
|
+
return bool(con.execute(query).scalar())
|
|
41
|
+
|
|
42
|
+
def holds(self, target: Target, value: object) -> bool:
|
|
43
|
+
"""Tell whether a row of the target's column holds ``value``."""
|
|
44
|
+
t = _table(target.table, target.column)
|
|
45
|
+
query = sa.select(sa.literal(1)).select_from(t).where(t.c[target.column] == value).limit(1)
|
|
46
|
+
with self._engine.connect() as con:
|
|
47
|
+
return con.execute(query).first() is not None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def identifier_columns(decl: Declaration) -> Iterator[tuple[str, str, str]]:
|
|
51
|
+
"""Yield (table, column, key) of every identifier column of an entity table."""
|
|
52
|
+
keys = {e.table: e.pk for e in decl.entities}
|
|
53
|
+
for table in sorted(decl.columns):
|
|
54
|
+
for column, cls in sorted(decl.columns[table].items()):
|
|
55
|
+
if cls is ColumnClass.IDENTIFIER and table in keys:
|
|
56
|
+
yield table, column, keys[table]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def hidden_columns(engine: Engine, decl: Declaration) -> list[tuple[str, str]]:
|
|
60
|
+
"""Return (table, column) of every database column the declaration does not name.
|
|
61
|
+
|
|
62
|
+
The tables and views are those the server reflects, named as it resolves them.
|
|
63
|
+
"""
|
|
64
|
+
out = []
|
|
65
|
+
for table, columns in sorted(reflect(engine, decl.dialect).columns.items()):
|
|
66
|
+
named = decl.columns.get(table.lower(), {})
|
|
67
|
+
out += [(table, c) for c in sorted(columns) if c.lower() not in named]
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def targets(engine: Engine, table: str, column: str, key: str, count: int) -> list[Target]:
|
|
72
|
+
"""Return up to ``count`` targets of one column, rows spread over its first ones."""
|
|
73
|
+
t = _table(table, key, column)
|
|
74
|
+
query = sa.select(t.c[key], t.c[column]).where(t.c[column].is_not(None)).order_by(t.c[key])
|
|
75
|
+
with engine.connect() as con:
|
|
76
|
+
rows = con.execute(query.limit(_POOL)).all()
|
|
77
|
+
pool = [
|
|
78
|
+
Target(table, key, row, column, value)
|
|
79
|
+
for row, value in rows
|
|
80
|
+
if isinstance(value, str | int | float) and len(str(value)) >= MIN_TARGET
|
|
81
|
+
]
|
|
82
|
+
step = max(1, len(pool) // max(count, 1))
|
|
83
|
+
return pool[::step][:count]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def values(engine: Engine, table: str, column: str, cap: int) -> list[object]:
|
|
87
|
+
"""Return what is looked for in a column's answers.
|
|
88
|
+
|
|
89
|
+
Up to ``cap`` distinct values, and those a cut can show whatever the sample holds:
|
|
90
|
+
the first rows and the extremes.
|
|
91
|
+
"""
|
|
92
|
+
t = _table(table, column)
|
|
93
|
+
present = t.c[column].is_not(None)
|
|
94
|
+
queries = [
|
|
95
|
+
sa.select(t.c[column]).where(present).distinct().limit(cap),
|
|
96
|
+
sa.select(t.c[column]).where(present).limit(ROWS),
|
|
97
|
+
sa.select(sa.func.min(t.c[column])).where(present),
|
|
98
|
+
sa.select(sa.func.max(t.c[column])).where(present),
|
|
99
|
+
]
|
|
100
|
+
found: dict[object, None] = {}
|
|
101
|
+
with engine.connect() as con:
|
|
102
|
+
for query in queries:
|
|
103
|
+
found.update((v, None) for (v,) in con.execute(query) if v is not None)
|
|
104
|
+
return list(found)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""`incognito-sql audit verify`: check the audit log's chain with the service's key."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from incognito_sql.core.audit_chain import Anchor
|
|
9
|
+
from incognito_sql.core.audit_verify import verify
|
|
10
|
+
from incognito_sql.core.tokens.crypto import Cipher, load_key
|
|
11
|
+
from incognito_sql.envfile import EnvFileError, load_env_file
|
|
12
|
+
from incognito_sql.wiring import audit_path, store_path
|
|
13
|
+
|
|
14
|
+
EXIT_BROKEN, EXIT_UNREADABLE = 1, 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def add_audit_parser(commands: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
|
|
18
|
+
"""Add `audit verify` to the command line."""
|
|
19
|
+
audit = commands.add_parser("audit", help="the audit log")
|
|
20
|
+
actions = audit.add_subparsers(dest="audit_command", required=True)
|
|
21
|
+
check = actions.add_parser("verify", help="check that no line was edited, removed or added")
|
|
22
|
+
check.add_argument("--config", type=Path, required=True, help="the service's declaration")
|
|
23
|
+
check.add_argument("--env-file", type=Path, help="KEY=VALUE secrets for unset variables")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_audit(args: argparse.Namespace) -> int:
|
|
27
|
+
"""Check the chain; print what was checked and the first break; return the exit code."""
|
|
28
|
+
log, store = audit_path(args.config), store_path(args.config)
|
|
29
|
+
try:
|
|
30
|
+
if args.env_file is not None:
|
|
31
|
+
load_env_file(args.env_file, os.environ)
|
|
32
|
+
cipher = Cipher(load_key())
|
|
33
|
+
anchor = Anchor(store) if store.exists() else None
|
|
34
|
+
try:
|
|
35
|
+
verdict = verify(log, cipher, anchor.get() if anchor else None)
|
|
36
|
+
finally:
|
|
37
|
+
if anchor is not None:
|
|
38
|
+
anchor.close()
|
|
39
|
+
except (OSError, EnvFileError, RuntimeError, ValueError) as exc:
|
|
40
|
+
sys.stderr.write(f"incognito-sql: audit verify: {type(exc).__name__}\n")
|
|
41
|
+
return EXIT_UNREADABLE
|
|
42
|
+
sys.stdout.write(
|
|
43
|
+
f"lines: {verdict.lines}; before the chain: {verdict.before_chain}; "
|
|
44
|
+
f"checked: {verdict.checked}; anchor: {verdict.anchor}\n"
|
|
45
|
+
)
|
|
46
|
+
if verdict.holds:
|
|
47
|
+
sys.stdout.write("the chain holds\n")
|
|
48
|
+
return 0
|
|
49
|
+
sys.stdout.write(f"broken: {verdict.broken}\n")
|
|
50
|
+
return EXIT_BROKEN
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""`incognito-sql check`: try, as the agent's user, what an agent could do around the server."""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""MCP client settings this user can read, and the entries that start incognito-sql itself."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from collections.abc import Iterator, Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from incognito_sql.check.probes import Finding
|
|
10
|
+
|
|
11
|
+
_NAMES = ("incognito-sql", "incognito_sql")
|
|
12
|
+
# A value that only names a variable the client fills in: `${env:X}`, `{env:X}`, `${X}`.
|
|
13
|
+
_REFERENCE = re.compile(r"\$?\{(?:env:)?[A-Za-z_][A-Za-z0-9_]*\}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def client_settings(home: Path, cwd: Path, environ: Mapping[str, str]) -> list[tuple[str, Path]]:
|
|
17
|
+
"""Return the MCP settings files of common clients, each with a fixed label.
|
|
18
|
+
|
|
19
|
+
A finding names the file by its label: a path built from a variable such as
|
|
20
|
+
CODEX_HOME may hold anything pasted there, and is not repeated.
|
|
21
|
+
"""
|
|
22
|
+
codex = environ.get("CODEX_HOME")
|
|
23
|
+
files = [
|
|
24
|
+
("Claude Code project file (.mcp.json)", cwd / ".mcp.json"),
|
|
25
|
+
("Cursor project file (.cursor/mcp.json)", cwd / ".cursor" / "mcp.json"),
|
|
26
|
+
("OpenCode project file (opencode.json)", cwd / "opencode.json"),
|
|
27
|
+
("Claude Code user file (~/.claude.json)", home / ".claude.json"),
|
|
28
|
+
("Cursor user file (~/.cursor/mcp.json)", home / ".cursor" / "mcp.json"),
|
|
29
|
+
(
|
|
30
|
+
"OpenCode user file (~/.config/opencode/opencode.json)",
|
|
31
|
+
home / ".config" / "opencode" / "opencode.json",
|
|
32
|
+
),
|
|
33
|
+
("Codex user file (~/.codex/config.toml)", home / ".codex" / "config.toml"),
|
|
34
|
+
(
|
|
35
|
+
"Claude Desktop file (macOS)",
|
|
36
|
+
home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
|
|
37
|
+
),
|
|
38
|
+
("Claude Desktop file (Linux)", home / ".config" / "Claude" / "claude_desktop_config.json"),
|
|
39
|
+
]
|
|
40
|
+
if codex:
|
|
41
|
+
files.append(("Codex file ($CODEX_HOME/config.toml)", Path(codex) / "config.toml"))
|
|
42
|
+
if "APPDATA" in environ:
|
|
43
|
+
path = Path(environ["APPDATA"]) / "Claude" / "claude_desktop_config.json"
|
|
44
|
+
files.append(("Claude Desktop file (Windows)", path))
|
|
45
|
+
return files
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _servers(tree: object) -> Iterator[Mapping[str, object]]:
|
|
49
|
+
"""Yield every server entry at any depth.
|
|
50
|
+
|
|
51
|
+
The keys are `mcpServers` (Claude Code nests them per project; Cursor),
|
|
52
|
+
`mcp_servers` (Codex) and `mcp` (OpenCode).
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(tree, dict):
|
|
55
|
+
for key in ("mcpServers", "mcp_servers", "mcp"):
|
|
56
|
+
servers = tree.get(key)
|
|
57
|
+
if isinstance(servers, dict):
|
|
58
|
+
yield from (s for s in servers.values() if isinstance(s, dict))
|
|
59
|
+
for value in tree.values():
|
|
60
|
+
yield from _servers(value)
|
|
61
|
+
elif isinstance(tree, list):
|
|
62
|
+
for value in tree:
|
|
63
|
+
yield from _servers(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _starts_us(server: Mapping[str, object]) -> bool:
|
|
67
|
+
command = server.get("command", "")
|
|
68
|
+
words = list(command) if isinstance(command, list) else [command] # OpenCode: a list
|
|
69
|
+
args = server.get("args", [])
|
|
70
|
+
if isinstance(args, list):
|
|
71
|
+
words.extend(args)
|
|
72
|
+
# The script name, or the package run as a module (`python -m incognito_sql`).
|
|
73
|
+
return any(name in str(word) for word in words for name in _NAMES)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parsed(path: Path) -> object:
|
|
77
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
78
|
+
return tomllib.loads(text) if path.suffix == ".toml" else json.loads(text)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def client_findings(files: list[tuple[str, Path]]) -> list[Finding]:
|
|
82
|
+
"""Flag each client entry that runs incognito-sql as this user, with secrets beside it."""
|
|
83
|
+
found: list[Finding] = []
|
|
84
|
+
for label, path in files:
|
|
85
|
+
try:
|
|
86
|
+
servers = list(_servers(_parsed(path)))
|
|
87
|
+
except (OSError, ValueError, RecursionError): # unreadable, not JSON or TOML, too deep
|
|
88
|
+
continue
|
|
89
|
+
for server in servers:
|
|
90
|
+
if not _starts_us(server):
|
|
91
|
+
continue
|
|
92
|
+
env = server.get("env", server.get("environment")) # OpenCode: `environment`
|
|
93
|
+
what = f"{label} starts incognito-sql as this user"
|
|
94
|
+
if isinstance(env, dict) and any(
|
|
95
|
+
not _REFERENCE.fullmatch(str(value)) for value in env.values()
|
|
96
|
+
):
|
|
97
|
+
what += ", with its secrets in the file"
|
|
98
|
+
found.append(Finding(what, hole=True, fix="run it as a service and connect over HTTP"))
|
|
99
|
+
return found
|