From 6d9da892152d7d16b018e0f461f426573734a621 Mon Sep 17 00:00:00 2001 From: Alex Weisberger Date: Mon, 6 Apr 2026 11:29:05 -0400 Subject: [PATCH] Ensure tuple size matches INSERT column lists. --- normalizer.go | 18 ++++++++++++++---- obfuscate_and_normalize_test.go | 12 ++++++++++++ .../mssql/insert/insert-identity-insert.json | 2 +- testdata/mysql/insert/insert-blob-data.json | 2 +- testdata/mysql/insert/insert-spatial-data.json | 2 +- .../insert/insert-with-curdate-curtime.json | 2 +- .../insert-with-encryption-functions.json | 2 +- .../mysql/insert/insert-with-spatial-data.json | 2 +- .../mysql/insert/insert-with-timestamp.json | 2 +- .../postgresql/insert/insert-with-default.json | 2 +- 10 files changed, 34 insertions(+), 12 deletions(-) diff --git a/normalizer.go b/normalizer.go index 7563e3d..fe731f9 100644 --- a/normalizer.go +++ b/normalizer.go @@ -129,7 +129,8 @@ type colonContext struct { } type groupablePlaceholder struct { - groupable bool + groupable bool + skippedCount int // number of (comma, placeholder) pairs skipped during grouping } type headState struct { @@ -434,7 +435,9 @@ func (n *Normalizer) isObfuscatedValueGroupable(token *Token, lastValueToken *La // we know it's the start of groupable placeholders // we don't return here because we still need to write the first placeholder groupablePlaceholder.groupable = true + groupablePlaceholder.skippedCount = 0 } else if lastValueToken.Value == "," && groupablePlaceholder.groupable { + groupablePlaceholder.skippedCount++ return true } } @@ -446,14 +449,21 @@ func (n *Normalizer) isObfuscatedValueGroupable(token *Token, lastValueToken *La if groupablePlaceholder.groupable && (token.Value == ")" || token.Value == "]") { // end of groupable placeholders groupablePlaceholder.groupable = false + groupablePlaceholder.skippedCount = 0 return false } if groupablePlaceholder.groupable && token.Value != NumberPlaceholder && token.Value != StringPlaceholder && lastValueToken != nil && lastValueToken.Value == "," { - // This is a tricky edge case. If we are inside a groupbale block, and the current token is not a placeholder, - // we not only want to write the current token to the normalizedSQLBuilder, but also write the last comma that we skipped. - // For example, (?, ARRAY[?, ?, ?]) should be normalized as (?, ARRAY[?]) + // A non-placeholder was encountered while grouping. This means the parenthesized + // list contains mixed values (e.g. VALUES (?, ?, NOW(), ?)) and should not be grouped. + // Write back all the previously skipped (comma, placeholder) pairs and the trailing comma. + for i := 0; i < groupablePlaceholder.skippedCount; i++ { + normalizedSQLBuilder.WriteString(", ") + normalizedSQLBuilder.WriteString(NumberPlaceholder) + } normalizedSQLBuilder.WriteString(lastValueToken.Value) + groupablePlaceholder.groupable = false + groupablePlaceholder.skippedCount = 0 return false } diff --git a/obfuscate_and_normalize_test.go b/obfuscate_and_normalize_test.go index 215629d..ff89404 100644 --- a/obfuscate_and_normalize_test.go +++ b/obfuscate_and_normalize_test.go @@ -643,6 +643,18 @@ multiline comment */ Size: 19, }, }, + { + // INSERT with mixed placeholders and function calls should preserve all value positions + input: "INSERT INTO t (a, b, c, d, e, f, g, h, i, j) VALUES ('x', 'y', 'z', 0, NOW(), NOW(), 1, 64, 18446744073709551615, '2026-04-11 00:00:00+00')", + expected: "INSERT INTO t ( a, b, c, d, e, f, g, h, i, j ) VALUES ( ?, ?, ?, ?, NOW ( ), NOW ( ), ?, ?, ?, ? )", + statementMetadata: StatementMetadata{ + Tables: []string{"t"}, + Comments: []string{}, + Commands: []string{"INSERT"}, + Procedures: []string{}, + Size: 7, + }, + }, } obfuscator := NewObfuscator( diff --git a/testdata/mssql/insert/insert-identity-insert.json b/testdata/mssql/insert/insert-identity-insert.json index 5fdc3e0..8501075 100644 --- a/testdata/mssql/insert/insert-identity-insert.json +++ b/testdata/mssql/insert/insert-identity-insert.json @@ -2,7 +2,7 @@ "input": "SET IDENTITY_INSERT orders ON; INSERT INTO orders (id, customer_id, order_date, status) VALUES (100, 3, GETDATE(), 'Pending'); SET IDENTITY_INSERT orders OFF;", "outputs": [ { - "expected": "SET IDENTITY_INSERT orders ON; INSERT INTO orders ( id, customer_id, order_date, status ) VALUES ( ?, GETDATE ( ), ? ); SET IDENTITY_INSERT orders OFF", + "expected": "SET IDENTITY_INSERT orders ON; INSERT INTO orders ( id, customer_id, order_date, status ) VALUES ( ?, ?, GETDATE ( ), ? ); SET IDENTITY_INSERT orders OFF", "statement_metadata": { "size": 12, "tables": ["orders"], diff --git a/testdata/mysql/insert/insert-blob-data.json b/testdata/mysql/insert/insert-blob-data.json index 10f1fe2..f93afca 100644 --- a/testdata/mysql/insert/insert-blob-data.json +++ b/testdata/mysql/insert/insert-blob-data.json @@ -2,7 +2,7 @@ "input": "INSERT INTO orders (customer_id, status, document) VALUES (5, 'Pending', LOAD_FILE('/path/to/file'));", "outputs": [ { - "expected": "INSERT INTO orders ( customer_id, status, document ) VALUES ( ?, LOAD_FILE ( ? ) )", + "expected": "INSERT INTO orders ( customer_id, status, document ) VALUES ( ?, ?, LOAD_FILE ( ? ) )", "statement_metadata": { "size": 12, "tables": ["orders"], diff --git a/testdata/mysql/insert/insert-spatial-data.json b/testdata/mysql/insert/insert-spatial-data.json index e7a245e..78b2b8d 100644 --- a/testdata/mysql/insert/insert-spatial-data.json +++ b/testdata/mysql/insert/insert-spatial-data.json @@ -2,7 +2,7 @@ "input": "INSERT INTO orders (customer_id, status, location) VALUES (6, 'Delivered', ST_GeomFromText('POINT(1 1)'));", "outputs": [ { - "expected": "INSERT INTO orders ( customer_id, status, location ) VALUES ( ?, ST_GeomFromText ( ? ) )", + "expected": "INSERT INTO orders ( customer_id, status, location ) VALUES ( ?, ?, ST_GeomFromText ( ? ) )", "statement_metadata": { "size": 12, "tables": ["orders"], diff --git a/testdata/mysql/insert/insert-with-curdate-curtime.json b/testdata/mysql/insert/insert-with-curdate-curtime.json index 1eb52da..a8ed0f3 100644 --- a/testdata/mysql/insert/insert-with-curdate-curtime.json +++ b/testdata/mysql/insert/insert-with-curdate-curtime.json @@ -2,7 +2,7 @@ "input": "INSERT INTO orders (customer_id, status, order_date, order_time) VALUES (15, 'Pending', CURDATE(), CURTIME());", "outputs": [ { - "expected": "INSERT INTO orders ( customer_id, status, order_date, order_time ) VALUES ( ?, CURDATE ( ), CURTIME ( ) )", + "expected": "INSERT INTO orders ( customer_id, status, order_date, order_time ) VALUES ( ?, ?, CURDATE ( ), CURTIME ( ) )", "statement_metadata": { "size": 12, "tables": ["orders"], diff --git a/testdata/mysql/insert/insert-with-encryption-functions.json b/testdata/mysql/insert/insert-with-encryption-functions.json index 40427ea..4374d2b 100644 --- a/testdata/mysql/insert/insert-with-encryption-functions.json +++ b/testdata/mysql/insert/insert-with-encryption-functions.json @@ -2,7 +2,7 @@ "input": "INSERT INTO orders (customer_id, status, encrypted_note) VALUES (13, 'Pending', AES_ENCRYPT('Confidential note', 'encryption_key'));", "outputs": [ { - "expected": "INSERT INTO orders ( customer_id, status, encrypted_note ) VALUES ( ?, AES_ENCRYPT ( ? ) )", + "expected": "INSERT INTO orders ( customer_id, status, encrypted_note ) VALUES ( ?, ?, AES_ENCRYPT ( ? ) )", "statement_metadata": { "size": 12, "tables": ["orders"], diff --git a/testdata/mysql/insert/insert-with-spatial-data.json b/testdata/mysql/insert/insert-with-spatial-data.json index 4059132..fa5d806 100644 --- a/testdata/mysql/insert/insert-with-spatial-data.json +++ b/testdata/mysql/insert/insert-with-spatial-data.json @@ -2,7 +2,7 @@ "input": "INSERT INTO orders (customer_id, status, location) VALUES (14, 'Pending', ST_GeomFromText('POINT(1 1)'));", "outputs": [ { - "expected": "INSERT INTO orders ( customer_id, status, location ) VALUES ( ?, ST_GeomFromText ( ? ) )", + "expected": "INSERT INTO orders ( customer_id, status, location ) VALUES ( ?, ?, ST_GeomFromText ( ? ) )", "statement_metadata": { "size": 12, "tables": ["orders"], diff --git a/testdata/mysql/insert/insert-with-timestamp.json b/testdata/mysql/insert/insert-with-timestamp.json index e93ee97..2fe5dbd 100644 --- a/testdata/mysql/insert/insert-with-timestamp.json +++ b/testdata/mysql/insert/insert-with-timestamp.json @@ -2,7 +2,7 @@ "input": "INSERT INTO orders (customer_id, status, created_at) VALUES (4, 'Shipped', CURRENT_TIMESTAMP);", "outputs": [ { - "expected": "INSERT INTO orders ( customer_id, status, created_at ) VALUES ( ?, CURRENT_TIMESTAMP )", + "expected": "INSERT INTO orders ( customer_id, status, created_at ) VALUES ( ?, ?, CURRENT_TIMESTAMP )", "statement_metadata": { "size": 12, "tables": ["orders"], diff --git a/testdata/postgresql/insert/insert-with-default.json b/testdata/postgresql/insert/insert-with-default.json index 1f3a69f..c72e1ce 100644 --- a/testdata/postgresql/insert/insert-with-default.json +++ b/testdata/postgresql/insert/insert-with-default.json @@ -2,7 +2,7 @@ "input": "INSERT INTO products (name, price, description) VALUES ('New Product', 123, DEFAULT);", "outputs": [ { - "expected": "INSERT INTO products ( name, price, description ) VALUES ( ?, DEFAULT )", + "expected": "INSERT INTO products ( name, price, description ) VALUES ( ?, ?, DEFAULT )", "statement_metadata": { "size": 14, "tables": [