@@ -48,60 +48,108 @@ func securePath() string {
4848 }
4949}
5050
51+ // isShellBuiltin checks if a command is a shell builtin.
52+ func isShellBuiltin (cmd string ) bool {
53+ shellBuiltins := map [string ]bool {
54+ "echo" : true , "test" : true , "[" : true , "[[" : true , "if" : true ,
55+ "then" : true , "else" : true , "fi" : true , "for" : true , "while" : true ,
56+ "do" : true , "done" : true , "case" : true , "esac" : true , "function" : true ,
57+ "return" : true , "break" : true , "continue" : true , "exit" : true ,
58+ "source" : true , "." : true , "eval" : true , "exec" : true , "export" : true ,
59+ "unset" : true , "shift" : true , "cd" : true , "pwd" : true , "read" : true ,
60+ "readonly" : true , "declare" : true , "typeset" : true , "local" : true ,
61+ "true" : true , "false" : true , "type" : true , "command" : true ,
62+ // Include sudo and doas since they're commonly used
63+ "sudo" : true , "doas" : true ,
64+ }
65+ return shellBuiltins [cmd ]
66+ }
67+
68+ // logCommandOutput logs command output for debugging.
69+ func logCommandOutput (checkName , command , stdout , stderr string , exitCode int , duration time.Duration ) {
70+ if quiet {
71+ return
72+ }
73+
74+ prefix := ""
75+ if checkName != "" {
76+ prefix = fmt .Sprintf ("[%s] " , checkName )
77+ }
78+
79+ // Log non-empty stdout
80+ if stdout != "" && strings .TrimSpace (stdout ) != "" {
81+ trimmed := strings .TrimSpace (stdout )
82+ if len (trimmed ) > maxLogLength {
83+ trimmed = trimmed [:maxLogLength ] + "..."
84+ }
85+ log .Printf ("[INFO] %sstdout (%d bytes): %s" , prefix , len (stdout ), trimmed )
86+ }
87+
88+ // Log non-empty stderr
89+ if stderr != "" && strings .TrimSpace (stderr ) != "" {
90+ trimmed := strings .TrimSpace (stderr )
91+ if len (trimmed ) > maxLogLength {
92+ trimmed = trimmed [:maxLogLength ] + "..."
93+ }
94+ log .Printf ("[INFO] %sstderr (%d bytes): %s" , prefix , len (stderr ), trimmed )
95+ }
96+
97+ if * debugMode {
98+ log .Printf ("[DEBUG] Command completed in %v (exit: %d, stdout: %d bytes, stderr: %d bytes): %s" ,
99+ duration , exitCode , len (stdout ), len (stderr ), command )
100+ }
101+ }
102+
103+ // validateCommand checks if a command exists in PATH.
104+ func validateCommand (checkName , command string ) * gitmdm.CommandOutput {
105+ if containsShellOperators (command ) {
106+ return nil // Commands with shell operators need shell interpretation
107+ }
108+
109+ commandParts := strings .Fields (command )
110+ if len (commandParts ) == 0 {
111+ return nil
112+ }
113+
114+ primaryCmd := commandParts [0 ]
115+ if isShellBuiltin (primaryCmd ) || strings .Contains (primaryCmd , "/" ) {
116+ return nil // Shell builtins and absolute paths don't need validation
117+ }
118+
119+ // Temporarily set PATH for LookPath
120+ oldPath := os .Getenv ("PATH" )
121+ if err := os .Setenv ("PATH" , securePath ()); err != nil {
122+ log .Printf ("[WARN] Failed to set PATH for command check: %v" , err )
123+ }
124+ _ , lookupErr := exec .LookPath (primaryCmd )
125+ if err := os .Setenv ("PATH" , oldPath ); err != nil {
126+ log .Printf ("[WARN] Failed to restore PATH: %v" , err )
127+ }
128+
129+ if lookupErr != nil {
130+ if * debugMode {
131+ log .Printf ("[DEBUG] Command '%s' not found in PATH for check '%s', skipping" , primaryCmd , checkName )
132+ }
133+ return & gitmdm.CommandOutput {
134+ Command : command ,
135+ Stdout : "" ,
136+ Stderr : fmt .Sprintf ("Skipped: %s not found" , primaryCmd ),
137+ ExitCode : - 2 , // Special exit code for skipped
138+ }
139+ }
140+ return nil
141+ }
142+
51143// executeCommandWithPipes executes a command and captures stdout/stderr separately.
52144// SECURITY: Commands come from checks.yaml which must be controlled by the system admin.
53145// We set a minimal secure PATH to prevent PATH-based attacks.
54146// The agent runs with user privileges only.
55147func (* Agent ) executeCommandWithPipes (ctx context.Context , checkName , command string ) gitmdm.CommandOutput {
56148 start := time .Now ()
57149
58- // Skip PATH checking if the command contains shell operators
59- // These commands need shell interpretation and can't be validated simply
60- if ! containsShellOperators (command ) {
61- // Extract the primary command (first word) to check if it exists
62- commandParts := strings .Fields (command )
63- if len (commandParts ) > 0 {
64- primaryCmd := commandParts [0 ]
65-
66- // Check if this is a shell builtin or special command
67- shellBuiltins := map [string ]bool {
68- "echo" : true , "test" : true , "[" : true , "[[" : true , "if" : true ,
69- "then" : true , "else" : true , "fi" : true , "for" : true , "while" : true ,
70- "do" : true , "done" : true , "case" : true , "esac" : true , "function" : true ,
71- "return" : true , "break" : true , "continue" : true , "exit" : true ,
72- "source" : true , "." : true , "eval" : true , "exec" : true , "export" : true ,
73- "unset" : true , "shift" : true , "cd" : true , "pwd" : true , "read" : true ,
74- "readonly" : true , "declare" : true , "typeset" : true , "local" : true ,
75- "true" : true , "false" : true , "type" : true , "command" : true ,
76- // Include sudo and doas since they're commonly used
77- "sudo" : true , "doas" : true ,
78- }
79-
80- // If it's not a shell builtin and not a path, check if the command exists
81- if ! shellBuiltins [primaryCmd ] && ! strings .Contains (primaryCmd , "/" ) {
82- // Temporarily set PATH for LookPath
83- oldPath := os .Getenv ("PATH" )
84- if err := os .Setenv ("PATH" , securePath ()); err != nil {
85- log .Printf ("[WARN] Failed to set PATH for command check: %v" , err )
86- }
87- _ , lookupErr := exec .LookPath (primaryCmd )
88- if err := os .Setenv ("PATH" , oldPath ); err != nil {
89- log .Printf ("[WARN] Failed to restore PATH: %v" , err )
90- }
91-
92- if lookupErr != nil {
93- if * debug {
94- log .Printf ("[DEBUG] Command '%s' not found in PATH for check '%s', skipping" , primaryCmd , checkName )
95- }
96- return gitmdm.CommandOutput {
97- Command : command ,
98- Stdout : "" ,
99- Stderr : fmt .Sprintf ("Skipped: %s not found" , primaryCmd ),
100- ExitCode : - 2 , // Special exit code for skipped
101- }
102- }
103- }
104- }
150+ // Validate command exists
151+ if result := validateCommand (checkName , command ); result != nil {
152+ return * result
105153 }
106154
107155 // Use longer timeout for software update checks (they contact remote servers)
@@ -113,7 +161,7 @@ func (*Agent) executeCommandWithPipes(ctx context.Context, checkName, command st
113161 ctx , cancel := context .WithTimeout (ctx , timeout )
114162 defer cancel ()
115163
116- if * debug {
164+ if * debugMode {
117165 log .Printf ("[DEBUG] Executing command: %s" , command )
118166 }
119167
@@ -131,7 +179,7 @@ func (*Agent) executeCommandWithPipes(ctx context.Context, checkName, command st
131179
132180 // Set a secure, minimal PATH for the subprocess
133181 securePath := securePath ()
134- if * debug {
182+ if * debugMode {
135183 log .Printf ("[DEBUG] Using secure PATH for %s: %s" , runtime .GOOS , securePath )
136184 }
137185 cmd .Env = append (os .Environ (), "PATH=" + securePath )
@@ -181,34 +229,8 @@ func (*Agent) executeCommandWithPipes(ctx context.Context, checkName, command st
181229 }
182230 }
183231
184- // Log stdout/stderr for debugging with check name
185- prefix := ""
186- if checkName != "" {
187- prefix = fmt .Sprintf ("[%s] " , checkName )
188- }
189-
190- // Only log non-empty outputs (unless in quiet mode)
191- if ! quiet {
192- if stdout != "" && strings .TrimSpace (stdout ) != "" {
193- trimmed := strings .TrimSpace (stdout )
194- if len (trimmed ) > maxLogLength {
195- trimmed = trimmed [:maxLogLength ] + "..."
196- }
197- log .Printf ("[INFO] %sstdout (%d bytes): %s" , prefix , len (stdout ), trimmed )
198- }
199- if stderr != "" && strings .TrimSpace (stderr ) != "" {
200- trimmed := strings .TrimSpace (stderr )
201- if len (trimmed ) > maxLogLength {
202- trimmed = trimmed [:maxLogLength ] + "..."
203- }
204- log .Printf ("[INFO] %sstderr (%d bytes): %s" , prefix , len (stderr ), trimmed )
205- }
206- }
207-
208- if * debug {
209- log .Printf ("[DEBUG] Command completed in %v (exit: %d, stdout: %d bytes, stderr: %d bytes): %s" ,
210- duration , exitCode , len (stdout ), len (stderr ), command )
211- }
232+ // Log command output
233+ logCommandOutput (checkName , command , stdout , stderr , exitCode , duration )
212234
213235 return gitmdm.CommandOutput {
214236 Command : command ,
0 commit comments