From bb66150abacfcf85a20c0d8d71128c7a15a479e3 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Wed, 16 Sep 2026 13:12:38 +0530 Subject: [PATCH 1/4] Restrict app-setup command lists to their own allowlist --- .../service/commands/create_service.py | 5 + .../commands/integrations/command_policy.py | 53 +++++++++++ .../integrations/integration_setup_base.py | 19 +++- .../commands/integrations/runtime_policy.py | 85 +++++++++++++++++ .../integrations/sailpoint/command_policy.py | 21 +---- .../integrations/sailpoint_app_setup.py | 6 +- .../service/commands/terraform_app_setup.py | 4 +- unit-tests/service/test_command_policy.py | 59 ++++++++++++ ...integration_setup_base_command_sanitize.py | 71 ++++++++++++++ unit-tests/service/test_runtime_policy.py | 93 +++++++++++++++++++ .../service/test_terraform_app_setup.py | 14 +++ 11 files changed, 410 insertions(+), 20 deletions(-) create mode 100644 keepercommander/service/commands/integrations/command_policy.py create mode 100644 keepercommander/service/commands/integrations/runtime_policy.py create mode 100644 unit-tests/service/test_command_policy.py create mode 100644 unit-tests/service/test_integration_setup_base_command_sanitize.py create mode 100644 unit-tests/service/test_runtime_policy.py diff --git a/keepercommander/service/commands/create_service.py b/keepercommander/service/commands/create_service.py index d035fde91..99ef90a44 100644 --- a/keepercommander/service/commands/create_service.py +++ b/keepercommander/service/commands/create_service.py @@ -106,6 +106,11 @@ def execute(self, params: KeeperParams, **kwargs) -> None: from .integrations.sailpoint.service import SailPointService SailPointService.maybe_enable(params, args) + # Re-sanitize commands for Slack/Teams/GChat/Terraform against their own + # allowlist, in case docker-compose.yml was hand-edited or is stale. + from .integrations.runtime_policy import apply_runtime_command_policy + apply_runtime_command_policy(args) + from .integrations.vault_metadata import get_existing_api_key, write_service_metadata existing_api_key = ( get_existing_api_key(params, args.update_vault_record) diff --git a/keepercommander/service/commands/integrations/command_policy.py b/keepercommander/service/commands/integrations/command_policy.py new file mode 100644 index 000000000..3ae79bed9 --- /dev/null +++ b/keepercommander/service/commands/integrations/command_policy.py @@ -0,0 +1,53 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """ + Keep only allowed commands from ``commands``; always drop banned ones. + + Also ensures every allowed, non-banned command is present in the result, + so required commands are not dropped when the input is a partial or + stale command list (e.g. a hand-edited docker-compose.yml). + """ + allowed = list(allowed) + allowed_set = {c.strip().lower() for c in allowed} + banned_set = {c.strip().lower() for c in banned} + filtered = [ + cmd for raw in (commands or '').split(',') + if (cmd := raw.strip()) + and (key := cmd.lower()) not in banned_set + and key in allowed_set + ] + # Input order first, then any missing required allowlist entries. + by_key = {cmd.lower(): cmd for cmd in filtered} + for cmd in allowed: + key = cmd.strip().lower() + if key not in banned_set and key not in by_key: + by_key[key] = cmd.strip() + return ','.join(by_key.values()) + + +def default_allowlist(allowed: Iterable[str], banned: Iterable[str] = ()) -> str: + allowed = list(allowed) + return sanitize_commands(','.join(allowed), allowed, banned) diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index 3d26a5169..00ea96fc5 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -30,6 +30,7 @@ ) from .approvals_setup import ApprovalsChannelProfile, collect_approvals_config, is_valid_keeper_uid from .approvals_sync import merge_approvals_custom_fields, run_approvals_sync_down +from .command_policy import sanitize_commands UUID_PATTERN = re.compile( r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' @@ -121,6 +122,17 @@ def get_service_commands(self) -> str: commands = f'{commands},{self.get_command_name()}' return commands + def get_banned_commands(self) -> tuple: + """Commands to always strip from this integration's command list, even if + present in get_service_commands(). Override per integration only if a specific + command in that integration's own list needs to be banned.""" + return () + + def sanitize_service_commands(self, commands: str) -> str: + """Restrict `commands` to this integration's own get_service_commands() list.""" + allowed = self.get_service_commands().split(',') + return sanitize_commands(commands, allowed, self.get_banned_commands()) + # -- Parser (auto-built from name, cached per subclass) ---------- def get_parser(self): @@ -456,10 +468,13 @@ def _update_docker_compose(self, setup_result: SetupResult, ) try: + cfg = asdict(service_config) + cfg['commands'] = self.sanitize_service_commands(cfg.get('commands') or '') builder = DockerComposeBuilder( - setup_result, asdict(service_config), + setup_result, cfg, commander_service_name=self.get_commander_service_name(), - commander_container_name=self.get_commander_container_name() + commander_container_name=self.get_commander_container_name(), + commander_environment={self.get_record_env_key(): record_uid}, ) yaml_content = builder.add_integration_service( service_name=service_name, diff --git a/keepercommander/service/commands/integrations/runtime_policy.py b/keepercommander/service/commands/integrations/runtime_policy.py new file mode 100644 index 000000000..cd3a65643 --- /dev/null +++ b/keepercommander/service/commands/integrations/runtime_policy.py @@ -0,0 +1,85 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' None: + if not getattr(args, 'commands', None): + return + + matched = [ + env_key for env_key in _integration_sanitizers() + if (os.environ.get(env_key) or '').strip() + ] + if not matched: + return + if len(matched) > 1: + print( + f'Service Mode: multiple integration env vars are set ({", ".join(matched)}); ' + f'applying {matched[0]} allowlist. Remove the others from the container ' + f'environment to avoid ambiguous command policy.' + ) + + env_key = matched[0] + sanitize = _integration_sanitizers()[env_key] + cleaned = sanitize(args.commands) + if cleaned != args.commands: + print( + f'Service Mode ({env_key}): removed commands outside the integration ' + f'allowlist before service-create.\n Was: {args.commands}\n Now: {cleaned}' + ) + args.commands = cleaned + + +def _integration_sanitizers(): + """Map each integration's container-start env var to its own sanitize_service_commands() + (or equivalent), reusing the exact allow/ban lists each *-app-setup command declares. + + SailPoint is included here too, even though SailPointService.maybe_enable() (called + earlier in create_service.py) already sanitizes against the same allowlist: that call + silently skips sanitizing if the vault marker-field check on SAILPOINT_RECORD fails or + throws, so this acts as a fallback that applies regardless of that record's state. + """ + from ..terraform_app_setup import TerraformSetupConstants + from .command_policy import sanitize_commands + from .gchat_app_setup import GChatAppSetupCommand + from .sailpoint_app_setup import SailPointAppSetupCommand + from .slack_app_setup import SlackAppSetupCommand + from .teams_app_setup import TeamsAppSetupCommand + from ...decorators.min_commander_version import TERRAFORM_DOCKER_ENV + + slack = SlackAppSetupCommand() + teams = TeamsAppSetupCommand() + gchat = GChatAppSetupCommand() + sailpoint = SailPointAppSetupCommand() + + return { + slack.get_record_env_key(): slack.sanitize_service_commands, + teams.get_record_env_key(): teams.sanitize_service_commands, + gchat.get_record_env_key(): gchat.sanitize_service_commands, + sailpoint.get_record_env_key(): sailpoint.sanitize_service_commands, + TERRAFORM_DOCKER_ENV: lambda commands: sanitize_commands( + commands, TerraformSetupConstants.SERVICE_COMMANDS_LIST + ), + } diff --git a/keepercommander/service/commands/integrations/sailpoint/command_policy.py b/keepercommander/service/commands/integrations/sailpoint/command_policy.py index 88ec51451..13afa6b99 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_policy.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_policy.py @@ -17,6 +17,7 @@ from typing import Any, Optional, Tuple from .....utils import is_email +from .. import command_policy as _shared_policy from .command_parse import SailPointCommandParser from .constants import SAILPOINT_ALLOWED_COMMANDS, SAILPOINT_BANNED_COMMANDS @@ -50,25 +51,13 @@ def sanitize(cls, commands: str) -> str: commands are not dropped when the input list is a partial or older compose allowlist. """ - allowed = {c.strip().lower() for c in SAILPOINT_ALLOWED_COMMANDS} - banned = {c.lower() for c in SAILPOINT_BANNED_COMMANDS} - filtered = [ - cmd for raw in (commands or '').split(',') - if (cmd := raw.strip()) - and (key := cmd.lower()) not in banned - and key in allowed - ] - # Input order first, then any missing required allowlist entries. - by_key = {cmd.lower(): cmd for cmd in filtered} - for cmd in SAILPOINT_ALLOWED_COMMANDS: - key = cmd.lower() - if key not in banned and key not in by_key: - by_key[key] = cmd - return ','.join(by_key.values()) + return _shared_policy.sanitize_commands( + commands, SAILPOINT_ALLOWED_COMMANDS, SAILPOINT_BANNED_COMMANDS + ) @classmethod def default_allowlist(cls) -> str: - return cls.sanitize(','.join(SAILPOINT_ALLOWED_COMMANDS)) + return _shared_policy.default_allowlist(SAILPOINT_ALLOWED_COMMANDS, SAILPOINT_BANNED_COMMANDS) @classmethod def validate_enterprise_user(cls, command: str) -> Optional[str]: diff --git a/keepercommander/service/commands/integrations/sailpoint_app_setup.py b/keepercommander/service/commands/integrations/sailpoint_app_setup.py index 875eeee1d..71c27c6e5 100644 --- a/keepercommander/service/commands/integrations/sailpoint_app_setup.py +++ b/keepercommander/service/commands/integrations/sailpoint_app_setup.py @@ -33,6 +33,7 @@ MIN_POLL_INTERVAL_SECONDS, PENDING_ENTITLEMENTS_FIELD, POLL_INTERVAL_FIELD, + SAILPOINT_BANNED_COMMANDS, SAILPOINT_MARKER_FIELD, SAILPOINT_RECORD_ENV, TRANSFER_TARGET_EMAIL_FIELD, @@ -60,6 +61,9 @@ def get_record_env_key(self) -> str: def get_service_commands(self) -> str: return SailPointCommandPolicy.default_allowlist() + def get_banned_commands(self) -> tuple: + return tuple(SAILPOINT_BANNED_COMMANDS) + def collect_integration_config(self, params, transfer_target_default: str = ''): print(f"\n{bcolors.BOLD}SHARE ENTITLEMENTS:{bcolors.ENDC}") print(f" Control which share entitlements SailPoint may manage via Service Mode") @@ -186,7 +190,7 @@ def _update_docker_compose(self, setup_result, service_config, record_uid, confi try: cfg = asdict(service_config) - cfg['commands'] = SailPointCommandPolicy.sanitize(cfg.get('commands') or '') + cfg['commands'] = self.sanitize_service_commands(cfg.get('commands') or '') builder = DockerComposeBuilder( setup_result, cfg, diff --git a/keepercommander/service/commands/terraform_app_setup.py b/keepercommander/service/commands/terraform_app_setup.py index b802f0965..04f2dae7a 100644 --- a/keepercommander/service/commands/terraform_app_setup.py +++ b/keepercommander/service/commands/terraform_app_setup.py @@ -26,6 +26,7 @@ ) from ..decorators.min_commander_version import TERRAFORM_DOCKER_ENV from ..util.exceptions import ValidationError +from .integrations.command_policy import sanitize_commands from .service_docker_setup import ServiceDockerSetupCommand @@ -113,7 +114,7 @@ def execute(self, params, **kwargs): def _validate_terraform_commands(self, params) -> str: try: - return RuntimeServiceConfig().validate_command_list( + validated = RuntimeServiceConfig().validate_command_list( TerraformSetupConstants.SERVICE_COMMANDS, params ) except ValidationError as e: @@ -121,6 +122,7 @@ def _validate_terraform_commands(self, params) -> str: self.get_parser().prog, f'Terraform command allowlist validation failed: {e}', ) + return sanitize_commands(validated, TerraformSetupConstants.SERVICE_COMMANDS_LIST) def _get_commands_config(self, params) -> str: return self._validate_terraform_commands(params) diff --git a/unit-tests/service/test_command_policy.py b/unit-tests/service/test_command_policy.py new file mode 100644 index 000000000..a562faeb8 --- /dev/null +++ b/unit-tests/service/test_command_policy.py @@ -0,0 +1,59 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Date: Wed, 16 Sep 2026 13:44:28 +0530 Subject: [PATCH 2/4] Add nsf-move command to terraform-app-setup command list --- .../service/commands/create_service.py | 3 +-- .../commands/integrations/command_policy.py | 16 ++-------------- .../integrations/integration_setup_base.py | 4 +--- .../commands/integrations/runtime_policy.py | 19 ++----------------- .../service/commands/terraform_app_setup.py | 2 +- ...integration_setup_base_command_sanitize.py | 3 +-- unit-tests/service/test_runtime_policy.py | 3 +-- .../service/test_terraform_app_setup.py | 4 +--- 8 files changed, 10 insertions(+), 44 deletions(-) diff --git a/keepercommander/service/commands/create_service.py b/keepercommander/service/commands/create_service.py index 99ef90a44..8ddc5538d 100644 --- a/keepercommander/service/commands/create_service.py +++ b/keepercommander/service/commands/create_service.py @@ -106,8 +106,7 @@ def execute(self, params: KeeperParams, **kwargs) -> None: from .integrations.sailpoint.service import SailPointService SailPointService.maybe_enable(params, args) - # Re-sanitize commands for Slack/Teams/GChat/Terraform against their own - # allowlist, in case docker-compose.yml was hand-edited or is stale. + # Re-sanitize commands against each integration's own allowlist in case docker-compose.yml is stale/hand-edited. from .integrations.runtime_policy import apply_runtime_command_policy apply_runtime_command_policy(args) diff --git a/keepercommander/service/commands/integrations/command_policy.py b/keepercommander/service/commands/integrations/command_policy.py index 3ae79bed9..b16c66c8c 100644 --- a/keepercommander/service/commands/integrations/command_policy.py +++ b/keepercommander/service/commands/integrations/command_policy.py @@ -9,13 +9,7 @@ # Contact: commander@keepersecurity.com # -"""Shared command-list sanitization for Service Mode *-app-setup commands. - -Each integration keeps its own allowed (and optional banned) command list; -this module only provides the generic filter/force-include mechanism so -every integration can restrict its docker-compose command list to what it -already declares as allowed. -""" +"""Shared command-list sanitization for Service Mode *-app-setup commands.""" from __future__ import annotations @@ -23,13 +17,7 @@ def sanitize_commands(commands: str, allowed: Iterable[str], banned: Iterable[str] = ()) -> str: - """ - Keep only allowed commands from ``commands``; always drop banned ones. - - Also ensures every allowed, non-banned command is present in the result, - so required commands are not dropped when the input is a partial or - stale command list (e.g. a hand-edited docker-compose.yml). - """ + """Keep only allowed commands from `commands`, drop banned ones, and force-include any missing allowed entries.""" allowed = list(allowed) allowed_set = {c.strip().lower() for c in allowed} banned_set = {c.strip().lower() for c in banned} diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index 00ea96fc5..901b82c09 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -123,9 +123,7 @@ def get_service_commands(self) -> str: return commands def get_banned_commands(self) -> tuple: - """Commands to always strip from this integration's command list, even if - present in get_service_commands(). Override per integration only if a specific - command in that integration's own list needs to be banned.""" + """Commands to always strip from this integration's command list; override per integration if needed.""" return () def sanitize_service_commands(self, commands: str) -> str: diff --git a/keepercommander/service/commands/integrations/runtime_policy.py b/keepercommander/service/commands/integrations/runtime_policy.py index cd3a65643..e72f9f30e 100644 --- a/keepercommander/service/commands/integrations/runtime_policy.py +++ b/keepercommander/service/commands/integrations/runtime_policy.py @@ -9,15 +9,7 @@ # Contact: commander@keepersecurity.com # -"""Container-start command-list enforcement for *-app-setup integrations. - -`service-create` runs every time the Commander container boots (it's the -container's `command:` in docker-compose.yml), so a hand-edited compose file -or a stale/rebuilt image can otherwise pass through a `commands` value that -no longer matches what the integration was actually set up to allow. This -re-sanitizes `args.commands` against the deploying integration's own -declared allowlist right before it's persisted. -""" +"""Container-start command-list enforcement for *-app-setup integrations.""" from __future__ import annotations @@ -53,14 +45,7 @@ def apply_runtime_command_policy(args) -> None: def _integration_sanitizers(): - """Map each integration's container-start env var to its own sanitize_service_commands() - (or equivalent), reusing the exact allow/ban lists each *-app-setup command declares. - - SailPoint is included here too, even though SailPointService.maybe_enable() (called - earlier in create_service.py) already sanitizes against the same allowlist: that call - silently skips sanitizing if the vault marker-field check on SAILPOINT_RECORD fails or - throws, so this acts as a fallback that applies regardless of that record's state. - """ + """Maps each integration's env var to its own sanitize_service_commands(); SailPoint is included too as a fallback in case SailPointService.maybe_enable() skipped sanitizing.""" from ..terraform_app_setup import TerraformSetupConstants from .command_policy import sanitize_commands from .gchat_app_setup import GChatAppSetupCommand diff --git a/keepercommander/service/commands/terraform_app_setup.py b/keepercommander/service/commands/terraform_app_setup.py index 04f2dae7a..85d4b6be6 100644 --- a/keepercommander/service/commands/terraform_app_setup.py +++ b/keepercommander/service/commands/terraform_app_setup.py @@ -48,7 +48,7 @@ class TerraformSetupConstants: 'share-folder', 'rmdir', 'rndir', 'mkdir', 'epm', 'scim', 'mv', 'pam', 'secrets-manager', 'ln', 'share-record', 'nsf-mkdir', 'nsf-get', 'nsf-rmdir', 'nsf-record-add', 'nsf-record-update', - 'nsf-rm', 'nsf-rndir', 'nsf-share-folder', 'nsf-share-record', 'nsf-ln', + 'nsf-rm', 'nsf-rndir', 'nsf-share-folder', 'nsf-share-record', 'nsf-ln', 'nsf-move', ) SERVICE_COMMANDS = ','.join(SERVICE_COMMANDS_LIST) diff --git a/unit-tests/service/test_integration_setup_base_command_sanitize.py b/unit-tests/service/test_integration_setup_base_command_sanitize.py index cd8d0e112..17a5c03bb 100644 --- a/unit-tests/service/test_integration_setup_base_command_sanitize.py +++ b/unit-tests/service/test_integration_setup_base_command_sanitize.py @@ -21,8 +21,7 @@ class TestIntegrationSetupBaseCommandSanitize(unittest.TestCase): - """A tampered/injected commands string must be confined to the integration's - own get_service_commands() list when docker-compose.yml is (re)generated.""" + """A tampered/injected commands string must be confined to the integration's own get_service_commands() list.""" def _make_service_config(self, commands): return ServiceConfig( diff --git a/unit-tests/service/test_runtime_policy.py b/unit-tests/service/test_runtime_policy.py index a979eb4b1..bba433834 100644 --- a/unit-tests/service/test_runtime_policy.py +++ b/unit-tests/service/test_runtime_policy.py @@ -65,8 +65,7 @@ def test_terraform_env_confines_to_terraform_allowlist(self): ) def test_sailpoint_record_env_confines_to_sailpoint_allowlist(self): - # Fallback path: applies even though SailPointService.maybe_enable() is a - # separate call that may have skipped sanitizing (e.g. marker check failed). + # Fallback path: applies even if SailPointService.maybe_enable() skipped sanitizing. allowed = set(SailPointAppSetupCommand().get_service_commands().split(',')) with mock.patch.dict(os.environ, {'SAILPOINT_RECORD': 'uid-789'}, clear=True): args = _Args(commands='search,download-attachment,ksm') diff --git a/unit-tests/service/test_terraform_app_setup.py b/unit-tests/service/test_terraform_app_setup.py index eace69c4d..ec7c25b60 100644 --- a/unit-tests/service/test_terraform_app_setup.py +++ b/unit-tests/service/test_terraform_app_setup.py @@ -79,9 +79,7 @@ def test_commands_config_uses_fixed_allowlist(self, mock_runtime_config): 'keepercommander.service.commands.terraform_app_setup.RuntimeServiceConfig' ) def test_commands_config_strips_commands_outside_terraform_allowlist(self, mock_runtime_config): - # Even if the global-membership check ever let through a command that - # isn't part of Terraform's own allowlist, the final result must not - # contain it. + # Final result must not contain commands outside Terraform's own allowlist. mock_runtime_config.return_value.validate_command_list.return_value = ( TerraformSetupConstants.SERVICE_COMMANDS + ',clipboard-copy' ) From 98d19f5a3a47733521dc163bdfb554a12453a12b Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Wed, 16 Sep 2026 13:55:49 +0530 Subject: [PATCH 3/4] Handle empty string case --- .../service/commands/integrations/command_policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/keepercommander/service/commands/integrations/command_policy.py b/keepercommander/service/commands/integrations/command_policy.py index b16c66c8c..f265e17c1 100644 --- a/keepercommander/service/commands/integrations/command_policy.py +++ b/keepercommander/service/commands/integrations/command_policy.py @@ -18,8 +18,8 @@ def sanitize_commands(commands: str, allowed: Iterable[str], banned: Iterable[str] = ()) -> str: """Keep only allowed commands from `commands`, drop banned ones, and force-include any missing allowed entries.""" - allowed = list(allowed) - allowed_set = {c.strip().lower() for c in allowed} + allowed = [c.strip() for c in allowed if c and c.strip()] + allowed_set = {c.lower() for c in allowed} banned_set = {c.strip().lower() for c in banned} filtered = [ cmd for raw in (commands or '').split(',') From 433799d01e2782b97e0ed51ea94c829c75d1459b Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Wed, 16 Sep 2026 14:34:23 +0530 Subject: [PATCH 4/4] Fix review comments --- .../commands/integrations/command_policy.py | 2 +- .../commands/integrations/runtime_policy.py | 20 +++++++------- unit-tests/service/test_runtime_policy.py | 26 +++++++++++-------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/keepercommander/service/commands/integrations/command_policy.py b/keepercommander/service/commands/integrations/command_policy.py index f265e17c1..f9e84d351 100644 --- a/keepercommander/service/commands/integrations/command_policy.py +++ b/keepercommander/service/commands/integrations/command_policy.py @@ -17,7 +17,7 @@ def sanitize_commands(commands: str, allowed: Iterable[str], banned: Iterable[str] = ()) -> str: - """Keep only allowed commands from `commands`, drop banned ones, and force-include any missing allowed entries.""" + """Normalizes `commands` to exactly the allowed set minus banned; cannot narrow below the allowlist, only add missing/drop extra entries.""" allowed = [c.strip() for c in allowed if c and c.strip()] allowed_set = {c.lower() for c in allowed} banned_set = {c.strip().lower() for c in banned} diff --git a/keepercommander/service/commands/integrations/runtime_policy.py b/keepercommander/service/commands/integrations/runtime_policy.py index e72f9f30e..109e2213b 100644 --- a/keepercommander/service/commands/integrations/runtime_policy.py +++ b/keepercommander/service/commands/integrations/runtime_policy.py @@ -15,27 +15,25 @@ import os +from ...util.exceptions import ValidationError + def apply_runtime_command_policy(args) -> None: - if not getattr(args, 'commands', None): + if getattr(args, 'commands', None) is None: return - matched = [ - env_key for env_key in _integration_sanitizers() - if (os.environ.get(env_key) or '').strip() - ] + sanitizers = _integration_sanitizers() + matched = [env_key for env_key in sanitizers if (os.environ.get(env_key) or '').strip()] if not matched: return if len(matched) > 1: - print( - f'Service Mode: multiple integration env vars are set ({", ".join(matched)}); ' - f'applying {matched[0]} allowlist. Remove the others from the container ' - f'environment to avoid ambiguous command policy.' + raise ValidationError( + f'Multiple integration env vars are set ({", ".join(matched)}); ' + f'remove all but one before starting the service.' ) env_key = matched[0] - sanitize = _integration_sanitizers()[env_key] - cleaned = sanitize(args.commands) + cleaned = sanitizers[env_key](args.commands) if cleaned != args.commands: print( f'Service Mode ({env_key}): removed commands outside the integration ' diff --git a/unit-tests/service/test_runtime_policy.py b/unit-tests/service/test_runtime_policy.py index bba433834..debdb4724 100644 --- a/unit-tests/service/test_runtime_policy.py +++ b/unit-tests/service/test_runtime_policy.py @@ -20,6 +20,7 @@ from keepercommander.service.commands.integrations.sailpoint_app_setup import SailPointAppSetupCommand from keepercommander.service.commands.integrations.slack_app_setup import SlackAppSetupCommand from keepercommander.service.commands.terraform_app_setup import TerraformSetupConstants +from keepercommander.service.util.exceptions import ValidationError @dataclass @@ -34,11 +35,20 @@ def test_noop_when_no_integration_env_var_set(self): apply_runtime_command_policy(args) self.assertEqual(args.commands, 'search,malicious-command') - def test_noop_when_commands_empty(self): + def test_noop_when_commands_none(self): + with mock.patch.dict(os.environ, {'SLACK_RECORD': 'uid-123'}, clear=True): + args = _Args(commands=None) + apply_runtime_command_policy(args) + self.assertIsNone(args.commands) + + def test_empty_commands_normalizes_to_integration_default(self): + # Matches compose-generation behavior: empty falls back to the integration's + # own default allowlist rather than leaving the service with no commands. + allowed = set(SlackAppSetupCommand().get_service_commands().split(',')) with mock.patch.dict(os.environ, {'SLACK_RECORD': 'uid-123'}, clear=True): args = _Args(commands='') apply_runtime_command_policy(args) - self.assertEqual(args.commands, '') + self.assertEqual(set(args.commands.split(',')), allowed) def test_slack_record_env_confines_to_slack_allowlist(self): allowed = set(SlackAppSetupCommand().get_service_commands().split(',')) @@ -73,19 +83,13 @@ def test_sailpoint_record_env_confines_to_sailpoint_allowlist(self): self.assertEqual(set(args.commands.split(',')), allowed) self.assertNotIn('download-attachment', args.commands.split(',')) - def test_multiple_integration_env_vars_uses_first_and_warns(self): + def test_multiple_integration_env_vars_raises(self): with mock.patch.dict( os.environ, {'SLACK_RECORD': 'uid-1', 'KEEPER_TERRAFORM': '1'}, clear=True ): - with mock.patch('builtins.print') as mock_print: - args = _Args(commands='search,malicious-command') + args = _Args(commands='search,malicious-command') + with self.assertRaises(ValidationError): apply_runtime_command_policy(args) - self.assertTrue( - any('multiple integration env vars' in call.args[0].lower() - for call in mock_print.call_args_list) - ) - slack_allowed = set(SlackAppSetupCommand().get_service_commands().split(',')) - self.assertEqual(set(args.commands.split(',')), slack_allowed) if __name__ == '__main__':