diff --git a/packages/engine/src/resource-access.ts b/packages/engine/src/resource-access.ts index 33ea88b..0da6f4b 100644 --- a/packages/engine/src/resource-access.ts +++ b/packages/engine/src/resource-access.ts @@ -554,7 +554,66 @@ function normalizeSqlSelector(selector: string): string { return normalizedSelector.trim(); } +function stripSqlCommentsAndLiterals(sql: string): string { + const sanitizedCharacters = sql.split(""); + let state: + | "single-quoted-literal" + | "double-quoted-identifier" + | "line-comment" + | "block-comment" + | undefined; + for (let index = 0; index !== sql.length; index += 1) { + const currentCharacter = sql[index]; + const nextCharacter = sql[index + 1]; + + if (state === "single-quoted-literal") { + sanitizedCharacters[index] = " "; + if (currentCharacter === "'") state = undefined; + continue; + } + + if (state === "double-quoted-identifier") { + if (currentCharacter === "\"") state = undefined; + continue; + } + + if (state === "line-comment") { + sanitizedCharacters[index] = " "; + if (currentCharacter === "\n") state = undefined; + continue; + } + + if (state === "block-comment") { + sanitizedCharacters[index] = " "; + if (currentCharacter === "*" && nextCharacter === "/") { + sanitizedCharacters[index + 1] = " "; + index += 1; + state = undefined; + } + continue; + } + + if (currentCharacter === "'") { + sanitizedCharacters[index] = " "; + state = "single-quoted-literal"; + } else if (currentCharacter === "\"") { + state = "double-quoted-identifier"; + } else if (currentCharacter === "-" && nextCharacter === "-") { + sanitizedCharacters[index] = " "; + state = "line-comment"; + } else if (currentCharacter === "/" && nextCharacter === "*") { + sanitizedCharacters[index] = " "; + sanitizedCharacters[index + 1] = " "; + index += 1; + state = "block-comment"; + } + } + + return sanitizedCharacters.join(""); +} + function sqlTableAccesses(text: string): Array<{ access: "read" | "write"; selector: string }> { + const sanitized = stripSqlCommentsAndLiterals(text); const accesses: Array<{ access: "read" | "write"; selector: string }> = []; const identifier = String.raw`(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_$]*))*`; const sqlPattern = new RegExp( @@ -562,7 +621,7 @@ function sqlTableAccesses(text: string): Array<{ access: "read" | "write"; selec "gi", ); let match: RegExpExecArray | null; - while ((match = sqlPattern.exec(text)) !== null) { + while ((match = sqlPattern.exec(sanitized)) !== null) { const verb = match[1].toLowerCase(); const selector = normalizeSqlSelector(match[2]); accesses.push({ access: verb === "from" || verb === "join" ? "read" : "write", selector }); @@ -1802,5 +1861,7 @@ export const resourceAccessTestHooks = { routeReceiverLooksHttp, queueReceiverLooksExternal, selectorLooksQueueTopic, + sqlTableAccesses, + stripSqlCommentsAndLiterals, }; // Stryker restore all diff --git a/tests/resource-access-coverage.test.mjs b/tests/resource-access-coverage.test.mjs index 2edb5f4..9e804f8 100644 --- a/tests/resource-access-coverage.test.mjs +++ b/tests/resource-access-coverage.test.mjs @@ -2286,3 +2286,97 @@ test("bug #52 supported Fastify route methods do not depend on scan hints", (tes } } }); + +test("bug #77 FROM in SQL string literals and comments is not mistaken for a table reference", () => { + const { sqlTableAccesses } = resourceAccessTestHooks; + assert.deepEqual(sqlTableAccesses("SELECT 'FROM ghost_table' AS label"), []); + assert.deepEqual(sqlTableAccesses("SELECT 1 /* FROM ghost_table */"), []); + assert.deepEqual(sqlTableAccesses("SELECT 1 -- FROM ghost_table"), []); + assert.deepEqual(sqlTableAccesses("SELECT id FROM actual_table"), [ + { access: "read", selector: "actual_table" }, + ]); + assert.deepEqual( + sqlTableAccesses("SELECT id FROM actual_table WHERE note = 'FROM false_table' /* JOIN ignored_table */"), + [{ access: "read", selector: "actual_table" }], + ); +}); + +test("bug #77 SQL masking preserves lexical boundaries and table separators", () => { + const { sqlTableAccesses, stripSqlCommentsAndLiterals } = resourceAccessTestHooks; + const readAccesses = (...selectors) => selectors.map((selector) => ({ access: "read", selector })); + const cases = [ + { + sql: "SELECT '-- literal', '/*', '*/', 'it''s FROM ghost' FROM actual_table", + expected: readAccesses("actual_table"), + }, + { + sql: "SELECT 'abc\\' FROM actual_table", + expected: readAccesses("actual_table"), + }, + { + sql: "SELECT '/*' AS marker FROM swallowed_table WHERE note = '*/'", + expected: readAccesses("swallowed_table"), + }, + { + sql: "SELECT'x'FROM/* JOIN ghost */actual_table", + expected: readAccesses("actual_table"), + }, + { + sql: "SELECT 1 -- ' FROM ghost_one\nFROM actual_table\n-- JOIN ghost_two", + expected: readAccesses("actual_table"), + }, + { + sql: "SELECT 1 -- FROM ghost_one\rFROM actual_table", + expected: [], + }, + { + sql: "SELECT 1 -- FROM ghost_one\r\nFROM actual_table", + expected: readAccesses("actual_table"), + }, + { + sql: 'SELECT * FROM "orders--archive" JOIN "items/*current*/"', + expected: readAccesses("orders--archive", "items/*current*/"), + }, + { + sql: 'SELECT * FROM "actual_table" /* JOIN ghost_table */', + expected: readAccesses("actual_table"), + }, + { + sql: "SELECT 1 /* ' -- FROM ghost */ FROM actual_table", + expected: readAccesses("actual_table"), + }, + { + sql: "SELECT * /*/*/ FROM actual_table", + expected: readAccesses("actual_table"), + }, + { + sql: "SELECT 1/*x*/*2 FROM actual_table", + expected: readAccesses("actual_table"), + }, + { + sql: "SELECT 8 / 2 * 3 - 1 FROM actual_table", + expected: readAccesses("actual_table"), + }, + { + sql: "SELECT 1 /* 8 / 2 * 3 FROM ghost_table */ FROM actual_table", + expected: readAccesses("actual_table"), + }, + { sql: "SELECT 1 /* FROM ghost_table", expected: [] }, + { + sql: "SELECT 1/*/ FROM ghost_table */ FROM actual_table", + expected: readAccesses("actual_table"), + }, + { sql: "SELECT 'FROM ghost_table", expected: [] }, + ]; + + for (const { sql, expected } of cases) { + assert.equal(stripSqlCommentsAndLiterals(sql).length, sql.length, sql); + assert.deepEqual(sqlTableAccesses(sql), expected, sql); + } + + const entirelyIgnored = "'x'--y\n/*z*/"; + assert.equal(stripSqlCommentsAndLiterals(entirelyIgnored), " ".repeat(entirelyIgnored.length)); + + const quotedIdentifiers = 'SELECT * FROM "orders--archive" JOIN "items/*current*/"'; + assert.equal(stripSqlCommentsAndLiterals(quotedIdentifiers), quotedIdentifiers); +});