diff --git a/deparser/api/pom.xml b/deparser/api/pom.xml index 78822f5..890b733 100644 --- a/deparser/api/pom.xml +++ b/deparser/api/pom.xml @@ -33,7 +33,7 @@ com.manticore-projects.jsqlformatter jsqlparser - 5.3.167 + 5.4.15 diff --git a/deparser/jsqlparser/pom.xml b/deparser/jsqlparser/pom.xml index a2d7536..568e63e 100644 --- a/deparser/jsqlparser/pom.xml +++ b/deparser/jsqlparser/pom.xml @@ -43,7 +43,7 @@ com.manticore-projects.jsqlformatter jsqlparser - 5.3.167 + 5.4.15 org.osgi diff --git a/guard/jsqltranspiler/pom.xml b/guard/jsqltranspiler/pom.xml index 7cf0c49..2d0b100 100644 --- a/guard/jsqltranspiler/pom.xml +++ b/guard/jsqltranspiler/pom.xml @@ -34,12 +34,12 @@ com.manticore-projects.jsqlformatter jsqlparser - 5.3.167 + 5.4.15 ai.starlake.jsqltranspiler jsqltranspiler - 1.8 + 1.13 org.eclipse.daanse diff --git a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/DeparserColumResolver.java b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/DeparserColumResolver.java index eb444d3..8d3228c 100644 --- a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/DeparserColumResolver.java +++ b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/DeparserColumResolver.java @@ -21,6 +21,7 @@ import ai.starlake.transpiler.schema.JdbcResultSetMetaData; import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.select.Select; @@ -40,33 +41,58 @@ public DeparserColumResolver(JdbcMetaData metaData, Dialect dialect, DialectDepa this.dialectDeparser = dialectDeparser; } + @Override public String getResolvedStatementText(String sqlStr) throws JSQLParserException { + return getResolvedStatementText(CCJSqlParserUtil.parse(sqlStr)); + } - Statement st = CCJSqlParserUtil.parse(sqlStr); + /** + * Resolves and deparses an already parsed statement, so a caller that has the AST + * in hand (the guard, which validated it first) does not pay for a second parse. + */ + public String getResolvedStatementText(Statement st) { if (st instanceof Select) { Select select = (Select) st; select.accept((SelectVisitor) this, JdbcMetaData.copyOf(metaData)); - stripCurrentCatalogQualifier(select); + normaliseQualifiers(select); } return dialectDeparser.deparse(st, dialect); } - // The resolver only ever fills in a catalog qualifier that is missing; it never clears one that - // was already explicit in the query, even when it names the current catalog. The database - // connection is already scoped to that catalog, so only the schema-qualified name may be sent. - private void stripCurrentCatalogQualifier(Select select) { + // The resolvers only ever fill in qualifiers, they never clear them. The connection is + // already scoped to the current catalog, so a table may only be sent schema-qualified. + // Column references keep just the table name or alias: the FROM item carries the schema. + // (JSQLResolver expands "*" with the FROM item's full qualification, JSQLColumResolver + // with the bare name; this normalisation makes both paths emit the same text.) + private void normaliseQualifiers(Select select) { String currentCatalogName = metaData.getCurrentCatalogName(); - if (currentCatalogName == null || currentCatalogName.isEmpty()) { - return; - } + String currentSchemaName = metaData.getCurrentSchemaName(); new TablesNamesFinder() { @Override public Void visit(Table table, S context) { - if (currentCatalogName.equalsIgnoreCase(table.getUnquotedDatabaseName())) { + stripCurrentCatalog(table); + return super.visit(table, context); + } + + @Override + public Void visit(Column column, S context) { + Table table = column.getTable(); + if (table != null) { + stripCurrentCatalog(table); + if (currentSchemaName != null && !currentSchemaName.isEmpty() + && currentSchemaName.equalsIgnoreCase(table.getUnquotedSchemaName())) { + table.setSchemaName(null); + } + } + return super.visit(column, context); + } + + private void stripCurrentCatalog(Table table) { + if (currentCatalogName != null && !currentCatalogName.isEmpty() + && currentCatalogName.equalsIgnoreCase(table.getUnquotedDatabaseName())) { table.setDatabaseName(null); } - return super.visit(table, context); } }.getTables((Statement) select); } diff --git a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/ReadOnlyStatementValidator.java b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/ReadOnlyStatementValidator.java index 9ba0fc8..eabd117 100644 --- a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/ReadOnlyStatementValidator.java +++ b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/ReadOnlyStatementValidator.java @@ -18,6 +18,7 @@ import net.sf.jsqlparser.statement.ParenthesedStatement; import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.select.ForMode; import net.sf.jsqlparser.statement.select.ParenthesedSelect; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; @@ -37,15 +38,23 @@ * on PostgreSQL/MariaDB the wrapped {@code UPDATE}/{@code INSERT}/{@code DELETE} * executes. The upstream resolver does not populate its update/insert column * lists for a {@code WithItem}, so it cannot be relied on to detect this; - *
  • {@code SELECT ... INTO newtable} — creates/writes a table - * (PostgreSQL, SQL Server);
  • - *
  • {@code SELECT ... FOR UPDATE}/{@code FOR SHARE} — takes row locks.
  • + *
  • {@code SELECT ... INTO newtable} / {@code INTO TEMP newtable} — creates/writes + * a table (PostgreSQL, SQL Server);
  • + *
  • {@code SELECT ... INTO OUTFILE '/path'} / {@code INTO DUMPFILE '/path'} — writes + * a file on the database host, and {@code SELECT ... INTO @var} — assigns session + * variables instead of returning rows (MySQL/MariaDB, parsed since JSqlParser 5.4);
  • + *
  • {@code SELECT ... FOR UPDATE}/{@code FOR SHARE}/{@code FOR NO KEY UPDATE}/ + * {@code FOR KEY SHARE} — takes row locks.
  • * * The validator reuses {@link TablesNamesFinder}'s full recursive traversal * (subqueries, joins, set operations, nested {@code WITH} clauses). A * {@code WithItem} is only permitted to wrap a {@link ParenthesedSelect}; a * {@code SELECT INTO} or a {@code FOR UPDATE}/{@code FOR SHARE} clause anywhere * in the tree is a violation. + *

    + * This validator is the guard's primary, precisely worded gate. {@code TranspilerSqlGuard} + * additionally consults JSqlParser's own {@code Statement.getFeatures()} classification + * as a second, coarser net for anything not modelled here. */ class ReadOnlyStatementValidator extends TablesNamesFinder { @@ -54,6 +63,8 @@ class ReadOnlyStatementValidator extends TablesNamesFinder { static final String INSERT_IS_NOT_PERMITTED = "INSERT is not permitted."; static final String STATEMENT_IS_NOT_PERMITTED = "Statement is not permitted."; static final String SELECT_INTO_IS_NOT_PERMITTED = "SELECT INTO is not permitted."; + static final String SELECT_INTO_FILE_OR_VARIABLE_IS_NOT_PERMITTED = + "SELECT INTO OUTFILE/DUMPFILE/variable is not permitted."; static final String ROW_LOCKING_IS_NOT_PERMITTED = "Row locking (FOR UPDATE/SHARE) is not permitted."; private String violation; @@ -75,9 +86,15 @@ private void record(String message) { } } - /** A FOR UPDATE / FOR SHARE clause can hang off any Select node. */ + /** + * A FOR UPDATE / FOR SHARE clause can hang off any Select node. Every + * {@link ForMode} is rejected, including the harmless {@code FOR READ ONLY}: it is + * not part of the SQL this guard is meant to pass through, and JSqlParser's own + * classification treats any {@code ForMode} as a transaction side effect anyway. + */ private void checkRowLocking(Select select) { - if (select.getForMode() != null || select.getForUpdateTable() != null) { + if (select.getForMode() != null || select.getForUpdate() != null + || select.getForUpdateTables() != null && !select.getForUpdateTables().isEmpty()) { record(ROW_LOCKING_IS_NOT_PERMITTED); } } @@ -88,6 +105,11 @@ public Void visit(PlainSelect plainSelect, S context) { || plainSelect.getIntoTempTable() != null) { record(SELECT_INTO_IS_NOT_PERMITTED); } + // MySQL INTO OUTFILE / INTO DUMPFILE write a file on the server, INTO @var + // assigns session variables; none of the three returns rows to the client. + if (plainSelect.getMySqlSelectIntoClause() != null) { + record(SELECT_INTO_FILE_OR_VARIABLE_IS_NOT_PERMITTED); + } checkRowLocking(plainSelect); return super.visit(plainSelect, context); } diff --git a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/TranspilerSqlGuard.java b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/TranspilerSqlGuard.java index 3e3b869..19a6078 100644 --- a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/TranspilerSqlGuard.java +++ b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/TranspilerSqlGuard.java @@ -36,7 +36,6 @@ import ai.starlake.transpiler.CatalogNotFoundException; import ai.starlake.transpiler.ColumnNotFoundException; -import ai.starlake.transpiler.JSQLColumResolver; import ai.starlake.transpiler.JSQLResolver; import ai.starlake.transpiler.SchemaNotFoundException; import ai.starlake.transpiler.TableNotDeclaredException; @@ -47,8 +46,10 @@ import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementFeatures; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; import net.sf.jsqlparser.statement.select.Select; -import net.sf.jsqlparser.util.deparser.StatementDeParser; public class TranspilerSqlGuard implements SqlGuard { @@ -58,6 +59,8 @@ public class TranspilerSqlGuard implements SqlGuard { private static final String NOTHING_WAS_SELECTED = "Nothing was selected."; private static final String QUERY_HAS_DISAllOWED_FUNCTIONS = "Query has disallowed functions."; private static final String COULD_NOT_VALIDATE = "Statement could not be validated."; + private static final String ONLY_ONE_STATEMENT_IS_PERMITTED = "Only a single statement is permitted."; + private static final String STATEMENT_HAS_SIDE_EFFECTS = "Statement has side effects or does not return rows."; private static final Logger LOGGER = LoggerFactory.getLogger(TranspilerSqlGuard.class); private JdbcMetaData jdbcMetaDataToCopy; private List whitelistFunctionsPatterns = new ArrayList(); @@ -75,20 +78,27 @@ public TranspilerSqlGuard(String currentCatalogName, String currentSchemaName, D @Override public String guard(String sqlStr) throws GuardException { - LOGGER.atInfo().log("guard incoming: %s", sqlStr); - StringBuilder builder = new StringBuilder(); - StatementDeParser deParser = new StatementDeParser(builder); + LOGGER.atInfo().log("guard incoming: {}", sqlStr); JSQLResolver resolver = new JSQLResolver(jdbcMetaDataToCopy); - // this does not really work, when there are comments - // @todo: apply a Regex for SQL Comments if (sqlStr == null || sqlStr.trim().isEmpty()) { throw new EmptyStatementGuardException(); } try { - Statement st = CCJSqlParserUtil.parse(sqlStr); + // parse the whole input as a script: CCJSqlParserUtil.parse() stops after the + // first statement and would silently drop a trailing "; DROP TABLE ..." (or + // any other statement) from what is validated and rewritten. + Statements statements = CCJSqlParserUtil.parseStatements(sqlStr); + if (statements == null || statements.isEmpty()) { + // e.g. a comment-only input parses to an empty script + throw new EmptyStatementGuardException(); + } + if (statements.size() > 1) { + throw new UnallowedStatementTypeGuardException(ONLY_ONE_STATEMENT_IS_PERMITTED); + } + Statement st = statements.get(0); // we can test for SELECT, though in practise it won't protect us from harmful statements if (st instanceof Select) { @@ -137,14 +147,7 @@ public String guard(String sqlStr) throws GuardException { List disallowedFunctions = new ArrayList<>(); for (String function : functionNames) { - boolean isAllowed = false; - for (String pattern : whitelistFunctionsPatterns) { - if (Pattern.matches(pattern, function)) { - isAllowed = true; - break; - } - } - if (isAllowed) { + if (isWhitelistedFunction(function)) { allowedFunctions.add(function); } else { disallowedFunctions.add(function); @@ -155,16 +158,23 @@ public String guard(String sqlStr) throws GuardException { throw new GuardException(QUERY_HAS_DISAllOWED_FUNCTIONS); } + // Second net: JSqlParser's own statement classification (since 5.4). It + // covers side effects this guard does not model explicitly (session or + // transaction changes, opaque calls, statement types added upstream). + // Functions the resolver saw were already proven against the whitelist + // above, so this can only reject for reasons the checks before did not see. + validateFeatures(st, allowedFunctions); + // we can finally resolve for the actually returned columns - JSQLColumResolver columResolver = new DeparserColumResolver(jdbcMetaDataToCopy, dialect,dialectDeparser); + // on the very same AST that was validated above: no second parse + DeparserColumResolver columResolver = new DeparserColumResolver(jdbcMetaDataToCopy, dialect, + dialectDeparser); columResolver.setCommentFlag(false); columResolver.setErrorMode(JdbcMetaData.ErrorMode.STRICT); - String rewritten = columResolver.getResolvedStatementText(sqlStr); - - deParser.visit((Select) st);// or rewritten + String rewritten = columResolver.getResolvedStatementText(st); - LOGGER.atInfo().log("guard outgoin: %s", rewritten); + LOGGER.atInfo().log("guard outgoing: {}", rewritten); return rewritten; @@ -186,6 +196,31 @@ public String guard(String sqlStr) throws GuardException { } + private boolean isWhitelistedFunction(String function) { + for (String pattern : whitelistFunctionsPatterns) { + if (Pattern.matches(pattern, function)) { + return true; + } + } + return false; + } + + private void validateFeatures(Statement st, List allowedFunctions) throws GuardException { + // the feature visitor reports function names lower-cased; the whitelist patterns + // are case-sensitive, so accept every name the resolver-based check already passed + StatementFeatures features = st.getFeatures(name -> isWhitelistedFunction(name) + || allowedFunctions.stream().anyMatch(allowed -> allowed.equalsIgnoreCase(name))); + boolean sideEffect = features.may(StmtFeature.MODIFIES_DATA) + || features.may(StmtFeature.MODIFIES_SCHEMA) + || features.may(StmtFeature.MODIFIES_SESSION) + || features.may(StmtFeature.MODIFIES_TRANSACTION) + || features.may(StmtFeature.OPAQUE); + if (sideEffect || !features.returnsResultSet()) { + LOGGER.atInfo().log("{} features: {}", STATEMENT_HAS_SIDE_EFFECTS, features); + throw new GuardException(STATEMENT_HAS_SIDE_EFFECTS); + } + } + private static void validateReadOnly(Select select) throws GuardException { Optional violation = new ReadOnlyStatementValidator().firstViolation(select); if (violation.isPresent()) { diff --git a/guard/jsqltranspiler/src/test/java/org/eclipse/daanse/sql/guard/jsqltranspiler/integration/SqlGuardTest.java b/guard/jsqltranspiler/src/test/java/org/eclipse/daanse/sql/guard/jsqltranspiler/integration/SqlGuardTest.java index 71a3812..d4248be 100644 --- a/guard/jsqltranspiler/src/test/java/org/eclipse/daanse/sql/guard/jsqltranspiler/integration/SqlGuardTest.java +++ b/guard/jsqltranspiler/src/test/java/org/eclipse/daanse/sql/guard/jsqltranspiler/integration/SqlGuardTest.java @@ -29,7 +29,9 @@ import org.eclipse.daanse.sql.guard.api.elements.DatabaseColumn; import org.eclipse.daanse.sql.guard.api.elements.DatabaseSchema; import org.eclipse.daanse.sql.guard.api.elements.DatabaseTable; +import org.eclipse.daanse.sql.guard.api.exception.EmptyStatementGuardException; import org.eclipse.daanse.sql.guard.api.exception.GuardException; +import org.eclipse.daanse.sql.guard.api.exception.UnallowedStatementTypeGuardException; import org.eclipse.daanse.sql.guard.api.exception.UnresolvableObjectsGuardException; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; @@ -282,6 +284,37 @@ y AS (UPDATE foo SET name='b' RETURNING id) private static final String SELECT_INTO_IS_NOT_PERMITTED = "SELECT INTO is not permitted."; private static final String ROW_LOCKING_IS_NOT_PERMITTED = "Row locking (FOR UPDATE/SHARE) is not permitted."; + + private static final String SELECT_INTO_FILE_OR_VARIABLE_IS_NOT_PERMITTED = + "SELECT INTO OUTFILE/DUMPFILE/variable is not permitted."; + + private static final String ONLY_ONE_STATEMENT_IS_PERMITTED = "Only a single statement is permitted."; + + // MySQL file/variable targets, parsed since JSqlParser 5.4; INTO @var and INTO TEMP + // do not parse with the default grammar and must then fail closed. + private static final String SQL_SELECT_INTO_OUTFILE = "SELECT foo.id INTO OUTFILE '/tmp/x' FROM foo"; + private static final String SQL_SELECT_INTO_OUTFILE_AFTER_FROM = "SELECT foo.id FROM foo INTO OUTFILE '/tmp/x'"; + private static final String SQL_SELECT_INTO_DUMPFILE = "SELECT foo.id INTO DUMPFILE '/tmp/x' FROM foo"; + private static final String SQL_SELECT_INTO_VARIABLE = "SELECT foo.id INTO @v FROM foo"; + private static final String SQL_SELECT_INTO_TEMP = "SELECT foo.id INTO TEMP bar FROM foo"; + private static final String SQL_SELECT_INTO_SCHEMA_QUALIFIED = "SELECT foo.id INTO public.evil FROM foo"; + + private static final String SQL_SELECT_FOR_UPDATE_OF = "SELECT foo.id FROM foo FOR UPDATE OF foo"; + private static final String SQL_SELECT_FOR_NO_KEY_UPDATE = "SELECT foo.id FROM foo FOR NO KEY UPDATE"; + private static final String SQL_SELECT_FOR_KEY_SHARE = "SELECT foo.id FROM foo FOR KEY SHARE"; + private static final String SQL_SELECT_FOR_UPDATE_SKIP_LOCKED = "SELECT foo.id FROM foo FOR UPDATE SKIP LOCKED"; + private static final String SQL_SELECT_LOCK_IN_SHARE_MODE = "SELECT foo.id FROM foo LOCK IN SHARE MODE"; + + private static final String SQL_TRAILING_DROP = "SELECT foo.id FROM foo; DROP TABLE foo"; + private static final String SQL_TRAILING_SELECT = "SELECT foo.id FROM foo; SELECT foo.name FROM foo"; + private static final String SQL_TRAILING_SEMICOLON = "SELECT foo.id FROM foo;"; + private static final String SQL_COMMENT_ONLY = "/* nothing */"; + + private static final String SQL_CORRELATED_EXISTS = """ + SELECT foo.id FROM foo WHERE EXISTS (SELECT 1 FROM fooFact WHERE fooFact.id = foo.id)"""; + private static final String SQL_CORRELATED_SCALAR = """ + SELECT foo.id, (SELECT count(fooFact.id) FROM fooFact WHERE fooFact.id = foo.id) FROM foo"""; + private static final String SQL_WINDOW_FUNCTION = "SELECT sum(foo.id) OVER () FROM foo"; private static final String SQL_LEFT_JOIN_ON_WRONG_COLUMN = """ SELECT foo.id FROM foo LEFT JOIN fooFact ON foo.wrongCol = fooFact.id"""; @@ -383,6 +416,8 @@ WHERE foo.id IN (SELECT fooFact.id FROM fooFact) private static final String SELECT_FROM_CATALOG_QUALIFIED_FOO = "select * from FoodMart.sch.foo"; + private static final String SELECT_QUALIFIED_COLUMNS_FROM_QUALIFIED_FOO = "select sch.foo.id, FoodMart.sch.foo.name from sch.foo"; + @BeforeAll public static void setUp() { dialect = new H2Dialect(); @@ -1150,6 +1185,80 @@ void testUnionForUpdate(@InjectService SqlGuardFactory sqlGuardFactory) throws E .isInstanceOf(GuardException.class) .hasMessage(ROW_LOCKING_IS_NOT_PERMITTED); } + + @Test + void testSelectIntoSchemaQualified(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception { + DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol(); + SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect); + assertThatThrownBy(() -> guard.guard(SQL_SELECT_INTO_SCHEMA_QUALIFIED)) + .isInstanceOf(GuardException.class) + .hasMessage(SELECT_INTO_IS_NOT_PERMITTED); + } + + @ParameterizedTest + @ValueSource(strings = { SQL_SELECT_INTO_OUTFILE, SQL_SELECT_INTO_OUTFILE_AFTER_FROM, SQL_SELECT_INTO_DUMPFILE }) + void testSelectIntoFile(String sql, @InjectService SqlGuardFactory sqlGuardFactory) throws Exception { + DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol(); + SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect); + assertThatThrownBy(() -> guard.guard(sql)) + .isInstanceOf(GuardException.class) + .hasMessage(SELECT_INTO_FILE_OR_VARIABLE_IS_NOT_PERMITTED); + } + + @ParameterizedTest + @ValueSource(strings = { SQL_SELECT_FOR_UPDATE_OF, SQL_SELECT_FOR_NO_KEY_UPDATE, SQL_SELECT_FOR_KEY_SHARE, + SQL_SELECT_FOR_UPDATE_SKIP_LOCKED }) + void testSelectRowLockVariants(String sql, @InjectService SqlGuardFactory sqlGuardFactory) throws Exception { + DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol(); + SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect); + assertThatThrownBy(() -> guard.guard(sql)) + .isInstanceOf(GuardException.class) + .hasMessage(ROW_LOCKING_IS_NOT_PERMITTED); + } + + // Not parsed by the default grammar today: whatever the parser does with them in + // a future version, the guard must never let them through. + @ParameterizedTest + @ValueSource(strings = { SQL_SELECT_INTO_VARIABLE, SQL_SELECT_INTO_TEMP, SQL_SELECT_LOCK_IN_SHARE_MODE }) + void testSideEffectingSelectFailsClosed(String sql, @InjectService SqlGuardFactory sqlGuardFactory) + throws Exception { + DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol(); + SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect); + assertThatThrownBy(() -> guard.guard(sql)).isInstanceOf(GuardException.class); + } + } + + @Nested + @DisplayName("Script Security Tests (multiple statements)") + class ScriptSecurityTests { + + // CCJSqlParserUtil.parse() stops after the first statement, so a trailing + // statement would otherwise be silently dropped instead of rejected. + + @ParameterizedTest + @ValueSource(strings = { SQL_TRAILING_DROP, SQL_TRAILING_SELECT }) + void testTrailingStatementIsRejected(String sql, @InjectService SqlGuardFactory sqlGuardFactory) + throws Exception { + DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol(); + SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect); + assertThatThrownBy(() -> guard.guard(sql)) + .isInstanceOf(UnallowedStatementTypeGuardException.class) + .hasMessage(ONLY_ONE_STATEMENT_IS_PERMITTED); + } + + @Test + void testTrailingSemicolonStillPasses(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception { + DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol(); + SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect); + assertThat(guard.guard(SQL_TRAILING_SEMICOLON)).doesNotContain(";"); + } + + @Test + void testCommentOnlyIsEmpty(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception { + DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol(); + SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect); + assertThatThrownBy(() -> guard.guard(SQL_COMMENT_ONLY)).isInstanceOf(EmptyStatementGuardException.class); + } } @Nested @@ -1367,6 +1476,23 @@ void testCountDistinctWrongColumn(@InjectService SqlGuardFactory sqlGuardFactory @DisplayName("Positive Tests - Valid Queries Should Pass") class PositiveTests { + // Correlated sub queries resolve against the enclosing query since jsqltranspiler 1.13. + @ParameterizedTest + @ValueSource(strings = { SQL_CORRELATED_EXISTS, SQL_CORRELATED_SCALAR }) + void testCorrelatedSubqueryPasses(String sql, @InjectService SqlGuardFactory sqlGuardFactory) + throws Exception { + DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol(); + SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, AGGREGATIONS, dialect); + assertThat(guard.guard(sql)).isNotNull(); + } + + @Test + void testWindowFunctionPasses(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception { + DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol(); + SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, AGGREGATIONS, dialect); + assertThat(guard.guard(SQL_WINDOW_FUNCTION)).contains("OVER"); + } + @Test void testValidSubselect(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception { DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol(); @@ -1441,6 +1567,14 @@ void aCatalogQualifiedNameResolvesWhenTheCatalogIsTheCurrentOne(@InjectService S assertThat(emitted).isEqualTo(SELECT_FROM_FOO_RESULT); } + @Test + void aQualifiedColumnReferenceIsReducedToTheTableName(@InjectService SqlGuardFactory sqlGuardFactory) + throws Exception { + SqlGuard guard = sqlGuardFactory.create(CAT, SCH, schemaWithTwoTableTwoCol(), List.of(), dialect); + // the FROM item carries the schema, column references only the table name + assertThat(guard.guard(SELECT_QUALIFIED_COLUMNS_FROM_QUALIFIED_FOO)).isEqualTo(SELECT_FROM_FOO_RESULT); + } + @Test void aBareNameWithNoCurrentSchemaIsReportedAsUnknown(@InjectService SqlGuardFactory sqlGuardFactory) { SqlGuard guard = sqlGuardFactory.create(CAT, "", schemaWithTwoTableTwoCol(), List.of(), dialect); diff --git a/guard/jsqltranspiler/test.bndrun b/guard/jsqltranspiler/test.bndrun index ed6794c..8d67daf 100644 --- a/guard/jsqltranspiler/test.bndrun +++ b/guard/jsqltranspiler/test.bndrun @@ -52,7 +52,7 @@ # -runbundles is calculated by the bnd-resolver-maven-plugin -runbundles: \ - ai.starlake.transpiler;version='[1.8.0,1.8.1)',\ + ai.starlake.transpiler;version='[1.13.0,1.13.1)',\ assertj-core;version='[3.24.2,3.24.3)',\ junit-jupiter-api;version='[5.12.2,5.12.3)',\ junit-jupiter-engine;version='[5.12.2,5.12.3)',\ @@ -62,7 +62,7 @@ junit-platform-launcher;version='[1.12.2,1.12.3)',\ net.bytebuddy.byte-buddy;version='[1.17.5,1.17.6)',\ net.bytebuddy.byte-buddy-agent;version='[1.17.5,1.17.6)',\ - net.sf.jsqlparser;version='[5.3.167,5.3.168)',\ + net.sf.jsqlparser;version='[5.4.15,5.4.16)',\ org.apache.felix.scr;version='[2.2.10,2.2.11)',\ org.eclipse.daanse.sql.deparser.api;version='[0.0.1,0.0.2)',\ org.eclipse.daanse.sql.deparser.jsqlparser;version='[0.0.1,0.0.2)',\