diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index b2db4b7914..fd6dd121fe 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -30,6 +30,10 @@ All notable changes to the **Prowler SDK** are documented in this file. - `return` statements in `finally` blocks replaced across IAM, Organizations, GCP provider, and custom checks metadata to stop silently swallowing exceptions [(#10102)](https://github.com/prowler-cloud/prowler/pull/10102) +### 🔐 Security + +- Sensitive CLI flag values (tokens, keys, passwords) in HTML output "Parameters used" field now redacted to prevent credential leaks [(#10518)](https://github.com/prowler-cloud/prowler/pull/10518) + --- ## [5.22.1] (Prowler UNRELEASED) diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 651e9db477..e82ead1db9 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -19,6 +19,8 @@ from prowler.providers.common.arguments import ( validate_provider_arguments, ) +SENSITIVE_ARGUMENTS = frozenset({"--shodan"}) + class ProwlerArgumentParser: # Set the default parser diff --git a/prowler/lib/cli/redact.py b/prowler/lib/cli/redact.py new file mode 100644 index 0000000000..2dfd9e2bfa --- /dev/null +++ b/prowler/lib/cli/redact.py @@ -0,0 +1,68 @@ +from functools import lru_cache +from importlib import import_module + +from prowler.lib.logger import logger +from prowler.providers.common.provider import Provider, providers_path + +REDACTED_VALUE = "REDACTED" + + +@lru_cache(maxsize=None) +def get_sensitive_arguments() -> frozenset: + """Collect SENSITIVE_ARGUMENTS from all provider argument modules and the common parser.""" + 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}") + + # Provider-specific sensitive arguments + for provider in Provider.get_available_providers(): + try: + module = import_module( + f"{providers_path}.{provider}.lib.arguments.arguments" + ) + sensitive.update(getattr(module, "SENSITIVE_ARGUMENTS", frozenset())) + except Exception as error: + logger.debug(f"Could not load SENSITIVE_ARGUMENTS from {provider}: {error}") + + return frozenset(sensitive) + + +def redact_argv(argv: list[str]) -> str: + """Redact values of sensitive CLI flags from an argument list. + + Handles both ``--flag value`` and ``--flag=value`` syntax. + Returns a single joined string suitable for display. + """ + sensitive = get_sensitive_arguments() + result: list[str] = [] + skip_next = False + + for i, arg in enumerate(argv): + if skip_next: + result.append(REDACTED_VALUE) + skip_next = False + continue + + # Handle --flag=value syntax + if "=" in arg: + flag = arg.split("=", 1)[0] + if flag in sensitive: + result.append(f"{flag}={REDACTED_VALUE}") + continue + + # Handle --flag value syntax + if arg in sensitive: + result.append(arg) + # Only redact the next token if it exists and is not another flag + if i + 1 < len(argv) and not argv[i + 1].startswith("-"): + skip_next = True + continue + + result.append(arg) + + return " ".join(result) diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index d17320453b..4ffbf1f402 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -9,6 +9,7 @@ from prowler.config.config import ( square_logo_img, timestamp, ) +from prowler.lib.cli.redact import redact_argv from prowler.lib.logger import logger from prowler.lib.outputs.output import Finding, Output from prowler.lib.outputs.utils import parse_html_string, unroll_dict @@ -196,7 +197,7 @@ class HTML(Output):
  • - Parameters used: {" ".join(sys.argv[1:]) if from_cli else ""} + Parameters used: {redact_argv(sys.argv[1:]) if from_cli else ""}
  • Date: {timestamp.isoformat()} diff --git a/prowler/providers/github/lib/arguments/arguments.py b/prowler/providers/github/lib/arguments/arguments.py index 57aed863ab..748d77a927 100644 --- a/prowler/providers/github/lib/arguments/arguments.py +++ b/prowler/providers/github/lib/arguments/arguments.py @@ -1,3 +1,6 @@ +SENSITIVE_ARGUMENTS = frozenset({"--personal-access-token", "--oauth-app-token"}) + + def init_parser(self): """Init the Github Provider CLI parser""" github_parser = self.subparsers.add_parser( diff --git a/prowler/providers/iac/lib/arguments/arguments.py b/prowler/providers/iac/lib/arguments/arguments.py index af4bf476e8..a489eb3063 100644 --- a/prowler/providers/iac/lib/arguments/arguments.py +++ b/prowler/providers/iac/lib/arguments/arguments.py @@ -1,5 +1,7 @@ import re +SENSITIVE_ARGUMENTS = frozenset({"--personal-access-token", "--oauth-app-token"}) + SCANNERS_CHOICES = [ "vuln", "misconfig", diff --git a/prowler/providers/mongodbatlas/lib/arguments/arguments.py b/prowler/providers/mongodbatlas/lib/arguments/arguments.py index 13bdb2a975..64aff68343 100644 --- a/prowler/providers/mongodbatlas/lib/arguments/arguments.py +++ b/prowler/providers/mongodbatlas/lib/arguments/arguments.py @@ -1,3 +1,6 @@ +SENSITIVE_ARGUMENTS = frozenset({"--atlas-private-key"}) + + def init_parser(self): """Initialize the MongoDB Atlas Provider CLI parser""" mongodbatlas_parser = self.subparsers.add_parser( diff --git a/prowler/providers/nhn/lib/arguments/arguments.py b/prowler/providers/nhn/lib/arguments/arguments.py index d4925090e6..8f7c71fd7c 100644 --- a/prowler/providers/nhn/lib/arguments/arguments.py +++ b/prowler/providers/nhn/lib/arguments/arguments.py @@ -1,3 +1,6 @@ +SENSITIVE_ARGUMENTS = frozenset({"--nhn-password"}) + + def init_parser(self): """Init the NHN Provider CLI parser""" nhn_parser = self.subparsers.add_parser( diff --git a/prowler/providers/openstack/lib/arguments/arguments.py b/prowler/providers/openstack/lib/arguments/arguments.py index e77e129d1d..459012c4ec 100644 --- a/prowler/providers/openstack/lib/arguments/arguments.py +++ b/prowler/providers/openstack/lib/arguments/arguments.py @@ -1,5 +1,7 @@ from argparse import Namespace +SENSITIVE_ARGUMENTS = frozenset({"--os-password"}) + def init_parser(self): """Initialize the OpenStack provider CLI parser.""" diff --git a/tests/lib/cli/redact_test.py b/tests/lib/cli/redact_test.py new file mode 100644 index 0000000000..1f33998356 --- /dev/null +++ b/tests/lib/cli/redact_test.py @@ -0,0 +1,108 @@ +from unittest.mock import patch + +import pytest + +from prowler.lib.cli.redact import REDACTED_VALUE, get_sensitive_arguments, redact_argv + + +@pytest.fixture +def mock_sensitive_args(): + """Mock get_sensitive_arguments to return a known set.""" + sensitive = frozenset( + {"--shodan", "--personal-access-token", "--atlas-private-key"} + ) + with patch( + "prowler.lib.cli.redact.get_sensitive_arguments", return_value=sensitive + ): + yield sensitive + + +class TestRedactArgv: + def test_empty_argv(self, mock_sensitive_args): + assert redact_argv([]) == "" + + def test_no_sensitive_flags(self, mock_sensitive_args): + argv = ["aws", "--region", "eu-west-1", "--output-formats", "html"] + assert redact_argv(argv) == "aws --region eu-west-1 --output-formats html" + + def test_sensitive_flag_with_value(self, mock_sensitive_args): + argv = ["aws", "--shodan", "abc123"] + assert redact_argv(argv) == f"aws --shodan {REDACTED_VALUE}" + + def test_sensitive_flag_with_equals_syntax(self, mock_sensitive_args): + argv = ["aws", "--shodan=abc123"] + assert redact_argv(argv) == f"aws --shodan={REDACTED_VALUE}" + + def test_sensitive_flag_at_end_without_value(self, mock_sensitive_args): + argv = ["aws", "--shodan"] + assert redact_argv(argv) == "aws --shodan" + + def test_sensitive_flag_followed_by_another_flag(self, mock_sensitive_args): + argv = ["aws", "--shodan", "--region", "eu-west-1"] + # --region starts with '-', so --shodan value is not redacted (it has no value) + assert redact_argv(argv) == "aws --shodan --region eu-west-1" + + def test_multiple_sensitive_flags(self, mock_sensitive_args): + argv = [ + "github", + "--personal-access-token", + "ghp_secret123", + "--shodan", + "shodan_key", + ] + assert ( + redact_argv(argv) + == f"github --personal-access-token {REDACTED_VALUE} --shodan {REDACTED_VALUE}" + ) + + def test_mixed_sensitive_and_non_sensitive(self, mock_sensitive_args): + argv = [ + "mongodbatlas", + "--atlas-private-key", + "my_secret", + "--atlas-project-id", + "proj123", + ] + assert ( + redact_argv(argv) + == f"mongodbatlas --atlas-private-key {REDACTED_VALUE} --atlas-project-id proj123" + ) + + def test_sensitive_flag_equals_with_other_args(self, mock_sensitive_args): + argv = [ + "aws", + "--region", + "us-east-1", + "--shodan=key123", + "--output-formats", + "html", + ] + assert ( + redact_argv(argv) + == f"aws --region us-east-1 --shodan={REDACTED_VALUE} --output-formats html" + ) + + def test_non_sensitive_flag_with_equals(self, mock_sensitive_args): + argv = ["aws", "--region=us-east-1"] + assert redact_argv(argv) == "aws --region=us-east-1" + + +class TestGetSensitiveArguments: + def test_discovers_known_sensitive_arguments(self): + """Integration test: verify the discovery mechanism finds flags from provider modules.""" + get_sensitive_arguments.cache_clear() + result = get_sensitive_arguments() + assert "--shodan" in result + assert "--personal-access-token" in result + assert "--oauth-app-token" in result + assert "--atlas-private-key" in result + assert "--nhn-password" in result + assert "--os-password" in result + + def test_does_not_include_non_sensitive_flags(self): + """Verify non-sensitive flags are not in the set.""" + get_sensitive_arguments.cache_clear() + result = get_sensitive_arguments() + assert "--region" not in result + assert "--profile" not in result + assert "--output-formats" not in result diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index 7bb5256d81..c8d4ac199d 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -4,6 +4,7 @@ from io import StringIO from mock import MagicMock, patch from prowler.config.config import prowler_version, timestamp +from prowler.lib.cli.redact import redact_argv from prowler.lib.logger import logger from prowler.lib.outputs.html.html import HTML from prowler.providers.github.models import GithubAppIdentityInfo @@ -473,7 +474,7 @@ def get_aws_html_header(args: list) -> str:
  • - Parameters used: {" ".join(args)} + Parameters used: {redact_argv(args)}
  • Date: {timestamp.isoformat()}