diff --git a/packaging/snclient.ini b/packaging/snclient.ini index fb707299..0d67a19f 100644 --- a/packaging/snclient.ini +++ b/packaging/snclient.ini @@ -349,7 +349,7 @@ allow arguments = false ; we will allow clients to specify nasty (as defined in nasty characters) characters in arguments. allow nasty characters = false -; Script root folder - Root path where all scripts are contained (You can not upload/download scripts outside this folder). +; Script root folder - Root path where all scripts are contained. Used in external script wrappers. script root = ${scripts} ; Load all scripts in a given folder - Load all (${script path}/*) scripts in a given directory and use them as commands. diff --git a/pkg/snclient/snclient.go b/pkg/snclient/snclient.go index a0e02366..313622bc 100644 --- a/pkg/snclient/snclient.go +++ b/pkg/snclient/snclient.go @@ -1096,8 +1096,8 @@ func (snc *Agent) restartWatcherCb(restartCb func()) { } func fixReturnCodes(output, stderr *string, exitCode *int64, timeout int64, cmd *exec.Cmd, procState *os.ProcessState, err error) { - log.Tracef("stdout: %s", *output) - log.Tracef("stderr: %s", *stderr) + log.Tracef("stdout: \n%s", *output) + log.Tracef("stderr: \n%s", *stderr) log.Tracef("exitCode: %d", *exitCode) log.Tracef("timeout: %d", timeout) log.Tracef("error: %#v", err) @@ -1367,9 +1367,9 @@ func (snc *Agent) MakeCmd(ctx context.Context, command string) (*exec.Cmd, error case err != nil: return nil, err case cmd.Args != nil: - log.Tracef("command object:\n path: %s\n args: %v\n dir: %s\n workingDirectory: %s\n SysProcAttr: %v\n", cmd.Path, cmd.Args, cmd.Dir, workingDirectory, cmd.SysProcAttr) + log.Tracef("command object:\n path: %s\n args: %v\n dir: %s\n workingDirectory: %s\n SysProcAttr: %#v\n", cmd.Path, cmd.Args, cmd.Dir, workingDirectory, cmd.SysProcAttr) default: - log.Tracef("command object:\n path: %s\n args: (none)\n dir: %s\n workingDirectory: %s\n SysProcAttr: %v\n", cmd.Path, cmd.Dir, workingDirectory, cmd.SysProcAttr) + log.Tracef("command object:\n path: %s\n args: (none)\n dir: %s\n workingDirectory: %s\n SysProcAttr: %#v\n", cmd.Path, cmd.Dir, workingDirectory, cmd.SysProcAttr) } return cmd, err diff --git a/pkg/snclient/snclient_windows.go b/pkg/snclient/snclient_windows.go index 56b96298..46bc366d 100644 --- a/pkg/snclient/snclient_windows.go +++ b/pkg/snclient/snclient_windows.go @@ -262,6 +262,8 @@ func (snc *Agent) makeCmd(ctx context.Context, command string) (*exec.Cmd, error cmd := execCommandContext(ctx, "powershell", env) cmd.SysProcAttr.CmdLine = fmt.Sprintf(`%s -command %s; exit($LASTEXITCODE)`, POWERSHELL, command) + log.Tracef("cmd.SysProcAttr.CmdLine: %s", cmd.SysProcAttr.CmdLine) + return cmd, nil // command does not exist @@ -283,17 +285,34 @@ func (snc *Agent) makeCmd(ctx context.Context, command string) (*exec.Cmd, error strings.Join(cmdArgs, " "), ) + log.Tracef("cmd.SysProcAttr.CmdLine: %s", cmd.SysProcAttr.CmdLine) + return cmd, nil // powershell files case isPsFile(cmdName): - for i, ca := range cmdArgs { - if strings.ContainsAny(ca, " \t") { - cmdArgs[i] = `'` + ca + `'` - } + // parse the command one more time, this time adding the shelltoken.KeepQuoutes option + cmdName, cmdArgs, _, err = snc.shellParse(command, shelltoken.SplitKeepQuotes) + if err != nil { + return nil, err + } + + cmdArgsModified := make([]string, 0, len(cmdArgs)) + for _, cmdArg := range cmdArgs { + // parsed arguments might include double quoutes. + // cmd.SysProcAttr.CmdLine is set so that the arguments are found within double quoutes inside the -Command parameter + // to include a double quoute here, you have to add three double quoutes. + // windows has very confusing, runtime-dependent command line argument parsing + // This is done with the assumption that its using GetCommandLineW, and it works for now + cmdArg = strings.ReplaceAll(cmdArg, `"`, `"""`) + + cmdArgsModified = append(cmdArgsModified, cmdArg) } + cmd := execCommandContext(ctx, "powershell", env) - cmd.SysProcAttr.CmdLine = fmt.Sprintf(`%s -Command ". '%s' %s; exit($LASTEXITCODE)"`, POWERSHELL, cmdName, strings.Join(cmdArgs, " ")) + cmd.SysProcAttr.CmdLine = fmt.Sprintf(`%s -Command ". '%s' %s ; exit($LASTEXITCODE)"`, POWERSHELL, cmdName, strings.Join(cmdArgsModified, " ")) + + log.Tracef("cmd.SysProcAttr.CmdLine: %s", cmd.SysProcAttr.CmdLine) return cmd, nil @@ -312,12 +331,29 @@ func (snc *Agent) makeCmd(ctx context.Context, command string) (*exec.Cmd, error shell, strings.Replace(command, cmdName, syscall.EscapeArg(cmdName), 1)) + log.Tracef("cmd.SysProcAttr.CmdLine: %s", cmd.SysProcAttr.CmdLine) + return cmd, nil } } -func (snc *Agent) shellParse(command string) (cmdName string, args []string, hasShellCode bool, err error) { - args, err = shelltoken.SplitQuotes(command, shelltoken.Whitespace, shelltoken.SplitKeepBackslashes|shelltoken.SplitContinueOnShellCharacters) +func (snc *Agent) shellParse(command string, additionalOptions ...shelltoken.SplitOption) (cmdName string, args []string, hasShellCode bool, err error) { + options := shelltoken.SplitKeepBackslashes | shelltoken.SplitContinueOnShellCharacters + + // shelltoken.SplitKeepQuotes may be passed as an additional Option + // when invoking a script, the script might use $ARG1$ macro + // arg1 here might be a single string composed of many arguments, e.g: + // powershell_detail_arg1 "-option1 option1 -option2 `'option2`' -option3 `"option3`" -option4 `'foo,bar`' -option5 `"baz,xyz`" " + // if sheltoken.SplitKeepQuoutes option is not set, it strips the quotation marks from each option value + // this leads to arguments like foo,bar not being quouted and being left as is + // powershell then thinks its an array due to comma + // if they were quouted, it would think that they are a string that includes comma character + + for _, opt := range additionalOptions { + options |= opt + } + + args, err = shelltoken.SplitQuotes(command, shelltoken.Whitespace, options) if err != nil { tst := &shelltoken.ShellCharactersFoundError{} if errors.As(err, &tst) { diff --git a/pkg/snclient/snclient_windows_test.go b/pkg/snclient/snclient_windows_test.go new file mode 100644 index 00000000..076a6d1d --- /dev/null +++ b/pkg/snclient/snclient_windows_test.go @@ -0,0 +1,368 @@ +package snclient + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Generates a config file, where snclient can call a script. +// scriptName does not have an extension +// scriptFilename does have (most likely an OS specific) script extension. +// It registers four commands for script +// scriptName_arg1 : ./${SCRIPT_FILENAME} "$ARG1$" +// scriptName_arg_numbered : ./${SCRIPT_FILENAME} "$ARG1$" "$ARG2$" "$ARG3$" "$ARG4$" "$ARG5$" "$ARG6$" "$ARG7$" "$ARG8$" "$ARG9$" "$ARG10$" +// scriptName_args : ./${SCRIPT_FILENAME} "$ARGS$" +// scriptName_args_quouted : ./${SCRIPT_FILENAME} "$ARGS"$" +// +//nolint:unparam // scriptName is so far always "powershell_detail" , no other test script uses this function. Keep it as a parameter for future use. +func snclientConfigFileWithScript(t *testing.T, scriptsDir, scriptName, scriptFilename string) string { + t.Helper() + + configTemplate := ` +[/modules] +CheckExternalScripts = enabled + +[/paths] +scripts = ${SCRIPTS_DIR} +shared-path = %(scripts) + +[/settings/external scripts] +timeout = 1111111 +allow arguments = true + +[/settings/external scripts/scripts] +${SCRIPT_NAME}_arg1 = ./${SCRIPT_FILENAME} $ARG1$ + +[/settings/external scripts/scripts/${SCRIPT_NAME}_arg1] +allow arguments = true +allow nasty characters = true + +[/settings/external scripts/scripts] +${SCRIPT_NAME}_arg_numbered = ./${SCRIPT_FILENAME} $ARG1$ $ARG2$ $ARG3$ $ARG4$ $ARG5$ $ARG6$ $ARG7$ $ARG8$ $ARG9$ $ARG10$ + +[/settings/external scripts/scripts/${SCRIPT_NAME}_arg_numbered] +allow arguments = true +allow nasty characters = true + +[/settings/external scripts/scripts] +${SCRIPT_NAME}_args = ./${SCRIPT_FILENAME} $ARGS$ + +[/settings/external scripts/scripts/${SCRIPT_NAME}_args] +allow arguments = true +allow nasty characters = true + +[/settings/external scripts/scripts] +${SCRIPT_NAME}_args_quouted = ./${SCRIPT_FILENAME} $ARGS"$ + +[/settings/external scripts/scripts/${SCRIPT_NAME}_args_quouted] +allow arguments = true +allow nasty characters = true +` + + mapper := func(placeholderName string) string { + switch placeholderName { + case "SCRIPTS_DIR": + return scriptsDir + case "SCRIPT_NAME": + return scriptName + case "SCRIPT_FILENAME": + return scriptFilename + default: + // if its not some value we know, leave it as is + return "$" + placeholderName + } + } + + return os.Expand(configTemplate, mapper) +} + +func TestMakeCmd(t *testing.T) { + config := "" + snc := StartTestAgent(t, config) + + commandString := `.\t\scripts\powershell_detail.ps1 -option1 option1 -option2 'option2' -option3 "option3" -option4 'option4.option4,option4:option4;option4|option4$option4' ` + cmd, err := snc.makeCmd(context.TODO(), commandString) + + require.NoErrorf(t, err, "there should not be any errors when converting command: %s into an exec.Cmd of os/exec", commandString) + + assert.NotEmptyf(t, cmd.SysProcAttr.CmdLine, "exec.Cmd from command: %s should not have an empty SysProcAttr.CmdLine", commandString) + + // cmd.Args is unused if cmd.SysProcAttr.CmdLine is set and nonempty + // snclient sets it and does not populate cmd.Args + + // the quoutes should not be removed + // the reasoning is to pass some arguments as written inside the quoutes, so that they can take a string form and not be converted + // if an argument is passed like this --optionX foo,bar powershell parameter parser thinks it is an object/string array and refuses to parse it as string + // it has to be passed like --optionX 'foo,bar' to be parsed as a string + + // 1 double quoute (") has to be converted to 3 double quoutes of cmd.SysProcAttr.CmdLine + // check snclient_windows.go powershell block for more explanation + cmdLineExpectedContains := `-option1 option1 -option2 'option2' -option3 """option3""" -option4 'option4.option4,option4:option4;option4|option4$option4'` + assert.Containsf(t, cmd.SysProcAttr.CmdLine, + cmdLineExpectedContains, + "exec.Cmd from command: %s\nshould contain this substring: %s\nbut it looks like this: %s", + commandString, cmdLineExpectedContains, cmd.SysProcAttr.CmdLine) + + var pathEnv string + for _, envVar := range cmd.Env { + if strings.HasPrefix(envVar, "PATH=") { + pathEnv = envVar + } + } + + assert.NotEmpty(t, pathEnv, "converted exec.Cmd from command: %s should contain PATH environment variable", commandString) + + // script is found under C:\Users\sorus\repositories\snclient\pkg\snclient\t + // scriptsPath, _ := snc.config.Section("/paths").GetString("scripts") + // assert.Containsf(t, pathEnv, scriptsPath+":", "converted exec.Cmd from command: %s should have its PATH variable: %s include the config ScriptsPath: %s", commandString, pathEnv, scriptsPath) + + assert.Truef(t, cmd.SysProcAttr.HideWindow, "converted exec.Cmd from command: %s should hide its spawned window", commandString) + + StopTestAgent(t, snc) +} + +func TestPowershell1(t *testing.T) { + testDir, _ := os.Getwd() + scriptsDir := filepath.Join(testDir, "t", "scripts") + scriptName := "powershell_detail" + scriptFilename := "powershell_detail.ps1" + + config := snclientConfigFileWithScript(t, scriptsDir, scriptName, scriptFilename) + snc := StartTestAgent(t, config) + + // simulate a default call of the script with no arguments + res := snc.RunCheck("powershell_detail_arg1", []string{}) + + outputString := string(res.BuildPluginOutput()) + + assert.Equalf(t, CheckExitOK, res.State, "check should return state ok") + + // the string rawCommandLine: is printed from the powershell_detail script. Invoke it locally and get snippets from its output + outputStringExpectedSubstrings := []string{ + `Raw Commandline: `, + `t\scripts\powershell_detail.ps1`, + } + + for _, outputStringExpectedSubstring := range outputStringExpectedSubstrings { + assert.Containsf(t, outputString, outputStringExpectedSubstring, "script output should contain: %s", outputStringExpectedSubstring) + } + + StopTestAgent(t, snc) +} + +func TestPowershellScriptArg1(t *testing.T) { + testDir, _ := os.Getwd() + scriptsDir := filepath.Join(testDir, "t", "scripts") + scriptName := "powershell_detail" + scriptFilename := scriptName + ".ps1" + // arg1 adds a single double-quoute around the first argument, which includes everything + scriptMacroType := "_arg1" + scriptArgs := []string{"-option1 option1 -option2 'option2' -option3 \"option3\" -option4 'foo,bar' -option5 \"baz,xyz\" "} + + config := snclientConfigFileWithScript(t, scriptsDir, scriptName, scriptFilename) + snc := StartTestAgent(t, config) + + // simulate a call from check_nsc_web. this calls the (snc *Agent).runCheck directly, skipping over RunCheck + // argument macros are evaluated after this function, + + checkResult, checkData := snc.runCheck(context.TODO(), scriptName+scriptMacroType, scriptArgs, 0, nil, false, false) + assert.NotNilf(t, checkResult, "check should return a checkResult") + assert.NotNilf(t, checkData, "check should return a checkData") + + outputString := string(checkResult.BuildPluginOutput()) + + assert.Equalf(t, CheckExitOK, checkResult.State, "check should return state OK") + outputStringExpectedSubstrings := []string{ + `Raw Commandline: `, + `t\scripts\powershell_detail.ps1`, + `-option1 option1`, + `-option2 'option2'`, + `-option3 "option3"`, + `-option4 'foo,bar`, + `-option5 "baz,xyz"`, + `Bound Parameter | Name: option1 | Type: String | Value: option1`, + `Bound Parameter | Name: option2 | Type: String | Value: option2`, + `Bound Parameter | Name: option3 | Type: String | Value: option3`, + `Bound Parameter | Name: option4 | Type: String | Value: foo,bar`, + `Bound Parameter | Name: option5 | Type: String | Value: baz,xyz`, + } + + for _, outputStringExpectedSubstring := range outputStringExpectedSubstrings { + assert.Containsf(t, outputString, outputStringExpectedSubstring, "script output should contain: %s", outputStringExpectedSubstring) + } + + StopTestAgent(t, snc) +} + +//nolint:dupl // the functions are largely the same, but scriptMacroType is different. Redefining expected strings for each macro type is easier to understand. +func TestPowershellScriptArgNumbered(t *testing.T) { + testDir, _ := os.Getwd() + scriptsDir := filepath.Join(testDir, "t", "scripts") + scriptName := "powershell_detail" + scriptFilename := scriptName + ".ps1" + // arg_numbered adds first 10 arguments with a space in-between + // the arguments are passed as a list here, not a single string + scriptMacroType := "_arg_numbered" + scriptArgs := []string{ + "-option1", + "option1", + "-option2", + "option2", + "-option3", + "\"option3\"", + "-option4", + "'foo,bar'", + "-option5", + "\"baz,xyz\"", + } + + config := snclientConfigFileWithScript(t, scriptsDir, scriptName, scriptFilename) + snc := StartTestAgent(t, config) + + checkResult, checkData := snc.runCheck(context.TODO(), scriptName+scriptMacroType, scriptArgs, 0, nil, false, false) + assert.NotNilf(t, checkResult, "check should return a checkResult") + assert.NotNilf(t, checkData, "check should return a checkData") + + outputString := string(checkResult.BuildPluginOutput()) + + assert.Equalf(t, CheckExitOK, checkResult.State, "check should return state OK") + + outputStringExpectedSubstrings := []string{ + `Raw Commandline: `, + `t\scripts\powershell_detail.ps1`, + `-option1 option1`, + `-option2 option2`, + `-option3 "option3"`, + `-option4 'foo,bar'`, + `-option5 "baz,xyz"`, + `Bound Parameter | Name: option1 | Type: String | Value: option1`, + `Bound Parameter | Name: option2 | Type: String | Value: option2`, + `Bound Parameter | Name: option3 | Type: String | Value: option3`, + `Bound Parameter | Name: option4 | Type: String | Value: foo,bar`, + `Bound Parameter | Name: option5 | Type: String | Value: baz,xyz`, + } + + for _, outputStringExpectedSubstring := range outputStringExpectedSubstrings { + assert.Containsf(t, outputString, outputStringExpectedSubstring, "output should contain: %s", outputStringExpectedSubstring) + } + + StopTestAgent(t, snc) +} + +//nolint:dupl // the functions are largely the same, but scriptMacroType is different. Redefining expected strings for each macro type is easier to understand. +func TestPowershellScriptArgs(t *testing.T) { + testDir, _ := os.Getwd() + scriptsDir := filepath.Join(testDir, "t", "scripts") + scriptName := "powershell_detail" + scriptFilename := scriptName + ".ps1" + scriptMacroType := "_args" + // args adds each argument with a space in-between + // the arguments are passed as a list here, not a single string + scriptArgs := []string{ + "-option1", + "option1", + "-option2", + "'option2'", + "-option3", + "\"option3\"", + "-option4", + "'foo,bar'", + "-option5", + "\"baz,xyz\"", + } + + config := snclientConfigFileWithScript(t, scriptsDir, scriptName, scriptFilename) + snc := StartTestAgent(t, config) + + checkResult, checkData := snc.runCheck(context.TODO(), scriptName+scriptMacroType, scriptArgs, 0, nil, false, false) + assert.NotNilf(t, checkResult, "check should return a checkResult") + assert.NotNilf(t, checkData, "check should return a checkData") + + outputString := string(checkResult.BuildPluginOutput()) + + assert.Equalf(t, CheckExitOK, checkResult.State, "check should return state OK") + + outputStringExpectedSubstrings := []string{ + `Raw Commandline: `, + `t\scripts\powershell_detail.ps1`, + `-option1 option1`, + `-option2 'option2'`, + `-option3 "option3"`, + `-option4 'foo,bar'`, + `-option5 "baz,xyz"`, + `Bound Parameter | Name: option1 | Type: String | Value: option1`, + `Bound Parameter | Name: option2 | Type: String | Value: option2`, + `Bound Parameter | Name: option3 | Type: String | Value: option3`, + `Bound Parameter | Name: option4 | Type: String | Value: foo,bar`, + `Bound Parameter | Name: option5 | Type: String | Value: baz,xyz`, + } + + for _, outputStringExpectedSubstring := range outputStringExpectedSubstrings { + assert.Containsf(t, outputString, outputStringExpectedSubstring, "output should contain: %s", outputStringExpectedSubstring) + } + + StopTestAgent(t, snc) +} + +//nolint:dupl // the functions are largely the same, but scriptMacroType is different. Redefining expected strings for each macro type is easier to understand. +func TestPowershellScriptArgsQuouted(t *testing.T) { + testDir, _ := os.Getwd() + scriptsDir := filepath.Join(testDir, "t", "scripts") + scriptName := "powershell_detail" + scriptFilename := scriptName + ".ps1" + scriptMacroType := "_args_quouted" + // args_quouted adds double quoutes around each argument and joins them with space in-between + // the arguments are passed as a list here, not a single string + scriptArgs := []string{ + "-option1", + "option1", + "-option2", + "option2", + "-option3", + "option3", + "-option4", + "foo,bar", + "-option5", + "baz,xyz", + } + + config := snclientConfigFileWithScript(t, scriptsDir, scriptName, scriptFilename) + snc := StartTestAgent(t, config) + + checkResult, checkData := snc.runCheck(context.TODO(), scriptName+scriptMacroType, scriptArgs, 0, nil, false, false) + assert.NotNilf(t, checkResult, "check should return a checkResult") + assert.NotNilf(t, checkData, "check should return a checkData") + + outputString := string(checkResult.BuildPluginOutput()) + + // since option specifiers like -option1 and -option2 are also quouted, this prevents them from working properly + + assert.Equalf(t, CheckExitUnknown, checkResult.State, "check should return state Unknown") + + outputStringExpectedSubstrings := []string{ + `Raw Commandline: `, + `t\scripts\powershell_detail.ps1`, + `"-option1"`, + `"option1"`, + `"-option2"`, + `"option2"`, + `"-option3"`, + `"option3"`, + `"-option4"`, + `"foo,bar"`, + `"-option5"`, + `"baz,xyz"`, + } + + for _, outputStringExpectedSubstring := range outputStringExpectedSubstrings { + assert.Containsf(t, outputString, outputStringExpectedSubstring, "output should contain: %s", outputStringExpectedSubstring) + } + + StopTestAgent(t, snc) +} diff --git a/pkg/snclient/t/scripts/powershell_detail.ps1 b/pkg/snclient/t/scripts/powershell_detail.ps1 new file mode 100644 index 00000000..af0a1d05 --- /dev/null +++ b/pkg/snclient/t/scripts/powershell_detail.ps1 @@ -0,0 +1,79 @@ +# command_line_test.ps1 +# A simple PowerShell script for testing purposes + +param( + [Parameter(Mandatory = $false)] + [object]$option1, + + [Parameter(Mandatory = $false)] + [object]$option2, + + [Parameter(Mandatory = $false)] + [object]$option3, + + [Parameter(Mandatory = $false)] + [object]$option4, + + [Parameter(Mandatory = $false)] + [object]$option5, + + [Parameter(Mandatory = $false, Position = 0, ValueFromRemainingArguments = $true)] + [object]$Arguments +) + +Write-Host "Raw Commandline: $($MyInvocation.Line)" +Write-Host "" + +Write-Host "PowerShell Version: $($PSVersionTable.PSVersion.ToString())" +Write-Host "" + +Write-Host "PowerShell Version Details:" -ForegroundColor Yellow +$PSVersionTable | Format-List +Write-Host "" + +Write-Host "Process Information:" -ForegroundColor Yellow +$process = Get-Process -Id $PID +Write-Host "Process ID: $($process.Id)" +Write-Host "Process Name: $($process.ProcessName)" +Write-Host "Process Commandline: $($process.CommandLine)" +Write-Host "" + +Write-Host "Script Information:" -ForegroundColor Yellow +Write-Host "Script Name: $MyInvocation.MyCommand.Name" +Write-Host "Script Path: $MyInvocation.MyCommand.Path" +Write-Host "" + +Write-Host "Working Directory: $($PWD.Path)" +Write-Host "" + +Write-Host "Environment Info:" -ForegroundColor Yellow +Write-Host "OS: $($env:OS)" +Write-Host "Computer Name: $($env:COMPUTERNAME)" +Write-Host "User: $($env:USERNAME)" +Write-Host "" + +Write-Host "Script Execution Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')" +Write-Host "" + +Write-Host "Arguments Received:" -ForegroundColor Yellow +if ($Arguments) { + for ($i = 0; $i -lt $Arguments.Length; $i++) { + Write-Host "Argument | [$i] : $($Arguments[$i])" + } +} +Write-Host "" + +Write-Host "Bound Parameters:" -ForegroundColor Yellow +$MyInvocation.BoundParameters.GetEnumerator() | ForEach-Object { + $paramName = $_.Key + $paramValue = $_.Value + + # Safely get the type (checking for $null first to prevent errors) + $typeDisplay = "null" + if ($null -ne $paramValue) { + $typeDisplay = $paramValue.GetType().Name + } + + Write-Host "Bound Parameter | Name: $paramName | Type: $typeDisplay | Value: $paramValue" +} +Write-Host "" \ No newline at end of file