@@ -47,6 +47,57 @@ const ORACLE_WHERE_MASKED_PATTERNS = [
4747 / ^ \s * (?: \d + (?: \. \d + ) ? | t r u e | f a l s e ) \s * $ / i,
4848] as const
4949
50+ const ORACLE_CONSTANT_ATOM = String . raw `(?:[+-]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)[fFdD]?|NULL|TRUE|FALSE|(?:DATE|TIMESTAMP)\s+0)`
51+ const ORACLE_GROUPED_CONSTANT_ATOM = String . raw `[\s(]*${ ORACLE_CONSTANT_ATOM } \s*\)*`
52+ const ORACLE_BOOLEAN_ARM_PREFIX = String . raw `(?:^|\b(?:OR|AND)\b)(?:[\s(]*NOT\b)*`
53+ const ORACLE_BOOLEAN_ARM_END = String . raw `(?=[\s)]*(?:$|\b(?:OR|AND)\b))`
54+ const MAX_ORACLE_WHERE_PARENTHESIS_DEPTH = 128
55+
56+ /**
57+ * Rejects common literal-only predicates at the start of a boolean arm. The
58+ * scanner maps ordinary, national, and q-quoted Oracle strings to `0`, so the
59+ * same expressions cover strings without reproducing Oracle's quoting grammar
60+ * in a regular expression. This remains a targeted defense-in-depth check, not
61+ * a general SQL expression evaluator. Quoted identifiers become `I` and remain
62+ * distinguishable from constants.
63+ */
64+ const ORACLE_CONSTANT_PREDICATE_PATTERNS = [
65+ new RegExp (
66+ `${ ORACLE_BOOLEAN_ARM_PREFIX } ${ ORACLE_GROUPED_CONSTANT_ATOM } \\s*(?:=|==|<>|!=|<=|>=|<|>)${ ORACLE_GROUPED_CONSTANT_ATOM } ${ ORACLE_BOOLEAN_ARM_END } ` ,
67+ 'i'
68+ ) ,
69+ new RegExp (
70+ `${ ORACLE_BOOLEAN_ARM_PREFIX } ${ ORACLE_GROUPED_CONSTANT_ATOM } \\s+(?:NOT\\s+)?IN\\s*\\(${ ORACLE_GROUPED_CONSTANT_ATOM } (?:\\s*,${ ORACLE_GROUPED_CONSTANT_ATOM } )*\\s*\\)${ ORACLE_BOOLEAN_ARM_END } ` ,
71+ 'i'
72+ ) ,
73+ new RegExp (
74+ `${ ORACLE_BOOLEAN_ARM_PREFIX } ${ ORACLE_GROUPED_CONSTANT_ATOM } \\s+(?:NOT\\s+)?BETWEEN${ ORACLE_GROUPED_CONSTANT_ATOM } \\s+AND${ ORACLE_GROUPED_CONSTANT_ATOM } ${ ORACLE_BOOLEAN_ARM_END } ` ,
75+ 'i'
76+ ) ,
77+ new RegExp (
78+ `${ ORACLE_BOOLEAN_ARM_PREFIX } ${ ORACLE_GROUPED_CONSTANT_ATOM } \\s+IS\\s+(?:NOT\\s+)?(?:NULL|TRUE|FALSE|UNKNOWN)${ ORACLE_BOOLEAN_ARM_END } ` ,
79+ 'i'
80+ ) ,
81+ ] as const
82+
83+ function validateOracleWhereParentheses ( masked : string ) : ValidationResult {
84+ let depth = 0
85+ for ( const character of masked ) {
86+ if ( character === '(' ) {
87+ depth += 1
88+ if ( depth > MAX_ORACLE_WHERE_PARENTHESIS_DEPTH ) {
89+ return invalid (
90+ `WHERE clause cannot nest parentheses more than ${ MAX_ORACLE_WHERE_PARENTHESIS_DEPTH } levels`
91+ )
92+ }
93+ } else if ( character === ')' ) {
94+ if ( depth === 0 ) return invalid ( 'WHERE clause contains unbalanced parentheses' )
95+ depth -= 1
96+ }
97+ }
98+ return depth === 0 ? { isValid : true } : invalid ( 'WHERE clause contains unbalanced parentheses' )
99+ }
100+
50101function ddlObjectType ( tokens : string [ ] ) : string | undefined {
51102 const statement = tokens [ 0 ]
52103 if ( statement !== 'CREATE' && statement !== 'ALTER' && statement !== 'DROP' ) return undefined
@@ -86,23 +137,35 @@ function scanOracleSql(sql: string): ScannedSql {
86137 for ( let index = 0 ; index < sql . length ; index += 1 ) {
87138 const character = sql [ index ]
88139 const next = sql [ index + 1 ]
140+ const isNationalQQuote =
141+ index > 0 &&
142+ ( sql [ index - 1 ] === 'n' || sql [ index - 1 ] === 'N' ) &&
143+ ( index === 1 || ! / [ A - Z a - z 0 - 9 _ $ # ] / . test ( sql [ index - 2 ] ) )
89144
90145 if (
91146 ( character === 'q' || character === 'Q' ) &&
92147 next === "'" &&
93148 index + 2 < sql . length &&
94- ( index === 0 || ! / [ A - Z a - z 0 - 9 _ $ # ] / . test ( sql [ index - 1 ] ) )
149+ ( index === 0 || ! / [ A - Z a - z 0 - 9 _ $ # ] / . test ( sql [ index - 1 ] ) || isNationalQQuote )
95150 ) {
96151 const ending = `${ closingDelimiter ( sql [ index + 2 ] ) } '`
97152 const endIndex = sql . indexOf ( ending , index + 3 )
98153 if ( endIndex === - 1 )
99154 return { masked : '' , hasComment, hasHint, error : 'Unterminated q-quoted string' }
100- for ( let cursor = index ; cursor < endIndex + 2 ; cursor += 1 ) masked [ cursor ] = ' '
155+ const literalStart = isNationalQQuote ? index - 1 : index
156+ for ( let cursor = literalStart ; cursor < endIndex + 2 ; cursor += 1 ) masked [ cursor ] = ' '
157+ masked [ literalStart ] = '0'
101158 index = endIndex + 1
102159 continue
103160 }
104161
105162 if ( character === "'" ) {
163+ const isNationalQuote =
164+ index > 0 &&
165+ ( sql [ index - 1 ] === 'n' || sql [ index - 1 ] === 'N' ) &&
166+ ( index === 1 || ! / [ A - Z a - z 0 - 9 _ $ # ] / . test ( sql [ index - 2 ] ) )
167+ const literalStart = isNationalQuote ? index - 1 : index
168+ if ( isNationalQuote ) masked [ literalStart ] = ' '
106169 masked [ index ] = ' '
107170 let closed = false
108171 for ( let cursor = index + 1 ; cursor < sql . length ; cursor += 1 ) {
@@ -118,10 +181,12 @@ function scanOracleSql(sql: string): ScannedSql {
118181 break
119182 }
120183 if ( ! closed ) return { masked : '' , hasComment, hasHint, error : 'Unterminated string literal' }
184+ masked [ literalStart ] = '0'
121185 continue
122186 }
123187
124188 if ( character === '"' ) {
189+ const identifierStart = index
125190 masked [ index ] = ' '
126191 let closed = false
127192 for ( let cursor = index + 1 ; cursor < sql . length ; cursor += 1 ) {
@@ -138,6 +203,7 @@ function scanOracleSql(sql: string): ScannedSql {
138203 }
139204 if ( ! closed )
140205 return { masked : '' , hasComment, hasHint, error : 'Unterminated quoted identifier' }
206+ masked [ identifierStart ] = 'I'
141207 continue
142208 }
143209
@@ -335,9 +401,14 @@ export function validateOracleWhere(where: string): ValidationResult {
335401 'Structured WHERE clauses cannot contain bind placeholders; use literal predicates or Execute with named binds'
336402 )
337403 }
404+ const parentheses = validateOracleWhereParentheses ( scan . masked )
405+ if ( ! parentheses . isValid ) return parentheses
338406 if ( ORACLE_WHERE_MASKED_PATTERNS . some ( ( pattern ) => pattern . test ( scan . masked ) ) ) {
339407 return invalid ( 'WHERE clause contains a disallowed or always-true expression' )
340408 }
409+ if ( ORACLE_CONSTANT_PREDICATE_PATTERNS . some ( ( pattern ) => pattern . test ( scan . masked ) ) ) {
410+ return invalid ( 'WHERE clause contains a disallowed constant-only predicate' )
411+ }
341412 const forbidden = new Set ( [
342413 'INSERT' ,
343414 'UPDATE' ,
0 commit comments