diff --git a/go.mod b/go.mod index f76efeb..227dbb3 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.24.5 require ( github.com/Azure/azure-extension-foundation v0.0.0-20250620154556-caff9e3c3c5c - github.com/Azure/azure-extension-platform v0.0.0-20250107200156-aa20f765d49f + github.com/Azure/azure-extension-platform v0.0.0-20260410171604-91b4725acbb1 github.com/Azure/azure-sdk-for-go v68.0.0+incompatible github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 diff --git a/go.sum b/go.sum index 6ee1b20..12af36b 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/Azure/azure-extension-platform v0.0.0-20240610175536-404c704f82f8 h1: github.com/Azure/azure-extension-platform v0.0.0-20240610175536-404c704f82f8/go.mod h1:nEQQIC3RKmMnpdc+RakYHIdu556jdcHv67ML8PdsQeQ= github.com/Azure/azure-extension-platform v0.0.0-20250107200156-aa20f765d49f h1:ddsUz/suc9txCMz/xWOslqNMvzhbWFMTflUrbcMNoSw= github.com/Azure/azure-extension-platform v0.0.0-20250107200156-aa20f765d49f/go.mod h1:0458BvQsi5ch6kn+KZtI5m88Z3L9UFXdoY1+6nKdivY= +github.com/Azure/azure-extension-platform v0.0.0-20260410171604-91b4725acbb1 h1:ijfz4hQtWTfTmaejzDrkNqhpCfOGFzKk/wUtPZmLrg0= +github.com/Azure/azure-extension-platform v0.0.0-20260410171604-91b4725acbb1/go.mod h1:0458BvQsi5ch6kn+KZtI5m88Z3L9UFXdoY1+6nKdivY= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 h1:JZg6HRh6W6U4OLl6lk7BZ7BLisIzM9dG1R50zUk9C/M= diff --git a/internal/cmds/cmds.go b/internal/cmds/cmds.go index 57c731c..43a1008 100755 --- a/internal/cmds/cmds.go +++ b/internal/cmds/cmds.go @@ -17,8 +17,11 @@ import ( "strings" "time" + "github.com/Azure/azure-extension-platform/pkg/extensionerrors" "github.com/Azure/azure-extension-platform/pkg/extensionevents" + "github.com/Azure/azure-extension-platform/pkg/extensionpolicysettings" "github.com/Azure/azure-extension-platform/pkg/handlerenv" + "github.com/Azure/azure-extension-platform/pkg/hashutils" "github.com/Azure/azure-extension-platform/pkg/logging" "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" @@ -28,6 +31,7 @@ import ( "github.com/Azure/run-command-handler-linux/internal/commandProcessor" "github.com/Azure/run-command-handler-linux/internal/constants" "github.com/Azure/run-command-handler-linux/internal/exec" + "github.com/Azure/run-command-handler-linux/internal/extensionpolicysettingsrc" "github.com/Azure/run-command-handler-linux/internal/files" "github.com/Azure/run-command-handler-linux/internal/handlersettings" "github.com/Azure/run-command-handler-linux/internal/immediatecmds" @@ -210,8 +214,38 @@ func enable(ctx *log.Context, h types.HandlerEnvironment, report *types.RunComma return "", "", err, exitCode } + // Load extension policy settings. + // If policy file exists, load the policy. If not, then don't load. + var extensionPolicyManagerPtr *extensionpolicysettings.ExtensionPolicySettingsManager[extensionpolicysettingsrc.RCv2ExtensionPolicySettings] + policyPath := filepath.Join(h.HandlerEnvironment.ConfigFolder, constants.PolicyFileName) + var rceps *extensionpolicysettingsrc.RCv2ExtensionPolicySettings + + if _, err := os.Stat(policyPath); err == nil { + extensionPolicyManagerPtr, rceps, err, exitCode = extensionpolicysettingsrc.InitializeExtensionPolicySettings(ctx, policyPath) + if err != nil { + return "", "", err, exitCode + } + ctx.Log("message", "successfully initialized extension policy settings") + } else if os.IsNotExist(err) { + ctx.Log("message", "extension policy settings file does not exist. No policy applied.", "error", err) + extensionPolicyManagerPtr = nil + } else { + return "", "", errors.Wrap(err, "failed to stat extension policy settings file"), constants.ExitCode_LoadExtensionPolicySettingsFailed + } + + // Validate handler settings against policy settings. + if extensionPolicyManagerPtr != nil && rceps != nil { + if err, exitCode = extensionpolicysettingsrc.ValidateHandlerSettingsAgainstPolicy(ctx, &cfg, rceps); err != nil { + return "", "", err, exitCode + } + } + dir := filepath.Join(metadata.DownloadPath, fmt.Sprintf("%d", metadata.SeqNum)) - scriptFilePath, err := downloadScript(ctx, dir, &cfg) + scriptFilePath, err := downloadScript(ctx, dir, &cfg, rceps) + if err != nil && errors.Is(err, extensionerrors.ErrItemNotInAllowlist) { + return "", "", errors.Wrap(err, "downloaded script file is not in the allowlist."), constants.ExitCode_DownloadedScriptBlockedByExtensionPolicy + } + if err != nil { errMessage := fmt.Sprintf("Failed to download script: %v due to: %v", download.GetUriForLogging(cfg.ScriptURI()), err) extensionEvents.LogErrorEvent("enable", errMessage) @@ -232,6 +266,7 @@ func enable(ctx *log.Context, h types.HandlerEnvironment, report *types.RunComma blobCreateOrReplaceError := "Error creating AppendBlob '%s' using SAS token or Managed identity. Please use a valid blob SAS URI with [read, append, create, write] permissions OR managed identity. If managed identity is used, make sure Azure blob and identity exist, and identity has been given access to storage blob's container with 'Storage Blob Data Contributor' role assignment. In case of user-assigned identity, make sure you add it under VM's identity and provide outputBlobUri / errorBlobUri and corresponding clientId in outputBlobManagedIdentity / errorBlobManagedIdentity parameter(s). In case of system-assigned identity, do not use outputBlobManagedIdentity / errorBlobManagedIdentity parameter(s). For more info, refer https://aka.ms/RunCommandManagedLinux" + // TO-DO: disable output blob if the policy settings has disableOutputBlobs set to true. var outputBlobSASRef *storage.Blob var outputBlobAppendClient *appendblob.Client var outputBlobAppendCreateOrReplaceError error @@ -847,7 +882,7 @@ func createDummyStatusFilesIfNeeded(ctx log.Logger, mrseqFilesNameList *list.Lis // downloadScript downloads the script file specified in cfg into dir (creates if does // not exist) and takes storage credentials specified in cfg into account. -func downloadScript(ctx *log.Context, dir string, cfg *handlersettings.HandlerSettings) (string, error) { +func downloadScript(ctx *log.Context, dir string, cfg *handlersettings.HandlerSettings, rceps *extensionpolicysettingsrc.RCv2ExtensionPolicySettings) (string, error) { // - prepare the output directory for files and the command output // - create the directory if missing ctx.Log("event", "creating output directory", "path", dir) @@ -872,6 +907,20 @@ func downloadScript(ctx *log.Context, dir string, cfg *handlersettings.HandlerSe } scriptFilePath = file ctx.Log("event", "download complete", "output", dir) + + if rceps != nil { + // Assume the downloaded script TYPE is already allowed, since this was already validated earlier in enable(). + err = extensionpolicysettings.ValidateFileHashInAllowlist(scriptFilePath, rceps.DownloadedScriptsAllowlist, hashutils.HashTypeSHA256) + if err != nil { + ctx.Log("message", "downloaded script file is not in the allowlist, attempting to delete", "scriptFilePath", scriptFilePath) + if delErr := os.Remove(scriptFilePath); delErr != nil { + ctx.Log("message", "failed to delete downloaded script file", "scriptFilePath", scriptFilePath, "error", delErr) + } else { + ctx.Log("message", "successfully deleted downloaded script file", "scriptFilePath", scriptFilePath) + } + return scriptFilePath, errors.Wrapf(err, "file %s blocked by policy", scriptFilePath) + } + } } return scriptFilePath, nil } diff --git a/internal/cmds/cmds_test.go b/internal/cmds/cmds_test.go index 72f4e76..e7e5b00 100755 --- a/internal/cmds/cmds_test.go +++ b/internal/cmds/cmds_test.go @@ -1,6 +1,8 @@ package commands import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "io/ioutil" @@ -16,7 +18,9 @@ import ( "github.com/Azure/azure-extension-platform/pkg/extensionevents" "github.com/Azure/azure-extension-platform/pkg/handlerenv" "github.com/Azure/azure-extension-platform/pkg/logging" + "github.com/Azure/run-command-handler-linux/internal/commandProcessor" "github.com/Azure/run-command-handler-linux/internal/constants" + "github.com/Azure/run-command-handler-linux/internal/extensionpolicysettingsrc" "github.com/Azure/run-command-handler-linux/internal/files" "github.com/Azure/run-command-handler-linux/internal/handlersettings" "github.com/Azure/run-command-handler-linux/internal/settings" @@ -551,7 +555,7 @@ func Test_downloadScriptUri(t *testing.T) { PublicSettings: handlersettings.PublicSettings{ Source: &handlersettings.ScriptSource{ScriptURI: srv.URL + "/bytes/10"}, }, - }) + }, nil) require.Nil(t, err) // check the downloaded file @@ -744,7 +748,7 @@ func Test_downloadScriptUri_BySASFailsSucceedsByManagedIdentity(t *testing.T) { ClientId: "00b64c6a-6dbf-41e0-8707-74132d5cf53f", }, }, - }) + }, nil) require.Nil(t, err) files.UseMockSASDownloadFailure = false } @@ -1445,3 +1449,248 @@ func mustReadFile(t *testing.T, p string) string { } return string(b) } + +// Test_downloadScript_BlockedByAllowlist verifies that downloadScript returns an error +// when the policy allows downloaded scripts (alloweddownloaded) but the script's +// SHA256 hash is not in the DownloadedScriptsAllowlist. +func Test_downloadScript_BlockedByAllowlist(t *testing.T) { + dir, err := ioutil.TempDir("", "") + require.Nil(t, err) + defer os.RemoveAll(dir) + + scriptContent := []byte("#!/bin/bash\necho hello\n") + srv := make_server_with_content(scriptContent) + defer srv.Close() + + policy := &extensionpolicysettingsrc.RCv2ExtensionPolicySettings{ + LimitScripts: "alloweddownloaded", + // A mismatch hash + DownloadedScriptsAllowlist: []string{"0000000000000000000000000000000000000000000000000000000000000000"}, + } + + _, err = downloadScript(log.NewContext(log.NewNopLogger()), + dir, + &handlersettings.HandlerSettings{ + PublicSettings: handlersettings.PublicSettings{ + Source: &handlersettings.ScriptSource{ScriptURI: srv.URL + "/script.sh"}, + }, + }, + policy, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "blocked by policy") + require.Contains(t, err.Error(), "item is not in the allowlist") +} + +// Test_downloadScript_AllowedByAllowlist verifies that downloadScript succeeds +// when the policy allows downloaded scripts and the script's SHA256 hash IS +// present in the DownloadedScriptsAllowlist. +func Test_downloadScript_AllowedByAllowlist(t *testing.T) { + dir, err := os.MkdirTemp("", "") + require.Nil(t, err) + defer os.RemoveAll(dir) + + // Content uses Unix LF only and has no BOM, so PostProcessFile leaves bytes + // unchanged, making the pre-computed hash match the on-disk file hash. + scriptContent := []byte("#!/bin/bash\necho hello\n") + srv := make_server_with_content(scriptContent) + defer srv.Close() + + // Compute the SHA256 hash that ValidateFileHashInAllowlist will compare against. + correctHash := hash_bytes_256(scriptContent) + + policy := &extensionpolicysettingsrc.RCv2ExtensionPolicySettings{ + LimitScripts: "alloweddownloaded", + DownloadedScriptsAllowlist: []string{correctHash}, + } + + _, err = downloadScript(log.NewContext(log.NewNopLogger()), + dir, + &handlersettings.HandlerSettings{ + PublicSettings: handlersettings.PublicSettings{ + Source: &handlersettings.ScriptSource{ScriptURI: srv.URL + "/script.sh"}, + }, + }, + policy, + ) + require.NoError(t, err) +} + +func setupPolicyE2E(t *testing.T, dataDir, extName string, seqNum int, scriptURI string, treatFailureAsDeploymentFailure bool, policy *extensionpolicysettingsrc.RCv2ExtensionPolicySettings, +) types.HandlerEnvironment { + t.Helper() + configFolder := create_folder(t, dataDir, "config") + statusFolder := create_folder(t, dataDir, constants.StatusFileDirectory) + eventsFolder := create_folder(t, dataDir, constants.ExtensionEventsDirectory) + + fakeEnv := types.HandlerEnvironment{} + update_handler_env(&fakeEnv, statusFolder, configFolder, eventsFolder) + + // Write the extension .settings file (mirrors enable_extension), but with a + // downloaded-script source so the allowlist check applies. + settingsCommon := settings.SettingsCommon{ + ExtensionName: &extName, + ProtectedSettingsBase64: "", + SettingsCertThumbprint: "", + PublicSettings: map[string]interface{}{ + "source": map[string]interface{}{ + "scriptUri": scriptURI, + "scriptType": string(handlersettings.DownloadedScript), + }, + "treatFailureAsDeploymentFailure": treatFailureAsDeploymentFailure, + }, + } + handlerSettings := handlersettings.HandlerSettingsFile{ + RuntimeSettings: []handlersettings.RunTimeSettingsFile{ + {HandlerSettings: settingsCommon}, + }, + } + settingsFilePath := filepath.Join(configFolder, extName+"."+strconv.Itoa(seqNum)+".settings") + file, err := os.Create(settingsFilePath) + require.Nil(t, err, "could not create settings file") + err = json.NewEncoder(file).Encode(handlerSettings) + require.Nil(t, err, "could not serialize settings file") + require.Nil(t, file.Close(), "could not close settings file") + + // Write the real policy file that will be parsed in enable() + policyBytes, err := json.Marshal(policy) + require.Nil(t, err, "could not marshal policy settings") + err = os.WriteFile(filepath.Join(configFolder, constants.PolicyFileName), policyBytes, 0600) + require.Nil(t, err, "could not write policy settings file") + + return fakeEnv +} + +func hash_bytes_256(b []byte) string { + h := sha256.New() + h.Write(b) + return hex.EncodeToString(h.Sum(nil)) +} + +func make_server_with_content(content []byte) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(content) + })) +} + +func readStatusReport(t *testing.T, env types.HandlerEnvironment, extName string, seqNum int) types.StatusReport { + t.Helper() + statusPath := filepath.Join(env.HandlerEnvironment.StatusFolder, + extName+"."+strconv.Itoa(seqNum)+constants.StatusFileExtension) + require.FileExists(t, statusPath) + + content, err := os.ReadFile(statusPath) + require.Nil(t, err) + + var report types.StatusReport + require.Nil(t, json.Unmarshal(content, &report)) + return report +} + +func Test_enable_e2e_extension_policy_settings_pass(t *testing.T) { + ctx := log.NewContext(log.NewNopLogger()) + extName, seqNum := "happyPolicyRun", 0 + scriptContent := []byte("#!/bin/bash\necho hello\n") + correctHash := hash_bytes_256(scriptContent) + + srv := make_server_with_content(scriptContent) + defer srv.Close() + + dataDir, err := os.MkdirTemp("", "policy-pass") + require.Nil(t, err) + defer os.RemoveAll(dataDir) + + policy := &extensionpolicysettingsrc.RCv2ExtensionPolicySettings{ + LimitScripts: "alloweddownloaded", + DownloadedScriptsAllowlist: []string{correctHash}, + } + // Policy will be marshaled and written to a file in the config folder. + fakeEnv := setupPolicyE2E(t, dataDir, extName, seqNum, srv.URL+"/script.sh", false, policy) + + scriptWasExecuted := false + RunCmd = func(ctx *log.Context, dir, scriptFilePath string, cfg *handlersettings.HandlerSettings, metadata types.RCMetadata) (error, int) { + scriptWasExecuted = true + return nil, 0 + } + + err = commandProcessor.ProcessHandlerCommandWithDetails(ctx, CmdEnable, fakeEnv, extName, seqNum, constants.DownloadFolder, dataDir) + require.Nil(t, err, "enable command should succeed") + require.True(t, scriptWasExecuted, "allowed script should be executed") + + report := readStatusReport(t, fakeEnv, extName, seqNum) // verify status report exists and is valid + require.Equal(t, types.StatusSuccess, report[0].Status.Status, "status report should indicate success") + + // Instance view is reported as the string value of "message", so it's easier to check for expected substrings. + require.True(t, strings.Contains(report[0].Status.FormattedMessage.Message, "executionState\":\"Succeeded\",\"executionMessage\":\"Execution completed"), "execution message should indicate success") +} + +func Test_enable_e2e_extension_policy_settings_block_statussuccess(t *testing.T) { + ctx := log.NewContext(log.NewNopLogger()) + extName, seqNum := "happyPolicyRun", 0 + scriptContent := []byte("#!/bin/bash\necho hello\n") + + srv := make_server_with_content(scriptContent) + defer srv.Close() + + dataDir, err := os.MkdirTemp("", "policy-pass") + require.Nil(t, err) + defer os.RemoveAll(dataDir) + + policy := &extensionpolicysettingsrc.RCv2ExtensionPolicySettings{ + LimitScripts: "alloweddownloaded", + DownloadedScriptsAllowlist: []string{"000000000000"}, + } + // Policy will be marshaled and written to a file in the config folder. + fakeEnv := setupPolicyE2E(t, dataDir, extName, seqNum, srv.URL+"/script.sh", false, policy) + + scriptWasExecuted := false + RunCmd = func(ctx *log.Context, dir, scriptFilePath string, cfg *handlersettings.HandlerSettings, metadata types.RCMetadata) (error, int) { + scriptWasExecuted = true + return nil, 0 + } + + err = commandProcessor.ProcessHandlerCommandWithDetails(ctx, CmdEnable, fakeEnv, extName, seqNum, constants.DownloadFolder, dataDir) + require.Nil(t, err, "enable command should succeed") + require.False(t, scriptWasExecuted, "disallowed script should not be executed") + + report := readStatusReport(t, fakeEnv, extName, seqNum) // verify status report exists and is valid + require.Equal(t, types.StatusSuccess, report[0].Status.Status, "status report should indicate success") + require.True(t, strings.Contains(report[0].Status.FormattedMessage.Message, "executionState\":\"Failed\",\"executionMessage\":\"Execution failed"), "execution message should indicate failure") +} + +// This test sets treatFailureAsDeploymentFailure to true, so failure to execute the script is reflected as a +// failed status. +func Test_enable_e2e_extension_policy_settings_block_statusfail(t *testing.T) { + ctx := log.NewContext(log.NewNopLogger()) + extName, seqNum := "happyPolicyRun", 0 + scriptContent := []byte("#!/bin/bash\necho hello\n") + + srv := make_server_with_content(scriptContent) + defer srv.Close() + + dataDir, err := os.MkdirTemp("", "policy-pass") + require.Nil(t, err) + defer os.RemoveAll(dataDir) + + policy := &extensionpolicysettingsrc.RCv2ExtensionPolicySettings{ + LimitScripts: "alloweddownloaded", + DownloadedScriptsAllowlist: []string{"000000000000"}, + } + // treatFailureAsDeploymentFailure set to true + fakeEnv := setupPolicyE2E(t, dataDir, extName, seqNum, srv.URL+"/script.sh", true, policy) + + scriptWasExecuted := false + RunCmd = func(ctx *log.Context, dir, scriptFilePath string, cfg *handlersettings.HandlerSettings, metadata types.RCMetadata) (error, int) { + scriptWasExecuted = true + return nil, 0 + } + + err = commandProcessor.ProcessHandlerCommandWithDetails(ctx, CmdEnable, fakeEnv, extName, seqNum, constants.DownloadFolder, dataDir) + require.Nil(t, err, "enable command should succeed") + require.False(t, scriptWasExecuted, "disallowed script should not be executed") + + report := readStatusReport(t, fakeEnv, extName, seqNum) // verify status report exists and is valid + require.Equal(t, types.StatusError, report[0].Status.Status, "status report should indicate failure") + require.True(t, strings.Contains(report[0].Status.FormattedMessage.Message, "executionState\":\"Failed\",\"executionMessage\":\"Execution failed"), "execution message should indicate failure") +} diff --git a/internal/commandProcessor/commandProcessor.go b/internal/commandProcessor/commandProcessor.go index 19cab6e..25ca5e5 100644 --- a/internal/commandProcessor/commandProcessor.go +++ b/internal/commandProcessor/commandProcessor.go @@ -46,7 +46,7 @@ func ProcessImmediateHandlerCommand(cmd types.Cmd, hs handlersettings.HandlerSet } // Store handler settings locally before moving forward... - return ProcessHandlerCommandWithDetails(ctx, cmd, hEnv, extensionName, seqNum, constants.ImmediateDownloadFolder) + return ProcessHandlerCommandWithDetails(ctx, cmd, hEnv, extensionName, seqNum, constants.ImmediateDownloadFolder, constants.DataDir) } func ProcessHandlerCommand(cmd types.Cmd) error { @@ -65,10 +65,10 @@ func ProcessHandlerCommand(cmd types.Cmd) error { return errors.Wrap(err, "failed on pre steps") } - return ProcessHandlerCommandWithDetails(ctx, cmd, hEnv, extensionName, seqNum, constants.DownloadFolder) + return ProcessHandlerCommandWithDetails(ctx, cmd, hEnv, extensionName, seqNum, constants.DownloadFolder, constants.DataDir) } -func ProcessHandlerCommandWithDetails(ctx *log.Context, cmd types.Cmd, hEnv types.HandlerEnvironment, extensionName string, seqNum int, downloadFolder string) error { +func ProcessHandlerCommandWithDetails(ctx *log.Context, cmd types.Cmd, hEnv types.HandlerEnvironment, extensionName string, seqNum int, downloadFolder string, dataDir string) error { ctx.Log("message", fmt.Sprintf("processing command for extensionName: %v and seqNum: %v", extensionName, seqNum)) instView := types.RunCommandInstanceView{ ExecutionState: types.Running, @@ -80,7 +80,7 @@ func ProcessHandlerCommandWithDetails(ctx *log.Context, cmd types.Cmd, hEnv type EndTime: "", } - metadata := types.NewRCMetadata(extensionName, seqNum, downloadFolder, constants.DataDir) + metadata := types.NewRCMetadata(extensionName, seqNum, downloadFolder, dataDir) instanceview.ReportInstanceView(ctx, hEnv, metadata, types.StatusTransitioning, cmd, &instView) // execute the subcommand diff --git a/internal/constants/constants.go b/internal/constants/constants.go index fe7f0da..c54965b 100755 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -77,4 +77,12 @@ const ( // The name of the file that contains the immediate goal states that reached the terminal status ImmediateGoalStatesInTerminalStatusFileName = "immediateGoalStatesInTerminalStatusFile.status" + + // The name of the policy file the extension uses to validate the command + scripts before execution. + PolicyFileName = "waagent_runtime_policy.json" + + // Name of our ICM queue for service errors. Right now, this is only displayed in logs for extension policy errors. + ICMQueueName = "AzureRT\\Extensions" + + ContactICMForServiceErrorsMessage = "Contact ICM team " + ICMQueueName + " for this service error" ) diff --git a/internal/constants/exitcodes.go b/internal/constants/exitcodes.go index 7414ec6..6ce07ca 100755 --- a/internal/constants/exitcodes.go +++ b/internal/constants/exitcodes.go @@ -5,9 +5,13 @@ const ( ExitCode_Okay = 0 // User errors (-100s): - ExitCode_ScriptBlobDownloadFailed = -100 - ExitCode_BlobCreateOrReplaceFailed = -101 - ExitCode_RunAsLookupUserFailed = -102 + ExitCode_ScriptBlobDownloadFailed = -100 + ExitCode_BlobCreateOrReplaceFailed = -101 + ExitCode_RunAsLookupUserFailed = -102 + ExitCode_ScriptTypeNotAllowedByExtensionPolicy = -103 + ExitCode_CommandIdNotAllowedByExtensionPolicy = -104 + ExitCode_RunAsUserNotAllowedByExtensionPolicy = -105 + ExitCode_DownloadedScriptBlockedByExtensionPolicy = -106 // Service Errors (-200s): ExitCode_CreateDataDirectoryFailed = -200 @@ -35,6 +39,12 @@ const ( ExitCode_ImmediateTaskTimeout = -222 ExitCode_ImmediateTaskFailed = -223 ExitCode_CouldNotRehydrateMrSeq = -224 + ExitCode_LoadExtensionPolicySettingsFailed = -225 + ExitCode_InitializeCalledWithNoPolicyPath = -226 + ExitCode_FailedToCreateExtensionPolicySettingsManager = -227 + ExitCode_FailedToGetExtensionPolicySettings = -228 + ExitCode_ExtensionPolicyInvalid = -229 + ExitCode_ValidateCalledWithNilPolicy = -230 // Unknown errors (-300s): ) diff --git a/internal/extensionpolicysettingsrc/extensionpolicysettingsrc.go b/internal/extensionpolicysettingsrc/extensionpolicysettingsrc.go new file mode 100644 index 0000000..0ff0a6f --- /dev/null +++ b/internal/extensionpolicysettingsrc/extensionpolicysettingsrc.go @@ -0,0 +1,106 @@ +package extensionpolicysettingsrc + +import ( + "fmt" + "strings" + + "github.com/Azure/azure-extension-platform/pkg/extensionpolicysettings" + "github.com/Azure/run-command-handler-linux/internal/constants" + "github.com/Azure/run-command-handler-linux/internal/handlersettings" + "github.com/go-kit/kit/log" + "github.com/pkg/errors" +) + +func InitializeExtensionPolicySettings(ctx *log.Context, policyPath string) (*extensionpolicysettings.ExtensionPolicySettingsManager[RCv2ExtensionPolicySettings], *RCv2ExtensionPolicySettings, error, int) { + if policyPath == "" { + err := fmt.Errorf("policy path to initialize extension policy settings is empty") + ctx.Log("message", "policy path is empty. "+constants.ContactICMForServiceErrorsMessage, "error", err) + return nil, nil, err, constants.ExitCode_InitializeCalledWithNoPolicyPath + } + extensionPolicyManager, err := extensionpolicysettings.NewExtensionPolicySettingsManager[RCv2ExtensionPolicySettings](policyPath) + if err != nil { + // Manager only fails to be created if policy path is empty, so this shouldn't fail. + err = errors.Wrap(err, "failed to create extension policy settings manager. Ensure the policy path is valid") + ctx.Log("message", "failed to create extension policy settings manager. "+constants.ContactICMForServiceErrorsMessage, "error", err, "policyPath", policyPath) + return nil, nil, err, constants.ExitCode_FailedToCreateExtensionPolicySettingsManager + } + + err = extensionPolicyManager.LoadExtensionPolicySettings() + if err != nil { + err = errors.Wrap(err, "failed to load extension policy settings from file. Ensure the policy format is valid and the file is accessible") + ctx.Log("message", "failed to load extension policy settings. "+constants.ContactICMForServiceErrorsMessage, "error", err, "policyPath", policyPath) + return nil, nil, err, constants.ExitCode_LoadExtensionPolicySettingsFailed + } + + rceps, err := extensionPolicyManager.GetSettings() //rceps is the pointer to the actual policy struct + if err != nil { + err = errors.Wrap(err, "failed to get extension policy settings after loading") + ctx.Log("message", "failed to get extension policy settings. "+constants.ContactICMForServiceErrorsMessage, "error", err, "policyPath", policyPath) + return nil, nil, err, constants.ExitCode_FailedToGetExtensionPolicySettings + } + return extensionPolicyManager, rceps, nil, 0 +} + +func ValidateHandlerSettingsAgainstPolicy(ctx *log.Context, settings *handlersettings.HandlerSettings, policy *RCv2ExtensionPolicySettings) (error, int) { + if policy == nil { + ctx.Log("message", "no policy provided for extension policy settings") + return fmt.Errorf("no policy provided to validate handler settings"), constants.ExitCode_ValidateCalledWithNilPolicy + } + if err := ValidateScriptTypeAgainstPolicy(ctx, settings.ScriptType(), policy.LimitScripts); err != nil { + return err, constants.ExitCode_ScriptTypeNotAllowedByExtensionPolicy + } + if settings.ScriptType() == handlersettings.CommandIdScript { + if err := ValidateCommandId(ctx, settings, policy); err != nil { + return err, constants.ExitCode_CommandIdNotAllowedByExtensionPolicy + } + } + if policy.RunAsUser != "" { + if err := ValidateRunAsUser(ctx, settings, policy); err != nil { + return err, constants.ExitCode_RunAsUserNotAllowedByExtensionPolicy + } + } + + // TO-DO: Validate Disable Outputblob and RequireSigning once those features are implemented for RCv2. + + return nil, 0 +} + +func ValidateScriptTypeAgainstPolicy(ctx *log.Context, scriptType handlersettings.ScriptType, allowedScriptTypesString string) error { + allowedScriptTypes, _ := StringToAllowedScriptTypeFlag(allowedScriptTypesString) + // Compare the script type of the command with the allowed script types in the policy. + err := CompareScriptTypeToAllowedScriptType(scriptType, allowedScriptTypes) + if err != nil { + ctx.Log("message", "script type not allowed by policy", "error", err, "scriptType", scriptType) + return errors.Wrapf(err, "script type %s is not allowed by policy", scriptType) + } + return nil +} + +func ValidateCommandId(ctx *log.Context, settings *handlersettings.HandlerSettings, policy *RCv2ExtensionPolicySettings) error { + settingsCommandId := settings.CommandId() + allowedCommandIds := policy.CommandIdAllowlist + + if len(allowedCommandIds) == 0 { + // if list is empty, all commandIds are allowed + ctx.Log("message", "allowedCommandID list empty, allowing all commands") + return nil + } + err := extensionpolicysettings.ValidateValueInAllowlist(settingsCommandId, allowedCommandIds) + if err != nil { + ctx.Log("message", "command ID is not allowed by policy", "error", err, "commandId", settingsCommandId) + return errors.Wrapf(err, "command ID %s is not allowed by policy", settingsCommandId) + } + return nil +} + +func ValidateRunAsUser(ctx *log.Context, settings *handlersettings.HandlerSettings, policy *RCv2ExtensionPolicySettings) error { + settingsRunAsUser := strings.ToLower(strings.TrimSpace(settings.RunAsUser)) + policyRunAsUser := strings.ToLower(strings.TrimSpace(policy.RunAsUser)) + + if strings.Compare(settingsRunAsUser, policyRunAsUser) != 0 { + err := fmt.Errorf("runAsUser '%s' in settings does not match runAsUser '%s' in policy", settingsRunAsUser, policyRunAsUser) + ctx.Log("message", "runAsUser settings does not match runAsUser in policy", "error", err, "settingsRunAsUser", settingsRunAsUser, "policyRunAsUser", policyRunAsUser) + return err + } + return nil +} diff --git a/internal/extensionpolicysettingsrc/extensionpolicysettingsrc_test.go b/internal/extensionpolicysettingsrc/extensionpolicysettingsrc_test.go new file mode 100644 index 0000000..54f938b --- /dev/null +++ b/internal/extensionpolicysettingsrc/extensionpolicysettingsrc_test.go @@ -0,0 +1,252 @@ +package extensionpolicysettingsrc + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Azure/run-command-handler-linux/internal/constants" + "github.com/Azure/run-command-handler-linux/internal/handlersettings" + "github.com/go-kit/kit/log" + "github.com/stretchr/testify/require" +) + +func makeSettings(scriptType handlersettings.ScriptType, commandID string, runAsUser string, outputBlobURI string) *handlersettings.HandlerSettings { + return &handlersettings.HandlerSettings{ + PublicSettings: handlersettings.PublicSettings{ + Source: &handlersettings.ScriptSource{ + ScriptType: scriptType, + CommandId: commandID, + }, + RunAsUser: runAsUser, + OutputBlobURI: outputBlobURI, + }, + } +} + +func TestInitializeExtensionPolicySettings_EmptyPath_ReturnsError(t *testing.T) { + _, _, err, exitCode := InitializeExtensionPolicySettings(nopCtx(), "") + require.Error(t, err) + require.Contains(t, err.Error(), "policy path to initialize extension policy settings is empty") + require.Equal(t, constants.ExitCode_InitializeCalledWithNoPolicyPath, exitCode) +} +func TestInitializeExtensionPolicySettings_InvalidPath_ReturnsError(t *testing.T) { + _, _, err, exitCode := InitializeExtensionPolicySettings(nopCtx(), "/definitely/not/found/policy.json") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to load extension policy settings from file. Ensure the policy format is valid and the file is accessible") + require.Equal(t, constants.ExitCode_LoadExtensionPolicySettingsFailed, exitCode) +} + +func TestInitializeExtensionPolicySettings_InvalidPolicyFails(t *testing.T) { + tmpDir := t.TempDir() + policyPath := filepath.Join(tmpDir, "policy.json") + + payload := `{"blah blah"}` + err := os.WriteFile(policyPath, []byte(payload), 0600) + require.NoError(t, err) + + _, _, err, exitCode := InitializeExtensionPolicySettings(nopCtx(), policyPath) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to load extension policy settings from file. Ensure the policy format is valid and the file is accessible") + require.Equal(t, constants.ExitCode_LoadExtensionPolicySettingsFailed, exitCode) +} + +func TestInitializeExtensionPolicySettings_ValidFile_ReturnsNil(t *testing.T) { + tmpDir := t.TempDir() + policyPath := filepath.Join(tmpDir, "policy.json") + + // Minimal valid payload for current ValidateFormat behavior. + err := os.WriteFile(policyPath, []byte("{}"), 0600) + require.NoError(t, err) + + _, _, err, exitCode := InitializeExtensionPolicySettings(nopCtx(), policyPath) + require.NoError(t, err) + require.Equal(t, 0, exitCode) +} + +func TestInitializeExtensionPolicySettings_PopulatesOutputStruct(t *testing.T) { + tmpDir := t.TempDir() + policyPath := filepath.Join(tmpDir, "policy.json") + + payload := `{"limitScripts":"inline","runAsUser":"alice"}` + err := os.WriteFile(policyPath, []byte(payload), 0600) + require.NoError(t, err) + + out := &RCv2ExtensionPolicySettings{} + + _, out, err, exitCode := InitializeExtensionPolicySettings(nopCtx(), policyPath) + require.NoError(t, err) + require.Equal(t, 0, exitCode) + + require.Equal(t, "inline", out.LimitScripts) + require.Equal(t, "alice", out.RunAsUser) +} + +// Test that validation passes and fails as expected. +func TestValidateHandlerSettingsAgainstPolicy(t *testing.T) { + t.Run("nil policy", func(t *testing.T) { + settings := makeSettings(handlersettings.InlineScript, "", "", "") + err, exitCode := ValidateHandlerSettingsAgainstPolicy(nopCtx(), settings, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "no policy provided to validate handler settings") + require.Equal(t, constants.ExitCode_ValidateCalledWithNilPolicy, exitCode) + }) + + // This test mimicks running an inline script, but policy only allows gallery scripts. + // Validation fails. + t.Run("script type blocked by policy", func(t *testing.T) { + settings := makeSettings(handlersettings.InlineScript, "", "", "") + policy := &RCv2ExtensionPolicySettings{ + LimitScripts: "gallery", + } + + err, exitCode := ValidateHandlerSettingsAgainstPolicy(nopCtx(), settings, policy) + require.Error(t, err) + require.Contains(t, err.Error(), "script type inline is not allowed by policy") + require.Equal(t, constants.ExitCode_ScriptTypeNotAllowedByExtensionPolicy, exitCode) + }) + + // This test mimicks running a commandId that is not in the allowlist. + // Additionally, only commandId types are allowed. + t.Run("command ID not in allowlist", func(t *testing.T) { + settings := makeSettings(handlersettings.CommandIdScript, "restartVM", "", "") + policy := &RCv2ExtensionPolicySettings{ + LimitScripts: "allowedcommandid", + CommandIdAllowlist: []string{"safeCommand"}, + } + + err, exitCode := ValidateHandlerSettingsAgainstPolicy(nopCtx(), settings, policy) + require.Error(t, err) + require.Equal(t, constants.ExitCode_CommandIdNotAllowedByExtensionPolicy, exitCode) + }) + + t.Run("runAs mismatch", func(t *testing.T) { + settings := makeSettings(handlersettings.InlineScript, "", "bob", "") + policy := &RCv2ExtensionPolicySettings{ + LimitScripts: "inline", + RunAsUser: "alice", + } + + err, exitCode := ValidateHandlerSettingsAgainstPolicy(nopCtx(), settings, policy) + require.Error(t, err) + require.Contains(t, err.Error(), "does not match") + require.Equal(t, constants.ExitCode_RunAsUserNotAllowedByExtensionPolicy, exitCode) + }) + + t.Run("enforce limitScripts must be set. If not set, all commands fail", func(t *testing.T) { + settings := makeSettings(handlersettings.CommandIdScript, "safeCommand", " Alice ", "https://example/blob") + policy := &RCv2ExtensionPolicySettings{ + LimitScripts: "", + CommandIdAllowlist: []string{"safeCommand"}, + RunAsUser: "Alice", + DisableOutputBlobs: true, + } + + err, exitCode := ValidateHandlerSettingsAgainstPolicy(nopCtx(), settings, policy) + require.Contains(t, err.Error(), "script type commandId is not allowed by policy") + require.Equal(t, constants.ExitCode_ScriptTypeNotAllowedByExtensionPolicy, exitCode) + }) + + t.Run("all checks pass commandId", func(t *testing.T) { + settings := makeSettings(handlersettings.CommandIdScript, "safeCommand", " Alice ", "https://example/blob") + policy := &RCv2ExtensionPolicySettings{ + LimitScripts: "allowall", + CommandIdAllowlist: []string{"safeCommand"}, + RunAsUser: "alice", + DisableOutputBlobs: true, + } + + err, exitCode := ValidateHandlerSettingsAgainstPolicy(nopCtx(), settings, policy) + require.NoError(t, err) + require.Equal(t, 0, exitCode) + }) + + t.Run("all checks pass downloadedScript", func(t *testing.T) { + settings := makeSettings(handlersettings.DownloadedScript, "safeCommand", " Alice ", "https://example/blob") + policy := &RCv2ExtensionPolicySettings{ + LimitScripts: "alloweddownloaded", + CommandIdAllowlist: []string{"safeCommand"}, + RunAsUser: "alice", + DisableOutputBlobs: true, + } + + err, exitCode := ValidateHandlerSettingsAgainstPolicy(nopCtx(), settings, policy) + require.NoError(t, err) + require.Equal(t, 0, exitCode) + }) +} + +func TestValidateScriptTypeAgainstPolicy(t *testing.T) { + t.Run("allowed", func(t *testing.T) { + err := ValidateScriptTypeAgainstPolicy(nopCtx(), handlersettings.InlineScript, "inline") + require.NoError(t, err) + }) + + t.Run("blocked", func(t *testing.T) { + err := ValidateScriptTypeAgainstPolicy(nopCtx(), handlersettings.GalleryScript, "inline") + require.Error(t, err) + require.Contains(t, err.Error(), "script type gallery is not allowed by policy") + }) + + // This tests edge case where policy has an invalid script type token. + t.Run("invalid policy token is treated as blocked", func(t *testing.T) { + err := ValidateScriptTypeAgainstPolicy(nopCtx(), handlersettings.InlineScript, "notARealScriptType") + require.Error(t, err) + require.Contains(t, err.Error(), "script type inline is not allowed by policy") + }) +} + +func TestValidateCommandId(t *testing.T) { + t.Run("empty allowlist allows all", func(t *testing.T) { + settings := makeSettings(handlersettings.CommandIdScript, "anything", "", "") + policy := &RCv2ExtensionPolicySettings{ + CommandIdAllowlist: nil, + } + err := ValidateCommandId(nopCtx(), settings, policy) + require.NoError(t, err) + }) + + t.Run("value present in allowlist", func(t *testing.T) { + settings := makeSettings(handlersettings.CommandIdScript, "safeCommand", "", "") + policy := &RCv2ExtensionPolicySettings{ + CommandIdAllowlist: []string{"safeCommand", "other"}, + } + err := ValidateCommandId(nopCtx(), settings, policy) + require.NoError(t, err) + }) + + t.Run("value missing from allowlist", func(t *testing.T) { + settings := makeSettings(handlersettings.CommandIdScript, "restartVM", "", "") + policy := &RCv2ExtensionPolicySettings{ + CommandIdAllowlist: []string{"safeCommand", "other"}, + } + err := ValidateCommandId(nopCtx(), settings, policy) + require.Contains(t, err.Error(), "command ID restartVM is not allowed by policy") + require.Contains(t, err.Error(), "item is not in the allowlist") + }) +} + +func TestValidateRunAsUser(t *testing.T) { + t.Run("match with whitespace and case differences", func(t *testing.T) { + settings := makeSettings(handlersettings.InlineScript, "", " Alice ", "") + policy := &RCv2ExtensionPolicySettings{ + RunAsUser: "alice", + } + err := ValidateRunAsUser(nopCtx(), settings, policy) + require.NoError(t, err) + }) + + t.Run("mismatch", func(t *testing.T) { + settings := makeSettings(handlersettings.InlineScript, "", "bob", "") + policy := &RCv2ExtensionPolicySettings{ + RunAsUser: "alice", + } + err := ValidateRunAsUser(nopCtx(), settings, policy) + require.Error(t, err) + require.Contains(t, err.Error(), "runAsUser 'bob' in settings does not match runAsUser 'alice' in policy") + }) +} + +func nopCtx() *log.Context { + return log.NewContext(log.NewNopLogger()) +} diff --git a/internal/extensionpolicysettingsrc/types.go b/internal/extensionpolicysettingsrc/types.go new file mode 100644 index 0000000..f6eea20 --- /dev/null +++ b/internal/extensionpolicysettingsrc/types.go @@ -0,0 +1,141 @@ +package extensionpolicysettingsrc + +import ( + "fmt" + "strings" + + "github.com/Azure/run-command-handler-linux/internal/handlersettings" +) + +// This refers *specifically* to file types that require signature verification +// when RequireSigning is enabled for RCv2. This is not a general enum for all file types in the extension. +// Non-script file types include binaries, parameter files, etc. +type FileType string + +const ( + All FileType = "all" + NoFiles FileType = "none" // Named NoFiles instead of None to avoid conflict with ScriptType.None below. + Scripts FileType = "scripts" +) + +// AllowedScriptType is a bitmask enum that defines which types of scripts run command is +// allowed to execute based on customer policy. This should always match the AllowedScriptType in RCv2 Windows. +type AllowedScriptTypeFlag uint32 + +const ( + AllowedCommandId = 1 << iota + Gallery + Diagnostic + Inline + AllowedDownloaded + AllowAll = AllowedCommandId | Gallery | Diagnostic | Inline | AllowedDownloaded + AllowedScriptNone = 0 +) + +func StringToAllowedScriptTypeFlag(s string) (AllowedScriptTypeFlag, error) { + // lowercase the input to make the parsing case-insensitive + s = strings.ToLower(s) + // trim whitespace and split by comma + s = strings.TrimSpace(s) + parts := strings.Split(s, ",") + + var flag AllowedScriptTypeFlag + for _, part := range parts { + part = strings.TrimSpace(part) + switch part { + case "inline": + flag |= Inline + case "alloweddownloaded": + flag |= AllowedDownloaded + case "gallery": + flag |= Gallery + case "diagnostic": + flag |= Diagnostic + case "allowedcommandid": + flag |= AllowedCommandId + case "allowall": + flag |= AllowAll + // TO-DO: consider the case where 'none' scripts are allowed to run. + default: + return 0, fmt.Errorf("Unknown script type in policy: %s", part) + } + } + return flag, nil +} + +// RCv2ExtensionPolicySettings defines the structure of the policy file for RCv2. +// RequireSigning: describes the types of files that require signature verification. +// FileRootCert: the root certificate used for signature verification. Required if RequireSigning is not "none". +// DownloadedScriptsAllowlist: if scripts are limited to a specific allowlist, this is the list of hashes of the allowed scripts. +// CommandIdAllowlist: if commandId scripts are allowed only from specific commandIds, this is the list of allowed commandIds. +// RunAsUser: the only user with permission to run scripts. If another user tries to run a script, the command will fail. +// LimitScripts: the types of scripts that are allowed to be executed. +type RCv2ExtensionPolicySettings struct { + // RequireSigning FileType `json:"requireSigning"` + // FileRootCert string `json:"fileRootCert,omitempty"` + DownloadedScriptsAllowlist []string `json:"downloadedScriptsAllowlist,omitempty"` + CommandIdAllowlist []string `json:"commandIdAllowlist,omitempty"` + RunAsUser string `json:"runAsUser,omitempty"` + LimitScripts string `json:"limitScripts,omitempty"` + DisableOutputBlobs bool `json:"disableOutputBlobs,omitempty"` +} + +// This function is called from within the LoadExtensionPolicySettings function in extensionpolicysettings.go +// to validate the format of our policy. +func (rceps RCv2ExtensionPolicySettings) ValidateFormat() error { + flag, err := StringToAllowedScriptTypeFlag(string(rceps.LimitScripts)) + // Requirements: + // 1. If RequireSigning is not "none", FileRootCert must be present and non-empty. + // TO-DO: implement RequireSigning and FileRootCert validation once signature verification is implemented for RCv2. + // 2. LimitScripts must be a valid AllowedScriptType value. so map/check the value to the AllowedScriptTypeFlag bitmask. + if rceps.LimitScripts != "" { + if err != nil { + return fmt.Errorf("at least one of the values in LimitScripts is not a valid script type: %v", rceps.LimitScripts) + } + } + // 3. If DownloadedScriptsAllowlist is not empty, limit scripts must allow "downloaded" scripts. + if len(rceps.DownloadedScriptsAllowlist) > 0 { + if (flag & AllowedDownloaded) == 0 { + return fmt.Errorf("DownloadedScriptsAllowlist not empty, but LimitScripts does not allow 'downloaded' scripts") + } + } + // 4. If CommandIdAllowlist is not empty, limit scripts must allow "commandId" scripts. + if len(rceps.CommandIdAllowlist) > 0 { + if (flag & AllowedCommandId) == 0 { + return fmt.Errorf("CommandIdAllowlist not empty, but LimitScripts does not allow 'commandId' scripts") + } + } + return nil +} + +// This function compares a script type (of string type ScriptType, defined in this file) to the allowed script types +// (of type AllowedScriptTypeFlag, also defined in this file) listed in the policy. +// Depending on the string case (the value of scriptType), it checks if the corresponding bit is enabled in the allowed script types bitmask. +// These values and mappings are specific to Run Command, hence why they are defined here and not in the shared library. +func CompareScriptTypeToAllowedScriptType(scriptType handlersettings.ScriptType, allowedScriptTypes AllowedScriptTypeFlag) error { + switch scriptType { + case handlersettings.InlineScript: + if (allowedScriptTypes & Inline) == 0 { + return fmt.Errorf("inline scripts are not allowed by policy") + } + case handlersettings.DownloadedScript: + if (allowedScriptTypes & AllowedDownloaded) == 0 { + return fmt.Errorf("downloaded scripts are not allowed by policy") + } + case handlersettings.GalleryScript: + if (allowedScriptTypes & Gallery) == 0 { + return fmt.Errorf("gallery scripts are not allowed by policy") + } + case handlersettings.DiagnosticScript: + if (allowedScriptTypes & Diagnostic) == 0 { + return fmt.Errorf("diagnostic scripts are not allowed by policy") + } + case handlersettings.CommandIdScript: + if (allowedScriptTypes & AllowedCommandId) == 0 { + return fmt.Errorf("commandId scripts are not allowed by policy") + } + default: + return fmt.Errorf("unknown script type: %v", scriptType) + } + return nil +} diff --git a/internal/extensionpolicysettingsrc/types_test.go b/internal/extensionpolicysettingsrc/types_test.go new file mode 100644 index 0000000..618018d --- /dev/null +++ b/internal/extensionpolicysettingsrc/types_test.go @@ -0,0 +1,253 @@ +package extensionpolicysettingsrc + +import ( + "testing" + + "github.com/Azure/run-command-handler-linux/internal/handlersettings" + "github.com/stretchr/testify/require" +) + +func TestTypeDefinitions_AreStable(t *testing.T) { + t.Run("file type values are stable", func(t *testing.T) { + require.Equal(t, "all", string(All)) + require.Equal(t, "none", string(NoFiles)) + require.Equal(t, "scripts", string(Scripts)) + }) + + t.Run("allowed script flag values are stable", func(t *testing.T) { + tests := []struct { + name string + got AllowedScriptTypeFlag + want AllowedScriptTypeFlag + }{ + {name: "AllowedScriptNone", got: AllowedScriptNone, want: 0}, + {name: "AllowedCommandId", got: AllowedCommandId, want: 1}, + {name: "Gallery", got: Gallery, want: 2}, + {name: "Diagnostic", got: Diagnostic, want: 4}, + {name: "Inline", got: Inline, want: 8}, + {name: "AllowedDownloaded", got: AllowedDownloaded, want: 16}, + {name: "AllowAll", got: AllowAll, want: 31}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, tt.got) + }) + } + }) + + t.Run("script type values are stable", func(t *testing.T) { + tests := []struct { + name string + got handlersettings.ScriptType + want handlersettings.ScriptType + }{ + {name: "InlineScript", got: handlersettings.InlineScript, want: "inline"}, + {name: "DownloadedScript", got: handlersettings.DownloadedScript, want: "downloaded"}, + {name: "GalleryScript", got: handlersettings.GalleryScript, want: "gallery"}, + {name: "DiagnosticScript", got: handlersettings.DiagnosticScript, want: "diagnostic"}, + {name: "CommandIdScript", got: handlersettings.CommandIdScript, want: "commandId"}, + {name: "NoneScript", got: handlersettings.NoneScript, want: "none"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, tt.got) + }) + } + }) +} + +func TestStringToAllowedScriptTypeFlag(t *testing.T) { + tests := []struct { + name string + input string + want AllowedScriptTypeFlag + wantErr string + }{ + { + name: "inline", + input: "inline", + want: Inline, + }, + { + name: "inline, gallery", + input: "inline,gallery", + want: Inline | Gallery, + }, + { + name: "allowed command ID, gallery, inline", + input: "allowedcommandid,gallery,inline", + want: AllowedCommandId | Gallery | Inline, + }, + { + name: "all explicit types", + input: "alloweddownloaded,allowedcommandid,diagnostic,inline,gallery", + want: AllowedDownloaded | AllowedCommandId | Diagnostic | Inline | Gallery, + }, + { + name: "allow all", + input: "allowall", + want: AllowAll, + }, + { + name: "whitespace and capitalization test", + input: " InLiNe , GALLERY , allowedCommandId ", + want: Inline | Gallery | AllowedCommandId, + }, + { + name: "unknown string", + input: "inline,banana", + wantErr: "Unknown script type in policy: banana", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := StringToAllowedScriptTypeFlag(tt.input) + + if tt.wantErr != "" { + require.Error(t, err) + require.Equal(t, tt.wantErr, err.Error()) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestValidateFormat(t *testing.T) { + tests := []struct { + name string + input RCv2ExtensionPolicySettings + wantErr string + }{ + { + name: "valid policy", + input: RCv2ExtensionPolicySettings{ + LimitScripts: "alloweddownloaded,allowedcommandid,diagnostic,inline,gallery", + DownloadedScriptsAllowlist: []string{"hash1"}, + CommandIdAllowlist: []string{"cmd1"}, + RunAsUser: "alice", + DisableOutputBlobs: true, + }, + }, + { + name: "invalid value for limit scripts", + input: RCv2ExtensionPolicySettings{ + LimitScripts: "inline,notARealType", + }, + wantErr: "at least one of the values in LimitScripts is not a valid script type: inline,notARealType", + }, + { + name: "downloaded allowlist present, but downloaded scripts are blocked", + input: RCv2ExtensionPolicySettings{ + LimitScripts: "inline,gallery", + DownloadedScriptsAllowlist: []string{"hash1"}, + }, + wantErr: "DownloadedScriptsAllowlist not empty, but LimitScripts does not allow 'downloaded' scripts", + }, + { + name: "command ID allowlist present, but command IDs are blocked", + input: RCv2ExtensionPolicySettings{ + LimitScripts: "inline,gallery", + CommandIdAllowlist: []string{"cmd1"}, + }, + wantErr: "CommandIdAllowlist not empty, but LimitScripts does not allow 'commandId' scripts", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.input.ValidateFormat() + + if tt.wantErr != "" { + require.Error(t, err) + require.Equal(t, tt.wantErr, err.Error()) + return + } + + require.NoError(t, err) + }) + } +} + +func TestCompareScriptTypeToAllowedScriptType(t *testing.T) { + tests := []struct { + name string + scriptType handlersettings.ScriptType + allowed AllowedScriptTypeFlag + wantErr string + }{ + { + name: "none allowed, gallery denied", + scriptType: handlersettings.GalleryScript, + allowed: AllowedScriptNone, + wantErr: "gallery scripts are not allowed by policy", + }, + { + name: "allow all, allow inline", + scriptType: handlersettings.InlineScript, + allowed: AllowAll, + }, + { + name: "allow all, allow downloaded", + scriptType: handlersettings.DownloadedScript, + allowed: AllowAll, + }, + { + name: "allow all, allow gallery", + scriptType: handlersettings.GalleryScript, + allowed: AllowAll, + }, + { + name: "allow all, allow diagnostic", + scriptType: handlersettings.DiagnosticScript, + allowed: AllowAll, + }, + { + name: "allow all, allow command id", + scriptType: handlersettings.CommandIdScript, + allowed: AllowAll, + }, + { + name: "diagnostic only, inline denied", + scriptType: handlersettings.InlineScript, + allowed: Diagnostic, + wantErr: "inline scripts are not allowed by policy", + }, + { + name: "allowed downloaded, allow downloaded", + scriptType: handlersettings.DownloadedScript, + allowed: AllowedDownloaded, + }, + { + name: "unknown script type", + scriptType: handlersettings.ScriptType("made-up"), + allowed: AllowAll, + wantErr: "unknown script type: made-up", + }, + { + name: "'none' script currently treated as unknown", + scriptType: handlersettings.NoneScript, + allowed: AllowAll, + wantErr: "unknown script type: none", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CompareScriptTypeToAllowedScriptType(tt.scriptType, tt.allowed) + + if tt.wantErr != "" { + require.Error(t, err) + require.Equal(t, tt.wantErr, err.Error()) + return + } + + require.NoError(t, err) + }) + } +} diff --git a/internal/handlersettings/types.go b/internal/handlersettings/types.go index 49447a6..4d68321 100644 --- a/internal/handlersettings/types.go +++ b/internal/handlersettings/types.go @@ -10,6 +10,25 @@ type HandlerSettings struct { ProtectedSettings } +// ScriptType refers to the type of script being executed in a run command. +// This type defintion matches the ScriptType definition in CRP in the Run Command Handler, +// and should always be kept in sync with the ScriptType definition in RCv2 Windows. +// None is defined in the case where no script is passed down, which is a valid scenario. +// +// Note: although this is a property that was introduced due to Extension Policy Settings, +// it is defined here to avoid a circular dependency between handlersettings and extensionpolicysettingsrc. +// CRP has been modified to also pass down the ScriptType, so it is appropriately defined here. +type ScriptType string + +const ( + InlineScript ScriptType = "inline" + DownloadedScript ScriptType = "downloaded" + GalleryScript ScriptType = "gallery" + DiagnosticScript ScriptType = "diagnostic" + CommandIdScript ScriptType = "commandId" + NoneScript ScriptType = "none" +) + // Gets the InstallAsService field from the RunCommand's properties func (s HandlerSettings) InstallAsService() bool { return s.PublicSettings.InstallAsService @@ -23,6 +42,14 @@ func (s HandlerSettings) ScriptURI() string { return s.PublicSettings.Source.ScriptURI } +func (s HandlerSettings) CommandId() string { + return s.PublicSettings.Source.CommandId // Only applicable when the ScriptType is a CommandId. +} + +func (s HandlerSettings) ScriptType() ScriptType { + return s.PublicSettings.Source.ScriptType +} + func (s HandlerSettings) ScriptSAS() string { return s.ProtectedSettings.SourceSASToken } @@ -149,8 +176,10 @@ type RunCommandManagedIdentity struct { } type ScriptSource struct { - Script string `json:"script"` - ScriptURI string `json:"scriptUri"` + Script string `json:"script"` + ScriptURI string `json:"scriptUri"` + CommandId string `json:"commandId"` + ScriptType ScriptType `json:"scriptType"` } type ParameterDefinition struct { diff --git a/misc/HandlerManifest.json b/misc/HandlerManifest.json index f5b1e52..cce9c66 100644 --- a/misc/HandlerManifest.json +++ b/misc/HandlerManifest.json @@ -6,6 +6,7 @@ "updateCommand": "bin/run-command-shim update", "enableCommand": "bin/run-command-shim enable", "disableCommand": "bin/run-command-shim disable", + "supportsPolicy": true, "rebootAfterInstall": false, "reportHeartbeat": false, "updateMode": "UpdateWithInstall",