diff --git a/build/common/installer/scripts/tomlparser-geneva-config.rb b/build/common/installer/scripts/tomlparser-geneva-config.rb index 2e28f6d46..75ed0d136 100644 --- a/build/common/installer/scripts/tomlparser-geneva-config.rb +++ b/build/common/installer/scripts/tomlparser-geneva-config.rb @@ -14,6 +14,124 @@ @disable_linux = false GENEVA_SUPPORTED_ENVIRONMENTS = ["Test", "Stage", "DiagnosticsProd", "FirstpartyProd", "BillingProd", "ExternalProd", "CaMooncake", "CaFairfax", "CaBlackforest", "Bleu"] + +# Everything under [integrations.geneva_logs] comes from the container-azm-ms-agentconfig config +# map, which is tenant writable, so it is untrusted input. On linux the generated +# geneva_config_env_var file is appended to ~/.bashrc and sourced by main.sh while the agent runs +# as root, so an unquoted value would be executed as shell. Every value is therefore validated +# against an anchored allowlist below and single quoted when written. +# The anchors must be \A and \z (not ^ and $) so that a value such as "prod\n" +# cannot pass validation by matching only its first line. + +# Geneva environment, account and namespace identifiers must start with a letter and then may +# contain letters, digits, underscores, hyphens or periods. +GENEVA_NAME_REGEX = /\A[A-Za-z][A-Za-z0-9_\-\.]{0,63}\z/ +# GCS region must start with a letter and contain only letters or digits. +GENEVA_REGION_REGEX = /\A[A-Za-z][A-Za-z0-9]{0,63}\z/ +# Agent xml config version, for example "1.0" or "Ver2v0". +GENEVA_CONFIG_VERSION_REGEX = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/ +# MUST be #, for example client_id# or mi_res_id#. +# The windows agent relies on the same shape when it splits the value on "#". +GENEVA_AUTH_ID_REGEX = /\A(?:client_id|object_id|mi_res_id)#[A-Za-z0-9._\-\/()]{1,512}\z/i +# Kubernetes namespaces are RFC 1123 labels. Keep these constants Geneva-specific because the +# unit test driver loads all common parser tests into one Ruby process. +GENEVA_KUBERNETES_NAMESPACE_REGEX = /\A[a-z0-9]([-a-z0-9]*[a-z0-9])?\z/ +GENEVA_KUBERNETES_NAMESPACE_MAX_LENGTH = 63 +# Infra namespaces may carry a trailing wildcard, which main.sh strips before using the value as +# part of a generated fluent-bit config file name. +GENEVA_INFRA_NAMESPACE_WILDCARD_SUFFIX = "-*" +GENEVA_DEFAULT_CONFIG_VERSION = "1.0" + +# Renders a value as a single quoted shell word. Every character inside single quotes is literal +# to the shell, and an embedded single quote is emitted as '\'' so that the value cannot terminate +# its own quoting. Values are validated before they reach this point; this is the last line of +# defense that keeps config map content out of the shell parser. +# The block form of gsub is required: with a replacement string ruby would treat the \' as the +# post-match backreference instead of a literal backslash and quote. +def toShellSingleQuoted(value) + return "'" + value.to_s.gsub("'") { "'\\''" } + "'" +end + +def isValidGenevaName(value) + return value.kind_of?(String) && !(value =~ GENEVA_NAME_REGEX).nil? +end + +def isValidGenevaRegion(region) + return region.kind_of?(String) && !(region =~ GENEVA_REGION_REGEX).nil? +end + +def isValidGenevaAuthId(authid) + return authid.kind_of?(String) && !(authid =~ GENEVA_AUTH_ID_REGEX).nil? +end + +def isValidGenevaConfigVersion(configVersion) + return configVersion.kind_of?(String) && !(configVersion =~ GENEVA_CONFIG_VERSION_REGEX).nil? +end + +# The environment identifiers of the airgap clouds are not known to the agent, which is why the +# allowlist below is a warning rather than a rejection. The character allowlist above is what +# makes the value safe to write into the environment file. +def isValidGenevaEnvironment(environment) + if !isValidGenevaName(environment) + return false + end + if !GENEVA_SUPPORTED_ENVIRONMENTS.map(&:downcase).include?(environment.downcase) + puts "config::geneva_logs::warn:geneva environment is not one of the known geneva environments" + end + return true +end + +def isValidKubernetesNamespace(namespace) + return namespace.kind_of?(String) && + namespace.length <= GENEVA_KUBERNETES_NAMESPACE_MAX_LENGTH && + !(namespace =~ GENEVA_KUBERNETES_NAMESPACE_REGEX).nil? +end + +def isValidGenevaInfraNamespace(namespace) + if !namespace.kind_of?(String) + return false + end + if namespace.end_with?(GENEVA_INFRA_NAMESPACE_WILDCARD_SUFFIX) + return isValidKubernetesNamespace(namespace[0...-GENEVA_INFRA_NAMESPACE_WILDCARD_SUFFIX.length]) + end + return isValidKubernetesNamespace(namespace) +end + +# Joins the namespaces that are safe to use into the comma separated list the agent expects. +# main.sh splits this list again and interpolates each entry into a file name and a sed +# expression, so an entry that is not a kubernetes namespace is dropped rather than passed on. +# The rejected entry is never echoed back since it is untrusted input. +def joinValidNamespaces(namespaces, settingName, isInfra) + validNamespaces = [] + if namespaces.nil? || !namespaces.kind_of?(Array) + return "" + end + namespaces.each do |namespace| + namespace = namespace.to_s.strip + next if namespace.empty? + isValid = isInfra ? isValidGenevaInfraNamespace(namespace) : isValidKubernetesNamespace(namespace) + if !isValid + ConfigParseErrorLogger.logError("Skipping an entry in #{settingName} because it is not a valid kubernetes namespace") + next + end + validNamespaces.push(namespace) + end + return validNamespaces.join(",") +end + +# Returns the config version to use, falling back to the default when the configured value is not +# usable. A typo in an optional setting should not take the integration down. +def resolveConfigVersion(configVersion, settingName) + if configVersion.nil? || configVersion.empty? + puts "Since #{settingName} not specified so using default config version : #{GENEVA_DEFAULT_CONFIG_VERSION}" + return GENEVA_DEFAULT_CONFIG_VERSION + end + if isValidGenevaConfigVersion(configVersion) + return configVersion + end + ConfigParseErrorLogger.logError("Invalid value specified for #{settingName}, using default config version : #{GENEVA_DEFAULT_CONFIG_VERSION}") + return GENEVA_DEFAULT_CONFIG_VERSION +end @geneva_account_environment = "" # Supported values Test, Stage, DiagnosticsProd, FirstpartyProd, BillingProd, ExternalProd, CaMooncake, CaFairfax, CaBlackforest, Bleu @geneva_account_name = "" @geneva_account_namespace = "" @@ -109,18 +227,10 @@ def populateSettingValuesFromConfigMap(parsedConfig) if @multi_tenancy # this is only applicable incase of multi-tenacy infra_namespaces = parsedConfig[:integrations][:geneva_logs][:infra_namespaces] - puts "config::geneva_logs:infra_namespaces provided in the configmap: #{infra_namespaces}" if !infra_namespaces.nil? && !infra_namespaces.empty? && infra_namespaces.kind_of?(Array) && infra_namespaces.length > 0 && infra_namespaces[0].kind_of?(String) # Checking only for the first element to be string because toml enforces the arrays to contain elements of same type - infra_namespaces.each do |namespace| - if @infra_namespaces.empty? - # To not append , for the first element - @infra_namespaces.concat(namespace) - else - @infra_namespaces.concat("," + namespace) - end - end + @infra_namespaces = joinValidNamespaces(infra_namespaces, "infra_namespaces", true) end enable_fbit_threading = parsedConfig[:integrations][:geneva_logs][:enable_threading].to_s puts "config::geneva_logs:enable_threading provided in the configmap: #{enable_fbit_threading}" @@ -168,16 +278,16 @@ def populateSettingValuesFromConfigMap(parsedConfig) @geneva_gcs_authid = geneva_gcs_authid if !geneva_logs_config_version.nil? && !geneva_logs_config_version.empty? - @geneva_logs_config_version = geneva_logs_config_version + @geneva_logs_config_version = resolveConfigVersion(geneva_logs_config_version, "configversion") else - @geneva_logs_config_version = "1.0" + @geneva_logs_config_version = GENEVA_DEFAULT_CONFIG_VERSION puts "Since config version not specified so using default config version : #{@geneva_logs_config_version}" end if !geneva_logs_config_version_windows.nil? && !geneva_logs_config_version_windows.empty? - @geneva_logs_config_version_windows = geneva_logs_config_version_windows + @geneva_logs_config_version_windows = resolveConfigVersion(geneva_logs_config_version_windows, "windowsconfigversion") else - @geneva_logs_config_version_windows = "1.0" + @geneva_logs_config_version_windows = GENEVA_DEFAULT_CONFIG_VERSION puts "Since config version for windows not specified so using default config version : #{@geneva_logs_config_version_windows}" end else @@ -187,18 +297,10 @@ def populateSettingValuesFromConfigMap(parsedConfig) if @multi_tenancy tenant_namespaces = parsedConfig[:integrations][:geneva_logs][:tenant_namespaces] - puts "config::geneva_logs:tenant_namespaces provided in the configmap: #{tenant_namespaces}" if !tenant_namespaces.nil? && !tenant_namespaces.empty? && tenant_namespaces.kind_of?(Array) && tenant_namespaces.length > 0 && tenant_namespaces[0].kind_of?(String) # Checking only for the first element to be string because toml enforces the arrays to contain elements of same type - tenant_namespaces.each do |namespace| - if @tenant_namespaces.empty? - # To not append , for the first element - @tenant_namespaces.concat(namespace) - else - @tenant_namespaces.concat("," + namespace) - end - end + @tenant_namespaces = joinValidNamespaces(tenant_namespaces, "tenant_namespaces", false) end end @@ -233,32 +335,37 @@ def populateSettingValuesFromConfigMap(parsedConfig) def isValidGenevaConfig(environment, namespace, namespacewindows, account, authid, region) isValid = false begin - if environment.nil? || environment.empty? + # The rejected values are never echoed back into the logs since they are untrusted input. + if environment.nil? || environment.empty? || !isValidGenevaEnvironment(environment) puts "config::geneva_logs::error:geneva environment MUST be valid" return isValid end - if namespace.nil? || namespace.empty? + if namespace.nil? || namespace.empty? || !isValidGenevaName(namespace) puts "config::geneva_logs::error:geneva account namespace MUST be valid" return isValid end - if region.nil? || region.empty? + if region.nil? || region.empty? || !isValidGenevaRegion(region) puts "config::geneva_logs::error:geneva GCS region MUST be valid" return isValid end - if authid.nil? || authid.empty? + if authid.nil? || authid.empty? || !isValidGenevaAuthId(authid) puts "config::geneva_logs::error:geneva GCS AuthID MUST be valid" return isValid end - ## namespacewindows is optional hence we dont need this validation - # if namespacewindows.nil? || namespacewindows.empty? - # puts "config::geneva_logs::error:geneva account namespace for windows MUST be valid" - # return isValid - # end - # TODO - add the validation once we figured out the environment for airgap clouds - # GENEVA_SUPPORTED_ENVIRONMENTS.map(&:downcase).include?(environment.downcase) + + ## account and namespacewindows are optional hence only the format is validated when provided + if !account.nil? && !account.empty? && !isValidGenevaName(account) + puts "config::geneva_logs::error:geneva account MUST be valid" + return isValid + end + + if !namespacewindows.nil? && !namespacewindows.empty? && !isValidGenevaName(namespacewindows) + puts "config::geneva_logs::error:geneva account namespace for windows MUST be valid" + return isValid + end isValid = true rescue => errorStr puts "config::geneva_logs::error:Exception while validating Geneva config - #{errorStr}" @@ -316,16 +423,18 @@ def is_configure_geneva_env_vars() end if is_configure_geneva_env_vars() - file.write("export MONITORING_GCS_ENVIRONMENT=#{@geneva_account_environment}\n") - file.write("export MONITORING_GCS_NAMESPACE=#{@geneva_account_namespace}\n") - file.write("export MONITORING_GCS_ACCOUNT=#{@geneva_account_name}\n") - file.write("export MONITORING_GCS_REGION=#{@geneva_gcs_region}\n") - file.write("export MONITORING_CONFIG_VERSION=#{@geneva_logs_config_version}\n") - file.write("export MONITORING_GCS_AUTH_ID=#{@geneva_gcs_authid}\n") - file.write("export MONITORING_GCS_AUTH_ID_TYPE=AuthMSIToken") + # Config map derived values are single quoted so that the shell that sources this file + # treats them as literal data and never as commands. + file.write("export MONITORING_GCS_ENVIRONMENT=#{toShellSingleQuoted(@geneva_account_environment)}\n") + file.write("export MONITORING_GCS_NAMESPACE=#{toShellSingleQuoted(@geneva_account_namespace)}\n") + file.write("export MONITORING_GCS_ACCOUNT=#{toShellSingleQuoted(@geneva_account_name)}\n") + file.write("export MONITORING_GCS_REGION=#{toShellSingleQuoted(@geneva_gcs_region)}\n") + file.write("export MONITORING_CONFIG_VERSION=#{toShellSingleQuoted(@geneva_logs_config_version)}\n") + file.write("export MONITORING_GCS_AUTH_ID=#{toShellSingleQuoted(@geneva_gcs_authid)}\n") + file.write("export MONITORING_GCS_AUTH_ID_TYPE=AuthMSIToken\n") end - file.write("export GENEVA_LOGS_INFRA_NAMESPACES=#{@infra_namespaces}\n") - file.write("export GENEVA_LOGS_TENANT_NAMESPACES=#{@tenant_namespaces}\n") + file.write("export GENEVA_LOGS_INFRA_NAMESPACES=#{toShellSingleQuoted(@infra_namespaces)}\n") + file.write("export GENEVA_LOGS_TENANT_NAMESPACES=#{toShellSingleQuoted(@tenant_namespaces)}\n") # This required environment variable in geneva mode file.write("export MDSD_MSGPACK_SORT_COLUMNS=1\n") diff --git a/build/common/installer/scripts/tomlparser-geneva-config_test.rb b/build/common/installer/scripts/tomlparser-geneva-config_test.rb new file mode 100644 index 000000000..4a7d531ec --- /dev/null +++ b/build/common/installer/scripts/tomlparser-geneva-config_test.rb @@ -0,0 +1,383 @@ +require "minitest/autorun" +require "fileutils" +require "tmpdir" +require "rbconfig" +require "open3" + +# Regression tests for the container-azm-ms-agentconfig -> geneva environment file generation. +# +# Values in that config map are attacker supplied whenever someone has write access to it. On +# linux the generated geneva_config_env_var file is appended to ~/.bashrc and sourced by +# kubernetes/linux/main.sh while the agent runs as root in a privileged daemonset, so a value +# must never be able to escape its assignment and become a command. +# +# The payloads below try exactly that. Nothing here runs the agent; the tests generate the file +# with the real parser and then source it in a throwaway shell. + +class GenevaConfigTest < Minitest::Test + SCRIPTS_DIR = File.expand_path(__dir__) + PARSER_PATH = File.join(SCRIPTS_DIR, "tomlparser-geneva-config.rb") + + # Loading the parser gives direct access to its helpers. The top level code is a no-op unless + # a config map is mounted at the hard coded agent path, which is not the case on a test machine, + # but it does write its (empty) output file, so it is loaded from a throwaway directory. + Dir.mktmpdir("geneva-config-load") { |dir| Dir.chdir(dir) { load PARSER_PATH } } + + SENTINEL_NAME = "ci-geneva-injection-sentinel" + + # Terminates the assignment it lands in and runs a command, then comments out the rest of the + # line so that the result would still be a valid shell script. + def payloads + [ + "Test; touch #{@sentinel} #", + "Test\ntouch #{@sentinel}\n#", + "Test$(touch #{@sentinel})", + "Test`touch #{@sentinel}`", + "Test' ; touch #{@sentinel} ; '", + "Test\" ; touch #{@sentinel} ; \"", + "Test\\\ntouch #{@sentinel}", + ] + end + + def setup + @sandbox = Dir.mktmpdir("geneva-config-test") + @sentinel = File.join(@sandbox, SENTINEL_NAME) + end + + def teardown + FileUtils.remove_entry(@sandbox) if @sandbox && File.exist?(@sandbox) + end + + VALID_SETTINGS = { + "enabled" => "true", + "environment" => "Test", + "namespace" => "TestNamespace", + "account" => "TestAccount", + "region" => "westus2", + "configversion" => "1.0", + "authid" => "client_id#11111111-2222-3333-4444-555555555555", + }.freeze + + def configmap_for(overrides = {}, extra_lines = []) + settings = VALID_SETTINGS.merge(overrides) + lines = settings.map do |key, value| + value == "true" || value == "false" ? "#{key} = #{value}" : "#{key} = #{value.inspect}" + end + "[integrations.geneva_logs]\n#{(lines + extra_lines).join("\n")}\n" + end + + # Runs the real parser against a config map in a sandbox. The parser reads absolute agent paths, + # so those prefixes are rewritten to point inside the sandbox, and writes its output files + # relative to the working directory. + def run_parser(configmap, env = {}) + workdir = Dir.mktmpdir("geneva-config-run", @sandbox) + FileUtils.mkdir_p(File.join(workdir, "etc/config/settings")) + File.write(File.join(workdir, "etc/config/settings/integrations"), configmap) + FileUtils.cp(File.join(SCRIPTS_DIR, "ConfigParseErrorLogger.rb"), File.join(workdir, "ConfigParseErrorLogger.rb")) + + parser = File.join(workdir, "parser.rb") + File.write(parser, File.read(PARSER_PATH).gsub('"/etc/', "\"#{workdir}/etc/")) + + parserEnv = { + "AZMON_AGENT_CFG_SCHEMA_VERSION" => "v1", + "CONTROLLER_TYPE" => "daemonset", + "OS_TYPE" => nil, + }.merge(env) + + stdout, stderr, = Open3.capture3(parserEnv, RbConfig.ruby, parser, chdir: workdir) + + windowsPath = File.join(workdir, "setgenevaconfigenv.txt") + { + workdir: workdir, + env_file: File.read(File.join(workdir, "geneva_config_env_var")), + env_file_path: File.join(workdir, "geneva_config_env_var"), + windows_file: File.exist?(windowsPath) ? File.read(windowsPath) : nil, + stdout: stdout, + stderr: stderr, + } + end + + # Sources the generated file the same way main.sh does and reports the resulting variables. + def source_env_file(result) + script = "set -e\n. \"#{result[:env_file_path]}\"\n" + + ["MONITORING_GCS_ENVIRONMENT", "MONITORING_GCS_NAMESPACE", "MONITORING_GCS_ACCOUNT", + "MONITORING_GCS_REGION", "MONITORING_CONFIG_VERSION", "MONITORING_GCS_AUTH_ID", + "MONITORING_GCS_AUTH_ID_TYPE", "GENEVA_LOGS_INFRA_NAMESPACES", + "GENEVA_LOGS_TENANT_NAMESPACES"].map { |name| "printf '%s=%s\\n' #{name} \"$#{name}\"" }.join("\n") + stdout, = Open3.capture3({}, "bash", "-c", script, chdir: result[:workdir]) + stdout.lines.map { |line| line.chomp.split("=", 2) }.to_h + end + + # The core security property: no config map value can execute a command when the generated file + # is sourced by a shell. + def assert_no_command_execution(result, context) + source_env_file(result) + refute File.exist?(@sentinel), "config map value was executed as a command via #{context}" + # main.sh copies the file into ~/.bashrc line by line, so a value must stay on its own line. + result[:env_file].each_line do |line| + next if line.strip.empty? + assert_match(/\A(export [A-Z_]+=|#)/, line, "unexpected line in the generated env file via #{context}") + end + end + + # --- value validation ---------------------------------------------------------------------- + + def test_known_geneva_environments_are_accepted + GENEVA_SUPPORTED_ENVIRONMENTS.each do |environment| + assert isValidGenevaEnvironment(environment), "expected #{environment.inspect} to be valid" + assert isValidGenevaEnvironment(environment.downcase) + end + end + + # Airgap clouds use environment identifiers the agent does not know, so an unknown alphanumeric + # value is still accepted. The character allowlist is what makes it safe. + def test_unknown_alphanumeric_environment_is_accepted + assert isValidGenevaEnvironment("SomeAirgapProd42") + end + + def test_geneva_names_must_start_with_a_letter + ["Prod_1", "Prod-1", "Prod.1", "A"].each do |value| + assert isValidGenevaName(value), "expected #{value.inspect} to be a valid geneva name" + end + + ["1Prod", "_Prod", "-Prod", ".Prod"].each do |value| + refute isValidGenevaName(value), "expected #{value.inspect} to be an invalid geneva name" + end + end + + def test_invalid_values_are_rejected + invalid = [ + "Test; id", + "Test$(id)", + "Test`id`", + "Test|id", + "Test id", + "Test'", + "Test\"", + "Test\\", + "Test\n", + "Test\nid", # \A and \z anchors, not ^ and $, keep a payload off a second line + "Test\u0000", + "", + nil, + 42, + "T" * 65, + ] + invalid.each do |value| + refute isValidGenevaName(value), "expected #{value.inspect} to be an invalid geneva name" + refute isValidGenevaEnvironment(value), "expected #{value.inspect} to be an invalid environment" + refute isValidGenevaRegion(value), "expected #{value.inspect} to be an invalid region" + refute isValidGenevaConfigVersion(value), "expected #{value.inspect} to be an invalid config version" + refute isValidGenevaAuthId(value), "expected #{value.inspect} to be an invalid authid" + refute isValidKubernetesNamespace(value), "expected #{value.inspect} to be an invalid namespace" + end + end + + def test_valid_regions_and_config_versions_are_accepted + ["westus2", "chinanorth3", "A"].each { |value| assert isValidGenevaRegion(value) } + ["1.0", "2.1", "Ver2v0", "1_0", "1-0"].each { |value| assert isValidGenevaConfigVersion(value) } + end + + def test_geneva_region_must_start_with_a_letter_and_be_alphanumeric + ["1westus", "east-us-2", "westus_2", "west.us2", "-westus", "westus-"].each do |value| + refute isValidGenevaRegion(value), "expected #{value.inspect} to be an invalid region" + end + end + + def test_authid_must_use_a_supported_identifier + ["client_id#11111111-2222-3333-4444-555555555555", + "object_id#11111111-2222-3333-4444-555555555555", + "mi_res_id#/subscriptions/sub/resourcegroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/name", + ].each { |value| assert isValidGenevaAuthId(value), "expected #{value.inspect} to be a valid authid" } + + ["11111111-2222-3333-4444-555555555555", + "client_id", + "some_id#guid", + "client_id#guid;id", + ].each { |value| refute isValidGenevaAuthId(value), "expected #{value.inspect} to be an invalid authid" } + end + + def test_infra_namespace_wildcard_is_supported + assert isValidGenevaInfraNamespace("kube-system") + assert isValidGenevaInfraNamespace("infra-*") + refute isValidGenevaInfraNamespace("infra-*-*") + refute isValidGenevaInfraNamespace("*") + refute isValidKubernetesNamespace("infra-*") + refute isValidKubernetesNamespace("a" * 64) + end + + # --- joinValidNamespaces ------------------------------------------------------------------- + + # main.sh splits this list again and interpolates each entry into a `cp` target, a file name and + # a `sed -i "s/.../.../g"` expression, so the joined list is a sink in its own right. + + def test_join_valid_namespaces_returns_empty_for_non_array_input + [nil, "kube-system", {}, 42].each do |value| + assert_equal "", joinValidNamespaces(value, "infra_namespaces", true), + "expected #{value.inspect} to produce an empty list" + end + assert_equal "", joinValidNamespaces([], "tenant_namespaces", false) + end + + def test_join_valid_namespaces_preserves_order_and_strips_whitespace + assert_equal "zeta,alpha,mid", + joinValidNamespaces(["zeta", "alpha", "mid"], "tenant_namespaces", false) + assert_equal "kube-system,default", + joinValidNamespaces([" kube-system ", "\tdefault\n"], "tenant_namespaces", false) + end + + # Duplicates are the tenant's problem, not a safety issue, so they are passed through unchanged. + def test_join_valid_namespaces_keeps_duplicates + assert_equal "team-a,team-a", joinValidNamespaces(["team-a", "team-a"], "tenant_namespaces", false) + end + + def test_join_valid_namespaces_skips_blank_and_invalid_entries + assert_equal "default", + joinValidNamespaces(["", " ", "default"], "tenant_namespaces", false) + assert_equal "", + joinValidNamespaces(["BadNamespace", "has space", "a" * 64], "tenant_namespaces", false) + assert_equal "kube-system,team-a", + joinValidNamespaces(["kube-system", "Bad_Namespace", "team-a"], "tenant_namespaces", false) + end + + # The wildcard suffix is only meaningful for infra namespaces, which main.sh strips before it + # builds the generated fluent-bit config file name. A tenant namespace must be an exact label. + def test_join_valid_namespaces_applies_the_wildcard_rule_only_for_infra + assert_equal "kube-system,infra-*", + joinValidNamespaces(["kube-system", "infra-*"], "infra_namespaces", true) + assert_equal "kube-system", + joinValidNamespaces(["kube-system", "infra-*"], "tenant_namespaces", false) + end + + def test_join_valid_namespaces_drops_injection_payloads + payloads.each do |payload| + assert_equal "kube-system", + joinValidNamespaces([payload, "kube-system"], "infra_namespaces", true), + "the payload #{payload.inspect} was not dropped from the infra list" + assert_equal "default", + joinValidNamespaces(["default", payload], "tenant_namespaces", false), + "the payload #{payload.inspect} was not dropped from the tenant list" + end + end + + # Non string entries cannot reach here through toml, which enforces a single element type, but + # the coercion is what keeps a surprising type from raising and taking the integration down. + def test_join_valid_namespaces_coerces_non_string_entries + assert_equal "default", joinValidNamespaces([nil, "default"], "tenant_namespaces", false) + assert_equal "42", joinValidNamespaces([42], "tenant_namespaces", false) + end + + def test_join_valid_namespaces_error_does_not_echo_the_rejected_entry + payload = "evil; touch /tmp/#{SENTINEL_NAME} #" + result = nil + _, stderr = capture_subprocess_io do + result = joinValidNamespaces(["kube-system", payload], "infra_namespaces", true) + end + + assert_equal "kube-system", result + assert_includes stderr, "infra_namespaces" + refute_includes stderr, payload + refute_includes stderr, SENTINEL_NAME + end + + def test_config_version_falls_back_to_default_without_echoing_input + assert_equal "1.0", resolveConfigVersion(nil, "configversion") + assert_equal "1.0", resolveConfigVersion("", "configversion") + assert_equal "1.0", resolveConfigVersion("1.0; id", "configversion") + assert_equal "2.1", resolveConfigVersion("2.1", "configversion") + end + + def test_single_quoting_keeps_a_value_literal + assert_equal "'plain'", toShellSingleQuoted("plain") + assert_equal "'it'\\''s'", toShellSingleQuoted("it's") + assert_equal "'a; id #'", toShellSingleQuoted("a; id #") + assert_equal "''", toShellSingleQuoted("") + end + + # --- generated file ------------------------------------------------------------------------ + + def test_valid_config_is_written_and_sourced_intact + result = run_parser(configmap_for) + values = source_env_file(result) + + assert_equal "Test", values["MONITORING_GCS_ENVIRONMENT"] + assert_equal "TestNamespace", values["MONITORING_GCS_NAMESPACE"] + assert_equal "TestAccount", values["MONITORING_GCS_ACCOUNT"] + assert_equal "westus2", values["MONITORING_GCS_REGION"] + assert_equal "1.0", values["MONITORING_CONFIG_VERSION"] + assert_equal "client_id#11111111-2222-3333-4444-555555555555", values["MONITORING_GCS_AUTH_ID"] + # This used to run together with the following export because of a missing newline. + assert_equal "AuthMSIToken", values["MONITORING_GCS_AUTH_ID_TYPE"] + assert_includes result[:env_file], "export MDSD_MSGPACK_SORT_COLUMNS=1\n" + end + + def test_payload_in_any_scalar_setting_cannot_execute + ["environment", "namespace", "account", "region", "configversion", "authid"].each do |setting| + payloads.each do |payload| + result = run_parser(configmap_for(setting => payload)) + assert_no_command_execution(result, "#{setting}=#{payload.inspect}") + refute_includes result[:env_file], "touch", "the payload reached the env file via #{setting}" + end + end + end + + def test_payload_in_namespace_lists_cannot_execute + payloads.each do |payload| + result = run_parser(configmap_for( + { "multi_tenancy" => "true" }, + ["infra_namespaces = [#{payload.inspect}, \"kube-system\"]", + "tenant_namespaces = [#{payload.inspect}, \"default\"]"] + )) + assert_no_command_execution(result, "namespace lists with #{payload.inspect}") + refute_includes result[:env_file], "touch", "the payload reached the env file via the namespace lists" + end + end + + def test_invalid_scalar_setting_discards_the_geneva_config + result = run_parser(configmap_for("environment" => "Test; touch /tmp/x #")) + values = source_env_file(result) + assert_equal "", values["MONITORING_GCS_ENVIRONMENT"] + assert_equal "", values["MONITORING_GCS_ACCOUNT"] + end + + def test_invalid_namespaces_are_skipped_and_valid_ones_kept + result = run_parser(configmap_for( + { "multi_tenancy" => "true" }, + ["infra_namespaces = [\"kube-system\", \"bad namespace\", \"infra-*\"]", + "tenant_namespaces = [\"default\", \"Bad_Namespace\", \"team-a\"]"] + )) + values = source_env_file(result) + assert_equal "kube-system,infra-*", values["GENEVA_LOGS_INFRA_NAMESPACES"] + assert_equal "default,team-a", values["GENEVA_LOGS_TENANT_NAMESPACES"] + end + + def test_invalid_config_version_falls_back_to_the_default + result = run_parser(configmap_for("configversion" => "1.0; id")) + assert_equal "1.0", source_env_file(result)["MONITORING_CONFIG_VERSION"] + end + + def test_rejected_values_are_not_echoed_back_into_the_logs + payload = "Test; touch #{@sentinel} #" + result = run_parser(configmap_for("environment" => payload)) + refute_includes result[:stdout], payload + refute_includes result[:stderr], payload + refute_includes result[:stdout], SENTINEL_NAME + refute_includes result[:stderr], SENTINEL_NAME + end + + # --- windows ------------------------------------------------------------------------------- + + # The windows agent assigns these values through the environment provider rather than a shell, + # but a value with a newline would still forge additional assignments in the generated file. + def test_windows_file_has_one_assignment_per_line + payloads.each do |payload| + result = run_parser(configmap_for("namespacewindows" => payload), "OS_TYPE" => "windows") + refute_nil result[:windows_file] + result[:windows_file].each_line do |line| + next if line.strip.empty? + assert_match(/\A[A-Z_]+=[^\n]*\n?\z/, line, "unexpected line in the windows env file via #{payload.inspect}") + end + refute_includes result[:windows_file], "touch" + end + end +end diff --git a/kubernetes/linux/main.sh b/kubernetes/linux/main.sh index 53aeb26d3..3f735d777 100644 --- a/kubernetes/linux/main.sh +++ b/kubernetes/linux/main.sh @@ -778,9 +778,12 @@ if [ "${GENEVA_LOGS_INTEGRATION_SERVICE_MODE}" != "true" ]; then #Parse geneva config ruby tomlparser-geneva-config.rb - cat geneva_config_env_var | while read line; do - echo $line >> ~/.bashrc - done + # The values in this file come from the config map, so the lines are copied verbatim: + # an unquoted echo would word split and glob expand them (an infra namespace may end + # with a wildcard) instead of preserving the quoting that the parser emitted. + while IFS= read -r line; do + printf '%s\n' "$line" >> ~/.bashrc + done < geneva_config_env_var source geneva_config_env_var if [ ! -e "/etc/config/kube.conf" ]; then