fix(analytics): replace the control-character class with a code-point scan (no-control-regex)
CI (SIAX Cloud) / quality (push) Successful in 45s
CI (SIAX Cloud) / integration (push) Successful in 34s
CI (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / quality (pull_request) Successful in 45s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 27s
CI (SIAX Cloud) / security (pull_request) Successful in 37s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
CI (SIAX Cloud) / quality (push) Successful in 45s
CI (SIAX Cloud) / integration (push) Successful in 34s
CI (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / quality (pull_request) Successful in 45s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 27s
CI (SIAX Cloud) / security (pull_request) Successful in 37s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
The ASCII-only check used a regex character class of control characters. It tripped eslint's no-control-regex, which the repo's quality gate enforces — and the rule has a point: such a class is unreadable and is exactly the kind of expression a reviewer nods at without checking. Same behaviour, written as an explicit scan naming the permitted set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"prompt": "BI-P017",
|
||||
"claim": "Every bypass attempt, executed directly against a live ClickHouse as the tenant's restricted user WITH THE STATEMENT GUARD REMOVED, fails to return a foreign-tenant row, an ungranted-table row, or server metadata.",
|
||||
"clickhouse_version": "24.8.14.39",
|
||||
"recorded_at": "2026-09-07T00:15:15.942Z",
|
||||
"recorded_at": "2026-09-07T00:19:07.198Z",
|
||||
"base_table_rows_both_tenants": 100,
|
||||
"tenant_view_rows_alpha": 50,
|
||||
"attempts_total": 64,
|
||||
@@ -607,7 +607,7 @@
|
||||
"leaked_foreign_tenant_row": false,
|
||||
"leaked_ungranted_table_row": false,
|
||||
"leaked_server_metadata": false,
|
||||
"guard_verdict_for_reference": "refused: [AUTHORIZATION_DENIED] 0pt.analytics.sql: statement contains a non-ASCII character (U+0430) outside a string literal",
|
||||
"guard_verdict_for_reference": "refused: [AUTHORIZATION_DENIED] 0pt.analytics.sql: statement contains a disallowed character (U+0430) outside a string literal",
|
||||
"engine_response_head": "Code: 62. DB::Exception: Syntax error: failed at position 17 ('�'): аlpha_sql.sales . Unrecognized token: '�'. (SYNTAX_ERROR) (version 24.8.14.39 (official buil"
|
||||
},
|
||||
{
|
||||
@@ -619,7 +619,7 @@
|
||||
"leaked_foreign_tenant_row": false,
|
||||
"leaked_ungranted_table_row": false,
|
||||
"leaked_server_metadata": false,
|
||||
"guard_verdict_for_reference": "refused: [AUTHORIZATION_DENIED] 0pt.analytics.sql: statement contains a non-ASCII character (U+200b) outside a string literal",
|
||||
"guard_verdict_for_reference": "refused: [AUTHORIZATION_DENIED] 0pt.analytics.sql: statement contains a disallowed character (U+200B) outside a string literal",
|
||||
"engine_response_head": "Code: 62. DB::Exception: Syntax error: failed at position 24 ('.'): .tables . Expected one of: FINAL, SAMPLE, table, table function, subquery or list of joined"
|
||||
},
|
||||
{
|
||||
@@ -631,7 +631,7 @@
|
||||
"leaked_foreign_tenant_row": false,
|
||||
"leaked_ungranted_table_row": false,
|
||||
"leaked_server_metadata": false,
|
||||
"guard_verdict_for_reference": "refused: [AUTHORIZATION_DENIED] 0pt.analytics.sql: statement contains a non-ASCII character (U+0000) outside a string literal",
|
||||
"guard_verdict_for_reference": "refused: [AUTHORIZATION_DENIED] 0pt.analytics.sql: statement contains a disallowed character (U+0000) outside a string literal",
|
||||
"engine_response_head": "Code: 62. DB::Exception: Syntax error: failed at position 40 ('\u0000'): \u0000 UNION ALL SELECT name FROM system.tables . Unrecognized token: '\u0000'. (SYNTAX_ERROR) (versio"
|
||||
},
|
||||
{
|
||||
@@ -656,7 +656,7 @@
|
||||
"leaked_ungranted_table_row": false,
|
||||
"leaked_server_metadata": false,
|
||||
"guard_verdict_for_reference": "refused: [AUTHORIZATION_DENIED] 0pt.analytics.sql: \"SYSTEM\" is not permitted in a read-only analyst session",
|
||||
"engine_response_head": "opt_analytics events f6d0a7de-8f53-4fe5-ad8f-7cff5fdd2005 MergeTree 0 ['/var/lib/clickhouse/store/f6d/f6d0a7de-8f53-4fe5-ad8f-7cff5fdd2005/'] /var/lib/clickhous"
|
||||
"engine_response_head": "opt_analytics events 12706469-5605-436f-94ff-fc3c112aec6f MergeTree 0 ['/var/lib/clickhouse/store/127/12706469-5605-436f-94ff-fc3c112aec6f/'] /var/lib/clickhous"
|
||||
},
|
||||
{
|
||||
"id": "describe-subquery",
|
||||
|
||||
@@ -377,13 +377,21 @@ export function guardAnalystStatement(sql: string, opts: StatementGuardOptions):
|
||||
// Homoglyphs, zero-width joiners and RTL overrides stop being expressible
|
||||
// against the keyword scan. Values are unaffected: they live in literals,
|
||||
// which have already been removed at this point.
|
||||
const nonAscii = /[^\x09\x0a\x0d\x20-\x7e]/.exec(stripped);
|
||||
if (nonAscii) {
|
||||
deny(
|
||||
`statement contains a non-ASCII character (U+${nonAscii[0].codePointAt(0)?.toString(16).padStart(4, '0')}) outside a string literal`,
|
||||
);
|
||||
// Written as a code-point scan rather than a character class: a regex of
|
||||
// control characters is unreadable, trips `no-control-regex`, and is the
|
||||
// kind of expression a reviewer nods at without checking. Permitted: tab,
|
||||
// LF, CR and printable ASCII. Everything else, NUL included, is refused.
|
||||
for (let i = 0; i < stripped.length; i += 1) {
|
||||
const code = stripped.charCodeAt(i);
|
||||
const printable = code >= 0x20 && code <= 0x7e;
|
||||
const allowedWhitespace = code === 0x09 || code === 0x0a || code === 0x0d;
|
||||
if (!printable && !allowedWhitespace) {
|
||||
deny(
|
||||
`statement contains a disallowed character (U+${code.toString(16).toUpperCase().padStart(4, '0')}) ` +
|
||||
`outside a string literal`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (stripped.includes('\0')) deny('statement contains a NUL byte');
|
||||
|
||||
// ── exactly one statement ──────────────────────────────────────────────
|
||||
const withoutTrailingSemi = stripped.replace(/;\s*$/, '');
|
||||
|
||||
Reference in New Issue
Block a user