This is an automated email from the ASF dual-hosted git repository.
dockerzhang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/inlong.git
The following commit(s) were added to refs/heads/master by this push:
new 4e56b8df2a [INLONG-12149][Improve] [Manager] Add command whitelist
validation in Manager (#12155)
4e56b8df2a is described below
commit 4e56b8df2a9abbca10f8404d3e88c7d9ec9b2ddb
Author: spirit_sx <[email protected]>
AuthorDate: Wed Jul 15 14:19:41 2026 +0800
[INLONG-12149][Improve] [Manager] Add command whitelist validation in
Manager (#12155)
---
.../manager/common/consts/InlongConstants.java | 14 +
.../inlong/manager/common/enums/ErrorCodeEnum.java | 2 +
.../service/module/ModuleCommandAccessors.java | 52 +++
.../service/module/ModuleCommandValidator.java | 411 +++++++++++++++++++++
.../manager/service/module/ModuleServiceImpl.java | 18 +-
.../service/module/ModuleCommandValidatorTest.java | 203 ++++++++++
.../service/module/ModuleServiceImplTest.java | 180 +++++++++
7 files changed, 879 insertions(+), 1 deletion(-)
diff --git
a/inlong-manager/manager-common/src/main/java/org/apache/inlong/manager/common/consts/InlongConstants.java
b/inlong-manager/manager-common/src/main/java/org/apache/inlong/manager/common/consts/InlongConstants.java
index e7cc03602b..ca22ebbd82 100644
---
a/inlong-manager/manager-common/src/main/java/org/apache/inlong/manager/common/consts/InlongConstants.java
+++
b/inlong-manager/manager-common/src/main/java/org/apache/inlong/manager/common/consts/InlongConstants.java
@@ -74,10 +74,24 @@ public class InlongConstants {
public static final String AMPERSAND = "&";
+ public static final String ASTERISK = "*";
+
public static final String NEW_LINE = "\n";
public static final String REGEX_WHITESPACE = "\\s";
+ public static final char SINGLE_QUOTE_CHAR = '\'';
+
+ public static final char DOUBLE_QUOTE_CHAR = '"';
+
+ public static final char NEW_LINE_CHAR = '\n';
+
+ public static final char CARRIAGE_RETURN_CHAR = '\r';
+
+ public static final char SEMICOLON_CHAR = ';';
+
+ public static final char PIPE_CHAR = '|';
+
public static final String ADMIN_USER = "admin";
public static final Integer AFFECTED_ONE_ROW = 1;
diff --git
a/inlong-manager/manager-common/src/main/java/org/apache/inlong/manager/common/enums/ErrorCodeEnum.java
b/inlong-manager/manager-common/src/main/java/org/apache/inlong/manager/common/enums/ErrorCodeEnum.java
index 9364fda8e7..34068816a9 100644
---
a/inlong-manager/manager-common/src/main/java/org/apache/inlong/manager/common/enums/ErrorCodeEnum.java
+++
b/inlong-manager/manager-common/src/main/java/org/apache/inlong/manager/common/enums/ErrorCodeEnum.java
@@ -169,6 +169,8 @@ public enum ErrorCodeEnum {
MODULE_NOT_FOUND(6001, "Module does not exist/no operation authority"),
MODULE_INFO_INCORRECT(6002, "Module info was incorrect"),
+ MODULE_COMMAND_NOT_IN_WHITELIST(6003,
+ "Module command not in whitelist: %s"),
PACKAGE_NOT_FOUND(7001, "Package does not exist/no operation authority"),
PACKAGE_INFO_INCORRECT(7002, "Package info was incorrect"),
diff --git
a/inlong-manager/manager-service/src/main/java/org/apache/inlong/manager/service/module/ModuleCommandAccessors.java
b/inlong-manager/manager-service/src/main/java/org/apache/inlong/manager/service/module/ModuleCommandAccessors.java
new file mode 100644
index 0000000000..8cb181eed0
--- /dev/null
+++
b/inlong-manager/manager-service/src/main/java/org/apache/inlong/manager/service/module/ModuleCommandAccessors.java
@@ -0,0 +1,52 @@
+/*
+ * 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.inlong.manager.service.module;
+
+import org.apache.inlong.manager.pojo.module.ModuleDTO;
+import org.apache.inlong.manager.pojo.module.ModuleRequest;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Flattens a carrier ({@link ModuleRequest} / {@link ModuleDTO} / ...) into a
plain list
+ * of raw command strings.
+ */
+public final class ModuleCommandAccessors {
+
+ private ModuleCommandAccessors() {
+ }
+
+ public static List<String> of(ModuleRequest request) {
+ return Arrays.asList(
+ request.getStartCommand(),
+ request.getStopCommand(),
+ request.getCheckCommand(),
+ request.getInstallCommand(),
+ request.getUninstallCommand());
+ }
+
+ public static List<String> of(ModuleDTO dto) {
+ return Arrays.asList(
+ dto.getStartCommand(),
+ dto.getStopCommand(),
+ dto.getCheckCommand(),
+ dto.getInstallCommand(),
+ dto.getUninstallCommand());
+ }
+}
diff --git
a/inlong-manager/manager-service/src/main/java/org/apache/inlong/manager/service/module/ModuleCommandValidator.java
b/inlong-manager/manager-service/src/main/java/org/apache/inlong/manager/service/module/ModuleCommandValidator.java
new file mode 100644
index 0000000000..3094205a2b
--- /dev/null
+++
b/inlong-manager/manager-service/src/main/java/org/apache/inlong/manager/service/module/ModuleCommandValidator.java
@@ -0,0 +1,411 @@
+/*
+ * 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.inlong.manager.service.module;
+
+import org.apache.inlong.manager.common.consts.InlongConstants;
+import org.apache.inlong.manager.common.enums.ErrorCodeEnum;
+import org.apache.inlong.manager.common.exceptions.BusinessException;
+import org.apache.inlong.manager.pojo.module.ModuleDTO;
+import org.apache.inlong.manager.pojo.module.ModuleRequest;
+
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Manager-side command whitelist validator. Validates that command names
(argv[0]) in module
+ * commands (start/stop/check/install/uninstall) are in the allowed whitelist.
This catches
+ * misconfiguration at save time rather than waiting until the agent tries to
execute.
+ *
+ * <p>Unlike the agent-side validator, this class does <b>not</b> perform
path-under-root
+ * checks, which are filesystem-dependent and only meaningful at the agent
runtime.
+ *
+ * <p>Whitelist baseline:
+ * <pre>{@code
+ * cd, sh, bash, ps, grep, awk, kill, rm, mkdir, cp, mv, ln,
+ * tar, unzip, chmod, chown, echo, cat, test, [, true, false, java
+ * }</pre>
+ *
+ * <p>Extend via {@code module.command.extraWhitelist} (comma-separated) in
application
+ * properties, e.g.: {@code module.command.extraWhitelist=python3,nohup,curl}
+ */
+@Component
+public class ModuleCommandValidator {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(ModuleCommandValidator.class);
+
+ /** Baseline argv[0] whitelist — kept in sync with agent-side
ModuleCommandValidator. */
+ private static final Set<String> BASELINE_WHITELIST = buildImmutableSet(
+ "cd", "sh", "bash", "ps", "grep", "awk", "kill", "rm", "mkdir",
"cp", "mv", "ln",
+ "tar", "unzip", "chmod", "chown", "echo", "cat", "test", "[",
"true", "false", "java");
+
+ /**
+ * Metacharacter substring blacklist — kept in sync with agent-side
+ * {@code ModuleCommandValidator.META_CHAR_BLACKLIST}. These characters
indicate shell
+ * injection attempts (command substitution, chaining, redirection,
escaping). Unlike
+ * path-under-root checks, metacharacter validation is
filesystem-independent and
+ * belongs on both the Manager and Agent side.
+ */
+ private static final String[] META_CHAR_BLACKLIST = new String[]{
+ "`", "$(", "${", "&&", "||", ">>", ">", "<", "\\", "\u0000",
+ /*
+ * glob wildcards — Agent uses ProcessBuilder without shell, so
'*' and '?' are NOT expanded. Passing e.g.
+ * 'rm *.log' as argv[1] would delete a literal file named "*.log"
(or silently no-op), which is far more
+ * dangerous than an explicit error. Reject them here with a clear
hint.
+ */
+ "*", "?"
+ };
+
+ /** Extra whitelist entries from Spring config, comma-separated. */
+ @Value("${module.command.extraWhitelist:}")
+ private String extraWhitelist;
+
+ /** Whitelist enforcement mode: STRICT (block), WARN (log only), OFF
(skip). */
+ @Value("${module.command.whitelistMode:WARN}")
+ @Getter
+ private WhitelistMode whitelistModeConfig;
+
+ /**
+ * Whitelist enforcement mode.
+ *
+ * <ul>
+ * <li>{@code STRICT} — save + update both block non-whitelisted
commands.</li>
+ * <li>{@code WARN} — save blocks, update only logs a warning (for
+ * gradually tightening existing modules).</li>
+ * <li>{@code OFF} — all validation is skipped.</li>
+ * </ul>
+ */
+ public enum WhitelistMode {
+
+ STRICT, WARN, OFF;
+ }
+
+ /** Lazily-computed effective whitelist (baseline + config extras). */
+ private volatile Set<String> effectiveWhitelist;
+
+ private static Set<String> buildImmutableSet(String... items) {
+ Set<String> s = new HashSet<>(items.length * 2);
+ Collections.addAll(s, items);
+ return Collections.unmodifiableSet(s);
+ }
+
+ /**
+ * Get the effective (merged) whitelist — baseline + config extras.
Lazy-init and
+ * cached after the first call.
+ */
+ private Set<String> getEffectiveWhitelist() {
+ if (effectiveWhitelist != null) {
+ return effectiveWhitelist;
+ }
+ synchronized (this) {
+ if (effectiveWhitelist != null) {
+ return effectiveWhitelist;
+ }
+ Set<String> merged = new LinkedHashSet<>(BASELINE_WHITELIST);
+ if (StringUtils.isNotBlank(extraWhitelist)) {
+ for (String item :
extraWhitelist.split(InlongConstants.COMMA)) {
+ String trimmed = item.trim();
+ if (trimmed.isEmpty()) {
+ continue;
+ }
+ merged.add(trimmed);
+ }
+ }
+ effectiveWhitelist = Collections.unmodifiableSet(new
HashSet<>(merged));
+ LOGGER.info("ModuleCommandValidator initialized: whitelist={}",
+ new java.util.TreeSet<>(effectiveWhitelist));
+ }
+ return effectiveWhitelist;
+ }
+
+ /**
+ * Save-time entry point. Runs the full command whitelist check and throws
+ * {@link BusinessException} on the first violation; otherwise returns
{@code true}.
+ * Callers do <b>not</b> need to check the mode or wrap the exception
themselves.
+ *
+ * <p>In {@link WhitelistMode#OFF} the check is skipped and {@code true}
is returned.
+ * In {@link WhitelistMode#STRICT} and {@link WhitelistMode#WARN} the save
path is
+ * always blocking (WARN only softens the update path).
+ */
+ public void validateOnSave(ModuleRequest request) {
+ if (whitelistModeConfig == WhitelistMode.OFF || request == null) {
+ return;
+ }
+ String violation =
findFirstViolation(ModuleCommandAccessors.of(request));
+ if (violation != null) {
+ throw new
BusinessException(ErrorCodeEnum.MODULE_COMMAND_NOT_IN_WHITELIST,
+
String.format(ErrorCodeEnum.MODULE_COMMAND_NOT_IN_WHITELIST.getMessage(),
+ violation));
+ }
+ }
+
+ /**
+ * Update-time entry point. Only validates command fields that actually
changed
+ * between {@code oldDto} (existing DB row) and the incoming {@code
request}.
+ *
+ * <ul>
+ * <li>{@link WhitelistMode#OFF} — skip entirely, return {@code
true}.</li>
+ * <li>{@link WhitelistMode#STRICT} — throw {@link BusinessException} on
any
+ * violation.</li>
+ * <li>{@link WhitelistMode#WARN} — log a warning and let the update
proceed.</li>
+ * </ul>
+ *
+ * @param oldDto ModuleDTO parsed from stored extParams; {@code null}
means "treat
+ * every field as changed" (behaves like save-time)
+ * @param request the incoming update request
+ */
+ public void validateOnUpdate(ModuleDTO oldDto, ModuleRequest request) {
+ if (whitelistModeConfig == WhitelistMode.OFF || request == null) {
+ return;
+ }
+ List<String> toCheck = changedCommands(oldDto, request);
+ String violation = findFirstViolation(toCheck);
+ if (violation == null) {
+ return;
+ }
+ if (whitelistModeConfig == WhitelistMode.STRICT) {
+ throw new
BusinessException(ErrorCodeEnum.MODULE_COMMAND_NOT_IN_WHITELIST,
+
String.format(ErrorCodeEnum.MODULE_COMMAND_NOT_IN_WHITELIST.getMessage(),
+ violation));
+ }
+ // WARN mode: do not block the update, only surface the reason in logs.
+ LOGGER.warn("ModuleCommandValidator: non-whitelisted command in update
"
+ + "(mode=WARN, not blocking): moduleId={}, {}",
request.getId(),
+ violation);
+ }
+
+ /**
+ * Compute the sub-list of new-side commands whose value differs from the
stored
+ * one, positionally (both accessors return the five command fields in the
same
+ * order). A {@code null} {@code oldDto} means "no baseline", so every
field is
+ * treated as changed — which mirrors save-time semantics.
+ */
+ private static List<String> changedCommands(ModuleDTO oldDto,
ModuleRequest request) {
+ List<String> newCmds = ModuleCommandAccessors.of(request);
+ if (oldDto == null) {
+ return newCmds;
+ }
+ List<String> oldCmds = ModuleCommandAccessors.of(oldDto);
+ List<String> changed = new ArrayList<>(newCmds.size());
+ for (int i = 0; i < newCmds.size(); i++) {
+ String oldRaw = i < oldCmds.size() ? oldCmds.get(i) : null;
+ if (!Objects.equals(oldRaw, newCmds.get(i))) {
+ changed.add(newCmds.get(i));
+ }
+ }
+ return changed;
+ }
+
+ /**
+ * Iterate the given commands and return {@code "<reason> in [<raw>]"} for
the
+ * first offending one, or {@code null} if all pass. The raw command is
echoed
+ * verbatim so operators can immediately locate which configured line
triggered
+ * the rejection.
+ */
+ private String findFirstViolation(List<String> commands) {
+ for (String raw : commands) {
+ String reason = validate(raw);
+ if (StringUtils.isBlank(reason)) {
+ continue;
+ }
+ return reason + " in [" + raw + "]";
+ }
+ return null;
+ }
+
+ /**
+ * Scan the raw command string for any metacharacter substring from
+ * {@link #META_CHAR_BLACKLIST}. Returns the first hit, or {@code null} if
clean.
+ * (Same logic as the agent-side validator.)
+ */
+ private static String firstMetaCharHit(String raw) {
+ for (String meta : META_CHAR_BLACKLIST) {
+ if (raw.contains(meta)) {
+ return meta;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Validate a raw command string. Returns a descriptive error string if
invalid, or
+ * {@code null} if valid. The error prefix indicates the rejection reason:
+ * {@code DISALLOWED_META_CHAR}, {@code FORBIDDEN_SH_C_FLAG}, or just the
offending
+ * command name (NOT_IN_WHITELIST).
+ * Package-private for the unit tests.
+ */
+ String validate(String rawCmd) {
+ if (StringUtils.isBlank(rawCmd)) {
+ return null;
+ }
+
+ // metacharacter blacklist (whole string, before splitting).
+ // Offending chars are wrapped with [] so callers can unambiguously
see the boundary,
+ // e.g. "DISALLOWED_META_CHAR: [`]" rather than a bare backtick which
is easy to miss.
+ String metaHit = firstMetaCharHit(rawCmd);
+ if (StringUtils.isNotBlank(metaHit)) {
+ if (InlongConstants.ASTERISK.equals(metaHit) ||
InlongConstants.QUESTION_MARK.equals(metaHit)) {
+ return "DISALLOWED_META_CHAR: [" + metaHit + "]"
+ + " — glob wildcards are not supported (Agent runs
commands without a shell,"
+ + " so [*] and [?] will NOT be expanded); please
specify explicit file paths";
+ }
+ return "DISALLOWED_META_CHAR: [" + metaHit + "]";
+ }
+ if (rawCmd.indexOf(InlongConstants.NEW_LINE_CHAR) >= 0
+ || rawCmd.indexOf(InlongConstants.CARRIAGE_RETURN_CHAR) >= 0) {
+ return "DISALLOWED_META_CHAR: line-break";
+ }
+
+ Set<String> whitelist = getEffectiveWhitelist();
+
+ // Split by ';' into sub-commands (quote-aware), then by '|' into pipe
segments
+ List<String> segments = splitTopLevel(rawCmd,
InlongConstants.SEMICOLON_CHAR);
+ if (segments == null) {
+ return "DISALLOWED_META_CHAR: unterminated quote";
+ }
+ for (String seg : segments) {
+ if (StringUtils.isBlank(seg)) {
+ continue;
+ }
+ List<String> pipeSegs = splitTopLevel(seg,
InlongConstants.PIPE_CHAR);
+ if (pipeSegs == null) {
+ return "DISALLOWED_META_CHAR: unterminated quote in pipe
segment";
+ }
+ for (String pipeSeg : pipeSegs) {
+ if (StringUtils.isBlank(pipeSeg)) {
+ continue;
+ }
+ List<String> argv = tokenize(pipeSeg);
+ if (argv.isEmpty()) {
+ continue;
+ }
+ String cmdName = argv.get(0);
+
+ // argv[0] whitelist
+ if (!whitelist.contains(cmdName)) {
+ return cmdName;
+ }
+
+ // forbid sh/bash -c flag (inline script execution)
+ if (("sh".equals(cmdName) || "bash".equals(cmdName)) &&
argv.size() > 1) {
+ for (int i = 1; i < argv.size(); i++) {
+ if (argv.get(i).startsWith("-c")) {
+ return "FORBIDDEN_SH_C_FLAG: " + cmdName + " " +
argv.get(i);
+ }
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Quote-aware split of {@code raw} on the top-level {@code delim}
character. Characters
+ * inside a single-quoted or double-quoted region are treated as literals
and do not
+ * split the string. Returns {@code null} when the input has an
unterminated quote.
+ */
+ static List<String> splitTopLevel(String raw, char delim) {
+ List<String> out = new ArrayList<>();
+ if (StringUtils.isEmpty(raw)) {
+ return out;
+ }
+ StringBuilder cur = new StringBuilder();
+ // current quote char (0 if not in a quote)
+ char quote = 0;
+ for (int i = 0; i < raw.length(); i++) {
+ char c = raw.charAt(i);
+ if (quote != 0) {
+ cur.append(c);
+ if (c == quote) {
+ quote = 0;
+ }
+ continue;
+ }
+ if (c == InlongConstants.SINGLE_QUOTE_CHAR || c ==
InlongConstants.DOUBLE_QUOTE_CHAR) {
+ quote = c;
+ cur.append(c);
+ continue;
+ }
+ if (c == delim) {
+ out.add(cur.toString());
+ cur.setLength(0);
+ continue;
+ }
+ cur.append(c);
+ }
+ if (quote != 0) {
+ return null;
+ }
+ out.add(cur.toString());
+ return out;
+ }
+
+ /**
+ * Whitespace-based tokenizer that honours single/double quotes. Uses
{@code substring}
+ * to avoid per-character copying into a {@code StringBuilder}.
+ *
+ * <p>For a shell-command whitelist/blacklist validator, mixed
quoted/unquoted tokens
+ * (e.g. {@code abc"def"ghi}) are intentionally <b>not</b> supported — it
is safer
+ * that way. A quote character always starts a fresh token, and any
unquoted run
+ * terminates as soon as a quote character is encountered.
+ */
+ private static List<String> tokenize(String s) {
+ List<String> tokens = new ArrayList<>();
+ int i = 0;
+ int n = s.length();
+ while (i < n) {
+ while (i < n && Character.isWhitespace(s.charAt(i))) {
+ i++;
+ }
+ if (i >= n) {
+ break;
+ }
+ char c = s.charAt(i);
+ if (c == InlongConstants.SINGLE_QUOTE_CHAR || c ==
InlongConstants.DOUBLE_QUOTE_CHAR) {
+ int start = i + 1;
+ int end = s.indexOf(c, start);
+ if (end < 0) {
+ tokens.add(s.substring(start));
+ break;
+ }
+ tokens.add(s.substring(start, end));
+ i = end + 1;
+ } else {
+ int start = i;
+ while (i < n && !Character.isWhitespace(s.charAt(i))
+ && s.charAt(i) != InlongConstants.SINGLE_QUOTE_CHAR
+ && s.charAt(i) != InlongConstants.DOUBLE_QUOTE_CHAR) {
+ i++;
+ }
+ tokens.add(s.substring(start, i));
+ }
+ }
+ return tokens;
+ }
+}
diff --git
a/inlong-manager/manager-service/src/main/java/org/apache/inlong/manager/service/module/ModuleServiceImpl.java
b/inlong-manager/manager-service/src/main/java/org/apache/inlong/manager/service/module/ModuleServiceImpl.java
index 92579a2437..ff499d1e67 100644
---
a/inlong-manager/manager-service/src/main/java/org/apache/inlong/manager/service/module/ModuleServiceImpl.java
+++
b/inlong-manager/manager-service/src/main/java/org/apache/inlong/manager/service/module/ModuleServiceImpl.java
@@ -52,10 +52,17 @@ public class ModuleServiceImpl implements ModuleService {
private ModuleConfigEntityMapper moduleConfigEntityMapper;
@Autowired
private ObjectMapper objectMapper;
+ @Autowired
+ private ModuleCommandValidator commandValidator;
@Override
public Integer save(ModuleRequest request, String operator) {
LOGGER.info("begin to save module info: {}", request);
+
+ // Whitelist check — mode handling and exception wrapping live inside
the
+ // validator, so a violation surfaces as a BusinessException directly.
+ commandValidator.validateOnSave(request);
+
ModuleConfigEntity moduleConfigEntity =
CommonBeanUtils.copyProperties(request, ModuleConfigEntity::new);
try {
ModuleDTO dto = ModuleDTO.getFromRequest(request,
moduleConfigEntity.getExtParams(),
@@ -68,7 +75,8 @@ public class ModuleServiceImpl implements ModuleService {
}
moduleConfigEntity.setCreator(operator);
moduleConfigEntity.setModifier(operator);
- int id = moduleConfigEntityMapper.insert(moduleConfigEntity);
+ moduleConfigEntityMapper.insert(moduleConfigEntity);
+ int id = moduleConfigEntity.getId();
LOGGER.info("success to save module info: {}", request);
return id;
@@ -82,6 +90,14 @@ public class ModuleServiceImpl implements ModuleService {
throw new BusinessException(ErrorCodeEnum.MODULE_NOT_FOUND,
String.format("Module does not exist with id=%s",
request.getId()));
}
+
+ // Incremental whitelist check — the validator internally picks the
enforcement
+ // mode (STRICT throws, WARN logs, OFF skips) so no branching is
needed here.
+ ModuleDTO oldDto = null;
+ if (moduleConfigEntity.getExtParams() != null) {
+ oldDto = ModuleDTO.getFromJson(moduleConfigEntity.getExtParams());
+ }
+ commandValidator.validateOnUpdate(oldDto, request);
CommonBeanUtils.copyProperties(request, moduleConfigEntity, true);
try {
ModuleDTO dto = ModuleDTO.getFromRequest(request,
moduleConfigEntity.getExtParams(),
diff --git
a/inlong-manager/manager-service/src/test/java/org/apache/inlong/manager/service/module/ModuleCommandValidatorTest.java
b/inlong-manager/manager-service/src/test/java/org/apache/inlong/manager/service/module/ModuleCommandValidatorTest.java
new file mode 100644
index 0000000000..da862d280e
--- /dev/null
+++
b/inlong-manager/manager-service/src/test/java/org/apache/inlong/manager/service/module/ModuleCommandValidatorTest.java
@@ -0,0 +1,203 @@
+/*
+ * 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.inlong.manager.service.module;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+/**
+ * Pure unit tests for {@link ModuleCommandValidator} — no Spring context.
+ * Focuses on:
+ * 1. rule detection (meta-char / whitelist / sh -c);
+ * 2. exact error message format returned to the caller (which becomes the
+ * user-facing message once wrapped by {@code
ErrorCodeEnum.MODULE_COMMAND_NOT_IN_WHITELIST}).
+ */
+public class ModuleCommandValidatorTest {
+
+ private ModuleCommandValidator validator;
+
+ @BeforeEach
+ public void setUp() {
+ validator = new ModuleCommandValidator();
+ // Force STRICT so getMode() != OFF and validation actually happens.
+ ReflectionTestUtils.setField(validator, "whitelistModeConfig",
+ ModuleCommandValidator.WhitelistMode.STRICT);
+ ReflectionTestUtils.setField(validator, "extraWhitelist", "");
+ }
+
+ /* ---------------- happy path ---------------- */
+
+ @Test
+ public void validate_null_shouldReturnNull() {
+ Assertions.assertNull(validator.validate(null));
+ Assertions.assertNull(validator.validate(""));
+ Assertions.assertNull(validator.validate(" "));
+ }
+
+ @Test
+ public void validate_simpleWhitelistedCommand_shouldPass() {
+ Assertions.assertNull(validator.validate("ps -ef"));
+ Assertions.assertNull(validator.validate("cd /opt/inlong"));
+ Assertions.assertNull(validator.validate("mkdir -p /tmp/x"));
+ }
+
+ @Test
+ public void validate_pipeChainOfWhitelistedCommands_shouldPass() {
+ Assertions.assertNull(validator.validate("ps -ef | grep java | awk
'{print $2}'"));
+ }
+
+ @Test
+ public void validate_semicolonChainOfWhitelistedCommands_shouldPass() {
+ Assertions.assertNull(validator.validate("cd /opt; mkdir logs; echo
done"));
+ }
+
+ /* ---------------- P0: glob wildcards must be rejected with a helpful
hint ---------------- */
+
+ @Test
+ public void validate_starWildcard_shouldRejectWithGlobHint() {
+ String r = validator.validate("rm /opt/inlong/*.log");
+ Assertions.assertNotNull(r);
+ Assertions.assertTrue(r.startsWith("DISALLOWED_META_CHAR: [*]"),
+ "should start with DISALLOWED_META_CHAR: [*], got: " + r);
+ Assertions.assertTrue(r.contains("glob wildcards are not supported"),
+ "must explain wildcards are not supported, got: " + r);
+ Assertions.assertTrue(r.contains("will NOT be expanded"),
+ "must warn user the wildcard will not be expanded, got: " + r);
+ Assertions.assertTrue(r.contains("explicit file paths"),
+ "must suggest explicit file paths, got: " + r);
+ }
+
+ @Test
+ public void validate_questionMarkWildcard_shouldRejectWithGlobHint() {
+ String r = validator.validate("ls /opt/inlong/?.txt");
+ Assertions.assertNotNull(r);
+ Assertions.assertTrue(r.startsWith("DISALLOWED_META_CHAR: [?]"),
+ "should start with DISALLOWED_META_CHAR: [?], got: " + r);
+ Assertions.assertTrue(r.contains("glob wildcards are not supported"),
+ "must explain wildcards are not supported, got: " + r);
+ }
+
+ @Test
+ public void validate_starAsArgv_shouldRejectEvenIfArgvIsSingleChar() {
+ String r = validator.validate("rm *");
+ Assertions.assertNotNull(r);
+ Assertions.assertTrue(r.contains("*"), "must mention '*', got: " + r);
+ Assertions.assertTrue(r.contains("glob wildcards are not supported"),
+ "must include glob hint, got: " + r);
+ }
+
+ /* ---------------- other meta chars — reject but WITHOUT the glob hint
---------------- */
+
+ @Test
+ public void validate_backtick_shouldRejectWithoutGlobHint() {
+ String r = validator.validate("echo `whoami`");
+ Assertions.assertNotNull(r);
+ Assertions.assertTrue(r.startsWith("DISALLOWED_META_CHAR: [`]"), r);
+ Assertions.assertFalse(r.contains("glob wildcards"),
+ "non-glob meta char must NOT get the glob hint, got: " + r);
+ }
+
+ @Test
+ public void validate_dollarParen_shouldReject() {
+ String r = validator.validate("echo $(whoami)");
+ Assertions.assertNotNull(r);
+ Assertions.assertTrue(r.startsWith("DISALLOWED_META_CHAR: [$(]"), r);
+ }
+
+ @Test
+ public void validate_doubleAmp_shouldReject() {
+ String r = validator.validate("echo a && echo b");
+ Assertions.assertNotNull(r);
+ Assertions.assertTrue(r.startsWith("DISALLOWED_META_CHAR: [&&]"), r);
+ }
+
+ @Test
+ public void validate_redirect_shouldReject() {
+ String r = validator.validate("echo hi > /tmp/x");
+ Assertions.assertNotNull(r);
+ Assertions.assertTrue(r.startsWith("DISALLOWED_META_CHAR: [>]"), r);
+ }
+
+ @Test
+ public void validate_lineBreak_shouldReject() {
+ String r = validator.validate("echo a\necho b");
+ Assertions.assertNotNull(r);
+ Assertions.assertEquals("DISALLOWED_META_CHAR: line-break", r);
+ }
+
+ /* ---------------- argv[0] whitelist ---------------- */
+
+ @Test
+ public void validate_notInWhitelist_shouldReturnCmdName() {
+ String r = validator.validate("python3 script.py");
+ Assertions.assertEquals("python3", r,
+ "non-whitelisted argv[0] should be returned verbatim (no
prefix)");
+ }
+
+ @Test
+ public void validate_extraWhitelist_shouldExpandBaseline() {
+ ReflectionTestUtils.setField(validator, "extraWhitelist",
"python3,curl");
+ // reset lazy cache
+ ReflectionTestUtils.setField(validator, "effectiveWhitelist", null);
+ Assertions.assertNull(validator.validate("python3 script.py"));
+ Assertions.assertNull(validator.validate("curl http://x"));
+ }
+
+ /* ---------------- forbid sh -c / bash -c ---------------- */
+
+ @Test
+ public void validate_shDashC_shouldReject() {
+ String r = validator.validate("sh -c 'echo hi'");
+ Assertions.assertNotNull(r);
+ Assertions.assertTrue(r.startsWith("FORBIDDEN_SH_C_FLAG:"),
+ "should start with FORBIDDEN_SH_C_FLAG, got: " + r);
+ Assertions.assertTrue(r.contains("sh"), r);
+ }
+
+ @Test
+ public void validate_bashDashC_shouldReject() {
+ String r = validator.validate("bash -c 'ls'");
+ Assertions.assertNotNull(r);
+ Assertions.assertTrue(r.startsWith("FORBIDDEN_SH_C_FLAG:"), r);
+ Assertions.assertTrue(r.contains("bash"), r);
+ }
+
+ /* ---------------- tokenizer: mixed quoted/unquoted is intentionally NOT
supported ---------------- */
+
+ @Test
+ public void validate_mixedQuotedUnquoted_argv0IsUnquotedPrefix() {
+ // Under the tokenizer, a quote character always starts a fresh token
and any
+ // unquoted run terminates as soon as a quote character is encountered.
+ // So `python3"foo" bar` splits into: [python3, foo, bar] — argv[0] is
'python3',
+ // which is NOT in the baseline whitelist and must be rejected
verbatim.
+ String r = validator.validate("python3\"foo\" bar");
+ Assertions.assertEquals("python3", r,
+ "argv[0] should be the unquoted prefix 'python3', not the
concatenated 'python3foo'");
+ }
+
+ @Test
+ public void validate_mixedQuotedUnquoted_whitelistedPrefix_shouldPass() {
+ // `ps"aux"` splits into [ps, aux] — argv[0] 'ps' is whitelisted, so
this passes.
+ // (Note: under the old concat-tokenizer this would have been
argv[0]='psaux',
+ // which would have been rejected. The new stricter semantics are
intentional.)
+ Assertions.assertNull(validator.validate("ps\"aux\""));
+ }
+
+}
diff --git
a/inlong-manager/manager-service/src/test/java/org/apache/inlong/manager/service/module/ModuleServiceImplTest.java
b/inlong-manager/manager-service/src/test/java/org/apache/inlong/manager/service/module/ModuleServiceImplTest.java
new file mode 100644
index 0000000000..bd142c6fd3
--- /dev/null
+++
b/inlong-manager/manager-service/src/test/java/org/apache/inlong/manager/service/module/ModuleServiceImplTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.inlong.manager.service.module;
+
+import org.apache.inlong.manager.common.exceptions.BusinessException;
+import org.apache.inlong.manager.pojo.module.ModuleRequest;
+import org.apache.inlong.manager.service.ServiceBaseTest;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.util.ReflectionTestUtils;
+
+/**
+ * Integration test for {@link ModuleServiceImpl} — walks the real save() code
path
+ * that the {@code /api/module/save} endpoint invokes, and verifies that the
+ * {@link BusinessException#getMessage()} eventually surfaced to the API caller
+ * carries the expected user-facing error text (including the glob-wildcard
hint
+ * added for the P0 fix).
+ *
+ * <p>The wrapper format is defined by
+ * {@code ErrorCodeEnum.MODULE_COMMAND_NOT_IN_WHITELIST}: {@code "Module
command not in whitelist: '%s'"}.
+ */
+public class ModuleServiceImplTest extends ServiceBaseTest {
+
+ @Autowired
+ private ModuleService moduleService;
+
+ @Autowired
+ private ModuleCommandValidator commandValidator;
+
+ @BeforeEach
+ public void forceStrictMode() {
+ // Ensure the test is independent of application-*.properties: force
STRICT
+ // so the validator blocks rather than logs.
+ ReflectionTestUtils.setField(commandValidator, "whitelistModeConfig",
+ ModuleCommandValidator.WhitelistMode.STRICT);
+ }
+
+ private ModuleRequest baseRequest() {
+ ModuleRequest r = new ModuleRequest();
+ r.setName("m-" + System.nanoTime());
+ r.setType("AGENT");
+ r.setVersion("1.0.0");
+ r.setPackageId(1);
+ // sensible default: use only whitelisted commands
+ r.setStartCommand("echo start");
+ r.setStopCommand("echo stop");
+ r.setCheckCommand("echo check");
+ r.setInstallCommand("echo install");
+ r.setUninstallCommand("echo uninstall");
+ return r;
+ }
+
+ /* ---------- P0 focus: '*' rejected at save time with a helpful message
---------- */
+
+ @Test
+ public void save_startCommandContainsStar_apiCallerSeesGlobHint() {
+ ModuleRequest req = baseRequest();
+ req.setStartCommand("rm /opt/inlong/*.log");
+
+ BusinessException ex = Assertions.assertThrows(BusinessException.class,
+ () -> moduleService.save(req, "admin"));
+
+ String msg = ex.getMessage();
+ Assertions.assertNotNull(msg);
+ // ErrorCodeEnum wrapper
+ Assertions.assertTrue(msg.startsWith("Module command not in
whitelist:"),
+ "API caller should see the wrapped error, got: " + msg);
+ // pinpoint the offending command by echoing its raw text
+ Assertions.assertTrue(msg.contains("in [rm /opt/inlong/*.log]"),
+ "must echo the offending command verbatim, got: " + msg);
+ // pinpoint the offending char
+ Assertions.assertTrue(msg.contains("DISALLOWED_META_CHAR: [*]"),
+ "must tell the user which char triggered rejection, got: " +
msg);
+ // and — critically — WHY it is rejected
+ Assertions.assertTrue(msg.contains("glob wildcards are not supported"),
+ "must explain glob wildcards are not supported, got: " + msg);
+ Assertions.assertTrue(msg.contains("will NOT be expanded"),
+ "must warn the wildcard is not expanded, got: " + msg);
+ Assertions.assertTrue(msg.contains("explicit file paths"),
+ "must guide the user toward the workaround, got: " + msg);
+ }
+
+ @Test
+ public void
save_installCommandContainsQuestionMark_apiCallerSeesGlobHint() {
+ ModuleRequest req = baseRequest();
+ req.setInstallCommand("cp /pkg/a?.tar /opt/");
+
+ BusinessException ex = Assertions.assertThrows(BusinessException.class,
+ () -> moduleService.save(req, "admin"));
+
+ String msg = ex.getMessage();
+ Assertions.assertTrue(msg.startsWith("Module command not in
whitelist:"), msg);
+ Assertions.assertTrue(msg.contains("in [cp /pkg/a?.tar /opt/]"), msg);
+ Assertions.assertTrue(msg.contains("DISALLOWED_META_CHAR: [?]"), msg);
+ Assertions.assertTrue(msg.contains("glob wildcards are not
supported"), msg);
+ }
+
+ /* ---------- other meta chars: rejected, but WITHOUT the glob hint
---------- */
+
+ @Test
+ public void
save_backtickInCheckCommand_apiCallerSeesRejectionWithoutGlobHint() {
+ ModuleRequest req = baseRequest();
+ req.setCheckCommand("echo `whoami`");
+
+ BusinessException ex = Assertions.assertThrows(BusinessException.class,
+ () -> moduleService.save(req, "admin"));
+
+ String msg = ex.getMessage();
+ Assertions.assertTrue(msg.startsWith("Module command not in
whitelist:"), msg);
+ Assertions.assertTrue(msg.contains("in [echo `whoami`]"), msg);
+ Assertions.assertTrue(msg.contains("DISALLOWED_META_CHAR: [`]"), msg);
+ Assertions.assertFalse(msg.contains("glob wildcards"),
+ "non-glob meta char must NOT carry the glob hint, got: " +
msg);
+ }
+
+ @Test
+ public void save_shDashCInStopCommand_apiCallerSeesForbiddenShCFlag() {
+ ModuleRequest req = baseRequest();
+ req.setStopCommand("sh -c ls");
+
+ BusinessException ex = Assertions.assertThrows(BusinessException.class,
+ () -> moduleService.save(req, "admin"));
+
+ String msg = ex.getMessage();
+ Assertions.assertTrue(msg.startsWith("Module command not in
whitelist:"), msg);
+ Assertions.assertTrue(msg.contains("in [sh -c ls]"), msg);
+ Assertions.assertTrue(msg.contains("FORBIDDEN_SH_C_FLAG"), msg);
+ }
+
+ @Test
+ public void save_nonWhitelistedArgv0_apiCallerSeesCmdName() {
+ ModuleRequest req = baseRequest();
+ req.setUninstallCommand("python3 uninstall.py");
+
+ BusinessException ex = Assertions.assertThrows(BusinessException.class,
+ () -> moduleService.save(req, "admin"));
+
+ String msg = ex.getMessage();
+ Assertions.assertTrue(msg.startsWith("Module command not in
whitelist:"), msg);
+ Assertions.assertTrue(msg.contains("in [python3 uninstall.py]"), msg);
+ Assertions.assertTrue(msg.contains("python3"),
+ "must tell the user the offending command name, got: " + msg);
+ }
+
+ /* ---------- OFF mode: validation is completely skipped ---------- */
+
+ @Test
+ public void save_modeOff_shouldNotBlockEvenIfCommandsAreDirty() {
+ ReflectionTestUtils.setField(commandValidator, "whitelistModeConfig",
+ ModuleCommandValidator.WhitelistMode.OFF);
+ ModuleRequest req = baseRequest();
+ req.setStartCommand("rm /opt/*.log");
+ // In OFF mode, save must not throw due to validator. It may still
throw for
+ // other reasons; here we only assert the validator is NOT the throw
source.
+ try {
+ moduleService.save(req, "admin");
+ } catch (BusinessException e) {
+
Assertions.assertFalse(String.valueOf(e.getMessage()).startsWith("Module
command not in whitelist:"),
+ "OFF mode must not surface the whitelist wrapper, got: " +
e.getMessage());
+ }
+ }
+}