1 | /* Vuls - Vulnerability Scanner |
||
2 | Copyright (C) 2016 Future Corporation , Japan. |
||
3 | |||
4 | This program is free software: you can redistribute it and/or modify |
||
5 | it under the terms of the GNU General Public License as published by |
||
6 | the Free Software Foundation, either version 3 of the License, or |
||
7 | (at your option) any later version. |
||
8 | |||
9 | This program is distributed in the hope that it will be useful, |
||
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
12 | GNU General Public License for more details. |
||
13 | |||
14 | You should have received a copy of the GNU General Public License |
||
15 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
||
16 | */ |
||
17 | |||
18 | package scan |
||
19 | |||
20 | import ( |
||
21 | "bytes" |
||
22 | "crypto/x509" |
||
23 | "encoding/pem" |
||
24 | "fmt" |
||
25 | "io/ioutil" |
||
26 | "net" |
||
27 | "os" |
||
28 | ex "os/exec" |
||
29 | "path/filepath" |
||
30 | "strings" |
||
31 | "syscall" |
||
32 | "time" |
||
33 | |||
34 | "golang.org/x/crypto/ssh" |
||
35 | "golang.org/x/crypto/ssh/agent" |
||
36 | "golang.org/x/xerrors" |
||
37 | |||
38 | "github.com/cenkalti/backoff" |
||
39 | conf "github.com/future-architect/vuls/config" |
||
40 | "github.com/future-architect/vuls/util" |
||
41 | homedir "github.com/mitchellh/go-homedir" |
||
42 | "github.com/sirupsen/logrus" |
||
43 | ) |
||
44 | |||
45 | type execResult struct { |
||
46 | Servername string |
||
47 | Container conf.Container |
||
48 | Host string |
||
49 | Port string |
||
50 | Cmd string |
||
51 | Stdout string |
||
52 | Stderr string |
||
53 | ExitStatus int |
||
54 | Error error |
||
55 | } |
||
56 | |||
57 | func (s execResult) String() string { |
||
58 | sname := "" |
||
59 | if s.Container.ContainerID == "" { |
||
60 | sname = s.Servername |
||
61 | } else { |
||
62 | sname = s.Container.Name + "@" + s.Servername |
||
63 | } |
||
64 | |||
65 | return fmt.Sprintf( |
||
66 | "execResult: servername: %s\n cmd: %s\n exitstatus: %d\n stdout: %s\n stderr: %s\n err: %s", |
||
67 | sname, s.Cmd, s.ExitStatus, s.Stdout, s.Stderr, s.Error) |
||
68 | } |
||
69 | |||
70 | func (s execResult) isSuccess(expectedStatusCodes ...int) bool { |
||
71 | if len(expectedStatusCodes) == 0 { |
||
72 | return s.ExitStatus == 0 |
||
73 | } |
||
74 | for _, code := range expectedStatusCodes { |
||
75 | if code == s.ExitStatus { |
||
76 | return true |
||
77 | } |
||
78 | } |
||
79 | if s.Error != nil { |
||
80 | return false |
||
81 | } |
||
82 | return false |
||
83 | } |
||
84 | |||
85 | // sudo is Const value for sudo mode |
||
86 | const sudo = true |
||
87 | |||
88 | // noSudo is Const value for normal user mode |
||
89 | const noSudo = false |
||
90 | |||
91 | // Issue commands to the target servers in parallel via SSH or local execution. If execution fails, the server will be excluded from the target server list(servers) and added to the error server list(errServers). |
||
92 | func parallelExec(fn func(osTypeInterface) error, timeoutSec ...int) { |
||
93 | resChan := make(chan osTypeInterface, len(servers)) |
||
94 | defer close(resChan) |
||
95 | |||
96 | for _, s := range servers { |
||
97 | go func(s osTypeInterface) { |
||
98 | defer func() { |
||
99 | if p := recover(); p != nil { |
||
100 | util.Log.Debugf("Panic: %s on %s", |
||
101 | p, s.getServerInfo().GetServerName()) |
||
102 | } |
||
103 | }() |
||
104 | if err := fn(s); err != nil { |
||
105 | s.setErrs([]error{err}) |
||
106 | resChan <- s |
||
107 | } else { |
||
108 | resChan <- s |
||
109 | } |
||
110 | }(s) |
||
111 | } |
||
112 | |||
113 | var timeout int |
||
114 | if len(timeoutSec) == 0 { |
||
115 | timeout = 10 * 60 |
||
116 | } else { |
||
117 | timeout = timeoutSec[0] |
||
118 | } |
||
119 | |||
120 | var successes []osTypeInterface |
||
121 | isTimedout := false |
||
122 | for i := 0; i < len(servers); i++ { |
||
123 | select { |
||
124 | case s := <-resChan: |
||
125 | if len(s.getErrs()) == 0 { |
||
126 | successes = append(successes, s) |
||
127 | } else { |
||
128 | util.Log.Errorf("Error on %s, err: %+v", |
||
129 | s.getServerInfo().GetServerName(), s.getErrs()) |
||
130 | errServers = append(errServers, s) |
||
131 | } |
||
132 | case <-time.After(time.Duration(timeout) * time.Second): |
||
133 | isTimedout = true |
||
134 | } |
||
135 | } |
||
136 | |||
137 | if isTimedout { |
||
138 | // set timed out error and append to errServers |
||
139 | for _, s := range servers { |
||
140 | name := s.getServerInfo().GetServerName() |
||
141 | found := false |
||
142 | for _, ss := range successes { |
||
143 | if name == ss.getServerInfo().GetServerName() { |
||
144 | found = true |
||
145 | break |
||
146 | } |
||
147 | } |
||
148 | if !found { |
||
149 | err := xerrors.Errorf("Timed out: %s", |
||
150 | s.getServerInfo().GetServerName()) |
||
151 | util.Log.Errorf("%+v", err) |
||
152 | s.setErrs([]error{err}) |
||
153 | errServers = append(errServers, s) |
||
154 | } |
||
155 | } |
||
156 | } |
||
157 | servers = successes |
||
158 | return |
||
159 | } |
||
160 | |||
161 | func exec(c conf.ServerInfo, cmd string, sudo bool, log ...*logrus.Entry) (result execResult) { |
||
162 | logger := getSSHLogger(log...) |
||
163 | logger.Debugf("Executing... %s", strings.Replace(cmd, "\n", "", -1)) |
||
164 | |||
165 | if isLocalExec(c.Port, c.Host) { |
||
166 | result = localExec(c, cmd, sudo) |
||
167 | } else if conf.Conf.SSHNative { |
||
168 | result = sshExecNative(c, cmd, sudo) |
||
169 | } else { |
||
170 | result = sshExecExternal(c, cmd, sudo) |
||
171 | } |
||
172 | |||
173 | logger.Debug(result) |
||
174 | return |
||
175 | } |
||
176 | |||
177 | func isLocalExec(port, host string) bool { |
||
178 | return port == "local" && (host == "127.0.0.1" || host == "localhost") |
||
179 | } |
||
180 | |||
181 | func localExec(c conf.ServerInfo, cmdstr string, sudo bool) (result execResult) { |
||
182 | cmdstr = decorateCmd(c, cmdstr, sudo) |
||
183 | var cmd *ex.Cmd |
||
184 | switch c.Distro.Family { |
||
185 | // case conf.FreeBSD, conf.Alpine, conf.Debian: |
||
186 | // cmd = ex.Command("/bin/sh", "-c", cmdstr) |
||
187 | default: |
||
188 | cmd = ex.Command("/bin/sh", "-c", cmdstr) |
||
189 | } |
||
190 | var stdoutBuf, stderrBuf bytes.Buffer |
||
191 | cmd.Stdout = &stdoutBuf |
||
192 | cmd.Stderr = &stderrBuf |
||
193 | |||
194 | if err := cmd.Run(); err != nil { |
||
195 | result.Error = err |
||
196 | if exitError, ok := err.(*ex.ExitError); ok { |
||
197 | waitStatus := exitError.Sys().(syscall.WaitStatus) |
||
198 | result.ExitStatus = waitStatus.ExitStatus() |
||
199 | } else { |
||
200 | result.ExitStatus = 999 |
||
201 | } |
||
202 | } else { |
||
203 | result.ExitStatus = 0 |
||
204 | } |
||
205 | |||
206 | result.Stdout = stdoutBuf.String() |
||
207 | result.Stderr = stderrBuf.String() |
||
208 | result.Cmd = strings.Replace(cmdstr, "\n", "", -1) |
||
209 | return |
||
210 | } |
||
211 | |||
212 | func sshExecNative(c conf.ServerInfo, cmd string, sudo bool) (result execResult) { |
||
213 | result.Servername = c.ServerName |
||
214 | result.Container = c.Container |
||
215 | result.Host = c.Host |
||
216 | result.Port = c.Port |
||
217 | |||
218 | var client *ssh.Client |
||
219 | var err error |
||
220 | if client, err = sshConnect(c); err != nil { |
||
221 | result.Error = err |
||
222 | result.ExitStatus = 999 |
||
223 | return |
||
224 | } |
||
225 | defer client.Close() |
||
226 | |||
227 | var session *ssh.Session |
||
228 | if session, err = client.NewSession(); err != nil { |
||
229 | result.Error = xerrors.Errorf( |
||
0 ignored issues
–
show
introduced
by
![]() |
|||
230 | "Failed to create a new session. servername: %s, err: %w", |
||
231 | c.ServerName, err) |
||
232 | result.ExitStatus = 999 |
||
233 | return |
||
234 | } |
||
235 | defer session.Close() |
||
236 | |||
237 | // http://blog.ralch.com/tutorial/golang-ssh-connection/ |
||
238 | modes := ssh.TerminalModes{ |
||
239 | ssh.ECHO: 0, // disable echoing |
||
240 | ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud |
||
241 | ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud |
||
242 | } |
||
243 | if err = session.RequestPty("xterm", 400, 1000, modes); err != nil { |
||
244 | result.Error = xerrors.Errorf( |
||
0 ignored issues
–
show
|
|||
245 | "Failed to request for pseudo terminal. servername: %s, err: %w", |
||
246 | c.ServerName, err) |
||
247 | result.ExitStatus = 999 |
||
248 | return |
||
249 | } |
||
250 | |||
251 | var stdoutBuf, stderrBuf bytes.Buffer |
||
252 | session.Stdout = &stdoutBuf |
||
253 | session.Stderr = &stderrBuf |
||
254 | |||
255 | cmd = decorateCmd(c, cmd, sudo) |
||
256 | if err := session.Run(cmd); err != nil { |
||
257 | if exitErr, ok := err.(*ssh.ExitError); ok { |
||
258 | result.ExitStatus = exitErr.ExitStatus() |
||
259 | } else { |
||
260 | result.ExitStatus = 999 |
||
261 | } |
||
262 | } else { |
||
263 | result.ExitStatus = 0 |
||
264 | } |
||
265 | |||
266 | result.Stdout = stdoutBuf.String() |
||
267 | result.Stderr = stderrBuf.String() |
||
268 | result.Cmd = strings.Replace(cmd, "\n", "", -1) |
||
269 | return |
||
270 | } |
||
271 | |||
272 | func sshExecExternal(c conf.ServerInfo, cmd string, sudo bool) (result execResult) { |
||
273 | sshBinaryPath, err := ex.LookPath("ssh") |
||
274 | if err != nil { |
||
275 | return sshExecNative(c, cmd, sudo) |
||
276 | } |
||
277 | |||
278 | defaultSSHArgs := []string{"-tt"} |
||
279 | |||
280 | if !conf.Conf.SSHConfig { |
||
281 | home, err := homedir.Dir() |
||
282 | if err != nil { |
||
283 | msg := fmt.Sprintf("Failed to get HOME directory: %s", err) |
||
284 | result.Stderr = msg |
||
285 | result.ExitStatus = 997 |
||
286 | return |
||
287 | } |
||
288 | controlPath := filepath.Join(home, ".vuls", `controlmaster-%r-`+c.ServerName+`.%p`) |
||
289 | |||
290 | defaultSSHArgs = append(defaultSSHArgs, |
||
291 | "-o", "StrictHostKeyChecking=yes", |
||
292 | "-o", "LogLevel=quiet", |
||
293 | "-o", "ConnectionAttempts=3", |
||
294 | "-o", "ConnectTimeout=10", |
||
295 | "-o", "ControlMaster=auto", |
||
296 | "-o", fmt.Sprintf("ControlPath=%s", controlPath), |
||
297 | "-o", "Controlpersist=10m", |
||
298 | ) |
||
299 | } |
||
300 | |||
301 | if conf.Conf.Vvv { |
||
302 | defaultSSHArgs = append(defaultSSHArgs, "-vvv") |
||
303 | } |
||
304 | |||
305 | args := append(defaultSSHArgs, fmt.Sprintf("%s@%s", c.User, c.Host)) |
||
306 | args = append(args, "-p", c.Port) |
||
307 | if 0 < len(c.KeyPath) { |
||
308 | args = append(args, "-i", c.KeyPath) |
||
309 | args = append(args, "-o", "PasswordAuthentication=no") |
||
310 | } |
||
311 | |||
312 | cmd = decorateCmd(c, cmd, sudo) |
||
313 | cmd = fmt.Sprintf("stty cols 1000; %s", cmd) |
||
314 | |||
315 | args = append(args, cmd) |
||
316 | execCmd := ex.Command(sshBinaryPath, args...) |
||
317 | |||
318 | var stdoutBuf, stderrBuf bytes.Buffer |
||
319 | execCmd.Stdout = &stdoutBuf |
||
320 | execCmd.Stderr = &stderrBuf |
||
321 | if err := execCmd.Run(); err != nil { |
||
322 | if e, ok := err.(*ex.ExitError); ok { |
||
323 | if s, ok := e.Sys().(syscall.WaitStatus); ok { |
||
324 | result.ExitStatus = s.ExitStatus() |
||
325 | } else { |
||
326 | result.ExitStatus = 998 |
||
327 | } |
||
328 | } else { |
||
329 | result.ExitStatus = 999 |
||
330 | } |
||
331 | } else { |
||
332 | result.ExitStatus = 0 |
||
333 | } |
||
334 | |||
335 | result.Stdout = stdoutBuf.String() |
||
336 | result.Stderr = stderrBuf.String() |
||
337 | result.Servername = c.ServerName |
||
338 | result.Container = c.Container |
||
339 | result.Host = c.Host |
||
340 | result.Port = c.Port |
||
341 | result.Cmd = fmt.Sprintf("%s %s", sshBinaryPath, strings.Join(args, " ")) |
||
342 | return |
||
343 | } |
||
344 | |||
345 | func getSSHLogger(log ...*logrus.Entry) *logrus.Entry { |
||
346 | if len(log) == 0 { |
||
347 | return util.NewCustomLogger(conf.ServerInfo{}) |
||
348 | } |
||
349 | return log[0] |
||
350 | } |
||
351 | |||
352 | func dockerShell(family string) string { |
||
353 | switch family { |
||
354 | // case conf.Alpine, conf.Debian: |
||
355 | // return "/bin/sh" |
||
356 | default: |
||
357 | // return "/bin/bash" |
||
358 | return "/bin/sh" |
||
359 | } |
||
360 | } |
||
361 | |||
362 | func decorateCmd(c conf.ServerInfo, cmd string, sudo bool) string { |
||
363 | if sudo && c.User != "root" && !c.IsContainer() { |
||
364 | cmd = fmt.Sprintf("sudo -S %s", cmd) |
||
365 | } |
||
366 | |||
367 | // If you are using pipe and you want to detect preprocessing errors, remove comment out |
||
368 | // switch c.Distro.Family { |
||
369 | // case "FreeBSD", "ubuntu", "debian", "raspbian": |
||
370 | // default: |
||
371 | // // set pipefail option. Bash only |
||
372 | // // http://unix.stackexchange.com/questions/14270/get-exit-status-of-process-thats-piped-to-another |
||
373 | // cmd = fmt.Sprintf("set -o pipefail; %s", cmd) |
||
374 | // } |
||
375 | |||
376 | if c.IsContainer() { |
||
377 | switch c.ContainerType { |
||
378 | case "", "docker": |
||
379 | cmd = fmt.Sprintf(`docker exec --user 0 %s %s -c '%s'`, |
||
380 | c.Container.ContainerID, dockerShell(c.Distro.Family), cmd) |
||
381 | case "lxd": |
||
382 | // If the user belong to the "lxd" group, root privilege is not required. |
||
383 | cmd = fmt.Sprintf(`lxc exec %s -- %s -c '%s'`, |
||
384 | c.Container.Name, dockerShell(c.Distro.Family), cmd) |
||
385 | case "lxc": |
||
386 | cmd = fmt.Sprintf(`lxc-attach -n %s 2>/dev/null -- %s -c '%s'`, |
||
387 | c.Container.Name, dockerShell(c.Distro.Family), cmd) |
||
388 | // LXC required root privilege |
||
389 | if c.User != "root" { |
||
390 | cmd = fmt.Sprintf("sudo -S %s", cmd) |
||
391 | } |
||
392 | } |
||
393 | } |
||
394 | // cmd = fmt.Sprintf("set -x; %s", cmd) |
||
395 | return cmd |
||
396 | } |
||
397 | |||
398 | func getAgentAuth() (auth ssh.AuthMethod, ok bool) { |
||
399 | if sock := os.Getenv("SSH_AUTH_SOCK"); 0 < len(sock) { |
||
400 | if agconn, err := net.Dial("unix", sock); err == nil { |
||
401 | ag := agent.NewClient(agconn) |
||
402 | auth = ssh.PublicKeysCallback(ag.Signers) |
||
403 | ok = true |
||
404 | } |
||
405 | } |
||
406 | return |
||
407 | } |
||
408 | |||
409 | func tryAgentConnect(c conf.ServerInfo) *ssh.Client { |
||
410 | if auth, ok := getAgentAuth(); ok { |
||
411 | config := &ssh.ClientConfig{ |
||
412 | User: c.User, |
||
413 | Auth: []ssh.AuthMethod{auth}, |
||
414 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), |
||
415 | } |
||
416 | client, _ := ssh.Dial("tcp", c.Host+":"+c.Port, config) |
||
417 | return client |
||
418 | } |
||
419 | return nil |
||
420 | } |
||
421 | |||
422 | func sshConnect(c conf.ServerInfo) (client *ssh.Client, err error) { |
||
423 | if client = tryAgentConnect(c); client != nil { |
||
424 | return client, nil |
||
425 | } |
||
426 | |||
427 | var auths = []ssh.AuthMethod{} |
||
428 | if auths, err = addKeyAuth(auths, c.KeyPath, c.KeyPassword); err != nil { |
||
429 | return nil, err |
||
430 | } |
||
431 | |||
432 | // http://blog.ralch.com/tutorial/golang-ssh-connection/ |
||
433 | config := &ssh.ClientConfig{ |
||
434 | User: c.User, |
||
435 | Auth: auths, |
||
436 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), |
||
437 | } |
||
438 | |||
439 | notifyFunc := func(e error, t time.Duration) { |
||
440 | logger := getSSHLogger() |
||
441 | logger.Debugf("Failed to Dial to %s, err: %s, Retrying in %s...", |
||
442 | c.ServerName, e, t) |
||
443 | } |
||
444 | err = backoff.RetryNotify(func() error { |
||
445 | if client, err = ssh.Dial("tcp", c.Host+":"+c.Port, config); err != nil { |
||
446 | return err |
||
447 | } |
||
448 | return nil |
||
449 | }, backoff.NewExponentialBackOff(), notifyFunc) |
||
450 | |||
451 | return |
||
452 | } |
||
453 | |||
454 | // https://github.com/rapidloop/rtop/blob/ba5b35e964135d50e0babedf0bd69b2fcb5dbcb4/src/sshhelper.go#L100 |
||
455 | func addKeyAuth(auths []ssh.AuthMethod, keypath string, keypassword string) ([]ssh.AuthMethod, error) { |
||
456 | if len(keypath) == 0 { |
||
457 | return auths, nil |
||
458 | } |
||
459 | |||
460 | // read the file |
||
461 | pemBytes, err := ioutil.ReadFile(keypath) |
||
462 | if err != nil { |
||
463 | return auths, err |
||
464 | } |
||
465 | |||
466 | // get first pem block |
||
467 | block, _ := pem.Decode(pemBytes) |
||
468 | if block == nil { |
||
469 | return auths, xerrors.Errorf("no key found in %s", keypath) |
||
470 | } |
||
471 | |||
472 | // handle plain and encrypted keyfiles |
||
473 | if x509.IsEncryptedPEMBlock(block) { |
||
474 | block.Bytes, err = x509.DecryptPEMBlock(block, []byte(keypassword)) |
||
475 | if err != nil { |
||
476 | return auths, err |
||
477 | } |
||
478 | key, err := parsePemBlock(block) |
||
479 | if err != nil { |
||
480 | return auths, err |
||
481 | } |
||
482 | signer, err := ssh.NewSignerFromKey(key) |
||
483 | if err != nil { |
||
484 | return auths, err |
||
485 | } |
||
486 | return append(auths, ssh.PublicKeys(signer)), nil |
||
487 | } |
||
488 | |||
489 | signer, err := ssh.ParsePrivateKey(pemBytes) |
||
490 | if err != nil { |
||
491 | return auths, err |
||
492 | } |
||
493 | return append(auths, ssh.PublicKeys(signer)), nil |
||
494 | } |
||
495 | |||
496 | // ref golang.org/x/crypto/ssh/keys.go#ParseRawPrivateKey. |
||
497 | func parsePemBlock(block *pem.Block) (interface{}, error) { |
||
498 | switch block.Type { |
||
499 | case "RSA PRIVATE KEY": |
||
500 | return x509.ParsePKCS1PrivateKey(block.Bytes) |
||
501 | case "EC PRIVATE KEY": |
||
502 | return x509.ParseECPrivateKey(block.Bytes) |
||
503 | case "DSA PRIVATE KEY": |
||
504 | return ssh.ParseDSAPrivateKey(block.Bytes) |
||
505 | default: |
||
506 | return nil, xerrors.Errorf("Unsupported key type %q", block.Type) |
||
507 | } |
||
508 | } |
||
509 |