This is an automated email from the ASF dual-hosted git repository.
zqr10159 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new 7a8156a2d0 [fix] validate sql reaching the alert query executor (#4275)
7a8156a2d0 is described below
commit 7a8156a2d0d5937e19298349af1ee3f74e2aa8f6
Author: Duansg <[email protected]>
AuthorDate: Mon Aug 24 18:48:50 2026 +0800
[fix] validate sql reaching the alert query executor (#4275)
Signed-off-by: Duansg <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Logic <[email protected]>
---
.../alert/expr/AlertExpressionEvalVisitor.java | 14 +-
.../service/impl/AlertQueryBudgetExecutor.java | 135 +++++++
.../alert/service/impl/DataSourceServiceImpl.java | 50 +--
.../service/impl/SqlValidatingQueryExecutor.java | 83 +++++
.../alert/expr/AlertExpressionEvalVisitorTest.java | 47 ++-
.../alert/service/DataSourceServiceTest.java | 107 ++++++
.../common/support/valid/SqlSecurityValidator.java | 403 ++++++++++++++++++++-
.../support/valid/SqlSecurityValidatorTest.java | 232 ++++++++++++
hertzbeat-startup/src/main/resources/sureness.yml | 6 +
.../startup/security/SurenessResourceRuleTest.java | 48 +++
.../hertzbeat-mysql-iotdb/conf/sureness.yml | 6 +
.../hertzbeat-mysql-tdengine/conf/sureness.yml | 6 +
.../conf/sureness.yml | 6 +
.../conf/sureness.yml | 6 +
.../conf/sureness.yml | 6 +
script/sureness.yml | 6 +
16 files changed, 1124 insertions(+), 37 deletions(-)
diff --git
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitor.java
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitor.java
index bc84566083..1775ce6c46 100644
---
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitor.java
+++
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitor.java
@@ -39,6 +39,11 @@ public class AlertExpressionEvalVisitor extends
AlertExpressionBaseVisitor<List<
private static final String VALUE = "__value__";
private static final String TIMESTAMP = "__timestamp__";
+ /**
+ * Every statement this visitor evaluates goes to this executor, so
whether a statement is
+ * allowed to run is decided there rather than at each visit method, see
+ * {@code DataSourceServiceImpl}.
+ */
private final QueryExecutor executor;
private final CommonTokenStream tokens;
@@ -259,17 +264,16 @@ public class AlertExpressionEvalVisitor extends
AlertExpressionBaseVisitor<List<
@Override
public List<Map<String, Object>>
visitSqlCallExpr(AlertExpressionParser.SqlCallExprContext ctx) {
- return callSqlOrPromql(tokens.getText(ctx.string()));
+ return executor.execute(unquote(tokens.getText(ctx.string())));
}
@Override
public List<Map<String, Object>>
visitPromqlCallExpr(AlertExpressionParser.PromqlCallExprContext ctx) {
- return callSqlOrPromql(tokens.getText(ctx.string()));
+ return executor.execute(unquote(tokens.getText(ctx.string())));
}
- private List<Map<String, Object>> callSqlOrPromql(String text) {
- String script = text.substring(1, text.length() - 1);
- return executor.execute(script);
+ private String unquote(String text) {
+ return text.substring(1, text.length() - 1);
}
/**
diff --git
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertQueryBudgetExecutor.java
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertQueryBudgetExecutor.java
new file mode 100644
index 0000000000..44552bed65
--- /dev/null
+++
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertQueryBudgetExecutor.java
@@ -0,0 +1,135 @@
+/*
+ * 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.alert.service.impl;
+
+import org.apache.hertzbeat.common.entity.dto.query.DatasourceQuery;
+import org.apache.hertzbeat.common.entity.dto.query.DatasourceQueryData;
+import org.apache.hertzbeat.common.support.exception.AlertExpressionException;
+import org.apache.hertzbeat.warehouse.db.QueryExecutor;
+
+import java.math.BigInteger;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Applies the resource budget shared by alert previews and periodic
evaluation.
+ *
+ * <p>The query endpoint uses warehouse credentials and an alert definition
runs repeatedly,
+ * so accepting a read-only query is not sufficient on its own. This wrapper
limits the input,
+ * the lookback selected by PromQL or Greptime range syntax, and the rows
returned to the alert
+ * evaluator. The warehouse client supplies the execution timeout; these
checks bound the work
+ * requested and the data retained by the caller.
+ */
+final class AlertQueryBudgetExecutor implements QueryExecutor {
+
+ static final int MAX_QUERY_LENGTH = 8_192;
+
+ static final int MAX_RESULT_ROWS = 1_000;
+
+ private static final BigInteger MAX_RANGE_MILLIS =
BigInteger.valueOf(86_400_000L);
+
+ private static final Pattern PROMQL_RANGE = Pattern.compile(
+
"\\[\\s*([0-9]+(?:ms|[smhdwy])(?:\\s*[0-9]+(?:ms|[smhdwy]))*)\\s*(?::[^]]*)?]",
+ Pattern.CASE_INSENSITIVE);
+
+ private static final Pattern SQL_RANGE = Pattern.compile(
+ "\\bRANGE\\s*['\"]\\s*([^'\"]+)\\s*['\"]",
+ Pattern.CASE_INSENSITIVE);
+
+ private static final Pattern DURATION_PART =
Pattern.compile("([0-9]+)(ms|[smhdwy])",
+ Pattern.CASE_INSENSITIVE);
+
+ private final QueryExecutor delegate;
+
+ AlertQueryBudgetExecutor(QueryExecutor delegate) {
+ this.delegate = delegate;
+ }
+
+ static void validateInput(String query) {
+ if (query.length() > MAX_QUERY_LENGTH) {
+ throw new AlertExpressionException("Alert query exceeds the 8192
character limit.");
+ }
+ validateRanges(PROMQL_RANGE.matcher(query));
+ validateRanges(SQL_RANGE.matcher(query));
+ }
+
+ private static void validateRanges(Matcher ranges) {
+ while (ranges.find()) {
+ String duration = ranges.group(1).replaceAll("\\s+", "");
+ if (durationMillis(duration).compareTo(MAX_RANGE_MILLIS) > 0) {
+ throw new AlertExpressionException("Alert query range exceeds
the one day limit.");
+ }
+ }
+ }
+
+ private static BigInteger durationMillis(String duration) {
+ Matcher parts = DURATION_PART.matcher(duration);
+ BigInteger total = BigInteger.ZERO;
+ int end = 0;
+ while (parts.find()) {
+ if (parts.start() != end) {
+ return BigInteger.ZERO;
+ }
+ BigInteger value = new BigInteger(parts.group(1));
+ total = total.add(value.multiply(unitMillis(parts.group(2))));
+ end = parts.end();
+ }
+ return end == duration.length() ? total : BigInteger.ZERO;
+ }
+
+ private static BigInteger unitMillis(String unit) {
+ return switch (unit.toLowerCase(Locale.ROOT)) {
+ case "ms" -> BigInteger.ONE;
+ case "s" -> BigInteger.valueOf(1_000L);
+ case "m" -> BigInteger.valueOf(60_000L);
+ case "h" -> BigInteger.valueOf(3_600_000L);
+ case "d" -> BigInteger.valueOf(86_400_000L);
+ case "w" -> BigInteger.valueOf(604_800_000L);
+ case "y" -> BigInteger.valueOf(31_536_000_000L);
+ default -> BigInteger.ZERO;
+ };
+ }
+
+ @Override
+ public List<Map<String, Object>> execute(String query) {
+ validateInput(query);
+ List<Map<String, Object>> rows = delegate.execute(query);
+ if (rows != null && rows.size() > MAX_RESULT_ROWS) {
+ throw new AlertExpressionException("Alert query returned more than
1000 rows.");
+ }
+ return rows;
+ }
+
+ @Override
+ public DatasourceQueryData query(DatasourceQuery datasourceQuery) {
+ return delegate.query(datasourceQuery);
+ }
+
+ @Override
+ public String getDatasource() {
+ return delegate.getDatasource();
+ }
+
+ @Override
+ public boolean support(String queryLanguage) {
+ return delegate.support(queryLanguage);
+ }
+}
diff --git
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
index 136777c085..7f268d48cb 100644
---
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
+++
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
@@ -31,7 +31,6 @@ 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;
@@ -58,6 +57,13 @@ public class DataSourceServiceImpl implements
DataSourceService {
*/
private static final List<String> DEFAULT_ALLOWED_TABLES =
List.of(WarehouseConstants.LOG_TABLE_NAME);
+ /**
+ * The policy for an alert expression is read only within the configured
database. Metric
+ * tables are created per metric on demand, so an exact table whitelist
would reject every
+ * legitimate metric query.
+ */
+ private static final SqlSecurityValidator EXPRESSION_SQL_VALIDATOR =
SqlSecurityValidator.selectOnly();
+
protected ResourceBundle bundle = ResourceBundleUtil.getBundle("alerter");
@Setter
@@ -82,6 +88,7 @@ public class DataSourceServiceImpl implements
DataSourceService {
if (!StringUtils.hasText(expr)) {
throw new IllegalArgumentException("Empty expression");
}
+ AlertQueryBudgetExecutor.validateInput(expr);
if (executors == null || executors.isEmpty()) {
throw new
IllegalArgumentException(bundle.getString("alerter.datasource.executor.not.found"));
}
@@ -93,7 +100,7 @@ public class DataSourceServiceImpl implements
DataSourceService {
// replace all white space
expr = expr.replaceAll("\\s+", " ");
try {
- return evaluate(expr, executor);
+ return evaluate(expr, guardSql(new
AlertQueryBudgetExecutor(executor), EXPRESSION_SQL_VALIDATOR));
} catch (AlertExpressionException ae) {
log.error("Calculate query parse error, datasource: {}, expr: {},
msg: {}", datasource, expr, ae.getMessage(), ae);
throw ae;
@@ -108,6 +115,7 @@ public class DataSourceServiceImpl implements
DataSourceService {
if (!StringUtils.hasText(expr)) {
throw new IllegalArgumentException("Empty expression");
}
+ AlertQueryBudgetExecutor.validateInput(expr);
if (executors == null || executors.isEmpty()) {
throw new
IllegalArgumentException(bundle.getString("alerter.datasource.executor.not.found"));
}
@@ -119,13 +127,11 @@ 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);
+ return guardSql(new AlertQueryBudgetExecutor(executor),
sqlSecurityValidator).execute(expr);
+ } catch (AlertExpressionException ae) {
+ // A statement the policy rejected, whose message names the part
it broke.
+ throw ae;
} catch (Exception e) {
log.error("Error executing query on datasource {}: {}",
datasource, e.getMessage());
throw new AlertExpressionException(e.getMessage());
@@ -133,22 +139,22 @@ public class DataSourceServiceImpl implements
DataSourceService {
}
/**
- * Check if the datasource is SQL-based
- */
- private boolean isSqlDatasource(String datasource) {
- return datasource != null &&
datasource.equalsIgnoreCase(WarehouseConstants.SQL);
- }
-
- /**
- * Validate SQL statement for security
+ * Wraps an executor that speaks sql so that nothing runs on it
unvalidated.
+ *
+ * <p>The decision is made from the executor rather than from the
datasource string the
+ * caller passed, because the executor is what actually holds the database
credentials.
+ * A datasource that does not speak sql is handed back untouched: a promql
endpoint takes
+ * a query string, not a statement, and running it through a sql parser
would only reject
+ * valid promql.
+ * @param executor Executor chosen for this datasource
+ * @param validator Policy to enforce, read only for expressions and
whitelisting for raw log queries
+ * @return The executor, guarded when it speaks sql
*/
- 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());
+ private QueryExecutor guardSql(QueryExecutor executor,
SqlSecurityValidator validator) {
+ if (!executor.support(WarehouseConstants.SQL)) {
+ return executor;
}
+ return new SqlValidatingQueryExecutor(executor, validator);
}
private List<Map<String, Object>> evaluate(String expr, QueryExecutor
executor) {
diff --git
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/SqlValidatingQueryExecutor.java
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/SqlValidatingQueryExecutor.java
new file mode 100644
index 0000000000..020669594c
--- /dev/null
+++
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/SqlValidatingQueryExecutor.java
@@ -0,0 +1,83 @@
+/*
+ * 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.alert.service.impl;
+
+import org.apache.hertzbeat.common.entity.dto.query.DatasourceQuery;
+import org.apache.hertzbeat.common.entity.dto.query.DatasourceQueryData;
+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.warehouse.db.QueryExecutor;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A sql executor that validates before it runs anything.
+ *
+ * <p>An alert expression reaches a query executor by several routes: the
{@code sql("...")}
+ * and {@code promql("...")} spellings both carry an arbitrary string, a bare
select is
+ * parsed by the expression grammar itself, and each of them is evaluated for
the preview
+ * endpoint and for the periodic evaluation loop alike. All of them end at
+ * {@link QueryExecutor#execute(String)}, which runs the string with the
server side database
+ * credentials.
+ *
+ * <p>Guarding that one method rather than each route is what makes the check
complete: the
+ * spelling an expression happens to use does not decide whether the statement
is checked,
+ * the database it lands on does. It also means a route added later is covered
without anyone
+ * remembering to add a call.
+ */
+public class SqlValidatingQueryExecutor implements QueryExecutor {
+
+ private final QueryExecutor delegate;
+
+ private final SqlSecurityValidator validator;
+
+ public SqlValidatingQueryExecutor(QueryExecutor delegate,
SqlSecurityValidator validator) {
+ this.delegate = delegate;
+ this.validator = validator;
+ }
+
+ @Override
+ public List<Map<String, Object>> execute(String query) {
+ try {
+ validator.validate(query);
+ } catch (SqlSecurityException e) {
+ // AlertExpressionException rather than a generic failure: it is
the type
+ // DataSourceServiceImpl rethrows untouched and the preview
endpoint turns into a
+ // 400, so the author of the rule sees which part of the policy
the statement broke
+ throw new AlertExpressionException("SQL security validation
failed: " + e.getMessage());
+ }
+ return delegate.execute(query);
+ }
+
+ @Override
+ public DatasourceQueryData query(DatasourceQuery datasourceQuery) {
+ return delegate.query(datasourceQuery);
+ }
+
+ @Override
+ public String getDatasource() {
+ return delegate.getDatasource();
+ }
+
+ @Override
+ public boolean support(String queryLanguage) {
+ return delegate.support(queryLanguage);
+ }
+}
diff --git
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitorTest.java
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitorTest.java
index c12e23e80b..f384d501af 100644
---
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitorTest.java
+++
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitorTest.java
@@ -864,10 +864,55 @@ class AlertExpressionEvalVisitorTest {
assertEquals(0, result.get(0).get("__value__"));
}
+ @Test
+ void testSqlCallRunsPlainRead() {
+ when(mockExecutor.execute("select value from cpu where host =
'server1'"))
+ .thenReturn(List.of(new HashMap<>(Map.of("__value__", 80.0))));
+
+ final List<Map<String, Object>> result =
+ evaluate("sql(\"select value from cpu where host =
'server1'\") > 70");
+
+ assertEquals(1, result.size());
+ assertEquals(80.0, result.get(0).get("__value__"));
+ }
+
+ /**
+ * Subqueries and nested aggregation are supported in alert expressions,
and with every
+ * metric table already readable, rejecting them would cost features
without denying an
+ * attacker anything. The policy stops at read only on purpose.
+ */
+ @Test
+ void testSqlCallKeepsSubqueriesWorking() {
+ final String sql = "select value from cpu where host = (select host
from hosts limit 1)";
+ when(mockExecutor.execute(sql)).thenReturn(List.of(new
HashMap<>(Map.of("__value__", 80.0))));
+
+ final List<Map<String, Object>> result = evaluate("sql(\"" + sql +
"\") > 70");
+
+ assertEquals(1, result.size());
+ assertEquals(80.0, result.get(0).get("__value__"));
+ }
+
+ /**
+ * The visitor hands both spellings to the executor as written. Whether a
statement is
+ * allowed to run is decided by the executor it lands on, see
+ * {@code DataSourceServiceTest}, so promql keeps working for text that is
not valid sql
+ * at all.
+ */
+ @Test
+ void testBothCallSpellingsReachTheExecutorAsWritten() {
+ when(mockExecutor.execute("rate(http_requests_total[5m])"))
+ .thenReturn(List.of(new HashMap<>(Map.of("__value__", 80.0))));
+
+ final List<Map<String, Object>> result =
evaluate("promql(\"rate(http_requests_total[5m])\") > 70");
+
+ assertEquals(1, result.size());
+ assertEquals(80.0, result.get(0).get("__value__"));
+ }
+
private List<Map<String, Object>> evaluate(String expression) {
AlertExpressionLexer lexer = new
AlertExpressionLexer(CharStreams.fromString(expression));
CommonTokenStream tokens = new CommonTokenStream(lexer);
AlertExpressionParser parser = new AlertExpressionParser(tokens);
return new AlertExpressionEvalVisitor(mockExecutor,
tokens).visit(parser.expression());
}
-}
\ No newline at end of file
+}
diff --git
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
index e9b098b1b0..af73e77e94 100644
---
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
+++
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
@@ -787,4 +787,111 @@ class DataSourceServiceTest {
() -> dataSourceService.query("sql", "SELEC * FORM
hertzbeat_logs"));
verify(mockExecutor, never()).execute(anyString());
}
+
+ /**
+ * The expression grammar offers three ways to reach the executor, and the
datasource the
+ * caller names is what picks that executor. So a statement written with
the
+ * {@code promql("...")} spelling still lands on the sql executor, with
the server side
+ * database credentials behind it, whenever the caller names the sql
datasource: whether a
+ * statement may run cannot be decided from the spelling.
+ */
+ @Test
+ void calculateRejectsWritesWhateverSpellingTheyArrivedIn() {
+ final QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.calculate("sql", "sql(\"drop table
cpu\") > 0"));
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.calculate("sql", "promql(\"drop table
cpu\") > 0"));
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.calculate("sql", "sql(\"select 1; drop
table cpu\") > 0"));
+ verify(mockExecutor, never()).execute(anyString());
+ }
+
+ /**
+ * Only a datasource that speaks sql is guarded. A promql endpoint takes a
query string
+ * rather than a statement, so running it through a sql parser would
reject valid promql
+ * without denying an attacker anything.
+ */
+ @Test
+ void calculateLeavesPromqlDatasourcesAlone() {
+ final QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("promql")).thenReturn(true);
+ when(mockExecutor.execute(anyString())).thenReturn(List.of(new
HashMap<>(Map.of("__value__", 100.0))));
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ final List<Map<String, Object>> result = dataSourceService.calculate(
+ "promql", "promql(\"rate(http_requests_total[5m])\") > 70");
+
+ assertEquals(1, result.size());
+ verify(mockExecutor).execute("rate(http_requests_total[5m])");
+ }
+
+ /**
+ * A read still has to run, including the GreptimeDB range query syntax
that the sql
+ * parser cannot read.
+ */
+ @Test
+ void calculateStillRunsReadsOnSqlDatasources() {
+ final QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ when(mockExecutor.execute(anyString())).thenReturn(List.of(new
HashMap<>(Map.of("__value__", 100.0))));
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ final String rangeQuery = "select avg(value) RANGE '10s' from cpu
ALIGN '5s'";
+ final List<Map<String, Object>> result =
+ dataSourceService.calculate("sql", "sql(\"" + rangeQuery +
"\") > 70");
+
+ assertEquals(1, result.size());
+ verify(mockExecutor).execute(rangeQuery);
+ }
+
+ @Test
+ void rejectsQueriesThatExceedTheInputBudget() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("promql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ String oversized = "m".repeat(8193);
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("promql", oversized));
+ verify(mockExecutor, never()).execute(anyString());
+ }
+
+ @Test
+ void rejectsPromqlAndSqlRangesBeyondOneDay() {
+ QueryExecutor promqlExecutor = Mockito.mock(QueryExecutor.class);
+ when(promqlExecutor.support("promql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(promqlExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("promql",
"rate(http_requests_total[2d])"));
+ verify(promqlExecutor, never()).execute(anyString());
+
+ QueryExecutor sqlExecutor = Mockito.mock(QueryExecutor.class);
+ when(sqlExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(sqlExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.calculate(
+ "sql", "sql(\"select avg(value) RANGE '2d' from cpu
ALIGN '5m'\") > 1"));
+ verify(sqlExecutor, never()).execute(anyString());
+ }
+
+ @Test
+ void rejectsResultSetsBeyondTheAlertBudget() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("promql")).thenReturn(true);
+ List<Map<String, Object>> rows = new ArrayList<>();
+ for (int index = 0; index < 1001; index++) {
+ rows.add(Map.of("__value__", index));
+ }
+ when(mockExecutor.execute("metric_name")).thenReturn(rows);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("promql", "metric_name"));
+ }
}
diff --git
a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
index 6d58dff18b..0f01510461 100644
---
a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
+++
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
@@ -21,32 +21,81 @@ 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.Statements;
+import net.sf.jsqlparser.statement.delete.Delete;
+import net.sf.jsqlparser.statement.insert.Insert;
+import net.sf.jsqlparser.statement.merge.Merge;
import net.sf.jsqlparser.statement.select.LateralSubSelect;
import net.sf.jsqlparser.statement.select.ParenthesedSelect;
+import net.sf.jsqlparser.statement.select.PlainSelect;
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.statement.update.Update;
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.Locale;
import java.util.Set;
import java.util.stream.Collectors;
+import java.util.regex.Pattern;
/**
* 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.
+ *
+ * <p>Two modes, see {@link #SqlSecurityValidator(Collection)} and {@link
#selectOnly()}:
+ * <ul>
+ * <li>whitelisting: only SELECT, every referenced table must be
whitelisted, and
+ * subqueries, UNION, CTE and LATERAL are blocked because they are the ways
a statement
+ * can reach a table the whitelist never mentions.</li>
+ * <li>read only: only SELECT, any table, nested reads kept.</li>
+ * </ul>
+ *
+ * <p>Both modes reject a string that carries more than one statement, and
neither lets a
+ * write through at any depth of the statement.
*/
@Slf4j
public class SqlSecurityValidator {
+ private static final String SELECT_KEYWORD = "SELECT";
+
+ private static final String WITH_KEYWORD = "WITH";
+
+ /**
+ * Words that no read contains, matched as whole words outside literals
and comments.
+ *
+ * <p>This is the check that holds when the parser cannot read the dialect
and there is no
+ * tree to walk, so it has to catch a write wherever it sits, including
nested in a cte or
+ * a subquery. It is deliberately coarse; the tree walk is the precise one.
+ *
+ * <p>Statements that can only stand alone, {@code TRUNCATE} and {@code
CALL} among them,
+ * are absent: the leading keyword already rejects those, and several of
them double as
+ * ordinary functions, {@code TRUNCATE(value, 2)} and {@code REPLACE(msg,
'a', 'b')} being
+ * the ones a metric query really does use. An identifier that collides
with a word listed
+ * here can still be quoted, which the scan skips over.
+ */
+ private static final Set<String> WRITE_KEYWORDS = Set.of(
+ "DELETE", "INSERT", "UPDATE", "MERGE", "INTO", "DROP", "ALTER",
+ "CREATE", "GRANT", "REVOKE", "COPY", "RENAME", "ATTACH", "DETACH");
+
+ private static final String DURATION =
"[0-9]+(?:ms|[smhdwy])(?:\\s*[0-9]+(?:ms|[smhdwy]))*";
+
+ private static final Pattern GREPTIME_RANGE_CLAUSE = Pattern.compile(
+ "(?i)\\bRANGE\\s*'\\s*" + DURATION + "\\s*'");
+
+ private static final Pattern GREPTIME_ALIGN_CLAUSE = Pattern.compile(
+ "(?i)\\bALIGN\\s*'\\s*" + DURATION + "\\s*'");
+
+ private static final Pattern GREPTIME_FILL_CLAUSE = Pattern.compile(
+ "(?i)\\bFILL\\s+(?:LINEAR|PREV|NEXT|NULL)\\b");
+
private final Set<String> allowedTables;
+ private final ValidationPolicy validationPolicy;
+
public SqlSecurityValidator(Collection<String> allowedTables) {
if (CollectionUtils.isEmpty(allowedTables)) {
this.allowedTables = new HashSet<>();
@@ -55,18 +104,137 @@ public class SqlSecurityValidator {
.map(this::normalizeIdentifier)
.collect(Collectors.toSet());
}
+ this.validationPolicy = ValidationPolicy.WHITELISTED_SELECT;
+ }
+
+ private SqlSecurityValidator() {
+ this.allowedTables = new HashSet<>();
+ this.validationPolicy = ValidationPolicy.READ_ONLY;
+ }
+
+ /**
+ * A validator whose whole policy is "this statement may only read".
+ *
+ * <p>Use it where there is no table list to validate against: metric
tables are created
+ * on demand, one per metric, so the whitelisting constructor would reject
every
+ * legitimate metric query.
+ *
+ * <p>It deliberately keeps subqueries, unions and ctes, unlike the
whitelisting mode.
+ * Those structures are blocked there because they are the ways a
statement can reach a
+ * table the whitelist never mentions; with every table already readable
they buy no
+ * protection, while alert expressions do use subqueries and nested
aggregation.
+ * @return A validator that accepts only statements proven to be read-only
+ */
+ public static SqlSecurityValidator selectOnly() {
+ return new SqlSecurityValidator();
}
public void validate(String sql) throws SqlSecurityException {
if (sql == null || sql.trim().isEmpty()) {
throw new SqlSecurityException("SQL statement cannot be empty");
}
+ switch (validationPolicy) {
+ case WHITELISTED_SELECT -> validateAgainstWhitelist(sql);
+ case READ_ONLY -> validateReadOnly(sql);
+ // not dead: a policy added later that nobody wired up here must
refuse the
+ // statement rather than fall through this switch having validated
nothing
+ default -> throw new SqlSecurityException("Unknown SQL validation
policy.");
+ }
+ }
+
+ /**
+ * Read only mode, which runs against a time series database whose sql
dialect JSqlParser
+ * does not fully cover: GreptimeDB range queries such as
+ * {@code SELECT avg(v) RANGE '10s' FROM cpu ALIGN '5s'} are rejected by
the parser
+ * although they are ordinary reads.
+ *
+ * <p>So the first properties this mode has to guarantee are established
without the parser:
+ * statements are counted by scanning outside string literals and
comments, the leading
+ * keyword decides whether the statement reads, and no word that only a
write contains may
+ * appear anywhere. All three are dialect independent, and the last one is
what covers a
+ * write nested where the scan has no structure to reason about, as in
+ * {@code WITH x AS (DELETE FROM cpu RETURNING *) SELECT * FROM x}.
+ *
+ * <p>The parser must still prove the complete statement tree and
enumerate every table.
+ * For the Greptime clauses JSqlParser does not understand, a
validation-only copy has the
+ * bounded {@code RANGE}, {@code ALIGN}, and {@code FILL} clauses removed
before parsing.
+ * Any other unsupported syntax fails closed instead of relying on a
keyword denylist as
+ * the sole proof of read-only behavior.
+ * @param sql Statement to validate
+ * @throws SqlSecurityException If the statement writes, or carries more
than one statement
+ */
+ private void validateReadOnly(String sql) throws SqlSecurityException {
+ final StatementShape shape = scan(sql);
+ if (shape.statementCount() != 1) {
+ throw new SqlSecurityException("Only a single statement is
allowed.");
+ }
+ if (shape.writeKeyword() != null) {
+ throw new SqlSecurityException("'" + shape.writeKeyword() + "' is
not allowed, only reads are.");
+ }
+ if (!SELECT_KEYWORD.equals(shape.leadingKeyword()) &&
!WITH_KEYWORD.equals(shape.leadingKeyword())) {
+ throw new SqlSecurityException("Only SELECT statements are
allowed.");
+ }
+ final Statement statement = parseReadOnlyStatement(sql);
- Statement statement;
+ if (!(statement instanceof Select)) {
+ throw new SqlSecurityException("Only SELECT statements are
allowed.");
+ }
+ final List<String> tables = assertNothingWrites(statement);
+ if (tables.stream().map(this::normalizeIdentifier).anyMatch(table ->
table.contains("."))) {
+ throw new SqlSecurityException("Schema-qualified tables are not
allowed.");
+ }
+ }
+
+ private Statement parseReadOnlyStatement(String sql) throws
SqlSecurityException {
+ try {
+ return parseSingleStatement(sql);
+ } catch (JSQLParserException originalFailure) {
+ String parserCompatibleSql =
GREPTIME_RANGE_CLAUSE.matcher(sql).replaceAll("");
+ parserCompatibleSql =
GREPTIME_ALIGN_CLAUSE.matcher(parserCompatibleSql).replaceAll("");
+ parserCompatibleSql =
GREPTIME_FILL_CLAUSE.matcher(parserCompatibleSql).replaceAll("");
+ if (parserCompatibleSql.equals(sql)) {
+ throw new SqlSecurityException("Invalid SQL syntax: " +
originalFailure.getMessage(), originalFailure);
+ }
+ try {
+ return parseSingleStatement(parserCompatibleSql);
+ } catch (JSQLParserException normalizedFailure) {
+ log.debug("Failed to parse SQL after removing Greptime range
clauses: {}", sql, normalizedFailure);
+ throw new SqlSecurityException(
+ "SQL structure could not be verified as a read: " +
normalizedFailure.getMessage(),
+ normalizedFailure);
+ }
+ }
+ }
+
+ /**
+ * Walks the whole statement rather than its outermost node, because a
write hides at any
+ * depth: {@code SELECT * INTO backup FROM cpu UNION SELECT * FROM cpu}
puts the write in a
+ * branch of a set operation, and {@code SELECT * FROM (SELECT * INTO
backup FROM cpu) t}
+ * puts it in a subquery, so an outermost node that is a plain select
proves nothing.
+ *
+ * <p>Any other failure of the walk is a rejection too. A data modifying
cte makes
+ * JSqlParser's own finder cast a {@code ParenthesedDelete} to a {@code
ParenthesedSelect},
+ * and a walk that ended in an exception established nothing about the
statement.
+ * @param statement Parsed statement to walk
+ * @throws SqlSecurityException If any part of the statement writes, or
could not be walked
+ */
+ private List<String> assertNothingWrites(Statement statement) throws
SqlSecurityException {
try {
- statement = CCJSqlParserUtil.parse(sql);
+ return new ReadOnlyStatementFinder().getTableList(statement);
+ } catch (SecurityViolationException e) {
+ throw new SqlSecurityException(e.getMessage());
+ } catch (RuntimeException e) {
+ log.debug("Failed to walk SQL, so nothing about it is established:
{}", statement, e);
+ throw new SqlSecurityException("SQL structure could not be
verified as a read.");
+ }
+ }
+
+ private void validateAgainstWhitelist(String sql) throws
SqlSecurityException {
+ final Statement statement;
+ try {
+ statement = parseSingleStatement(sql);
} catch (JSQLParserException e) {
- log.warn("Failed to parse SQL: {}", sql, e);
+ log.debug("Failed to parse SQL: {}", sql, e);
throw new SqlSecurityException("Invalid SQL syntax: " +
e.getMessage(), e);
}
@@ -74,14 +242,18 @@ public class SqlSecurityValidator {
throw new SqlSecurityException("Only SELECT statements are
allowed.");
}
+ // The whitelist is about which tables a statement may touch, so on
its own it lets
+ // "select * into backup from hertzbeat_logs" through: every table it
names is allowed
+ assertNothingWrites(statement);
+
// 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;
+ final SecurityTablesNamesFinder finder = new
SecurityTablesNamesFinder();
+ final List<String> tables;
try {
tables = finder.getTableList(statement);
} catch (SecurityViolationException e) {
@@ -91,6 +263,179 @@ public class SqlSecurityValidator {
validateTables(tables);
}
+ /**
+ * @param sql Statement to parse
+ * @return The only statement the string carries
+ * @throws JSQLParserException If the string does not parse
+ * @throws SqlSecurityException If the string carries more than one
statement
+ */
+ private Statement parseSingleStatement(String sql) throws
JSQLParserException, SqlSecurityException {
+ final Statements statements = CCJSqlParserUtil.parseStatements(sql);
+ // ParseStatements rather than parse: parse() returns only the first
statement.
+ // Otherwise, "select 1; drop table x" would validate as a plain
select.
+ // The caller would still hand the whole string to the database.
+ if (statements.getStatements().size() != 1) {
+ throw new SqlSecurityException("Only a single statement is
allowed.");
+ }
+ return statements.getStatements().get(0);
+ }
+
+ /**
+ * What a statement string looks like from outside any sql dialect.
+ * @param statementCount Statements the string carries, a trailing
semicolon not counting as one
+ * @param leadingKeyword First word of the first statement, upper cased,
empty when it does not start with a word
+ * @param writeKeyword First word from {@link #WRITE_KEYWORDS} found
anywhere, null when there is none
+ */
+ private record StatementShape(int statementCount, String leadingKeyword,
String writeKeyword) {
+ }
+
+ /**
+ * Counts the statements a string carries and reads the word it opens
with, skipping over
+ * string literals, quoted identifiers and comments so that a semicolon
inside them is not
+ * mistaken for a statement separator.
+ *
+ * <p>A backslash is not treated as an escape, because assuming it escapes
the closing
+ * quote in a dialect where it does not would let {@code 'a\'; DROP TABLE
t} hide a second
+ * statement inside what this scan thinks is one literal. Not assuming it
costs at worst a
+ * rejection of a statement that uses backslash escapes, which errs the
safe way.
+ * @param sql Statement string to scan
+ * @return The shape of the string
+ * @throws SqlSecurityException If a literal or a block comment is left
open
+ */
+ private StatementShape scan(String sql) throws SqlSecurityException {
+ int statementCount = 0;
+ boolean statementHasContent = false;
+ String leadingKeyword = "";
+ String writeKeyword = null;
+ int index = 0;
+ while (index < sql.length()) {
+ final char current = sql.charAt(index);
+ if (current == '-' && index + 1 < sql.length() && sql.charAt(index
+ 1) == '-') {
+ final int lineEnd = sql.indexOf('\n', index);
+ index = lineEnd < 0 ? sql.length() : lineEnd + 1;
+ } else if (current == '/' && index + 1 < sql.length() &&
sql.charAt(index + 1) == '*') {
+ final int commentEnd = sql.indexOf("*/", index + 2);
+ if (commentEnd < 0) {
+ throw new SqlSecurityException("Unterminated block
comment.");
+ }
+ index = commentEnd + 2;
+ } else if (current == '$') {
+ rejectDollarQuote(sql, index);
+ statementHasContent = true;
+ index++;
+ } else if (current == '\'' || current == '"' || current == '`') {
+ index = skipQuoted(sql, index, current);
+ statementHasContent = true;
+ } else if (current == ';') {
+ if (statementHasContent) {
+ statementCount++;
+ }
+ statementHasContent = false;
+ index++;
+ } else if (Character.isLetter(current) || current == '_') {
+ // Read the whole word and step past it.
+ // Match a write keyword only on its own, never inside an
identifier like delete_count.
+ final int wordEnd = wordEnd(sql, index);
+ final String word = sql.substring(index,
wordEnd).toUpperCase(Locale.ROOT);
+ if (statementCount == 0 && !statementHasContent) {
+ leadingKeyword = word;
+ }
+ if (writeKeyword == null && WRITE_KEYWORDS.contains(word)) {
+ writeKeyword = word;
+ }
+ statementHasContent = true;
+ index = wordEnd;
+ } else {
+ if (!Character.isWhitespace(current)) {
+ statementHasContent = true;
+ }
+ index++;
+ }
+ }
+ if (statementHasContent) {
+ statementCount++;
+ }
+ return new StatementShape(statementCount, leadingKeyword,
writeKeyword);
+ }
+
+ /**
+ * @param sql Statement string being scanned
+ * @param start Index of the opening quote
+ * @param quote Quote character to close on, a doubled one being an
escaped quote rather than the close
+ * @return Index just past the closing quote
+ * @throws SqlSecurityException If the quote is never closed
+ */
+ private int skipQuoted(String sql, int start, char quote) throws
SqlSecurityException {
+ int index = start + 1;
+ while (index < sql.length()) {
+ if (sql.charAt(index) == quote) {
+ if (index + 1 < sql.length() && sql.charAt(index + 1) ==
quote) {
+ index += 2;
+ continue;
+ }
+ return index + 1;
+ }
+ index++;
+ }
+ throw new SqlSecurityException("Unterminated quoted literal.");
+ }
+
+ /**
+ * Rejects a PostgreSQL dollar-quoted literal, {@code $$...$$} or {@code
$tag$...$tag$}.
+ *
+ * <p>Comment markers and semicolons inside such a literal are data, so a
scan that reads
+ * them as syntax loses the rest of the input: {@code SELECT $$--$$; DROP
TABLE cpu} looks
+ * like one statement once the {@code --} is taken for a comment.
+ *
+ * <p>Rejecting rather than skipping, because skipping would be the same
assumption in
+ * reverse. A dialect without dollar quoting means the semicolon inside
one really does
+ * separate statements, and a scan that skipped the literal would be the
thing hiding
+ * them. Rejecting needs no assumption either way: a dialect that has
dollar quoting is
+ * refused, and one that does not was going to fail on the syntax
regardless. No read of a
+ * metric table spells anything this way.
+ * @param sql Statement string being scanned
+ * @param start Index of the dollar sign
+ * @throws SqlSecurityException If a dollar-quoted literal opens here
+ */
+ private void rejectDollarQuote(String sql, int start) throws
SqlSecurityException {
+ int tagEnd = start + 1;
+ if (tagEnd >= sql.length()) {
+ return;
+ }
+ final char firstTagCharacter = sql.charAt(tagEnd);
+ if (firstTagCharacter != '$'
+ && !Character.isLetter(firstTagCharacter)
+ && firstTagCharacter != '_') {
+ // a positional parameter such as $1, or a dollar sign that is
just a character
+ return;
+ }
+ while (tagEnd < sql.length()
+ && (Character.isLetterOrDigit(sql.charAt(tagEnd)) ||
sql.charAt(tagEnd) == '_')) {
+ tagEnd++;
+ }
+ if (tagEnd >= sql.length() || sql.charAt(tagEnd) != '$') {
+ return;
+ }
+ throw new SqlSecurityException("Dollar-quoted literals are not
allowed.");
+ }
+
+ /**
+ * @param sql Statement string being scanned
+ * @param start Index of the first character of a word
+ * @return Index just past the word, digits and underscores counting as
part of it so that
+ * {@code delete_count} is one word rather than a {@code delete}
followed by a remainder.
+ * A dollar sign ends the word, so that a literal opening right after
an identifier is
+ * still seen by {@link #rejectDollarQuote}
+ */
+ private int wordEnd(String sql, int start) {
+ int index = start;
+ while (index < sql.length()
+ && (Character.isLetterOrDigit(sql.charAt(index)) ||
sql.charAt(index) == '_')) {
+ index++;
+ }
+ return index;
+ }
+
private void validateTables(List<String> tables) throws
SqlSecurityException {
if (CollectionUtils.isEmpty(tables)) {
return;
@@ -121,6 +466,46 @@ public class SqlSecurityValidator {
}
}
+ private enum ValidationPolicy {
+ WHITELISTED_SELECT,
+ READ_ONLY
+ }
+
+ /**
+ * Walks a statement and throws as soon as it finds a part of it that
writes, at any depth.
+ */
+ private static class ReadOnlyStatementFinder extends
TablesNamesFinder<Void> {
+
+ @Override
+ public Void visit(PlainSelect plainSelect, Object context) {
+ // SELECT ... INTO writes a new table in the dialects that support
it, so it is not a read.
+ if (!CollectionUtils.isEmpty(plainSelect.getIntoTables())) {
+ throw new SecurityViolationException("SELECT ... INTO is not
allowed.");
+ }
+ return super.visit(plainSelect, context);
+ }
+
+ @Override
+ public Void visit(Delete delete, Object context) {
+ throw new SecurityViolationException("DELETE is not allowed, only
reads are.");
+ }
+
+ @Override
+ public Void visit(Insert insert, Object context) {
+ throw new SecurityViolationException("INSERT is not allowed, only
reads are.");
+ }
+
+ @Override
+ public Void visit(Update update, Object context) {
+ throw new SecurityViolationException("UPDATE is not allowed, only
reads are.");
+ }
+
+ @Override
+ public Void visit(Merge merge, Object context) {
+ throw new SecurityViolationException("MERGE is not allowed, only
reads are.");
+ }
+ }
+
/**
* Custom TablesNamesFinder that throws exceptions on dangerous SQL
structures.
* Extends TablesNamesFinder with proper generic type to avoid raw type
warnings.
diff --git
a/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
index 782ee31383..4c7063d7c9 100644
---
a/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
+++
b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
@@ -264,4 +264,236 @@ class SqlSecurityValidatorTest {
assertThrows(SqlSecurityException.class, () -> validator.validate(
"SELECT * FROM hertzbeat_logs WHERE id = 1 + (SELECT id FROM
secret_table)"));
}
+
+ /**
+ * `CCJSqlParserUtil.parse` returns the first statement and silently
discards the rest,
+ * so a stacked statement used to validate as a plain select while the
caller still
+ * handed the whole string to the database.
+ */
+ @Test
+ void testStackedStatementIsRejected() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT * FROM hertzbeat_logs; DROP TABLE hertzbeat_logs"));
+ assertThrows(SqlSecurityException.class, () ->
SqlSecurityValidator.selectOnly().validate(
+ "SELECT 1; DROP TABLE cpu"));
+ assertThrows(SqlSecurityException.class, () ->
SqlSecurityValidator.selectOnly().validate(
+ "SELECT $$--$$; DROP TABLE cpu"));
+ assertThrows(SqlSecurityException.class, () ->
SqlSecurityValidator.selectOnly().validate(
+ "SELECT $body$--$body$; DROP TABLE cpu"));
+ }
+
+ @Test
+ void testTrailingSemicolonIsStillAcceptedAsOneStatement() {
+ assertDoesNotThrow(() -> validator.validate("SELECT * FROM
hertzbeat_logs ; "));
+ }
+
+ @Test
+ void testSelectOnlyRejectsWrites() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("DROP TABLE cpu"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("DELETE FROM cpu"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("INSERT INTO cpu VALUES (1)"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("UPDATE cpu SET value = 1"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("TRUNCATE TABLE cpu"));
+ }
+
+ /**
+ * Metric tables are created per metric on demand, so this mode constrains
what a
+ * statement may do, not which table it may touch.
+ */
+ @Test
+ void testSelectOnlyAcceptsAnyTableAndNestedReads() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT value FROM
any_metric_table"));
+ assertDoesNotThrow(() -> selectOnly.validate(
+ "SELECT value FROM cpu WHERE host = (SELECT host FROM hosts LIMIT
1)"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT a FROM t1 UNION
ALL SELECT b FROM t2"));
+ }
+
+ /**
+ * The only sql executor today talks to GreptimeDB, whose range query
syntax JSqlParser
+ * cannot parse. These are ordinary reads and used to run, so read only
mode has to keep
+ * accepting them rather than turn a richer dialect into a rule that no
longer fires.
+ */
+ @Test
+ void testSelectOnlyAcceptsDialectTheParserDoesNotUnderstand() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertDoesNotThrow(() -> selectOnly.validate(
+ "SELECT ts, avg(value) RANGE '10s' FROM cpu ALIGN '5s' FILL
LINEAR"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu ALIGN
'5s'"));
+ }
+
+ /**
+ * Accepting what the parser cannot read must not become a way through:
the statement
+ * count and the leading keyword are established without the parser, so
they still hold
+ * for a string it never understood.
+ */
+ @Test
+ void testUnparsableStatementMustStillBeOneRead() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+ "SELECT avg(value) RANGE '10s' FROM cpu ALIGN '5s'; DROP TABLE
cpu"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("DROP TABLE cpu ALIGN '5s'"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("TQL EVAL (0, 10, '5s') sum(cpu)"));
+ }
+
+ /**
+ * A semicolon that is data or commentary is not a statement separator,
and a statement
+ * scan that cannot tell the difference would reject ordinary queries.
+ */
+ @Test
+ void testSemicolonInsideLiteralOrCommentDoesNotSplitTheStatement() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu WHERE
msg = 'a; DROP TABLE cpu'"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu WHERE
msg = 'it''s; fine'"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu -- ;
DROP TABLE cpu"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu /* ;
DROP TABLE cpu */ LIMIT 1"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT value FROM
\"cpu;usage\""));
+ }
+
+ @Test
+ void testUnclosedLiteralOrCommentIsRejected() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("SELECT * FROM cpu WHERE msg = 'open"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("SELECT * FROM cpu /* open"));
+ }
+
+ /**
+ * A dollar-quoted literal is refused outright rather than skipped over,
so that neither
+ * answer to "does this dialect have dollar quoting" can hide a statement.
+ *
+ * <p>Skipping would lose a stacked statement to a dialect that has it,
since the comment
+ * marker in {@code SELECT $$--$$; DROP TABLE cpu} is data rather than a
comment. Skipping
+ * would equally lose one to a dialect that does not, since the semicolon
in
+ * {@code SELECT 1 $$;DROP TABLE cpu$$} really does separate statements
there.
+ */
+ @Test
+ void testDollarQuotedLiteralIsRejectedRatherThanSkipped() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("SELECT $$--$$; DROP TABLE cpu"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("SELECT 1 $$;DROP TABLE cpu$$"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("SELECT 1 $t$;DROP TABLE cpu$t$"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("SELECT $$a; -- DROP TABLE cpu$$"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("SELECT $body$a; -- DROP TABLE cpu$body$"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("SELECT $$open"));
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("SELECT $body$open"));
+ // a literal opening right after an identifier is still a literal
+ assertThrows(SqlSecurityException.class, () ->
selectOnly.validate("SELECT a$$b;c$$ FROM cpu"));
+ }
+
+ /**
+ * A dollar sign that opens nothing is an ordinary character, so reads
keep working.
+ */
+ @Test
+ void testLoneDollarSignIsNotTreatedAsLiteral() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT $1 FROM cpu"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT a$b FROM cpu"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu WHERE
cost = '$5; x'"));
+ }
+
+ /**
+ * `SELECT ... INTO` parses as a select but writes a table in the dialects
that support it.
+ */
+ @Test
+ void testSelectOnlyRejectsSelectInto() {
+ assertThrows(SqlSecurityException.class, () ->
SqlSecurityValidator.selectOnly()
+ .validate("SELECT * INTO backup FROM cpu"));
+ }
+
+ @Test
+ void testSelectOnlyAcceptsCte() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertDoesNotThrow(() -> selectOnly.validate("WITH x AS (SELECT 1 AS
v) SELECT * FROM x"));
+ assertDoesNotThrow(() -> selectOnly.validate(
+ "WITH x AS (SELECT avg(v) RANGE '10s' FROM cpu ALIGN '5s') SELECT
* FROM x"));
+ }
+
+ /**
+ * An outermost node that is a plain select proves nothing about the rest
of the tree: a
+ * write hides in a cte, in a branch of a set operation, or in a subquery,
and JSqlParser
+ * reports the outermost node of all three as a select.
+ */
+ @Test
+ void testSelectOnlyRejectsWritesNestedInsideReads() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+ "WITH x AS (DELETE FROM cpu RETURNING *) SELECT * FROM x"));
+ assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+ "WITH x AS (INSERT INTO cpu VALUES (1) RETURNING *) SELECT * FROM
x"));
+ assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+ "WITH x AS (SELECT id FROM t) DELETE FROM cpu WHERE id IN (SELECT
id FROM x)"));
+ assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+ "SELECT * INTO backup FROM cpu UNION SELECT * FROM cpu"));
+ assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+ "SELECT * FROM cpu UNION SELECT * INTO backup FROM cpu"));
+ assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+ "SELECT * FROM (SELECT * INTO backup FROM cpu) t"));
+ }
+
+ /**
+ * The nested writes above have to stay rejected when the parser cannot
read the dialect
+ * and there is no tree to walk, which is the case the whole read only
mode exists for.
+ */
+ @Test
+ void testNestedWritesStayRejectedWithoutTheParser() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+ "SELECT * FROM (DELETE FROM cpu RETURNING *) t ALIGN '5s'"));
+ assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+ "WITH x AS (DELETE FROM cpu RETURNING *) SELECT avg(v) RANGE '10s'
FROM x ALIGN '5s'"));
+ assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+ "SELECT * INTO backup FROM cpu ALIGN '5s'"));
+ }
+
+ /**
+ * The word scan matches whole words only, so ordinary reads whose
identifiers or functions
+ * merely contain one keep working. An identifier that collides outright
can be quoted.
+ */
+ @Test
+ void testWriteWordScanDoesNotCatchOrdinaryReads() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT delete_count,
insert_rate FROM cpu"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT truncate(value,
2) FROM cpu"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT replace(msg, 'a',
'b') FROM logs"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu WHERE
msg = 'drop table cpu'"));
+ assertDoesNotThrow(() -> selectOnly.validate("SELECT \"drop\" FROM
cpu"));
+ }
+
+ /**
+ * The whitelist says which tables a statement may touch, so on its own it
lets a write
+ * through as long as every table it names is allowed.
+ */
+ @Test
+ void testWhitelistModeRejectsSelectIntoOnAnAllowedTable() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT * INTO backup FROM hertzbeat_logs"));
+ }
+
+ /**
+ * The whitelisting mode needs the parse tree to enumerate table names, so
unlike read
+ * only mode it has nothing to fall back on and keeps rejecting what it
cannot parse.
+ */
+ @Test
+ void testWhitelistModeStillRejectsWhatItCannotParse() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT avg(value) RANGE '10s' FROM hertzbeat_logs ALIGN '5s'"));
+ }
+
+ @Test
+ void testSelectOnlyCannotEscapeTheConfiguredDatabase() {
+ final SqlSecurityValidator selectOnly =
SqlSecurityValidator.selectOnly();
+
+ assertThrows(SqlSecurityException.class,
+ () -> selectOnly.validate("SELECT * FROM
information_schema.tables"));
+ assertThrows(SqlSecurityException.class,
+ () -> selectOnly.validate("SELECT * FROM pg_catalog.pg_tables"));
+ assertThrows(SqlSecurityException.class,
+ () -> selectOnly.validate("SELECT * FROM public.cpu"));
+ assertThrows(SqlSecurityException.class,
+ () -> selectOnly.validate("SELECT * FROM
\"information_schema\".\"tables\""));
+ assertThrows(SqlSecurityException.class,
+ () -> selectOnly.validate(
+ "SELECT * FROM cpu, information_schema.tables RANGE '1m' ALIGN
'1m'"));
+ }
}
diff --git a/hertzbeat-startup/src/main/resources/sureness.yml
b/hertzbeat-startup/src/main/resources/sureness.yml
index e8aed38e31..2749420cbe 100644
--- a/hertzbeat-startup/src/main/resources/sureness.yml
+++ b/hertzbeat-startup/src/main/resources/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
- /api/monitors/**===delete===[admin]
+ - /api/alert/define/preview/**===get===[admin]
+ - /api/alert/define/**===get===[admin,user]
+ - /api/alert/define===post===[admin]
+ - /api/alert/define===put===[admin]
+ - /api/alert/defines/**===get===[admin,user]
+ - /api/alert/defines/**===post===[admin]
- /api/alert/**===get===[admin,user,guest]
- /api/alert/**===post===[admin,user]
- /api/alert/**===put===[admin,user]
diff --git
a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessResourceRuleTest.java
b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessResourceRuleTest.java
index 17301b30b7..564cd5c179 100644
---
a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessResourceRuleTest.java
+++
b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessResourceRuleTest.java
@@ -117,6 +117,28 @@ class SurenessResourceRuleTest {
assertEquals("[admin,user,guest]",
roleTree.searchPathFilterRoles("/api/monitors" + SEPARATOR + "get"));
}
+ @Test
+ void readingAlertDefinitionsExcludesGuest() {
+ assertEquals("[admin,user]",
+ roleTree.searchPathFilterRoles("/api/alert/define/1" +
SEPARATOR + "get"));
+ assertEquals("[admin,user]",
+ roleTree.searchPathFilterRoles("/api/alert/defines" +
SEPARATOR + "get"));
+ assertEquals("[admin,user]",
+ roleTree.searchPathFilterRoles("/api/alert/defines/export" +
SEPARATOR + "get"));
+ }
+
+ @Test
+ void previewingAndAuthoringPeriodicQueriesIsRestrictedToAdmin() {
+ assertEquals("[admin]",
+ roleTree.searchPathFilterRoles("/api/alert/define/preview/sql"
+ SEPARATOR + "get"));
+ assertEquals("[admin]",
+ roleTree.searchPathFilterRoles("/api/alert/define" + SEPARATOR
+ "post"));
+ assertEquals("[admin]",
+ roleTree.searchPathFilterRoles("/api/alert/define" + SEPARATOR
+ "put"));
+ assertEquals("[admin]",
+ roleTree.searchPathFilterRoles("/api/alert/defines/import" +
SEPARATOR + "post"));
+ }
+
/**
* Only the {@code get} verb under {@code /api/apps} used to be listed, so
the monitoring
* template writes behind {@code AppController} reached any authenticated
caller, down to
@@ -200,6 +222,32 @@ class SurenessResourceRuleTest {
}
}
+ @Test
+ void deploymentCopiesRestrictAlertQueryAuthoring() throws IOException {
+ for (Path copy : deploymentCopies()) {
+ TirePathTree copyTree = new TirePathTree();
+ copyTree.buildTree(new LinkedHashSet<>(sectionOf(copy,
"resourceRole")));
+ assertEquals("[admin,user]",
+ copyTree.searchPathFilterRoles("/api/alert/define/1" +
SEPARATOR + "get"),
+ "alert definitions expose query expressions to guest in "
+ copy);
+ assertEquals("[admin,user]",
+ copyTree.searchPathFilterRoles("/api/alert/defines" +
SEPARATOR + "get"),
+ "alert definition lists expose query expressions to guest
in " + copy);
+ assertEquals("[admin]",
+
copyTree.searchPathFilterRoles("/api/alert/define/preview/sql" + SEPARATOR +
"get"),
+ "alert query preview is over-granted in " + copy);
+ assertEquals("[admin]",
+ copyTree.searchPathFilterRoles("/api/alert/define" +
SEPARATOR + "post"),
+ "alert query authoring is over-granted in " + copy);
+ assertEquals("[admin]",
+ copyTree.searchPathFilterRoles("/api/alert/define" +
SEPARATOR + "put"),
+ "alert query modification is over-granted in " + copy);
+ assertEquals("[admin]",
+ copyTree.searchPathFilterRoles("/api/alert/defines/import"
+ SEPARATOR + "post"),
+ "alert query import is over-granted in " + copy);
+ }
+ }
+
/**
* @return the {@code sureness.yml} copies shipped by the deployment
scripts
*/
diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
index a13913e44c..bc25d799ad 100644
--- a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
- /api/monitors/**===delete===[admin]
+ - /api/alert/define/preview/**===get===[admin]
+ - /api/alert/define/**===get===[admin,user]
+ - /api/alert/define===post===[admin]
+ - /api/alert/define===put===[admin]
+ - /api/alert/defines/**===get===[admin,user]
+ - /api/alert/defines/**===post===[admin]
- /api/alert/**===get===[admin,user,guest]
- /api/alert/**===post===[admin,user]
- /api/alert/**===put===[admin,user]
diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
index a13913e44c..bc25d799ad 100644
--- a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
- /api/monitors/**===delete===[admin]
+ - /api/alert/define/preview/**===get===[admin]
+ - /api/alert/define/**===get===[admin,user]
+ - /api/alert/define===post===[admin]
+ - /api/alert/define===put===[admin]
+ - /api/alert/defines/**===get===[admin,user]
+ - /api/alert/defines/**===post===[admin]
- /api/alert/**===get===[admin,user,guest]
- /api/alert/**===post===[admin,user]
- /api/alert/**===put===[admin,user]
diff --git
a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
index a13913e44c..bc25d799ad 100644
--- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
- /api/monitors/**===delete===[admin]
+ - /api/alert/define/preview/**===get===[admin]
+ - /api/alert/define/**===get===[admin,user]
+ - /api/alert/define===post===[admin]
+ - /api/alert/define===put===[admin]
+ - /api/alert/defines/**===get===[admin,user]
+ - /api/alert/defines/**===post===[admin]
- /api/alert/**===get===[admin,user,guest]
- /api/alert/**===post===[admin,user]
- /api/alert/**===put===[admin,user]
diff --git
a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
index 37174e7ecc..74a4ce4358 100644
--- a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
- /api/monitors/**===delete===[admin]
+ - /api/alert/define/preview/**===get===[admin]
+ - /api/alert/define/**===get===[admin,user]
+ - /api/alert/define===post===[admin]
+ - /api/alert/define===put===[admin]
+ - /api/alert/defines/**===get===[admin,user]
+ - /api/alert/defines/**===post===[admin]
- /api/alert/**===get===[admin,user,guest]
- /api/alert/**===post===[admin,user]
- /api/alert/**===put===[admin,user]
diff --git
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
index a13913e44c..bc25d799ad 100644
---
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
+++
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
- /api/monitors/**===delete===[admin]
+ - /api/alert/define/preview/**===get===[admin]
+ - /api/alert/define/**===get===[admin,user]
+ - /api/alert/define===post===[admin]
+ - /api/alert/define===put===[admin]
+ - /api/alert/defines/**===get===[admin,user]
+ - /api/alert/defines/**===post===[admin]
- /api/alert/**===get===[admin,user,guest]
- /api/alert/**===post===[admin,user]
- /api/alert/**===put===[admin,user]
diff --git a/script/sureness.yml b/script/sureness.yml
index 37174e7ecc..74a4ce4358 100644
--- a/script/sureness.yml
+++ b/script/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
- /api/monitors/**===post===[admin,user]
- /api/monitors/**===put===[admin,user]
- /api/monitors/**===delete===[admin]
+ - /api/alert/define/preview/**===get===[admin]
+ - /api/alert/define/**===get===[admin,user]
+ - /api/alert/define===post===[admin]
+ - /api/alert/define===put===[admin]
+ - /api/alert/defines/**===get===[admin,user]
+ - /api/alert/defines/**===post===[admin]
- /api/alert/**===get===[admin,user,guest]
- /api/alert/**===post===[admin,user]
- /api/alert/**===put===[admin,user]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]