diff --git a/docs/developer-guide/provider.mdx b/docs/developer-guide/provider.mdx index 0fb3bc549d..ec3e106150 100644 --- a/docs/developer-guide/provider.mdx +++ b/docs/developer-guide/provider.mdx @@ -750,6 +750,35 @@ def init_parser(self): # More arguments for the provider. ``` +##### Sensitive CLI Arguments + +CLI flags that accept secrets (tokens, passwords, API keys) require special handling to protect credentials from leaking in HTML output and process listings: + +1. **Use `nargs="?"` with `default=None`** so the flag works both with and without an inline value. This allows the provider to fall back to an environment variable when no value is passed. +2. **Add a `SENSITIVE_ARGUMENTS` frozenset** at the top of the `arguments.py` file listing every flag that accepts secret values: + + ```python + SENSITIVE_ARGUMENTS = frozenset({"--your-provider-password", "--your-provider-token"}) + ``` + + Prowler automatically discovers these frozensets and uses them to redact values in HTML output and warn users who pass secrets directly on the command line. + +3. **Document the environment variable** in the `help` text so users know the recommended alternative: + + ```python + _parser.add_argument( + "--your-provider-password", + nargs="?", + default=None, + metavar="PASSWORD", + help="Password for authentication. We recommend using the YOUR_PROVIDER_PASSWORD environment variable instead.", + ) + ``` + + +Do not add new arguments that require passing secrets as CLI values without an environment variable fallback. Prowler CLI warns users when sensitive flags receive explicit values on the command line. + + #### Step 5: Implement Mutelist **Explanation:** diff --git a/docs/user-guide/cli/tutorials/pentesting.mdx b/docs/user-guide/cli/tutorials/pentesting.mdx index f59c9ccc2e..0ad5313611 100644 --- a/docs/user-guide/cli/tutorials/pentesting.mdx +++ b/docs/user-guide/cli/tutorials/pentesting.mdx @@ -66,22 +66,38 @@ prowler --categories internet-exposed ### Shodan -Prowler allows you check if any public IPs in your Cloud environments are exposed in Shodan with the `-N`/`--shodan ` option: +Prowler can check whether any public IPs in cloud environments are exposed in Shodan using the `-N`/`--shodan` option. -For example, you can check if any of your AWS Elastic Compute Cloud (EC2) instances has an elastic IP exposed in Shodan: +#### Using the Environment Variable (Recommended) + +Set the `SHODAN_API_KEY` environment variable to avoid exposing the API key in process listings and shell history: ```console -prowler aws -N/--shodan -c ec2_elastic_ip_shodan +export SHODAN_API_KEY= ``` -Also, you can check if any of your Azure Subscription has an public IP exposed in Shodan: +Then run Prowler with the `--shodan` flag (no value needed): ```console -prowler azure -N/--shodan -c network_public_ip_shodan +prowler aws --shodan -c ec2_elastic_ip_shodan ``` -And finally, you can check if any of your GCP projects has an public IP address exposed in Shodan: - ```console -prowler gcp -N/--shodan -c compute_public_address_shodan +prowler azure --shodan -c network_public_ip_shodan ``` + +```console +prowler gcp --shodan -c compute_public_address_shodan +``` + +#### Using the CLI Flag + +Alternatively, pass the API key directly on the command line: + +```console +prowler aws --shodan -c ec2_elastic_ip_shodan +``` + + +Passing secret values directly on the command line exposes them in process listings and shell history. Prowler CLI displays a warning when this pattern is detected. Use the `SHODAN_API_KEY` environment variable instead. + diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index f483ce9d56..06d1aba5a8 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Added `internet-exposed` category to 13 AWS checks (CloudFront, CodeArtifact, EC2, EFS, RDS, SageMaker, Shield, VPC) [(#10502)](https://github.com/prowler-cloud/prowler/pull/10502) - Minimum Python version from 3.9 to 3.10 and updated classifiers to reflect supported versions (3.10, 3.11, 3.12) [(#10464)](https://github.com/prowler-cloud/prowler/pull/10464) +- Sensitive CLI flags now warn when values are passed directly, recommending environment variables instead [(#10532)](https://github.com/prowler-cloud/prowler/pull/10532) ### 🐞 Fixed diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 47275b12cf..d2afa70e15 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -12,6 +12,7 @@ from prowler.config.config import ( default_output_directory, ) from prowler.lib.check.models import Severity +from prowler.lib.cli.redact import warn_sensitive_argument_values from prowler.lib.outputs.common import Status from prowler.providers.common.arguments import ( init_providers_parser, @@ -19,8 +20,6 @@ from prowler.providers.common.arguments import ( validate_provider_arguments, ) -SENSITIVE_ARGUMENTS = frozenset({"--shodan"}) - class ProwlerArgumentParser: # Set the default parser @@ -126,6 +125,10 @@ Detailed documentation at https://docs.prowler.com elif sys.argv[1] == "oci": sys.argv[1] = "oraclecloud" + # Warn about sensitive flags passed with explicit values + # Snapshot argv before parse_args() which may exit on errors + warn_sensitive_argument_values(list(sys.argv[1:])) + # Parse arguments args = self.parser.parse_args() @@ -434,7 +437,7 @@ Detailed documentation at https://docs.prowler.com nargs="?", default=None, metavar="SHODAN_API_KEY", - help="Check if any public IPs in your Cloud environments are exposed in Shodan.", + help="Check if any public IPs in your Cloud environments are exposed in Shodan. We recommend to use the SHODAN_API_KEY environment variable to provide the API key.", ) third_party_subparser.add_argument( "--slack", diff --git a/prowler/lib/cli/redact.py b/prowler/lib/cli/redact.py index 2dfd9e2bfa..3984139bae 100644 --- a/prowler/lib/cli/redact.py +++ b/prowler/lib/cli/redact.py @@ -1,6 +1,9 @@ from functools import lru_cache from importlib import import_module +from colorama import Fore, Style + +from prowler.lib.cli.sensitive import SENSITIVE_ARGUMENTS as COMMON_SENSITIVE_ARGUMENTS from prowler.lib.logger import logger from prowler.providers.common.provider import Provider, providers_path @@ -13,11 +16,7 @@ def get_sensitive_arguments() -> frozenset: sensitive: set[str] = set() # Common parser sensitive arguments (e.g., --shodan) - try: - parser_module = import_module("prowler.lib.cli.parser") - sensitive.update(getattr(parser_module, "SENSITIVE_ARGUMENTS", frozenset())) - except Exception as error: - logger.debug(f"Could not load SENSITIVE_ARGUMENTS from parser: {error}") + sensitive.update(COMMON_SENSITIVE_ARGUMENTS) # Provider-specific sensitive arguments for provider in Provider.get_available_providers(): @@ -66,3 +65,49 @@ def redact_argv(argv: list[str]) -> str: result.append(arg) return " ".join(result) + + +def warn_sensitive_argument_values(argv: list[str]) -> None: + """Log a warning for each sensitive CLI flag that was passed with an explicit value. + + Scans the raw argv list (not parsed args) to detect when users pass + secret values directly on the command line instead of using environment + variables. Handles both ``--flag value`` and ``--flag=value`` syntax. + + Args: + argv: The argument list to check (typically ``sys.argv[1:]``). + """ + sensitive = get_sensitive_arguments() + if not sensitive: + return + + use_color = "--no-color" not in argv + flags_with_values: list[str] = [] + + for i, arg in enumerate(argv): + # --flag=value syntax + if "=" in arg: + flag = arg.split("=", 1)[0] + if flag in sensitive: + flags_with_values.append(flag) + continue + + # --flag value syntax + if arg in sensitive: + if i + 1 < len(argv) and not argv[i + 1].startswith("-"): + flags_with_values.append(arg) + + for flag in flags_with_values: + if use_color: + logger.warning( + f"{Fore.YELLOW}{Style.BRIGHT}WARNING:{Style.RESET_ALL}{Fore.YELLOW} " + f"Passing a value directly to {flag} is not recommended. " + f"Use the corresponding environment variable instead to avoid " + f"exposing secrets in process listings and shell history.{Style.RESET_ALL}" + ) + else: + logger.warning( + f"Passing a value directly to {flag} is not recommended. " + f"Use the corresponding environment variable instead to avoid " + f"exposing secrets in process listings and shell history." + ) diff --git a/prowler/lib/cli/sensitive.py b/prowler/lib/cli/sensitive.py new file mode 100644 index 0000000000..4f5ad004d7 --- /dev/null +++ b/prowler/lib/cli/sensitive.py @@ -0,0 +1,8 @@ +"""Common parser sensitive arguments. + +This module is kept dependency-free (no prowler-internal imports) so that +``prowler.lib.cli.redact`` and any provider argument module can import it +without circular-import risk. +""" + +SENSITIVE_ARGUMENTS = frozenset({"--shodan"}) diff --git a/prowler/providers/nhn/lib/arguments/arguments.py b/prowler/providers/nhn/lib/arguments/arguments.py index 8f7c71fd7c..a102665a26 100644 --- a/prowler/providers/nhn/lib/arguments/arguments.py +++ b/prowler/providers/nhn/lib/arguments/arguments.py @@ -13,7 +13,11 @@ def init_parser(self): "--nhn-username", nargs="?", default=None, help="NHN API Username" ) nhn_auth_subparser.add_argument( - "--nhn-password", nargs="?", default=None, help="NHN API Password" + "--nhn-password", + nargs="?", + default=None, + metavar="NHN_PASSWORD", + help="NHN API Password", ) nhn_auth_subparser.add_argument( "--nhn-tenant-id", nargs="?", default=None, help="NHN Tenant ID" diff --git a/prowler/providers/openstack/lib/arguments/arguments.py b/prowler/providers/openstack/lib/arguments/arguments.py index 459012c4ec..68674528b6 100644 --- a/prowler/providers/openstack/lib/arguments/arguments.py +++ b/prowler/providers/openstack/lib/arguments/arguments.py @@ -46,6 +46,7 @@ def init_parser(self): "--os-password", nargs="?", default=None, + metavar="OS_PASSWORD", help="OpenStack password for authentication. Can also be set via OS_PASSWORD environment variable", ) openstack_explicit_subparser.add_argument( diff --git a/skills/prowler-provider/SKILL.md b/skills/prowler-provider/SKILL.md index 994d134776..c9f2811e97 100644 --- a/skills/prowler-provider/SKILL.md +++ b/skills/prowler-provider/SKILL.md @@ -45,6 +45,34 @@ prowler/providers/{provider}/ └── {check_name}.metadata.json ``` +## Sensitive CLI Arguments + +Flags that accept secrets (tokens, passwords, API keys) MUST follow these rules: + +1. **Use `nargs="?"` with `default=None`** — the flag accepts an optional value for backward compatibility; the recommended path is environment variables. +2. **Set `metavar` to the environment variable name** users should use (e.g., `metavar="GITHUB_PERSONAL_ACCESS_TOKEN"`). +3. **Add the flag to the `SENSITIVE_ARGUMENTS` frozenset** at the top of the provider's `arguments.py`. This set is used to redact values in HTML output and warn users who pass secrets directly. +4. **Do not add new arguments that require passing secrets as CLI values** — secrets should come from environment variables. The flag accepts a value for backward compatibility, but CLI warns users to prefer env vars. + +### Pattern + +```python +# prowler/providers/{provider}/lib/arguments/arguments.py + +SENSITIVE_ARGUMENTS = frozenset({"--my-api-key", "--my-password"}) + + +def init_parser(self): + auth_subparser = parser.add_argument_group("Authentication Modes") + auth_subparser.add_argument( + "--my-api-key", + nargs="?", + default=None, + metavar="MY_API_KEY", + help="API key for authentication. Use MY_API_KEY env var instead of passing directly.", + ) +``` + ## Provider Class Template ```python diff --git a/tests/lib/cli/redact_test.py b/tests/lib/cli/redact_test.py index 1f33998356..5de4cc8fd7 100644 --- a/tests/lib/cli/redact_test.py +++ b/tests/lib/cli/redact_test.py @@ -1,8 +1,14 @@ +import logging from unittest.mock import patch import pytest -from prowler.lib.cli.redact import REDACTED_VALUE, get_sensitive_arguments, redact_argv +from prowler.lib.cli.redact import ( + REDACTED_VALUE, + get_sensitive_arguments, + redact_argv, + warn_sensitive_argument_values, +) @pytest.fixture @@ -87,6 +93,62 @@ class TestRedactArgv: assert redact_argv(argv) == "aws --region=us-east-1" +class TestWarnSensitiveArgumentValues: + def test_no_warning_without_sensitive_flags(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values(["aws", "--region", "eu-west-1"]) + assert caplog.text == "" + + def test_no_warning_flag_without_value(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values(["github", "--personal-access-token"]) + assert caplog.text == "" + + def test_no_warning_flag_followed_by_another_flag( + self, caplog, mock_sensitive_args + ): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values( + ["github", "--personal-access-token", "--region", "eu-west-1"] + ) + assert caplog.text == "" + + def test_warning_flag_with_value(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values( + ["github", "--personal-access-token", "ghp_secret"] + ) + assert "--personal-access-token" in caplog.text + assert "not recommended" in caplog.text + + def test_warning_flag_with_equals_syntax(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values(["aws", "--shodan=key123"]) + assert "--shodan" in caplog.text + assert "not recommended" in caplog.text + + def test_warning_multiple_flags(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values( + [ + "github", + "--personal-access-token", + "ghp_secret", + "--shodan", + "key", + ] + ) + assert "--personal-access-token" in caplog.text + assert "--shodan" in caplog.text + + def test_no_color_output(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values(["--no-color", "aws", "--shodan", "key123"]) + assert "not recommended" in caplog.text + # Should not contain ANSI escape codes + assert "\033[" not in caplog.text + + class TestGetSensitiveArguments: def test_discovers_known_sensitive_arguments(self): """Integration test: verify the discovery mechanism finds flags from provider modules."""