sql-event-parser.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants'
  2. import { describe, expect, it } from 'vitest'
  3. import { sqlEventParser } from './sql-event-parser'
  4. describe('SQL Event Parser', () => {
  5. describe('CREATE TABLE detection', () => {
  6. it('detects basic CREATE TABLE', () => {
  7. const results = sqlEventParser.getTableEvents('CREATE TABLE users (id INT PRIMARY KEY)')
  8. expect(results).toHaveLength(1)
  9. expect(results[0]).toEqual({
  10. type: TABLE_EVENT_ACTIONS.TableCreated,
  11. schema: undefined,
  12. tableName: 'users',
  13. })
  14. })
  15. it('detects CREATE TABLE with schema', () => {
  16. const results = sqlEventParser.getTableEvents('CREATE TABLE public.users (id INT)')
  17. expect(results).toHaveLength(1)
  18. expect(results[0]).toEqual({
  19. type: TABLE_EVENT_ACTIONS.TableCreated,
  20. schema: 'public',
  21. tableName: 'users',
  22. })
  23. })
  24. it('detects CREATE TABLE IF NOT EXISTS', () => {
  25. const results = sqlEventParser.getTableEvents('CREATE TABLE IF NOT EXISTS users (id INT)')
  26. expect(results).toHaveLength(1)
  27. expect(results[0]).toEqual({
  28. type: TABLE_EVENT_ACTIONS.TableCreated,
  29. schema: undefined,
  30. tableName: 'users',
  31. })
  32. })
  33. it('handles quoted identifiers', () => {
  34. const results = sqlEventParser.getTableEvents('CREATE TABLE "public"."user_table" (id INT)')
  35. expect(results).toHaveLength(1)
  36. expect(results[0]).toEqual({
  37. type: TABLE_EVENT_ACTIONS.TableCreated,
  38. schema: 'public',
  39. tableName: 'user_table',
  40. })
  41. })
  42. it('returns empty array for non-matching SQL', () => {
  43. const results = sqlEventParser.getTableEvents('SELECT * FROM users')
  44. expect(results).toHaveLength(0)
  45. })
  46. it('detects CREATE TEMPORARY TABLE', () => {
  47. const results = sqlEventParser.getTableEvents('CREATE TEMPORARY TABLE temp_users (id INT)')
  48. expect(results).toHaveLength(1)
  49. expect(results[0]).toEqual({
  50. type: TABLE_EVENT_ACTIONS.TableCreated,
  51. schema: undefined,
  52. tableName: 'temp_users',
  53. })
  54. })
  55. it('detects CREATE TEMP TABLE', () => {
  56. const results = sqlEventParser.getTableEvents('CREATE TEMP TABLE temp_users (id INT)')
  57. expect(results).toHaveLength(1)
  58. expect(results[0]).toEqual({
  59. type: TABLE_EVENT_ACTIONS.TableCreated,
  60. schema: undefined,
  61. tableName: 'temp_users',
  62. })
  63. })
  64. it('detects CREATE UNLOGGED TABLE', () => {
  65. const results = sqlEventParser.getTableEvents('CREATE UNLOGGED TABLE fast_table (id INT)')
  66. expect(results).toHaveLength(1)
  67. expect(results[0]).toEqual({
  68. type: TABLE_EVENT_ACTIONS.TableCreated,
  69. schema: undefined,
  70. tableName: 'fast_table',
  71. })
  72. })
  73. it('detects CREATE TEMP TABLE IF NOT EXISTS', () => {
  74. const results = sqlEventParser.getTableEvents(
  75. 'CREATE TEMP TABLE IF NOT EXISTS temp_users (id INT)'
  76. )
  77. expect(results).toHaveLength(1)
  78. expect(results[0]).toEqual({
  79. type: TABLE_EVENT_ACTIONS.TableCreated,
  80. schema: undefined,
  81. tableName: 'temp_users',
  82. })
  83. })
  84. })
  85. describe('INSERT detection', () => {
  86. it('detects basic INSERT INTO', () => {
  87. const results = sqlEventParser.getTableEvents("INSERT INTO users (name) VALUES ('John')")
  88. expect(results).toHaveLength(1)
  89. expect(results[0]).toEqual({
  90. type: TABLE_EVENT_ACTIONS.TableDataAdded,
  91. schema: undefined,
  92. tableName: 'users',
  93. })
  94. })
  95. it('detects INSERT with schema', () => {
  96. const results = sqlEventParser.getTableEvents(
  97. "INSERT INTO public.users (name) VALUES ('John')"
  98. )
  99. expect(results).toHaveLength(1)
  100. expect(results[0]).toEqual({
  101. type: TABLE_EVENT_ACTIONS.TableDataAdded,
  102. schema: 'public',
  103. tableName: 'users',
  104. })
  105. })
  106. it('handles quoted identifiers', () => {
  107. const results = sqlEventParser.getTableEvents('INSERT INTO "auth"."users" (id) VALUES (1)')
  108. expect(results).toHaveLength(1)
  109. expect(results[0]).toEqual({
  110. type: TABLE_EVENT_ACTIONS.TableDataAdded,
  111. schema: 'auth',
  112. tableName: 'users',
  113. })
  114. })
  115. it('returns empty array for non-matching SQL', () => {
  116. const results = sqlEventParser.getTableEvents('UPDATE users SET name = "John"')
  117. expect(results).toHaveLength(0)
  118. })
  119. })
  120. describe('COPY detection', () => {
  121. it('detects basic COPY FROM', () => {
  122. const results = sqlEventParser.getTableEvents("COPY users FROM '/tmp/users.csv'")
  123. expect(results).toHaveLength(1)
  124. expect(results[0]).toEqual({
  125. type: TABLE_EVENT_ACTIONS.TableDataAdded,
  126. schema: undefined,
  127. tableName: 'users',
  128. })
  129. })
  130. it('detects COPY with schema', () => {
  131. const results = sqlEventParser.getTableEvents(
  132. "COPY public.users FROM '/tmp/users.csv' WITH CSV HEADER"
  133. )
  134. expect(results).toHaveLength(1)
  135. expect(results[0]).toEqual({
  136. type: TABLE_EVENT_ACTIONS.TableDataAdded,
  137. schema: 'public',
  138. tableName: 'users',
  139. })
  140. })
  141. it('handles quoted identifiers', () => {
  142. const results = sqlEventParser.getTableEvents('COPY "auth"."users" FROM STDIN')
  143. expect(results).toHaveLength(1)
  144. expect(results[0]).toEqual({
  145. type: TABLE_EVENT_ACTIONS.TableDataAdded,
  146. schema: 'auth',
  147. tableName: 'users',
  148. })
  149. })
  150. it('returns empty array for COPY TO', () => {
  151. const results = sqlEventParser.getTableEvents("COPY users TO '/tmp/users.csv'")
  152. expect(results).toHaveLength(0)
  153. })
  154. it('returns empty array for non-matching SQL', () => {
  155. const results = sqlEventParser.getTableEvents('SELECT * FROM users')
  156. expect(results).toHaveLength(0)
  157. })
  158. })
  159. describe('SELECT INTO detection', () => {
  160. it('detects SELECT INTO', () => {
  161. const results = sqlEventParser.getTableEvents('SELECT * INTO new_users FROM users')
  162. expect(results).toHaveLength(1)
  163. expect(results[0]).toEqual({
  164. type: TABLE_EVENT_ACTIONS.TableCreated,
  165. schema: undefined,
  166. tableName: 'new_users',
  167. })
  168. })
  169. it('detects SELECT INTO with schema', () => {
  170. const results = sqlEventParser.getTableEvents(
  171. 'SELECT id, name INTO public.new_users FROM users'
  172. )
  173. expect(results).toHaveLength(1)
  174. expect(results[0]).toEqual({
  175. type: TABLE_EVENT_ACTIONS.TableCreated,
  176. schema: 'public',
  177. tableName: 'new_users',
  178. })
  179. })
  180. it('detects CREATE TABLE AS SELECT', () => {
  181. const results = sqlEventParser.getTableEvents('CREATE TABLE new_users AS SELECT * FROM users')
  182. expect(results).toHaveLength(1)
  183. expect(results[0]).toEqual({
  184. type: TABLE_EVENT_ACTIONS.TableCreated,
  185. schema: undefined,
  186. tableName: 'new_users',
  187. })
  188. })
  189. it('detects CREATE TABLE IF NOT EXISTS AS SELECT', () => {
  190. const results = sqlEventParser.getTableEvents(
  191. 'CREATE TABLE IF NOT EXISTS new_users AS SELECT * FROM users WHERE active = true'
  192. )
  193. expect(results).toHaveLength(1)
  194. expect(results[0]).toEqual({
  195. type: TABLE_EVENT_ACTIONS.TableCreated,
  196. schema: undefined,
  197. tableName: 'new_users',
  198. })
  199. })
  200. it('handles quoted identifiers', () => {
  201. const results = sqlEventParser.getTableEvents(
  202. 'SELECT * INTO "backup"."users_2024" FROM users'
  203. )
  204. expect(results).toHaveLength(1)
  205. expect(results[0]).toEqual({
  206. type: TABLE_EVENT_ACTIONS.TableCreated,
  207. schema: 'backup',
  208. tableName: 'users_2024',
  209. })
  210. })
  211. it('returns empty array for regular SELECT', () => {
  212. const results = sqlEventParser.getTableEvents('SELECT * FROM users')
  213. expect(results).toHaveLength(0)
  214. })
  215. })
  216. describe('RLS detection', () => {
  217. it('detects ALTER TABLE ENABLE ROW LEVEL SECURITY', () => {
  218. const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE ROW LEVEL SECURITY')
  219. expect(results).toHaveLength(1)
  220. expect(results[0]).toEqual({
  221. type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
  222. schema: undefined,
  223. tableName: 'users',
  224. })
  225. })
  226. it('detects short form ENABLE RLS', () => {
  227. const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE RLS')
  228. expect(results).toHaveLength(1)
  229. expect(results[0]).toEqual({
  230. type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
  231. schema: undefined,
  232. tableName: 'users',
  233. })
  234. })
  235. it('detects with schema', () => {
  236. const results = sqlEventParser.getTableEvents(
  237. 'ALTER TABLE public.users ENABLE ROW LEVEL SECURITY'
  238. )
  239. expect(results).toHaveLength(1)
  240. expect(results[0]).toEqual({
  241. type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
  242. schema: 'public',
  243. tableName: 'users',
  244. })
  245. })
  246. it('handles other ALTER TABLE statements in between', () => {
  247. const results = sqlEventParser.getTableEvents(
  248. 'ALTER TABLE users ADD COLUMN test INT, ENABLE ROW LEVEL SECURITY'
  249. )
  250. expect(results).toHaveLength(1)
  251. expect(results[0]).toEqual({
  252. type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
  253. schema: undefined,
  254. tableName: 'users',
  255. })
  256. })
  257. it('returns empty array for disabling RLS', () => {
  258. const results = sqlEventParser.getTableEvents('ALTER TABLE users DISABLE ROW LEVEL SECURITY')
  259. expect(results).toHaveLength(0)
  260. })
  261. it('detects ALTER TABLE IF EXISTS ENABLE ROW LEVEL SECURITY', () => {
  262. const results = sqlEventParser.getTableEvents(
  263. 'ALTER TABLE IF EXISTS public."Conversations" ENABLE ROW LEVEL SECURITY'
  264. )
  265. expect(results).toHaveLength(1)
  266. expect(results[0]).toEqual({
  267. type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
  268. schema: 'public',
  269. tableName: 'Conversations',
  270. })
  271. })
  272. it('detects ALTER TABLE ONLY ENABLE ROW LEVEL SECURITY', () => {
  273. const results = sqlEventParser.getTableEvents(
  274. 'ALTER TABLE ONLY public.users ENABLE ROW LEVEL SECURITY'
  275. )
  276. expect(results).toHaveLength(1)
  277. expect(results[0]).toEqual({
  278. type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
  279. schema: 'public',
  280. tableName: 'users',
  281. })
  282. })
  283. it('detects ALTER TABLE IF EXISTS ONLY ENABLE ROW LEVEL SECURITY', () => {
  284. const results = sqlEventParser.getTableEvents(
  285. 'ALTER TABLE IF EXISTS ONLY public.users ENABLE ROW LEVEL SECURITY'
  286. )
  287. expect(results).toHaveLength(1)
  288. expect(results[0]).toEqual({
  289. type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
  290. schema: 'public',
  291. tableName: 'users',
  292. })
  293. })
  294. })
  295. describe('ReDoS protection', () => {
  296. it('handles extremely long identifier names efficiently', () => {
  297. const longIdentifier = 'a'.repeat(10000)
  298. const sql = `CREATE TABLE ${longIdentifier} (id INT)`
  299. const startTime = Date.now()
  300. const results = sqlEventParser.getTableEvents(sql)
  301. const duration = Date.now() - startTime
  302. expect(duration).toBeLessThan(100)
  303. expect(results).toHaveLength(1)
  304. expect(results[0]).toEqual({
  305. type: TABLE_EVENT_ACTIONS.TableCreated,
  306. schema: undefined,
  307. tableName: longIdentifier,
  308. })
  309. })
  310. it('handles nested dots in schema names without catastrophic backtracking', () => {
  311. const maliciousInput = 'a.'.repeat(1000) + 'table'
  312. const sql = `CREATE TABLE ${maliciousInput} (id INT)`
  313. const startTime = Date.now()
  314. const results = sqlEventParser.getTableEvents(sql)
  315. const duration = Date.now() - startTime
  316. expect(duration).toBeLessThan(100)
  317. expect(results.length).toBeGreaterThan(0)
  318. })
  319. it('handles pathological SELECT INTO patterns', () => {
  320. const maliciousSQL = 'SELECT ' + 'a '.repeat(1000) + 'INTO table FROM users'
  321. const startTime = Date.now()
  322. const results = sqlEventParser.getTableEvents(maliciousSQL)
  323. const duration = Date.now() - startTime
  324. expect(duration).toBeLessThan(100)
  325. expect(results).toHaveLength(1)
  326. expect(results[0]).toEqual({
  327. type: TABLE_EVENT_ACTIONS.TableCreated,
  328. schema: undefined,
  329. tableName: 'table',
  330. })
  331. })
  332. it('handles ALTER TABLE with many operations between', () => {
  333. const manyOperations = 'ADD COLUMN test INT, '.repeat(100)
  334. const sql = `ALTER TABLE users ${manyOperations} ENABLE ROW LEVEL SECURITY`
  335. const startTime = Date.now()
  336. const results = sqlEventParser.getTableEvents(sql)
  337. const duration = Date.now() - startTime
  338. expect(duration).toBeLessThan(100)
  339. expect(results).toHaveLength(1)
  340. expect(results[0]).toEqual({
  341. type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
  342. schema: undefined,
  343. tableName: 'users',
  344. })
  345. })
  346. it('handles mixed quotes and backticks efficiently', () => {
  347. const mixedQuotes = '`"`.'.repeat(100) + 'tablename'
  348. const sql = `CREATE TABLE ${mixedQuotes} (id INT)`
  349. const startTime = Date.now()
  350. sqlEventParser.getTableEvents(sql)
  351. const duration = Date.now() - startTime
  352. expect(duration).toBeLessThan(100)
  353. })
  354. })
  355. describe('Edge cases and special characters', () => {
  356. it('handles Unicode identifiers', () => {
  357. const sql = 'CREATE TABLE 用户表 (id INT)'
  358. const results = sqlEventParser.getTableEvents(sql)
  359. expect(results).toHaveLength(0)
  360. })
  361. it('handles identifiers with numbers', () => {
  362. const sql = 'CREATE TABLE table123 (id INT)'
  363. const results = sqlEventParser.getTableEvents(sql)
  364. expect(results).toHaveLength(1)
  365. expect(results[0]).toEqual({
  366. type: TABLE_EVENT_ACTIONS.TableCreated,
  367. schema: undefined,
  368. tableName: 'table123',
  369. })
  370. })
  371. it('handles identifiers with underscores', () => {
  372. const sql = 'CREATE TABLE user_accounts (id INT)'
  373. const results = sqlEventParser.getTableEvents(sql)
  374. expect(results).toHaveLength(1)
  375. expect(results[0]).toEqual({
  376. type: TABLE_EVENT_ACTIONS.TableCreated,
  377. schema: undefined,
  378. tableName: 'user_accounts',
  379. })
  380. })
  381. it('handles escaped quotes in identifiers', () => {
  382. const sql = 'CREATE TABLE "user""table" (id INT)'
  383. const results = sqlEventParser.getTableEvents(sql)
  384. expect(results).toHaveLength(1)
  385. expect(results[0]).toEqual({
  386. type: TABLE_EVENT_ACTIONS.TableCreated,
  387. schema: undefined,
  388. tableName: 'usertable',
  389. })
  390. })
  391. it('does not scan inside dollar-quoted string literals', () => {
  392. // $$...$$ is a string literal in Postgres — its contents must not be
  393. // parsed as DDL, otherwise a user inserting SQL-shaped text into a log
  394. // table would trigger false-positive table-created events.
  395. const sql = `
  396. CREATE TABLE users (id INT);
  397. INSERT INTO logs VALUES ($$CREATE TABLE fake$$);
  398. INSERT INTO users VALUES (1);
  399. `
  400. const results = sqlEventParser.getTableEvents(sql)
  401. expect(results).toHaveLength(3)
  402. expect(results[0]).toMatchObject({
  403. type: TABLE_EVENT_ACTIONS.TableCreated,
  404. tableName: 'users',
  405. })
  406. expect(results[1]).toMatchObject({
  407. type: TABLE_EVENT_ACTIONS.TableDataAdded,
  408. tableName: 'logs',
  409. })
  410. expect(results[2]).toMatchObject({
  411. type: TABLE_EVENT_ACTIONS.TableDataAdded,
  412. tableName: 'users',
  413. })
  414. })
  415. it('does not treat SELECT..INTO inside a plpgsql body as table creation', () => {
  416. // Regression for the RLS warning modal false-positive: variable
  417. // assignment inside a function body must not be reported as a new table.
  418. const sql = `
  419. create or replace function schema_checks()
  420. returns jsonb
  421. language plpgsql
  422. as $$
  423. declare
  424. ret jsonb;
  425. begin
  426. select jsonb_build_object('value', 'ok') into ret;
  427. return ret;
  428. end;
  429. $$;
  430. `
  431. const results = sqlEventParser.getTableEvents(sql)
  432. expect(results).toEqual([])
  433. })
  434. it('does not leak nested dollar-quoted dynamic SQL through statement splitting', () => {
  435. // Regression: an inner $sql$...$sql$ tag used inside an outer $fn$...$fn$
  436. // body was pairing with the outer opening tag (the splitStatements regex
  437. // doesn't enforce matching tags), which caused the inner semicolon to
  438. // split the statement and exposed `create table fake` to the detectors.
  439. // The fix is that stripDollarQuoteBodies runs before splitStatements and
  440. // uses a backreference to require matching tags.
  441. const sql = `
  442. create function f()
  443. returns void
  444. language plpgsql
  445. as $fn$
  446. begin
  447. execute $sql$create table fake(id int);$sql$;
  448. end;
  449. $fn$;
  450. `
  451. const results = sqlEventParser.getTableEvents(sql)
  452. expect(results).toEqual([])
  453. })
  454. it('still detects a real top-level CREATE TABLE next to a function with nested dollar tags', () => {
  455. const sql = `
  456. create table public.real_table(id int);
  457. create function f()
  458. returns void
  459. language plpgsql
  460. as $fn$
  461. begin
  462. execute $sql$create table fake(id int);$sql$;
  463. end;
  464. $fn$;
  465. `
  466. const results = sqlEventParser.getTableEvents(sql)
  467. expect(results).toEqual([
  468. {
  469. type: TABLE_EVENT_ACTIONS.TableCreated,
  470. schema: 'public',
  471. tableName: 'real_table',
  472. },
  473. ])
  474. })
  475. it('handles SQL injection attempts safely', () => {
  476. const sql = "CREATE TABLE users'; DROP TABLE users; -- (id INT)"
  477. const results = sqlEventParser.getTableEvents(sql)
  478. expect(results).toHaveLength(1)
  479. expect(results[0]).toEqual({
  480. type: TABLE_EVENT_ACTIONS.TableCreated,
  481. schema: undefined,
  482. tableName: 'users',
  483. })
  484. })
  485. })
  486. describe('getTableEvents', () => {
  487. it('filters only table-related events', () => {
  488. const sql = `
  489. CREATE TABLE users (id INT);
  490. CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;
  491. INSERT INTO users (id) VALUES (1);
  492. ALTER TABLE users ENABLE RLS;
  493. CREATE VIEW user_view AS SELECT * FROM users;
  494. `
  495. const results = sqlEventParser.getTableEvents(sql)
  496. expect(results).toHaveLength(3)
  497. expect(results.map((r) => r.type)).toEqual([
  498. TABLE_EVENT_ACTIONS.TableCreated,
  499. TABLE_EVENT_ACTIONS.TableDataAdded,
  500. TABLE_EVENT_ACTIONS.TableRLSEnabled,
  501. ])
  502. })
  503. it('returns empty array for non-table SQL', () => {
  504. const sql = `
  505. CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;
  506. CREATE VIEW user_view AS SELECT * FROM users;
  507. SELECT * FROM users;
  508. `
  509. const results = sqlEventParser.getTableEvents(sql)
  510. expect(results).toHaveLength(0)
  511. })
  512. })
  513. })