[feat] Add an SQL editor and prevent SQL injection (#3900)

Signed-off-by: Yang Chen <1597081640@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Yang Chen
2025-12-12 17:52:31 +08:00
committed by GitHub
co-authored by Copilot
parent 93d8f92165
commit 950138a340
23 changed files with 1279 additions and 89 deletions
@@ -31,12 +31,16 @@ import org.apache.hertzbeat.alert.expr.AlertExpressionLexer;
import org.apache.hertzbeat.alert.expr.AlertExpressionParser;
import org.apache.hertzbeat.alert.service.DataSourceService;
import org.apache.hertzbeat.common.support.exception.AlertExpressionException;
import org.apache.hertzbeat.common.support.valid.SqlSecurityException;
import org.apache.hertzbeat.common.support.valid.SqlSecurityValidator;
import org.apache.hertzbeat.common.util.ResourceBundleUtil;
import org.apache.hertzbeat.warehouse.constants.WarehouseConstants;
import org.apache.hertzbeat.warehouse.db.QueryExecutor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.ResourceBundle;
@@ -49,12 +53,18 @@ import java.util.concurrent.TimeUnit;
@Slf4j
public class DataSourceServiceImpl implements DataSourceService {
/**
* Default allowed tables for SQL queries
*/
private static final List<String> DEFAULT_ALLOWED_TABLES = List.of(WarehouseConstants.LOG_TABLE_NAME);
protected ResourceBundle bundle = ResourceBundleUtil.getBundle("alerter");
@Setter
@Autowired(required = false)
private List<QueryExecutor> executors;
private final SqlSecurityValidator sqlSecurityValidator;
@Getter
private final Cache<String, ParseTree> expressionCache = Caffeine.newBuilder()
.maximumSize(256)
@@ -69,6 +79,11 @@ public class DataSourceServiceImpl implements DataSourceService {
.recordStats()
.build();
public DataSourceServiceImpl(@Autowired(required = false) List<QueryExecutor> executors) {
this.executors = executors != null ? executors : Collections.emptyList();
this.sqlSecurityValidator = new SqlSecurityValidator(DEFAULT_ALLOWED_TABLES);
}
@Override
public List<Map<String, Object>> calculate(String datasource, String expr) {
if (!StringUtils.hasText(expr)) {
@@ -110,11 +125,36 @@ public class DataSourceServiceImpl implements DataSourceService {
}
// replace all white space
expr = expr.replaceAll("\\s+", " ");
// SQL security validation for SQL-based datasources
if (isSqlDatasource(datasource)) {
validateSqlSecurity(expr);
}
try {
return executor.execute(expr);
} catch (Exception e) {
log.error("Error executing query on datasource {}: {}", datasource, e.getMessage());
throw new RuntimeException("Query execution failed", e);
throw new AlertExpressionException(e.getMessage());
}
}
/**
* Check if the datasource is SQL-based
*/
private boolean isSqlDatasource(String datasource) {
return datasource != null && datasource.equalsIgnoreCase(WarehouseConstants.SQL);
}
/**
* Validate SQL statement for security
*/
private void validateSqlSecurity(String sql) {
try {
sqlSecurityValidator.validate(sql);
} catch (SqlSecurityException e) {
log.warn("SQL security validation failed: {}", e.getMessage());
throw new AlertExpressionException("SQL security validation failed: " + e.getMessage());
}
}
@@ -133,4 +173,4 @@ public class DataSourceServiceImpl implements DataSourceService {
AlertExpressionLexer lexer = new AlertExpressionLexer(CharStreams.fromString(expr));
return new CommonTokenStream(lexer);
}
}
}
@@ -36,7 +36,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -48,7 +51,7 @@ class DataSourceServiceTest {
@BeforeEach
void setUp() {
dataSourceService = new DataSourceServiceImpl();
dataSourceService = new DataSourceServiceImpl(null);
}
@Test
@@ -639,4 +642,156 @@ class DataSourceServiceTest {
assertThrows(AlertExpressionException.class, () -> dataSourceService.calculate("promql", "http_server_requests_seconds_count{!@~!!#$%^&}"));
}
@Test
void query1() {
List<Map<String, Object>> sqlData = List.of(
new HashMap<>(Map.of("count", 10, "severity_text", "ERROR"))
);
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
when(mockExecutor.execute(anyString())).thenReturn(sqlData);
dataSourceService.setExecutors(List.of(mockExecutor));
String validSql = "SELECT count(*) FROM hertzbeat_logs WHERE severity_text = 'ERROR'";
List<Map<String, Object>> result = dataSourceService.query("sql", validSql);
assertNotNull(result);
assertEquals(1, result.size());
verify(mockExecutor).execute(anyString());
}
@Test
void query2() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
dataSourceService.setExecutors(List.of(mockExecutor));
assertThrows(AlertExpressionException.class,
() -> dataSourceService.query("sql", "INSERT INTO hertzbeat_logs (body) VALUES ('test')"));
verify(mockExecutor, never()).execute(anyString());
}
@Test
void query3() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
dataSourceService.setExecutors(List.of(mockExecutor));
assertThrows(AlertExpressionException.class,
() -> dataSourceService.query("sql", "DELETE FROM hertzbeat_logs WHERE id = 1"));
verify(mockExecutor, never()).execute(anyString());
}
@Test
void query4() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
dataSourceService.setExecutors(List.of(mockExecutor));
assertThrows(AlertExpressionException.class,
() -> dataSourceService.query("sql", "UPDATE hertzbeat_logs SET body = 'hacked' WHERE id = 1"));
verify(mockExecutor, never()).execute(anyString());
}
@Test
void query5() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
dataSourceService.setExecutors(List.of(mockExecutor));
assertThrows(AlertExpressionException.class,
() -> dataSourceService.query("sql", "DROP TABLE hertzbeat_logs"));
verify(mockExecutor, never()).execute(anyString());
}
@Test
void query6() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
dataSourceService.setExecutors(List.of(mockExecutor));
assertThrows(AlertExpressionException.class,
() -> dataSourceService.query("sql", "SELECT * FROM hertzbeat_logs UNION SELECT * FROM users"));
verify(mockExecutor, never()).execute(anyString());
}
@Test
void query7() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
dataSourceService.setExecutors(List.of(mockExecutor));
assertThrows(AlertExpressionException.class,
() -> dataSourceService.query("sql", "SELECT * FROM hertzbeat_logs WHERE id IN (SELECT id FROM other_table)"));
verify(mockExecutor, never()).execute(anyString());
}
@Test
void query8() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
dataSourceService.setExecutors(List.of(mockExecutor));
assertThrows(AlertExpressionException.class,
() -> dataSourceService.query("sql", "SELECT * FROM users"));
verify(mockExecutor, never()).execute(anyString());
}
@Test
void query9() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
dataSourceService.setExecutors(List.of(mockExecutor));
assertThrows(AlertExpressionException.class,
() -> dataSourceService.query("sql", "WITH cte AS (SELECT * FROM hertzbeat_logs) SELECT * FROM cte"));
verify(mockExecutor, never()).execute(anyString());
}
@Test
void query10() {
List<Map<String, Object>> sqlData = List.of(
new HashMap<>(Map.of("errorCount", 5))
);
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
when(mockExecutor.execute(anyString())).thenReturn(sqlData);
dataSourceService.setExecutors(List.of(mockExecutor));
String complexSql = "SELECT count(*) AS errorCount FROM hertzbeat_logs "
+ "WHERE time_unix_nano >= NOW() AND severity_text = 'ERROR' "
+ "GROUP BY severity_text HAVING count(*) > 2 ORDER BY errorCount LIMIT 10";
List<Map<String, Object>> result = dataSourceService.query("sql", complexSql);
assertNotNull(result);
verify(mockExecutor).execute(anyString());
}
@Test
void query11() {
List<Map<String, Object>> prometheusData = List.of(
new HashMap<>(Map.of("__value__", 100.0))
);
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("promql")).thenReturn(true);
when(mockExecutor.execute(anyString())).thenReturn(prometheusData);
dataSourceService.setExecutors(List.of(mockExecutor));
List<Map<String, Object>> result = dataSourceService.query("promql", "node_cpu_seconds_total > 50");
assertNotNull(result);
verify(mockExecutor).execute(anyString());
}
@Test
void query12() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
dataSourceService.setExecutors(List.of(mockExecutor));
assertThrows(AlertExpressionException.class,
() -> dataSourceService.query("sql", "SELEC * FORM hertzbeat_logs"));
verify(mockExecutor, never()).execute(anyString());
}
}
+5 -1
View File
@@ -171,7 +171,7 @@
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-memory-netty</artifactId>
</dependency>
<dependency>
<groupId>org.xerial.snappy</groupId>
<artifactId>snappy-java</artifactId>
@@ -183,6 +183,10 @@
<version>${javaparser.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,32 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.support.valid;
/**
* SQL security validation exception
*/
public class SqlSecurityException extends RuntimeException {
public SqlSecurityException(String message) {
super(message);
}
public SqlSecurityException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,150 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.support.valid;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.select.LateralSubSelect;
import net.sf.jsqlparser.statement.select.ParenthesedSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.SetOperationList;
import net.sf.jsqlparser.statement.select.WithItem;
import net.sf.jsqlparser.util.TablesNamesFinder;
import org.springframework.util.CollectionUtils;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* SQL Security Validator using JSqlParser 5.1+.
* Security Policy:
* 1. Only SELECT statements are allowed.
* 2. All referenced tables must be in the whitelist.
* 3. Subqueries, UNION, CTE, LATERAL are blocked.
*/
@Slf4j
public class SqlSecurityValidator {
private final Set<String> allowedTables;
public SqlSecurityValidator(Collection<String> allowedTables) {
if (CollectionUtils.isEmpty(allowedTables)) {
this.allowedTables = new HashSet<>();
} else {
this.allowedTables = allowedTables.stream()
.map(this::normalizeIdentifier)
.collect(Collectors.toSet());
}
}
public void validate(String sql) throws SqlSecurityException {
if (sql == null || sql.trim().isEmpty()) {
throw new SqlSecurityException("SQL statement cannot be empty");
}
Statement statement;
try {
statement = CCJSqlParserUtil.parse(sql);
} catch (JSQLParserException e) {
log.warn("Failed to parse SQL: {}", sql, e);
throw new SqlSecurityException("Invalid SQL syntax: " + e.getMessage(), e);
}
if (!(statement instanceof Select select)) {
throw new SqlSecurityException("Only SELECT statements are allowed.");
}
// Check for CTE at top level
if (select.getWithItemsList() != null && !select.getWithItemsList().isEmpty()) {
throw new SqlSecurityException("CTE (WITH clause) is not allowed");
}
// Use custom TablesNamesFinder that throws on dangerous structures
SecurityTablesNamesFinder finder = new SecurityTablesNamesFinder();
List<String> tables;
try {
tables = finder.getTableList(statement);
} catch (SecurityViolationException e) {
throw new SqlSecurityException(e.getMessage());
}
validateTables(tables);
}
private void validateTables(List<String> tables) throws SqlSecurityException {
if (CollectionUtils.isEmpty(tables)) {
return;
}
if (allowedTables.isEmpty()) {
throw new SqlSecurityException("No access allowed: whitelist is empty.");
}
for (String table : tables) {
String normalizedTable = normalizeIdentifier(table);
if (!allowedTables.contains(normalizedTable)) {
throw new SqlSecurityException("Access to table '" + table + "' is not allowed. "
+ "Allowed tables: " + allowedTables);
}
}
}
private String normalizeIdentifier(String identifier) {
if (identifier == null) {
return "";
}
return identifier.replace("\"", "").replace("`", "").replace("'", "").toLowerCase();
}
private static class SecurityViolationException extends RuntimeException {
SecurityViolationException(String message) {
super(message);
}
}
/**
* Custom TablesNamesFinder that throws exceptions on dangerous SQL structures.
* Extends TablesNamesFinder with proper generic type to avoid raw type warnings.
*/
private static class SecurityTablesNamesFinder extends TablesNamesFinder<Void> {
@Override
public Void visit(ParenthesedSelect parenthesedSelect, Object context) {
throw new SecurityViolationException("Subqueries are not allowed");
}
@Override
public Void visit(SetOperationList setOpList, Object context) {
throw new SecurityViolationException("UNION and set operations are not allowed");
}
@Override
public Void visit(LateralSubSelect lateralSubSelect, Object context) {
throw new SecurityViolationException("LATERAL subqueries are not allowed");
}
@Override
public Void visit(WithItem withItem, Object context) {
throw new SecurityViolationException("CTE (WITH clause) is not allowed");
}
}
}
@@ -0,0 +1,267 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.support.valid;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Test for {@link SqlSecurityValidator}
*/
class SqlSecurityValidatorTest {
private SqlSecurityValidator validator;
@BeforeEach
void setUp() {
validator = new SqlSecurityValidator(Arrays.asList("hertzbeat_logs", "app_logs", "access_logs"));
}
@Test
void testValidSelectStatement() {
assertDoesNotThrow(() -> validator.validate("SELECT * FROM hertzbeat_logs"));
assertDoesNotThrow(() -> validator.validate("SELECT id, message FROM hertzbeat_logs WHERE level = 'ERROR'"));
assertDoesNotThrow(() -> validator.validate("SELECT COUNT(*) FROM app_logs"));
assertDoesNotThrow(() -> validator.validate("select * from HERTZBEAT_LOGS")); // case insensitive
}
@Test
void testSelectWithJoin() {
assertDoesNotThrow(() -> validator.validate(
"SELECT a.id, b.message FROM hertzbeat_logs a JOIN app_logs b ON a.id = b.id"));
}
@Test
void testSelectWithSubqueryNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM hertzbeat_logs WHERE id IN (SELECT id FROM app_logs)"));
}
@Test
void testSelectWithSubqueryInFromNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM (SELECT * FROM hertzbeat_logs) AS subq"));
}
@Test
void testEmptySql() {
assertThrows(SqlSecurityException.class, () -> validator.validate(null));
assertThrows(SqlSecurityException.class, () -> validator.validate(""));
assertThrows(SqlSecurityException.class, () -> validator.validate(" "));
}
@Test
void testInvalidSqlSyntax() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FORM hertzbeat_logs")); // typo: FORM instead of FROM
}
@Test
void testInsertNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("INSERT INTO hertzbeat_logs (message) VALUES ('test')"));
}
@Test
void testUpdateNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("UPDATE hertzbeat_logs SET message = 'test' WHERE id = 1"));
}
@Test
void testDeleteNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("DELETE FROM hertzbeat_logs WHERE id = 1"));
}
@Test
void testDropNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("DROP TABLE hertzbeat_logs"));
}
@Test
void testTruncateNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("TRUNCATE TABLE hertzbeat_logs"));
}
@Test
void testAlterNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("ALTER TABLE hertzbeat_logs ADD COLUMN new_col VARCHAR(100)"));
}
@Test
void testCreateNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("CREATE TABLE new_table (id INT)"));
}
@Test
void testUnauthorizedTable() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM users"));
}
@Test
void testUnauthorizedTableInJoin() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM hertzbeat_logs JOIN users ON hertzbeat_logs.user_id = users.id"));
}
@Test
void testUnauthorizedTableInSubquery() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM hertzbeat_logs WHERE user_id IN (SELECT id FROM users)"));
}
@Test
void testTableWithQuotes() {
assertDoesNotThrow(() -> validator.validate("SELECT * FROM \"hertzbeat_logs\""));
assertDoesNotThrow(() -> validator.validate("SELECT * FROM `hertzbeat_logs`"));
}
@Test
void testEmptyAllowedTables() {
SqlSecurityValidator emptyValidator = new SqlSecurityValidator(Collections.emptyList());
assertThrows(SqlSecurityException.class,
() -> emptyValidator.validate("SELECT * FROM any_table"));
}
@Test
void testNullAllowedTables() {
SqlSecurityValidator nullValidator = new SqlSecurityValidator(null);
assertThrows(SqlSecurityException.class,
() -> nullValidator.validate("SELECT * FROM any_table"));
}
@Test
void testComplexSelectWithAggregation() {
assertDoesNotThrow(() -> validator.validate(
"SELECT level, COUNT(*) as cnt FROM hertzbeat_logs GROUP BY level HAVING COUNT(*) > 10 ORDER BY cnt DESC LIMIT 100"));
}
@Test
void testSelectWithUnionNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM hertzbeat_logs UNION SELECT * FROM app_logs"));
}
@Test
void testSelectWithUnionAllNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM hertzbeat_logs UNION ALL SELECT * FROM app_logs"));
}
@Test
void testSelectWithIntersectNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM hertzbeat_logs INTERSECT SELECT * FROM app_logs"));
}
@Test
void testSelectWithExceptNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM hertzbeat_logs EXCEPT SELECT * FROM app_logs"));
}
@Test
void testLateralSubqueryNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM hertzbeat_logs, LATERAL (SELECT * FROM app_logs) AS t"));
}
@Test
void testWithClauseNotAllowed() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("WITH cte AS (SELECT * FROM hertzbeat_logs) SELECT * FROM cte"));
}
@Test
void testSqlInjectionAttemptDropTable() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("DROP TABLE users"));
}
@Test
void testSqlInjectionAttemptUnauthorizedTable() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("SELECT * FROM users"));
}
@Test
void testSqlInjectionAttemptDeleteFrom() {
assertThrows(SqlSecurityException.class,
() -> validator.validate("DELETE FROM hertzbeat_logs WHERE 1=1"));
}
@Test
void testBypassInSelectItems() {
assertThrows(SqlSecurityException.class, () -> validator.validate(
"SELECT (SELECT password FROM secret_table) FROM hertzbeat_logs"));
}
@Test
void testBypassInWhereClauseAnd() {
assertThrows(SqlSecurityException.class, () -> validator.validate(
"SELECT * FROM hertzbeat_logs WHERE 1=1 AND id IN (SELECT id FROM secret_table)"));
}
@Test
void testBypassInFunction() {
assertThrows(SqlSecurityException.class, () -> validator.validate(
"SELECT * FROM hertzbeat_logs WHERE id = abs((SELECT count(*) FROM secret_table))"));
}
@Test
void testBypassInCaseWhen() {
assertThrows(SqlSecurityException.class, () -> validator.validate(
"SELECT * FROM hertzbeat_logs WHERE status = (CASE WHEN (SELECT 1 FROM secret_table)=1 THEN 1 ELSE 0 END)"));
}
@Test
void testBypassWithAndExpression() {
assertThrows(SqlSecurityException.class, () -> validator.validate(
"SELECT * FROM hertzbeat_logs WHERE 1=1 AND id = (SELECT id FROM secret_table)"));
}
@Test
void testBypassWithGreaterThan() {
assertThrows(SqlSecurityException.class, () -> validator.validate(
"SELECT * FROM hertzbeat_logs WHERE id > (SELECT count(*) FROM secret_table)"));
}
@Test
void testBypassWithBetween() {
assertThrows(SqlSecurityException.class, () -> validator.validate(
"SELECT * FROM hertzbeat_logs WHERE id BETWEEN 1 AND (SELECT id FROM secret_table)"));
}
@Test
void testBypassWithMathOperations() {
assertThrows(SqlSecurityException.class, () -> validator.validate(
"SELECT * FROM hertzbeat_logs WHERE id = 1 + (SELECT id FROM secret_table)"));
}
}
@@ -68,4 +68,6 @@ public interface WarehouseConstants {
String INSTANT = "instant";
String LOG_TABLE_NAME = "hertzbeat_logs";
}
@@ -63,61 +63,64 @@ public class GreptimeSqlQueryExecutor extends SqlQueryExecutor {
@Override
public List<Map<String, Object>> execute(String queryString) {
List<Map<String, Object>> results = new LinkedList<>();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
if (StringUtils.hasText(greptimeProperties.username())
&& StringUtils.hasText(greptimeProperties.password())) {
String authStr = greptimeProperties.username() + ":" + greptimeProperties.password();
String encodedAuth = Base64Util.encode(authStr);
headers.add(HttpHeaders.AUTHORIZATION, NetworkConstants.BASIC + SignConstants.BLANK + encodedAuth);
}
String requestBody = "sql=" + queryString;
HttpEntity<String> httpEntity = new HttpEntity<>(requestBody, headers);
String url = greptimeProperties.httpEndpoint() + QUERY_PATH;
if (StringUtils.hasText(greptimeProperties.database())) {
url += "?db=" + greptimeProperties.database();
}
ResponseEntity<GreptimeSqlQueryContent> responseEntity;
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
if (StringUtils.hasText(greptimeProperties.username())
&& StringUtils.hasText(greptimeProperties.password())) {
String authStr = greptimeProperties.username() + ":" + greptimeProperties.password();
String encodedAuth = Base64Util.encode(authStr);
headers.add(HttpHeaders.AUTHORIZATION, NetworkConstants.BASIC + SignConstants.BLANK + encodedAuth);
}
String requestBody = "sql=" + queryString;
HttpEntity<String> httpEntity = new HttpEntity<>(requestBody, headers);
String url = greptimeProperties.httpEndpoint() + QUERY_PATH;
if (StringUtils.hasText(greptimeProperties.database())) {
url += "?db=" + greptimeProperties.database();
}
ResponseEntity<GreptimeSqlQueryContent> responseEntity = restTemplate.exchange(url,
responseEntity = restTemplate.exchange(url,
HttpMethod.POST, httpEntity, GreptimeSqlQueryContent.class);
} catch (Exception e) {
log.error("Exception occurred while querying GreptimeDB SQL: {}", e.getMessage(), e);
throw new RuntimeException("Failed to execute GreptimeDB SQL query", e);
}
if (responseEntity.getStatusCode().is2xxSuccessful()) {
GreptimeSqlQueryContent responseBody = responseEntity.getBody();
if (responseBody != null && responseBody.getCode() == 0
&& responseBody.getOutput() != null && !responseBody.getOutput().isEmpty()) {
if (responseEntity.getStatusCode().is2xxSuccessful()) {
GreptimeSqlQueryContent responseBody = responseEntity.getBody();
if (responseBody != null && responseBody.getCode() == 0
&& responseBody.getOutput() != null && !responseBody.getOutput().isEmpty()) {
for (GreptimeSqlQueryContent.Output output : responseBody.getOutput()) {
if (output.getRecords() != null && output.getRecords().getRows() != null) {
GreptimeSqlQueryContent.Output.Records.Schema schema = output.getRecords().getSchema();
List<List<Object>> rows = output.getRecords().getRows();
for (GreptimeSqlQueryContent.Output output : responseBody.getOutput()) {
if (output.getRecords() != null && output.getRecords().getRows() != null) {
GreptimeSqlQueryContent.Output.Records.Schema schema = output.getRecords().getSchema();
List<List<Object>> rows = output.getRecords().getRows();
for (List<Object> row : rows) {
Map<String, Object> rowMap = new HashMap<>();
if (schema != null && schema.getColumnSchemas() != null) {
for (int i = 0; i < Math.min(schema.getColumnSchemas().size(), row.size()); i++) {
String columnName = schema.getColumnSchemas().get(i).getName();
Object value = row.get(i);
rowMap.put(columnName, value);
}
} else {
for (int i = 0; i < row.size(); i++) {
rowMap.put("col_" + i, row.get(i));
}
for (List<Object> row : rows) {
Map<String, Object> rowMap = new HashMap<>();
if (schema != null && schema.getColumnSchemas() != null) {
for (int i = 0; i < Math.min(schema.getColumnSchemas().size(), row.size()); i++) {
String columnName = schema.getColumnSchemas().get(i).getName();
Object value = row.get(i);
rowMap.put(columnName, value);
}
} else {
for (int i = 0; i < row.size(); i++) {
rowMap.put("col_" + i, row.get(i));
}
results.add(rowMap);
}
results.add(rowMap);
}
}
}
} else {
log.error("query metrics data from greptime failed. {}", responseEntity);
}
} catch (Exception e) {
log.error("query metrics data from greptime error. {}", e.getMessage(), e);
} else {
log.error("query metrics data from greptime failed. {}", responseEntity);
}
return results;
}
@@ -61,6 +61,7 @@ import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.util.Base64Util;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.apache.hertzbeat.common.util.TimePeriodUtil;
import org.apache.hertzbeat.warehouse.constants.WarehouseConstants;
import org.apache.hertzbeat.warehouse.store.history.tsdb.AbstractHistoryDataStorage;
import org.apache.hertzbeat.warehouse.store.history.tsdb.vm.PromQlQueryContent;
import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor;
@@ -90,7 +91,7 @@ public class GreptimeDbDataStorage extends AbstractHistoryDataStorage {
private static final String LABEL_KEY_NAME = "__name__";
private static final String LABEL_KEY_FIELD = "__field__";
private static final String LABEL_KEY_INSTANCE = "instance";
private static final String LOG_TABLE_NAME = "hertzbeat_logs";
private static final String LOG_TABLE_NAME = WarehouseConstants.LOG_TABLE_NAME;
private static final String LABEL_KEY_START_TIME = "start";
private static final String LABEL_KEY_END_TIME = "end";
private static final int LOG_BATCH_SIZE = 500;
@@ -21,7 +21,7 @@ package org.apache.hertzbeat.warehouse.db;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@@ -62,7 +62,7 @@ class GreptimeSqlQueryExecutorTest {
when(greptimeProperties.database()).thenReturn("hertzbeat");
when(greptimeProperties.username()).thenReturn("username");
when(greptimeProperties.password()).thenReturn("password");
greptimeSqlQueryExecutor = new GreptimeSqlQueryExecutor(greptimeProperties, restTemplate);
}
@@ -70,7 +70,7 @@ class GreptimeSqlQueryExecutorTest {
void testExecuteSuccess() {
// Mock successful response
GreptimeSqlQueryContent mockResponse = createMockResponse();
ResponseEntity<GreptimeSqlQueryContent> responseEntity =
ResponseEntity<GreptimeSqlQueryContent> responseEntity =
new ResponseEntity<>(mockResponse, HttpStatus.OK);
when(restTemplate.exchange(
@@ -101,11 +101,7 @@ class GreptimeSqlQueryExecutorTest {
)).thenThrow(new RuntimeException("Connection error"));
// Execute
List<Map<String, Object>> result = greptimeSqlQueryExecutor.execute("SELECT * FROM metrics");
// Verify returns empty list on error
assertNotNull(result);
assertTrue(result.isEmpty());
assertThrows(RuntimeException.class, () -> greptimeSqlQueryExecutor.execute("SELECT * FROM metrics"));
}
private GreptimeSqlQueryContent createMockResponse() {
@@ -117,7 +113,7 @@ class GreptimeSqlQueryExecutorTest {
columnSchemas.add(new GreptimeSqlQueryContent.Output.Records.Schema.ColumnSchema("metric_name", "String"));
columnSchemas.add(new GreptimeSqlQueryContent.Output.Records.Schema.ColumnSchema("value", "Float64"));
GreptimeSqlQueryContent.Output.Records.Schema schema =
GreptimeSqlQueryContent.Output.Records.Schema schema =
new GreptimeSqlQueryContent.Output.Records.Schema();
schema.setColumnSchemas(columnSchemas);
@@ -126,7 +122,7 @@ class GreptimeSqlQueryExecutorTest {
rows.add(List.of("cpu", 85.5));
// Build response structure
GreptimeSqlQueryContent.Output.Records records =
GreptimeSqlQueryContent.Output.Records records =
new GreptimeSqlQueryContent.Output.Records();
records.setSchema(schema);
records.setRows(rows);
+1
View File
@@ -212,6 +212,7 @@ The text of each license is the standard Apache 2.0 license.
https://mvnrepository.com/artifact/com.fasterxml.woodstox/woodstox-core/6.5.1 Apache-2.0
https://mvnrepository.com/artifact/com.fasterxml/classmate/1.6.0 Apache-2.0
https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine/2.9.3 Apache-2.0
https://mvnrepository.com/artifact/com.github.jsqlparser/jsqlparser/5.1 Apache-2.0
https://mvnrepository.com/artifact/com.google.android/annotations/4.1.1.4 Apache-2.0
https://mvnrepository.com/artifact/com.google.api.grpc/proto-google-common-protos/2.17.0 Apache-2.0
https://mvnrepository.com/artifact/com.google.code.findbugs/jsr305/3.0.2 Apache-2.0
+7 -1
View File
@@ -34,7 +34,7 @@
<url>https://hertzbeat.apache.org/</url>
<description>
Apache HertzBeat™, a real-time observability system with agentless, performance cluster, prometheus-compatible,
Apache HertzBeat™, a real-time observability system with agentless, performance cluster, prometheus-compatible,
custom monitoring and status page building capabilities.
</description>
@@ -116,6 +116,7 @@
<springdoc.version>2.8.5</springdoc.version>
<spring-boot-starter-sureness.version>1.1.0</spring-boot-starter-sureness.version>
<javaparser.version>3.26.1</javaparser.version>
<jsqlparser.version>5.1</jsqlparser.version>
<nekohtml.version>1.9.22</nekohtml.version>
<json-path.version>2.9.0</json-path.version>
<gson.version>2.10.1</gson.version>
@@ -329,6 +330,11 @@
<artifactId>commons-jexl3</artifactId>
<version>${commons-jexl3}</version>
</dependency>
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>${jsqlparser.version}</version>
</dependency>
<!-- database migration -->
<dependency>
<groupId>org.flywaydb</groupId>
@@ -198,7 +198,7 @@
</nz-form-control>
</nz-form-item>
<!-- Data Type Selection -->
<nz-form-item>
<nz-form-item *ngIf="isManageModalAdd">
<nz-form-label nzSpan="7" nzRequired="true" nzFor="dataType" [nzTooltipTitle]="'alert.setting.datatype.tip' | i18n">
{{ 'alert.setting.datatype' | i18n }}
</nz-form-label>
@@ -733,19 +733,14 @@
<nz-form-item *ngIf="define.type == 'periodic_log'">
<nz-form-label [nzSpan]="7" [nzNoColon]="true" nzFor="periodic_expr"></nz-form-label>
<nz-form-control [nzSpan]="12" [nzErrorTip]="'validation.required' | i18n">
<nz-textarea-count [nzMaxCharacterCount]="100">
<textarea
[(ngModel)]="define.expr"
required
rows="3"
nz-input
name="periodic_expr"
id="periodic_expr"
[placeholder]="'alert.setting.log.query.placeholder' | i18n"
>
</textarea>
</nz-textarea-count>
<button nz-button nzType="primary" style="width: 100%; margin-bottom: 10px" (click)="onPreviewLogExpr()">
<app-sql-editor
[(ngModel)]="define.expr"
name="periodic_expr"
[height]="'150px'"
tableName="hertzbeat_logs"
required
></app-sql-editor>
<button nz-button nzType="primary" style="width: 100%; margin-top: 10px; margin-bottom: 10px" (click)="onPreviewLogExpr()">
<i nz-icon nzType="eye" nzTheme="outline"></i>
{{ 'common.preview.button' | i18n }}
</button>
@@ -136,7 +136,7 @@ export class AlertSettingComponent implements OnInit {
previewData: any[] = [];
previewColumns: Array<{ title: string; key: string; width?: string }> = [];
previewTableLoading = false;
private defaultSql = `SELECT count(*) AS errorCount FROM hertzbeat_logs WHERE time_unix_nano >= NOW() - INTERVAL '30 second' AND severity_text = 'ERROR' HAVING count(*) > 2`;
/**
* Initialize log fields(todo: from backend api)
*/
@@ -346,7 +346,14 @@ export class AlertSettingComponent implements OnInit {
}
private updateAlertDefineType() {
// Combine main type with data type
// First: Reset form state when switching data source type
this.userExpr = '';
this.cascadeValues = [];
this.currentMetrics = [];
this.resetQbDataDefault();
this.clearPreview();
// Second: Init state when switching data source type
if (this.alertType === 'realtime' && this.dataType === 'metric') {
this.define.type = 'realtime_metric';
} else if (this.alertType === 'realtime' && this.dataType === 'log') {
@@ -358,14 +365,9 @@ export class AlertSettingComponent implements OnInit {
} else if (this.alertType === 'periodic' && this.dataType === 'log') {
this.define.type = 'periodic_log';
this.define.datasource = 'sql';
this.define.expr = this.defaultSql;
this.updateLogQbConfig();
}
// Reset form state when switching data source type
this.userExpr = '';
this.cascadeValues = [];
this.currentMetrics = [];
this.resetQbDataDefault();
}
onSelectTypeModalCancel() {
@@ -1027,6 +1029,9 @@ export class AlertSettingComponent implements OnInit {
onManageModalCancel() {
this.cascadeValues = [];
this.isExpr = false;
if (this.isManageModalAdd) {
this.dataType = 'metric';
}
this.resetQbDataDefault();
this.isManageModalVisible = false;
}
@@ -0,0 +1,28 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one
~ or more contributor license agreements. See the NOTICE file
~ distributed with this work for additional information
~ regarding copyright ownership. The ASF licenses this file
~ to you under the Apache License, Version 2.0 (the
~ "License"); you may not use this file except in compliance
~ with the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing,
~ software distributed under the License is distributed on an
~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
~ KIND, either express or implied. See the License for the
~ specific language governing permissions and limitations
~ under the License.
-->
<div class="sql-editor-wrapper" [style.height]="height">
<nz-code-editor
class="sql-editor"
[ngModel]="code"
(ngModelChange)="onCodeChange($event)"
[nzEditorOption]="editorOption"
(nzEditorInitialized)="onEditorInit($event)"
></nz-code-editor>
</div>
@@ -0,0 +1,68 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.sql-editor-wrapper {
border: 1px solid #d9d9d9;
border-radius: 4px;
width: 100%;
position: relative;
&:hover {
border-color: #40a9ff;
}
&:focus-within {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.sql-editor {
height: 100%;
width: 100%;
}
}
:host {
display: block;
position: relative;
}
:host ::ng-deep {
.monaco-editor {
.margin {
background-color: #fafafa;
}
}
.monaco-editor .suggest-widget {
z-index: 9999 !important;
.suggest-widget-status-bar {
display: none !important;
}
}
.monaco-editor .editor-widget {
z-index: 9999 !important;
}
.monaco-editor .overflowingContentWidgets {
z-index: 9999 !important;
}
}
@@ -0,0 +1,438 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Component, EventEmitter, forwardRef, Input, OnDestroy, Output } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
declare const monaco: any;
export interface SqlValidationError {
message: string;
startLine: number;
startColumn: number;
endLine: number;
endColumn: number;
}
export interface LogTableColumn {
name: string;
type: string;
description?: string;
}
@Component({
selector: 'app-sql-editor',
templateUrl: './sql-editor.component.html',
styleUrls: ['./sql-editor.component.less'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SqlEditorComponent),
multi: true
}
]
})
export class SqlEditorComponent implements OnDestroy, ControlValueAccessor {
@Input() height: string = '120px';
@Input() tableName: string = 'hertzbeat_logs';
@Output() readonly editorInit = new EventEmitter<any>();
@Output() readonly validationChange = new EventEmitter<SqlValidationError[]>();
code: string = '';
private editorInstance: any;
private validationTimeout: any;
editorOption: any = {
language: 'sql',
theme: 'vs',
minimap: { enabled: false },
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
folding: false,
wordWrap: 'on',
fontSize: 13,
tabSize: 2,
suggestOnTriggerCharacters: true,
quickSuggestions: true,
wordBasedSuggestions: false,
fixedOverflowWidgets: true,
overviewRulerLanes: 0
};
private completionProvider: any;
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
private readonly LOG_TABLE_COLUMNS: LogTableColumn[] = [
{ name: 'time_unix_nano', type: 'TimestampNanosecond', description: 'Log timestamp in nanoseconds' },
{ name: 'observed_time_unix_nano', type: 'TimestampNanosecond', description: 'Observed timestamp in nanoseconds' },
{ name: 'severity_number', type: 'Int32', description: 'Severity level number (1-24)' },
{ name: 'severity_text', type: 'String', description: 'Severity text (DEBUG, INFO, WARN, ERROR, etc.)' },
{ name: 'body', type: 'Json', description: 'Log message body content' },
{ name: 'trace_id', type: 'String', description: 'Distributed tracing trace ID' },
{ name: 'span_id', type: 'String', description: 'Distributed tracing span ID' },
{ name: 'trace_flags', type: 'Int32', description: 'Trace flags' },
{ name: 'attributes', type: 'Json', description: 'Log attributes as JSON' },
{ name: 'resource', type: 'Json', description: 'Resource information as JSON' },
{ name: 'instrumentation_scope', type: 'Json', description: 'Instrumentation scope information' },
{ name: 'dropped_attributes_count', type: 'Int32', description: 'Number of dropped attributes' }
];
ngOnDestroy(): void {
if (this.completionProvider) {
this.completionProvider.dispose();
}
if (this.validationTimeout) {
clearTimeout(this.validationTimeout);
}
}
onEditorInit(editor: any): void {
this.editorInstance = editor;
this.registerCompletionProvider();
this.configureSuggestWidget(editor);
this.editorInit.emit(editor);
}
private configureSuggestWidget(editor: any): void {
if (typeof monaco === 'undefined') {
return;
}
try {
editor.updateOptions({
suggest: {
maxVisibleSuggestions: 8,
showStatusBar: false,
preview: false
}
});
} catch (e) {
console.warn('Failed to configure suggest widget:', e);
}
}
writeValue(value: string): void {
this.code = value || '';
}
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
onCodeChange(value: string): void {
this.code = value;
this.onChange(value);
this.onTouched();
this.validateSqlWithDebounce(value);
}
private validateSqlWithDebounce(sql: string): void {
if (this.validationTimeout) {
clearTimeout(this.validationTimeout);
}
this.validationTimeout = setTimeout(() => {
const errors = this.validateSql(sql);
this.setEditorMarkers(errors);
this.validationChange.emit(errors);
}, 500);
}
/**
* SQL Security Validator - Simple regex-based validation
* Mirrors backend SqlSecurityValidator security policy:
* 1. Only SELECT statements are allowed
* 2. Table must be in whitelist
* 3. Dangerous patterns (subqueries, UNION, CTE) are blocked
* Note: This is for UX feedback only, real security is enforced by backend
*/
private validateSql(sql: string): SqlValidationError[] {
const errors: SqlValidationError[] = [];
if (!sql || !sql.trim()) {
return errors;
}
const lines = sql.split('\n');
const lastLine = lines.length;
const lastCol = lines[lastLine - 1].length + 1;
// Remove string literals and comments to avoid false positives
const sanitizedSql = this.removeStringsAndComments(sql);
const upperSql = sanitizedSql.toUpperCase().trim();
// Helper to create error
const addError = (message: string) => {
errors.push({
message,
startLine: 1,
startColumn: 1,
endLine: lastLine,
endColumn: lastCol
});
};
// 1. Must start with SELECT
if (!upperSql.startsWith('SELECT')) {
addError('Only SELECT statements are allowed');
return errors;
}
// 2. Check for dangerous statements
const dangerousPatterns = [
{ pattern: /\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i, message: 'Only SELECT statements are allowed' },
{ pattern: /\bUNION\b/i, message: 'UNION is not allowed' },
{ pattern: /\bINTERSECT\b/i, message: 'INTERSECT is not allowed' },
{ pattern: /\bEXCEPT\b/i, message: 'EXCEPT is not allowed' },
{ pattern: /^\s*WITH\b/i, message: 'CTE (WITH clause) is not allowed' }
];
for (const { pattern, message } of dangerousPatterns) {
if (pattern.test(sanitizedSql)) {
addError(message);
return errors;
}
}
// 3. Check for subqueries (SELECT inside parentheses, but not in function calls)
if (this.hasSubquery(sanitizedSql)) {
addError('Subqueries are not allowed');
return errors;
}
// 4. Must have FROM clause
if (!/\bFROM\b/i.test(sanitizedSql)) {
addError('SQL query must contain FROM clause');
return errors;
}
// 5. Validate table name
const tableMatch = sanitizedSql.match(/\bFROM\s+([a-zA-Z_][a-zA-Z0-9_]*)/i);
if (tableMatch) {
const tableName = tableMatch[1];
if (tableName.toLowerCase() !== this.tableName.toLowerCase()) {
addError(`Access to table '${tableName}' is not allowed. Allowed table: ${this.tableName}`);
}
}
// 6. Basic syntax checks
const openParens = (sanitizedSql.match(/\(/g) || []).length;
const closeParens = (sanitizedSql.match(/\)/g) || []).length;
if (openParens !== closeParens) {
addError('Mismatched parentheses');
}
return errors;
}
/**
* Remove string literals and comments to avoid false positives in pattern matching
*/
private removeStringsAndComments(sql: string): string {
return (
sql
// Remove single-quoted strings (handle escaped quotes)
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
// Remove double-quoted strings
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
// Remove single-line comments
.replace(/--.*$/gm, '')
// Remove multi-line comments
.replace(/\/\*[\s\S]*?\*\//g, '')
);
}
/**
* Check for subqueries (nested SELECT statements)
* Detects SELECT inside parentheses that's not part of IN(...values...)
*/
private hasSubquery(sql: string): boolean {
// Pattern to detect SELECT inside parentheses
const subqueryPattern = /\(\s*SELECT\b/i;
return subqueryPattern.test(sql);
}
private setEditorMarkers(errors: SqlValidationError[]): void {
if (typeof monaco === 'undefined' || !this.editorInstance) {
return;
}
const model = this.editorInstance.getModel();
if (!model) {
return;
}
const markers = errors.map(error => ({
severity: monaco.MarkerSeverity.Error,
message: error.message,
startLineNumber: error.startLine,
startColumn: error.startColumn,
endLineNumber: error.endLine,
endColumn: error.endColumn
}));
monaco.editor.setModelMarkers(model, 'sql-validator', markers);
}
private registerCompletionProvider(): void {
if (typeof monaco === 'undefined') {
return;
}
if (this.completionProvider) {
this.completionProvider.dispose();
}
this.completionProvider = monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: [' ', '.', ',', '(', '\n'],
provideCompletionItems: (model: any, position: any) => {
const textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column
});
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
};
const suggestions: any[] = [];
const upperText = textUntilPosition.toUpperCase();
const isAfterFrom = /FROM\s+$/i.test(textUntilPosition) || /FROM\s+\w*$/i.test(textUntilPosition);
const isAfterSelect = /SELECT\s+$/i.test(textUntilPosition) || /SELECT\s+.*,\s*$/i.test(textUntilPosition);
const isAfterWhere =
/WHERE\s+$/i.test(textUntilPosition) || /AND\s+$/i.test(textUntilPosition) || /OR\s+$/i.test(textUntilPosition);
const hasTableContext = new RegExp(this.tableName, 'i').test(textUntilPosition);
const isEmpty = !textUntilPosition.trim();
// Empty or start - show templates first
if (isEmpty) {
suggestions.push({
label: `SELECT * FROM ${this.tableName}`,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: `SELECT * FROM ${this.tableName} WHERE $0`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: 'Query template',
sortText: '0',
range: range
});
suggestions.push({
label: `SELECT COUNT(*) FROM ${this.tableName}`,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: `SELECT COUNT(*) as count FROM ${this.tableName} WHERE $0`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: 'Count template',
sortText: '1',
range: range
});
}
// After FROM - only show table name
if (isAfterFrom) {
suggestions.push({
label: this.tableName,
kind: monaco.languages.CompletionItemKind.Class,
insertText: this.tableName,
detail: 'Log table',
sortText: '0',
range: range
});
return { suggestions };
}
// After SELECT - show columns and *
if (isAfterSelect) {
suggestions.push({
label: '*',
kind: monaco.languages.CompletionItemKind.Operator,
insertText: '*',
detail: 'All columns',
sortText: '0',
range: range
});
this.LOG_TABLE_COLUMNS.forEach((column, idx) => {
suggestions.push({
label: column.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: column.name,
detail: column.type,
sortText: `1${idx}`,
range: range
});
});
return { suggestions };
}
// After WHERE/AND/OR - show columns for conditions
if (isAfterWhere || hasTableContext) {
this.LOG_TABLE_COLUMNS.forEach((column, idx) => {
suggestions.push({
label: column.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: column.name,
detail: column.type,
sortText: `0${idx}`,
range: range
});
});
}
// Show relevant keywords based on context
const keywordsToShow = this.getContextKeywords(upperText);
keywordsToShow.forEach((keyword, idx) => {
suggestions.push({
label: keyword,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: keyword,
detail: 'SQL',
sortText: `2${idx}`,
range: range
});
});
return { suggestions };
}
});
}
private getContextKeywords(upperText: string): string[] {
if (!upperText.includes('SELECT')) {
return ['SELECT'];
}
if (!upperText.includes('FROM')) {
return ['FROM'];
}
if (!upperText.includes('WHERE') && upperText.includes('FROM')) {
return ['WHERE', 'ORDER BY', 'LIMIT', 'GROUP BY'];
}
if (upperText.includes('WHERE')) {
return ['AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN', 'IS NULL', 'IS NOT NULL', 'ORDER BY', 'LIMIT', 'GROUP BY'];
}
return ['SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'ORDER BY', 'LIMIT'];
}
}
+5 -1
View File
@@ -9,6 +9,7 @@ import { DelonFormModule } from '@delon/form';
import { AlainThemeModule } from '@delon/theme';
import { NzBreadCrumbModule } from 'ng-zorro-antd/breadcrumb';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzCodeEditorModule } from 'ng-zorro-antd/code-editor';
import { NzDividerComponent } from 'ng-zorro-antd/divider';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzInputModule } from 'ng-zorro-antd/input';
@@ -28,6 +29,7 @@ import { LabelSelectorComponent } from './components/label-selector/label-select
import { MonitorSelectListComponent } from './components/monitor-select-list/monitor-select-list.component';
import { MonitorSelectMenuComponent } from './components/monitor-select-menu/monitor-select-menu.component';
import { MultiFuncInputComponent } from './components/multi-func-input/multi-func-input.component';
import { SqlEditorComponent } from './components/sql-editor/sql-editor.component';
import { ToolbarComponent } from './components/toolbar/toolbar.component';
import { ElapsedTimePipe } from './pipe/elapsed-time.pipe';
import { I18nElsePipe } from './pipe/i18n-else.pipe';
@@ -44,7 +46,8 @@ const COMPONENTS: Array<Type<void>> = [
FormFieldComponent,
MonitorSelectMenuComponent,
MonitorSelectListComponent,
LabelSelectorComponent
LabelSelectorComponent,
SqlEditorComponent
];
const DIRECTIVES: Array<Type<void>> = [TimezonePipe, I18nElsePipe, ElapsedTimePipe];
@@ -70,6 +73,7 @@ const DIRECTIVES: Array<Type<void>> = [TimezonePipe, I18nElsePipe, ElapsedTimePi
NzInputModule,
NzIconModule.forChild(icons),
NzSpinModule,
NzCodeEditorModule,
AiChatModule
],
declarations: [...COMPONENTS, ...DIRECTIVES, HelpMessageShowComponent],
-1
View File
@@ -318,7 +318,6 @@
"alert.setting.rule.string-value.place-holder": "Please input string",
"alert.setting.log.query": "Log Query",
"alert.setting.log.query.tip": "Configure log query conditions to filter logs that need to be monitored",
"alert.setting.log.query.placeholder": "Please enter log query conditions",
"alert.setting.log.expr": "Log Alert Expression",
"alert.setting.log.expr.tip": "Configure log alert trigger conditions based on query results",
"alert.setting.log.expr.placeholder": "Please enter alert expression, e.g.: count > 10",
-1
View File
@@ -303,7 +303,6 @@
"alert.setting.rule.string-value.place-holder": "文字列を入力してください",
"alert.setting.log.query": "ログクエリ",
"alert.setting.log.query.tip": "監視するログをフィルタリングするためのログクエリ条件を設定",
"alert.setting.log.query.placeholder": "ログクエリ条件を入力してください",
"alert.setting.log.expr": "ログアラート式",
"alert.setting.log.expr.tip": "クエリ結果に基づいてログアラートトリガー条件を設定",
"alert.setting.log.expr.placeholder": "アラート式を入力してください。例: count > 10",
-1
View File
@@ -38,7 +38,6 @@
"alert.setting.rule.string-value.place-holder": "Digite o texto",
"alert.setting.log.query": "Consulta de Log",
"alert.setting.log.query.tip": "Configure condições de consulta de log para filtrar logs que precisam ser monitorados",
"alert.setting.log.query.placeholder": "Digite as condições de consulta de log",
"alert.setting.log.expr": "Expressão de Alerta de Log",
"alert.setting.log.expr.tip": "Configure condições de disparo de alerta de log baseadas nos resultados da consulta",
"alert.setting.log.expr.placeholder": "Digite a expressão de alerta, ex: count > 10",
-1
View File
@@ -318,7 +318,6 @@
"alert.setting.rule.string-value.place-holder": "请输入匹配字符串",
"alert.setting.log.query": "日志查询",
"alert.setting.log.query.tip": "配置日志查询条件,用于筛选需要监控的日志",
"alert.setting.log.query.placeholder": "请输入日志查询条件",
"alert.setting.log.expr": "日志告警表达式",
"alert.setting.log.expr.tip": "配置日志告警触发条件,基于查询结果进行告警判断",
"alert.setting.log.expr.placeholder": "请输入告警表达式,例如: count > 10",
-1
View File
@@ -298,7 +298,6 @@
"alert.setting.rule.string-value.place-holder": "請輸入匹配字符串",
"alert.setting.log.query": "日誌查詢",
"alert.setting.log.query.tip": "配置日誌查詢條件,用於篩選需要監控的日誌",
"alert.setting.log.query.placeholder": "請輸入日誌查詢條件",
"alert.setting.log.expr": "日誌告警表達式",
"alert.setting.log.expr.tip": "配置日誌告警觸發條件,基於查詢結果進行告警判斷",
"alert.setting.log.expr.placeholder": "請輸入告警表達式,例如: count > 10",