go.mondoo.com/cnquery@v0.0.0-20231005093811-59568235f6ea/_motor/providers/ssh/provider.go (about)

     1  // Copyright (c) Mondoo, Inc.
     2  // SPDX-License-Identifier: BUSL-1.1
     3  
     4  package ssh
     5  
     6  import (
     7  	"fmt"
     8  	"io"
     9  	"net"
    10  	"os"
    11  	"strings"
    12  
    13  	"github.com/cockroachdb/errors"
    14  	rawsftp "github.com/pkg/sftp"
    15  	"github.com/rs/zerolog/log"
    16  	"github.com/spf13/afero"
    17  	"go.mondoo.com/cnquery/motor/providers"
    18  	os_provider "go.mondoo.com/cnquery/motor/providers/os"
    19  	"go.mondoo.com/cnquery/motor/providers/os/cmd"
    20  	"go.mondoo.com/cnquery/motor/providers/ssh/cat"
    21  	"go.mondoo.com/cnquery/motor/providers/ssh/scp"
    22  	"go.mondoo.com/cnquery/motor/providers/ssh/sftp"
    23  	"golang.org/x/crypto/ssh"
    24  )
    25  
    26  var (
    27  	_ providers.Instance                  = (*Provider)(nil)
    28  	_ providers.PlatformIdentifier        = (*Provider)(nil)
    29  	_ os_provider.OperatingSystemProvider = (*Provider)(nil)
    30  )
    31  
    32  func New(pCfg *providers.Config) (*Provider, error) {
    33  	host := pCfg.GetHost()
    34  	// ipv6 addresses w/o the surrounding [] will eventually error
    35  	// so check whether we have an ipv6 address by parsing it (the
    36  	// parsing will fail if the string DOES have the []s) and adding
    37  	// the []s
    38  	ip := net.ParseIP(host)
    39  	if ip != nil && ip.To4() == nil {
    40  		pCfg.Host = fmt.Sprintf("[%s]", host)
    41  	}
    42  
    43  	pCfg = ReadSSHConfig(pCfg)
    44  
    45  	// ensure all required configs are set
    46  	err := VerifyConfig(pCfg)
    47  	if err != nil {
    48  		return nil, err
    49  	}
    50  
    51  	activateScp := false
    52  	if os.Getenv("MONDOO_SSH_SCP") == "on" || pCfg.Options["ssh_scp"] == "on" {
    53  		activateScp = true
    54  	}
    55  
    56  	if pCfg.Insecure {
    57  		log.Debug().Msg("user allowed insecure ssh connection")
    58  	}
    59  
    60  	t := &Provider{
    61  		ConnectionConfig: pCfg,
    62  		UseScpFilesystem: activateScp,
    63  		kind:             pCfg.Kind,
    64  		runtime:          pCfg.Runtime,
    65  	}
    66  	err = t.Connect()
    67  	if err != nil {
    68  		return nil, err
    69  	}
    70  
    71  	var s cmd.Wrapper
    72  	// check uid of user and disable sudo if uid is 0
    73  	if pCfg.Sudo != nil && pCfg.Sudo.Active {
    74  		// the id command may not be available, eg. if ssh is used with windows
    75  		out, _ := t.RunCommand("id -u")
    76  		stdout, _ := io.ReadAll(out.Stdout)
    77  		// just check for the explicit positive case, otherwise just activate sudo
    78  		// we check sudo in VerifyConnection
    79  		if string(stdout) != "0" {
    80  			// configure sudo
    81  			log.Debug().Msg("activated sudo for ssh connection")
    82  			s = cmd.NewSudo()
    83  		}
    84  	}
    85  	t.Sudo = s
    86  
    87  	// verify connection
    88  	vErr := t.VerifyConnection()
    89  	// NOTE: for now we do not enforce connection verification to ensure we cover edge-cases
    90  	// TODO: in following minor version bumps, we want to enforce this behavior to ensure proper scans
    91  	if vErr != nil {
    92  		log.Warn().Err(vErr).Send()
    93  	}
    94  
    95  	return t, nil
    96  }
    97  
    98  type Provider struct {
    99  	ConnectionConfig *providers.Config
   100  	SSHClient        *ssh.Client
   101  	fs               afero.Fs
   102  	UseScpFilesystem bool
   103  	HostKey          ssh.PublicKey
   104  	Sudo             cmd.Wrapper
   105  	kind             providers.Kind
   106  	runtime          string
   107  	serverVersion    string
   108  }
   109  
   110  func (p *Provider) Connect() error {
   111  	cc := p.ConnectionConfig
   112  
   113  	// we always want to ensure we use the default port if nothing was specified
   114  	if cc.Port == 0 {
   115  		cc.Port = 22
   116  	}
   117  
   118  	// load known hosts and track the fingerprint of the ssh server for later identification
   119  	knownHostsCallback, err := KnownHostsCallback()
   120  	if err != nil {
   121  		return errors.Wrap(err, "could not read hostkey file")
   122  	}
   123  
   124  	var hostkey ssh.PublicKey
   125  	hostkeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
   126  		// store the hostkey for later identification
   127  		hostkey = key
   128  
   129  		// ignore hostkey check if the user provided an insecure flag
   130  		if cc.Insecure {
   131  			return nil
   132  		}
   133  
   134  		// knownhost.New returns a ssh.CertChecker which does not work with all ssh.HostKey types
   135  		// especially the newer edcsa keys (ssh.curve25519sha256) are not well supported.
   136  		// https://github.com/golang/crypto/blob/master/ssh/knownhosts/knownhosts.go#L417-L436
   137  		// creates the CertChecker which requires an instance of Certificate
   138  		// https://github.com/golang/crypto/blob/master/ssh/certs.go#L326-L348
   139  		// https://github.com/golang/crypto/blob/master/ssh/keys.go#L271-L283
   140  		// therefore it is best to skip the checking for now since it forces users to set the insecure flag otherwise
   141  		// TODO: implement custom host-key checking for normal public keys as well
   142  		_, ok := key.(*ssh.Certificate)
   143  		if !ok {
   144  			log.Debug().Msg("skip hostkey check the hostkey since the algo is not supported yet")
   145  			return nil
   146  		}
   147  
   148  		err := knownHostsCallback(hostname, remote, key)
   149  		if err != nil {
   150  			log.Debug().Err(err).Str("hostname", hostname).Str("ip", remote.String()).Msg("check known host")
   151  		}
   152  		return err
   153  	}
   154  
   155  	// establish connection
   156  	conn, _, err := establishClientConnection(cc, hostkeyCallback)
   157  	if err != nil {
   158  		log.Debug().Err(err).Str("provider", "ssh").Str("host", cc.Host).Int32("port", cc.Port).Bool("insecure", cc.Insecure).Msg("could not establish ssh session")
   159  		if strings.ContainsAny(cc.Host, "[]") {
   160  			log.Info().Str("host", cc.Host).Int32("port", cc.Port).Msg("ensure proper []s when combining IPv6 with port numbers")
   161  		}
   162  		return err
   163  	}
   164  	p.SSHClient = conn
   165  	p.HostKey = hostkey
   166  	p.serverVersion = string(conn.ServerVersion())
   167  	log.Debug().Str("provider", "ssh").Str("host", cc.Host).Int32("port", cc.Port).Str("server", p.serverVersion).Msg("ssh session established")
   168  	return nil
   169  }
   170  
   171  func (p *Provider) VerifyConnection() error {
   172  	var out *os_provider.Command
   173  	var err error
   174  
   175  	if p.Sudo != nil {
   176  		// Wrap sudo command, to see proper error messages. We set /dev/null to disable stdin
   177  		command := "sh -c '" + p.Sudo.Build("echo 'hi'") + " < /dev/null'"
   178  		out, err = p.runRawCommand(command)
   179  	} else {
   180  		out, err = p.runRawCommand("echo 'hi'")
   181  		if err != nil {
   182  			return err
   183  		}
   184  	}
   185  
   186  	if out.ExitStatus == 0 {
   187  		return nil
   188  	}
   189  
   190  	stderr, _ := io.ReadAll(out.Stderr)
   191  	errMsg := string(stderr)
   192  
   193  	// sample messages are:
   194  	// sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper
   195  	// sudo: a password is required
   196  	switch {
   197  	case strings.Contains(errMsg, "not found"):
   198  		return errors.New("sudo command is missing on target")
   199  	case strings.Contains(errMsg, "a password is required"):
   200  		return errors.New("could not establish connection: sudo password is not supported yet, configure password-less sudo")
   201  	default:
   202  		return errors.New("could not establish connection: " + errMsg)
   203  	}
   204  }
   205  
   206  // Reconnect closes a possible current connection and re-establishes a new connection
   207  func (p *Provider) Reconnect() error {
   208  	p.Close()
   209  	return p.Connect()
   210  }
   211  
   212  func (p *Provider) runRawCommand(command string) (*os_provider.Command, error) {
   213  	log.Debug().Str("command", command).Str("provider", "ssh").Msg("run command")
   214  	c := &Command{SSHProvider: p}
   215  	return c.Exec(command)
   216  }
   217  
   218  func (p *Provider) RunCommand(command string) (*os_provider.Command, error) {
   219  	if p.Sudo != nil {
   220  		command = p.Sudo.Build(command)
   221  	}
   222  	return p.runRawCommand(command)
   223  }
   224  
   225  func (p *Provider) FS() afero.Fs {
   226  	// if we cached an instance already, return it
   227  	if p.fs != nil {
   228  		return p.fs
   229  	}
   230  
   231  	// log the used ssh filesystem backend
   232  	defer func() {
   233  		log.Debug().Str("file-transfer", p.fs.Name()).Msg("initialized ssh filesystem")
   234  	}()
   235  
   236  	//// detect cisco network gear, they returns something like SSH-2.0-Cisco-1.25
   237  	//// NOTE: we need to understand why this happens
   238  	//if strings.Contains(strings.ToLower(t.serverVersion), "cisco") {
   239  	//	log.Debug().Msg("detected cisco device, deactivate file system support")
   240  	//	t.fs = &fsutil.NoFs{}
   241  	//	return t.fs
   242  	//}
   243  
   244  	// if any privilege elevation is used, we have no other chance as to use command-based file transfer
   245  	if p.Sudo != nil {
   246  		p.fs = cat.New(p)
   247  		return p.fs
   248  	}
   249  
   250  	// we always try to use sftp first (if scp is not user-enforced)
   251  	// and we also fallback to scp if sftp does not work
   252  	if !p.UseScpFilesystem {
   253  		fs, err := sftp.New(p, p.SSHClient)
   254  		if err != nil {
   255  			log.Info().Msg("use scp instead of sftp")
   256  			// enable fallback
   257  			p.UseScpFilesystem = true
   258  		} else {
   259  			p.fs = fs
   260  			return p.fs
   261  		}
   262  	}
   263  
   264  	if p.UseScpFilesystem {
   265  		p.fs = scp.NewFs(p, p.SSHClient)
   266  		return p.fs
   267  	}
   268  
   269  	// always fallback to catfs, slow but it works
   270  	p.fs = cat.New(p)
   271  	return p.fs
   272  }
   273  
   274  func (p *Provider) FileInfo(path string) (os_provider.FileInfoDetails, error) {
   275  	fs := p.FS()
   276  	afs := &afero.Afero{Fs: fs}
   277  	stat, err := afs.Stat(path)
   278  	if err != nil {
   279  		return os_provider.FileInfoDetails{}, err
   280  	}
   281  
   282  	uid := int64(-1)
   283  	gid := int64(-1)
   284  
   285  	if p.Sudo != nil || p.UseScpFilesystem {
   286  		if stat, ok := stat.Sys().(*os_provider.FileInfo); ok {
   287  			uid = int64(stat.Uid)
   288  			gid = int64(stat.Gid)
   289  		}
   290  	} else {
   291  		if stat, ok := stat.Sys().(*rawsftp.FileStat); ok {
   292  			uid = int64(stat.UID)
   293  			gid = int64(stat.GID)
   294  		}
   295  	}
   296  	mode := stat.Mode()
   297  
   298  	return os_provider.FileInfoDetails{
   299  		Mode: os_provider.FileModeDetails{mode},
   300  		Size: stat.Size(),
   301  		Uid:  uid,
   302  		Gid:  gid,
   303  	}, nil
   304  }
   305  
   306  func (p *Provider) Close() {
   307  	if p.SSHClient != nil {
   308  		p.SSHClient.Close()
   309  	}
   310  }
   311  
   312  func (p *Provider) Capabilities() providers.Capabilities {
   313  	return providers.Capabilities{
   314  		providers.Capability_RunCommand,
   315  		providers.Capability_File,
   316  	}
   317  }
   318  
   319  func (p *Provider) Kind() providers.Kind {
   320  	return p.kind
   321  }
   322  
   323  func (p *Provider) Runtime() string {
   324  	return p.runtime
   325  }
   326  
   327  func (p *Provider) PlatformIdDetectors() []providers.PlatformIdDetector {
   328  	return []providers.PlatformIdDetector{
   329  		providers.TransportPlatformIdentifierDetector,
   330  		providers.HostnameDetector,
   331  		providers.CloudDetector,
   332  	}
   333  }
   334  
   335  func (p *Provider) Identifier() (string, error) {
   336  	return PlatformIdentifier(p.HostKey), nil
   337  }
   338  
   339  func PlatformIdentifier(publicKey ssh.PublicKey) string {
   340  	fingerprint := ssh.FingerprintSHA256(publicKey)
   341  	fingerprint = strings.Replace(fingerprint, ":", "-", 1)
   342  	identifier := "//platformid.api.mondoo.app/runtime/ssh/hostkey/" + fingerprint
   343  	return identifier
   344  }