CI (SIAX Cloud) / quality (push) Successful in 50s
CI (SIAX Cloud) / integration (push) Successful in 35s
CI (SIAX Cloud) / security (push) Successful in 21s
Security Scan (SIAX Cloud) / security (push) Successful in 26s
CI (SIAX Cloud) / quality (pull_request) Successful in 46s
CI (SIAX Cloud) / integration (pull_request) Successful in 35s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
AnalyticsSQLSession is a SEPARATE class, not a method on SiaxAnalyticsEngine.
executeMetric has no raw-SQL surface at all; on the same object the closed DSL
would be one refactor away from growing one. ClickHouseAnalyticsEngine
.executeQuery stays a documented refusal.
FOUR LAYERS, and the guard is deliberately called the weakest:
1 role — the CL0UD analyst role is checked when the SESSION IS OPENED, so
a caller without it never obtains an object that can run SQL.
2 guard — statement admission. Comments and literals removed with
ClickHouse's own lexical rules, ASCII-only outside literals, one
statement, SELECT/WITH only, and every table reference POSITIVELY
resolved against the tenant's catalog. Deny-lists are a second
opinion, never the mechanism.
3 grants — a per-tenant ClickHouse user with SELECT on that tenant's catalog
alone, plus a restrictive row policy on the base tables.
4 views — the tenant catalog holds only VIEWS, each baking
`WHERE tenant_id = '<tenant>'`, and tenant_id is not projected.
61 bypass attempts, in one shared corpus, run TWICE: against the guard, and
against a live ClickHouse 24.8.14.39 with LAYER 2 REMOVED. The live assertion
is not "the engine errored" — it is "no canary came back", with BETA_CANARY
seeded into the other tenant's rows and SECRET_CANARY into an ungranted table.
The counterfactual runs first: as admin the same base table returns 100 rows
and BETA_CANARY IS readable, so the isolation numbers mean something.
Result: 0 of 61 leaked. Evidence: evidence/bi-p017/analyst-sql-live-bypass.json.
FOUR DEFECTS FOUND BY WRITING TESTS THAT CAN FAIL — three of them mine:
* `CREATE ROW POLICY IF NOT EXISTS` is a SILENT NO-OP on re-provisioning.
Dropping the user empties the policy's apply-to list; the next run skips
re-creation, so the policy survives with `apply_to_list = []`, applying to
nobody, while `system.row_policies` still shows the correct select_filter.
The restricted user read 100 of 100 rows — BOTH TENANTS — from the base
table. Fixed with OR REPLACE + `rowPolicyVerificationSql`, which turns
"the policy is applied" from an assumption into a query, and a test that
asserts applied_to_user = 1 per object.
* `CREATE SETTINGS PROFILE IF NOT EXISTS` has the identical shape: a changed
budget never lands. A stale 60-row ceiling survived re-provisioning and
starved every legitimate statement, with the DDL reporting success
throughout. Also OR REPLACE now.
* `describe.skipIf(!reachable)` where `reachable` is set in `beforeAll`
skips ALWAYS — vitest evaluates skipIf at collection time. Ten tests
reported green while skipping against a live, reachable server. Replaced
with the repo's existing env-var gate, which FAILS when CLICKHOUSE_URL is
set and the server is down rather than degrading to a skip.
* MEASURED, and it changes the design: a ClickHouse user whose profile
carries `readonly = 1` cannot have ANY setting changed from the request
(Code: 164). The BI-P012 ceilings therefore cannot travel with an analyst
statement; they are baked into the user's SETTINGS PROFILE at provisioning.
Proven both ways: over-budget scan fails with code 158 from the profile
with no setting sent, and an attempt to raise it from the request fails
with code 164.
Honest about what the guard does NOT catch: `SELECT tenant_id FROM
t_alpha_sql.sales` is structurally legitimate and IS admitted. It fails at the
engine because the view does not project the column. Recorded in its own
GUARD_ADMITS_ENGINE_REFUSES list and asserted live, rather than deleted from
the corpus.
evidence/bi-p017 is pinned by tests/analytics/analyst-sql-evidence.test.ts,
which never skips: adding an attack without re-running the live suite breaks
the build, and the pin also checks the counterfactual numbers so the evidence
cannot be a recording of an empty database.
246 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
/**
|
|
* tests/analytics/analyst-sql-bypass-corpus.ts
|
|
* =============================================
|
|
* BI-P017 — the bypass attempts, in one place, so the SAME corpus can be run
|
|
* twice: once against the guard (unit) and once against a LIVE ClickHouse with
|
|
* the guard REMOVED (integration), where the grants and the view predicate
|
|
* must refuse it anyway.
|
|
*
|
|
* Running it only against the guard would prove the guard, not the system.
|
|
* This estate has repeatedly shipped guards that were never tested against a
|
|
* real attempt: INF0's tenant-from-body hole was exploitable against LIVE
|
|
* production, and ST0RE turned out to have FOUR cross-tenant paths, not the
|
|
* one reported.
|
|
*
|
|
* The integration test asserts that with the guard removed the engine either
|
|
* ERRORS or returns zero foreign rows — never that "the guard said no", which
|
|
* is the assertion that would keep passing while the system was open.
|
|
*
|
|
* Session under test: tenant `t_alpha`, catalog `t_alpha_sql`, objects
|
|
* `sales` and `events`. The targets are `t_beta_sql`, the shared base database
|
|
* `opt_analytics`, and `system`.
|
|
*/
|
|
|
|
export interface BypassAttempt {
|
|
id: string;
|
|
/** What the attacker is trying to do. */
|
|
goal: string;
|
|
sql: string;
|
|
}
|
|
|
|
export const BYPASS_CORPUS: BypassAttempt[] = [
|
|
// ── reach another tenant's catalog ──────────────────────────────────────
|
|
{
|
|
id: 'cross-catalog-direct',
|
|
goal: "read the other tenant's catalog",
|
|
sql: 'SELECT * FROM t_beta_sql.sales',
|
|
},
|
|
{
|
|
id: 'cross-catalog-union',
|
|
goal: 'union in the other tenant',
|
|
sql: 'SELECT customer FROM t_alpha_sql.sales UNION ALL SELECT customer FROM t_beta_sql.sales',
|
|
},
|
|
{
|
|
id: 'cross-catalog-subquery',
|
|
goal: 'subquery on the other tenant',
|
|
sql: 'SELECT (SELECT count() FROM t_beta_sql.sales) AS n FROM t_alpha_sql.sales LIMIT 1',
|
|
},
|
|
{
|
|
id: 'cross-catalog-join',
|
|
goal: 'join the other tenant',
|
|
sql: 'SELECT a.customer FROM t_alpha_sql.sales a JOIN t_beta_sql.sales b ON a.customer = b.customer',
|
|
},
|
|
{
|
|
id: 'cross-catalog-cte',
|
|
goal: 'hide the other tenant in a CTE',
|
|
sql: 'WITH other AS (SELECT * FROM t_beta_sql.sales) SELECT count() AS n FROM other',
|
|
},
|
|
{
|
|
id: 'cross-catalog-quoted',
|
|
goal: 'quote the identifier to dodge the scan',
|
|
sql: 'SELECT * FROM "t_beta_sql"."sales"',
|
|
},
|
|
{
|
|
id: 'cross-catalog-backtick',
|
|
goal: 'backtick the identifier',
|
|
sql: 'SELECT * FROM `t_beta_sql`.`sales`',
|
|
},
|
|
|
|
// ── reach the unfiltered base table ─────────────────────────────────────
|
|
{
|
|
id: 'base-table-direct',
|
|
goal: 'read the shared base table, which holds every tenant',
|
|
sql: 'SELECT * FROM opt_analytics.opt_analytics_event',
|
|
},
|
|
{
|
|
id: 'base-table-in-cte',
|
|
goal: 'base table behind a CTE',
|
|
sql: 'WITH raw AS (SELECT * FROM opt_analytics.opt_analytics_event) SELECT count() AS n FROM raw',
|
|
},
|
|
{
|
|
id: 'base-table-union',
|
|
goal: 'union the base table in',
|
|
sql: 'SELECT customer FROM t_alpha_sql.sales UNION ALL SELECT tenant_id FROM opt_analytics.opt_analytics_event',
|
|
},
|
|
|
|
// ── system / metadata catalogs ──────────────────────────────────────────
|
|
{
|
|
id: 'system-tables',
|
|
goal: 'enumerate every table on the server',
|
|
sql: 'SELECT database, name FROM system.tables',
|
|
},
|
|
{ id: 'system-columns', goal: 'enumerate columns', sql: 'SELECT * FROM system.columns' },
|
|
{ id: 'system-users', goal: 'enumerate users', sql: 'SELECT * FROM system.users' },
|
|
{
|
|
id: 'system-processes',
|
|
goal: "read other tenants' running queries",
|
|
sql: 'SELECT query FROM system.processes',
|
|
},
|
|
{
|
|
id: 'system-query-log',
|
|
goal: "read other tenants' statement text out of the log",
|
|
sql: 'SELECT query FROM system.query_log',
|
|
},
|
|
{
|
|
id: 'information-schema',
|
|
goal: 'metadata via the ANSI schema',
|
|
sql: 'SELECT * FROM information_schema.tables',
|
|
},
|
|
{ id: 'one-table', goal: 'the always-present one-row table', sql: 'SELECT * FROM system.one' },
|
|
|
|
// ── table functions: these need NO table privilege at all ───────────────
|
|
{
|
|
id: 'tf-url',
|
|
goal: 'exfiltrate over HTTP',
|
|
sql: "SELECT * FROM url('http://127.0.0.1:1/x', 'JSONEachRow', 'a String')",
|
|
},
|
|
{
|
|
id: 'tf-file',
|
|
goal: 'read the server filesystem',
|
|
sql: "SELECT * FROM file('/etc/passwd', 'LineAsString', 'l String')",
|
|
},
|
|
{
|
|
id: 'tf-s3',
|
|
goal: 'read object storage directly',
|
|
sql: "SELECT * FROM s3('http://minio/b/*', 'CSV', 'a String')",
|
|
},
|
|
{
|
|
id: 'tf-remote',
|
|
goal: 'query another ClickHouse server',
|
|
sql: "SELECT * FROM remote('127.0.0.1:9000', 'opt_analytics', 'opt_analytics_event')",
|
|
},
|
|
{
|
|
id: 'tf-mysql',
|
|
goal: 'reach an external database',
|
|
sql: "SELECT * FROM mysql('h:3306', 'db', 't', 'u', 'p')",
|
|
},
|
|
{ id: 'tf-executable', goal: 'run a program', sql: "SELECT * FROM executable('sh', 'TSV', 'a String')" },
|
|
{
|
|
id: 'tf-numbers',
|
|
goal: 'a CPU bomb that touches no table',
|
|
sql: 'SELECT count() FROM numbers_mt(100000000000)',
|
|
},
|
|
{ id: 'tf-generate', goal: 'unbounded synthetic scan', sql: "SELECT * FROM generateRandom('a String')" },
|
|
{
|
|
id: 'tf-merge',
|
|
goal: 'merge() glob across a database',
|
|
sql: "SELECT * FROM merge('opt_analytics', '.*')",
|
|
},
|
|
{
|
|
id: 'tf-cluster',
|
|
goal: 'fan out across the cluster',
|
|
sql: "SELECT * FROM cluster('default', 'opt_analytics', 'opt_analytics_event')",
|
|
},
|
|
{
|
|
id: 'tf-view',
|
|
goal: 'wrap a subquery in view()',
|
|
sql: 'SELECT * FROM view(SELECT * FROM opt_analytics.opt_analytics_event)',
|
|
},
|
|
{
|
|
id: 'fn-dictget',
|
|
goal: 'read a dictionary, bypassing table grants',
|
|
sql: "SELECT dictGet('d', 'v', toUInt64(1)) AS x FROM t_alpha_sql.sales",
|
|
},
|
|
|
|
// ── write / DDL / privilege escalation ──────────────────────────────────
|
|
{
|
|
id: 'stacked-drop',
|
|
goal: 'stacked statement',
|
|
sql: 'SELECT 1 FROM t_alpha_sql.sales; DROP TABLE opt_analytics.opt_analytics_event',
|
|
},
|
|
{ id: 'insert', goal: 'write', sql: "INSERT INTO t_alpha_sql.sales VALUES ('x')" },
|
|
{
|
|
id: 'insert-select',
|
|
goal: 'write from a read',
|
|
sql: 'INSERT INTO t_alpha_sql.sales SELECT * FROM t_alpha_sql.sales',
|
|
},
|
|
{ id: 'alter-delete', goal: 'mutate', sql: 'ALTER TABLE opt_analytics.opt_analytics_event DELETE WHERE 1' },
|
|
{
|
|
id: 'create-view-escape',
|
|
goal: 'create an unfiltered view to read later',
|
|
sql: 'CREATE VIEW t_alpha_sql.escape AS SELECT * FROM opt_analytics.opt_analytics_event',
|
|
},
|
|
{ id: 'grant-self', goal: 'grant itself more', sql: 'GRANT SELECT ON opt_analytics.* TO CURRENT_USER' },
|
|
{ id: 'system-command', goal: 'operate the server', sql: 'SYSTEM RELOAD DICTIONARIES' },
|
|
{ id: 'kill-query', goal: "kill another tenant's query", sql: 'KILL QUERY WHERE 1' },
|
|
{ id: 'truncate', goal: 'destroy data', sql: 'TRUNCATE TABLE t_alpha_sql.sales' },
|
|
{
|
|
id: 'optimize',
|
|
goal: 'trigger heavy background work',
|
|
sql: 'OPTIMIZE TABLE opt_analytics.opt_analytics_event FINAL',
|
|
},
|
|
{
|
|
id: 'outfile',
|
|
goal: 'write to the filesystem',
|
|
sql: "SELECT * FROM t_alpha_sql.sales INTO OUTFILE '/tmp/x.csv'",
|
|
},
|
|
|
|
// ── lift the governance ceiling from inside the statement ───────────────
|
|
{
|
|
id: 'settings-raise-rows',
|
|
goal: 'raise max_rows_to_read',
|
|
sql: 'SELECT count() AS n FROM t_alpha_sql.sales SETTINGS max_rows_to_read = 0',
|
|
},
|
|
{
|
|
id: 'settings-readonly-off',
|
|
goal: 'turn readonly off',
|
|
sql: 'SELECT 1 FROM t_alpha_sql.sales SETTINGS readonly = 0',
|
|
},
|
|
{ id: 'set-statement', goal: 'SET before the query', sql: 'SET max_execution_time = 0' },
|
|
{
|
|
id: 'format-override',
|
|
goal: 'choose the wire format',
|
|
sql: 'SELECT * FROM t_alpha_sql.sales FORMAT Native',
|
|
},
|
|
|
|
// ── lexical evasion of the keyword scan ─────────────────────────────────
|
|
{
|
|
id: 'comment-split-name',
|
|
goal: 'hide a catalog boundary behind a comment',
|
|
sql: 'SELECT * FROM opt_analytics/**/.opt_analytics_event',
|
|
},
|
|
{
|
|
id: 'comment-line-hide',
|
|
goal: 'hide the tail after a -- comment',
|
|
sql: 'SELECT customer FROM t_alpha_sql.sales -- \nUNION ALL SELECT name FROM system.tables',
|
|
},
|
|
{
|
|
id: 'comment-hash-hide',
|
|
goal: 'hide the tail after a # comment',
|
|
sql: 'SELECT customer FROM t_alpha_sql.sales # \nUNION ALL SELECT name FROM system.tables',
|
|
},
|
|
{
|
|
id: 'unterminated-comment',
|
|
goal: 'run off the end of a comment',
|
|
sql: 'SELECT * FROM t_alpha_sql.sales /* x',
|
|
},
|
|
{
|
|
id: 'unterminated-string',
|
|
goal: 'run off the end of a literal',
|
|
sql: "SELECT * FROM t_alpha_sql.sales WHERE customer = 'x",
|
|
},
|
|
{
|
|
id: 'unicode-homoglyph',
|
|
// U+0430 CYRILLIC SMALL LETTER A in place of the ASCII "a".
|
|
goal: 'cyrillic homoglyph inside the catalog name',
|
|
sql: 'SELECT * FROM t_\u0430lpha_sql.sales',
|
|
},
|
|
{
|
|
id: 'zero-width',
|
|
// U+200B ZERO WIDTH SPACE inside "system".
|
|
goal: 'zero-width space inside a reserved catalog name',
|
|
sql: 'SELECT * FROM sys\u200btem.tables',
|
|
},
|
|
{
|
|
id: 'nul-byte',
|
|
goal: 'NUL truncation between two halves of a statement',
|
|
sql: 'SELECT customer FROM t_alpha_sql.sales \u0000 UNION ALL SELECT name FROM system.tables',
|
|
},
|
|
{ id: 'case-mixing', goal: 'mixed case keywords and names', sql: 'SeLeCt * FrOm SyStEm.TaBlEs' },
|
|
{
|
|
id: 'whitespace-newlines',
|
|
goal: 'newlines and tabs between tokens',
|
|
sql: 'SELECT\n\t*\nFROM\n\tsystem.tables',
|
|
},
|
|
{
|
|
id: 'describe-subquery',
|
|
goal: 'DESCRIBE a base table from inside a subquery, where the FROM-scan sees only a paren',
|
|
sql: 'SELECT * FROM (DESCRIBE TABLE opt_analytics.opt_analytics_event)',
|
|
},
|
|
{
|
|
id: 'desc-subquery-short',
|
|
goal: 'the same, using the DESC abbreviation',
|
|
sql: 'SELECT * FROM (DESC TABLE opt_analytics.opt_analytics_event)',
|
|
},
|
|
{
|
|
id: 'current-user',
|
|
goal: "identify the session's ClickHouse principal",
|
|
sql: 'SELECT currentUser() AS u FROM t_alpha_sql.sales',
|
|
},
|
|
{ id: 'three-part-name', goal: 'a three-part name', sql: 'SELECT * FROM a.b.c' },
|
|
{
|
|
id: 'unqualified-table',
|
|
goal: "unqualified name resolved by the connection's default database",
|
|
sql: 'SELECT * FROM opt_analytics_event',
|
|
},
|
|
{
|
|
id: 'cte-shadow',
|
|
goal: 'shadow a legitimate name with a CTE while also reading the real base table',
|
|
sql: 'WITH sales AS (SELECT 1 AS x) SELECT count() AS n FROM opt_analytics.opt_analytics_event',
|
|
},
|
|
{ id: 'no-from', goal: 'a statement that reads no table at all', sql: 'SELECT 1' },
|
|
{
|
|
id: 'object-not-granted',
|
|
goal: 'an object in the right catalog that the session was not given',
|
|
sql: 'SELECT * FROM t_alpha_sql.secret_ledger',
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Attempts the GUARD ADMITS — and which only layers 3 and 4 refuse.
|
|
*
|
|
* This list exists because the first run of the corpus found one, and hiding
|
|
* it by quietly deleting the case would have been the dishonest fix. The guard
|
|
* reasons about statement STRUCTURE; it does not know which columns a view
|
|
* projects, and teaching it would mean giving it a schema cache that goes
|
|
* stale — a hardcoded list, which is how api0's container job died this week.
|
|
*
|
|
* `SELECT tenant_id FROM t_alpha_sql.sales` is structurally a legitimate read
|
|
* of an allowed object. It fails at the ENGINE, because the view does not
|
|
* project `tenant_id` at all (tenant-catalog.ts refuses to build one that
|
|
* does). The integration test asserts that engine-side refusal directly, so
|
|
* this case is covered by evidence rather than by an assumption.
|
|
*/
|
|
export const GUARD_ADMITS_ENGINE_REFUSES: BypassAttempt[] = [
|
|
{
|
|
id: 'hidden-tenant-column',
|
|
goal: 'select the tenant column the view does not project',
|
|
sql: 'SELECT tenant_id FROM t_alpha_sql.sales',
|
|
},
|
|
{
|
|
id: 'hidden-column-star-then-filter',
|
|
goal: 'filter on a column the view does not expose',
|
|
sql: "SELECT count() AS n FROM t_alpha_sql.sales WHERE tenant_id = 't_beta'",
|
|
},
|
|
];
|
|
|
|
/** Statements that MUST be admitted — a guard that refuses everything is useless. */
|
|
export const LEGITIMATE_CORPUS: Array<{ id: string; sql: string }> = [
|
|
{ id: 'simple', sql: 'SELECT customer, amount FROM t_alpha_sql.sales LIMIT 10' },
|
|
{
|
|
id: 'aggregate',
|
|
sql: 'SELECT customer, sum(amount) AS total FROM t_alpha_sql.sales GROUP BY customer ORDER BY total DESC LIMIT 5',
|
|
},
|
|
{
|
|
id: 'cte',
|
|
sql:
|
|
'WITH top AS (SELECT customer, sum(amount) AS total FROM t_alpha_sql.sales GROUP BY customer) ' +
|
|
'SELECT customer, total FROM top ORDER BY total DESC LIMIT 3',
|
|
},
|
|
{
|
|
id: 'self-join',
|
|
sql: 'SELECT a.customer FROM t_alpha_sql.sales a JOIN t_alpha_sql.events b ON a.customer = b.customer LIMIT 10',
|
|
},
|
|
{
|
|
id: 'literal-with-quotes',
|
|
sql: "SELECT count() AS n FROM t_alpha_sql.sales WHERE customer = 'O''Brien'",
|
|
},
|
|
{
|
|
id: 'literal-containing-sql-text',
|
|
sql: "SELECT count() AS n FROM t_alpha_sql.sales WHERE customer = 'DROP TABLE system.tables'",
|
|
},
|
|
{ id: 'comment-then-query', sql: '-- daily revenue\nSELECT sum(amount) AS total FROM t_alpha_sql.sales' },
|
|
{
|
|
id: 'subquery',
|
|
sql: 'SELECT customer FROM t_alpha_sql.sales WHERE amount > (SELECT avg(amount) FROM t_alpha_sql.sales)',
|
|
},
|
|
{
|
|
id: 'union-own-catalog',
|
|
sql: 'SELECT customer FROM t_alpha_sql.sales UNION ALL SELECT customer FROM t_alpha_sql.events',
|
|
},
|
|
{ id: 'trailing-semicolon', sql: 'SELECT count() AS n FROM t_alpha_sql.sales;' },
|
|
];
|