From d53d06d01208cd2cd2f641fc797bb4a3f3b26ea6 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Thu, 13 Aug 2026 11:13:36 +0200 Subject: [PATCH] chore(v3): show prowler cloud + version upgrade --- prowler/__main__.py | 29 +++++++++++++++- prowler/config/config.py | 53 ++++++++++++++++++++++------- prowler/lib/banner.py | 67 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/config/config_test.py | 39 +++++++++++++++++++++ 5 files changed, 175 insertions(+), 15 deletions(-) diff --git a/prowler/__main__.py b/prowler/__main__.py index bb2be7db90..3f494eb587 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -3,10 +3,16 @@ import os import sys +import threading from colorama import Fore, Style -from prowler.lib.banner import print_banner +from prowler.config.config import get_available_update +from prowler.lib.banner import ( + print_banner, + print_prowler_cloud_banner, + print_update_notice, +) from prowler.lib.check.check import ( bulk_load_checks_metadata, bulk_load_compliance_frameworks, @@ -73,6 +79,18 @@ def prowler(): compliance_framework = args.compliance custom_checks_metadata_file = args.custom_checks_metadata_file + # Check in the background whether a newer Prowler release is available; + # the result is printed at the end of the scan so the check never adds + # latency. Skipped with --no-banner/--only-logs and via + # PROWLER_NO_VERSION_CHECK/DO_NOT_TRACK (handled in get_available_update). + available_update = {} + update_check_thread = threading.Thread( + target=lambda: available_update.update(latest=get_available_update()), + daemon=True, + ) + if not args.no_banner and not args.only_logs: + update_check_thread.start() + if not args.no_banner: print_banner(args) @@ -325,6 +343,15 @@ def prowler(): audit_output_options.output_directory, ) + # Promote Prowler Cloud as the last thing the user sees after the results, + # preceded by an update notice when a newer release is available + if not args.no_banner and not args.only_logs: + if update_check_thread.is_alive(): + update_check_thread.join(timeout=2) + if available_update.get("latest"): + print_update_notice(available_update["latest"]) + print_prowler_cloud_banner() + # If custom checks were passed, remove the modules if checks_folder: remove_custom_checks_module(checks_folder, provider) diff --git a/prowler/config/config.py b/prowler/config/config.py index 63f948d1c2..b90bbf4a1d 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -11,7 +11,7 @@ from prowler.lib.logger import logger timestamp = datetime.today() timestamp_utc = datetime.now(timezone.utc).replace(tzinfo=timezone.utc) -prowler_version = "3.16.17" +prowler_version = "3.16.18" html_logo_url = "https://github.com/prowler-cloud/prowler/" html_logo_img = "https://user-images.githubusercontent.com/3985464/113734260-7ba06900-96fb-11eb-82bc-d4f68a1e2710.png" square_logo_img = "https://user-images.githubusercontent.com/38561120/235905862-9ece5bd7-9aa3-4e48-807a-3a9035eb8bfb.png" @@ -64,23 +64,50 @@ default_config_file_path = ( encoding_format_utf_8 = "utf-8" -def check_current_version(): +def get_latest_release_version(): + """Return the latest Prowler release tag name from GitHub, or None if it cannot be retrieved.""" try: - prowler_version_string = f"Prowler {prowler_version}" release_response = requests.get( "https://api.github.com/repos/prowler-cloud/prowler/tags", timeout=1 ) - latest_version = release_response.json()[0]["name"] - if latest_version != prowler_version: - return f"{prowler_version_string} (latest is {latest_version}, upgrade for the latest features)" - else: - return ( - f"{prowler_version_string} (You are running the latest version, yay!)" - ) - except requests.RequestException: - return f"{prowler_version_string}" + return release_response.json()[0]["name"] except Exception: - return f"{prowler_version_string}" + return None + + +def _version_tuple(version_string): + """Return a comparable tuple from a dotted version string.""" + return tuple(int(part) for part in version_string.split(".")) + + +def get_available_update(): + """Return the latest Prowler version if it is newer than the running one, None otherwise. + + Honors the PROWLER_NO_VERSION_CHECK and DO_NOT_TRACK environment variables: + when either is set, no network call is made and None is returned. + """ + if os.environ.get("PROWLER_NO_VERSION_CHECK") or os.environ.get("DO_NOT_TRACK"): + return None + latest_version = get_latest_release_version() + try: + if latest_version and _version_tuple(latest_version) > _version_tuple( + prowler_version + ): + return latest_version + except Exception: + return None + return None + + +def check_current_version(): + prowler_version_string = f"Prowler {prowler_version}" + latest_version = get_latest_release_version() + if not latest_version: + return prowler_version_string + if latest_version != prowler_version: + return f"{prowler_version_string} (latest is {latest_version}, upgrade for the latest features)" + else: + return f"{prowler_version_string} (You are running the latest version, yay!)" def change_config_var(variable: str, value: str, audit_info): diff --git a/prowler/lib/banner.py b/prowler/lib/banner.py index a24ede341b..b2571d2933 100644 --- a/prowler/lib/banner.py +++ b/prowler/lib/banner.py @@ -2,6 +2,73 @@ from colorama import Fore, Style from prowler.config.config import banner_color, orange_color, prowler_version, timestamp +# Prowler Cloud landing URL used by the CLI banner. The visible text stays +# "cloud.prowler.com" while the clickable target carries the UTM parameters so +# terminals that support OSC 8 hyperlinks attribute the visit to the v3 CLI. +CLOUD_DISPLAY_TEXT = "cloud.prowler.com" +CLOUD_BANNER_URL = ( + "https://cloud.prowler.com/sign-up?utm_source=prowler-cli&utm_content=v3" +) + + +def _hyperlink(url, text): + """Wrap ``text`` in an OSC 8 terminal hyperlink pointing to ``url``. + + Terminals that support OSC 8 render ``text`` as a clickable link to ``url``; + those that do not simply display ``text`` unchanged. + """ + return f"\033]8;;{url}\033\\{text}\033]8;;\033\\" + + +def print_update_notice(latest_version): + """ + Prints a notice that a newer Prowler version is available. + + Parameters: + - latest_version (str): The latest released Prowler version. + + Returns: + - None + """ + print( + f"\n{Fore.YELLOW}A new version of Prowler is available: {prowler_version} → {latest_version}{Style.RESET_ALL}\n" + f"Upgrading from Prowler v3 is a major version upgrade — see the release notes at\n" + f"https://github.com/prowler-cloud/prowler/releases before upgrading.\n" + f"Upgrade with: {Style.BRIGHT}pipx upgrade prowler{Style.RESET_ALL} " + f"(disable this check with PROWLER_NO_VERSION_CHECK=1)" + ) + + +def print_prowler_cloud_banner(): + """ + Prints a promotional banner highlighting what Prowler Cloud adds on top of + the open-source CLI. + + Shown at the end of a scan to let users know about the managed platform + capabilities they are missing. + + Returns: + - None + """ + check = f"{Fore.GREEN}✓{Style.RESET_ALL}" + bar = f"{banner_color}│{Style.RESET_ALL}" + print(f""" +{bar} {Style.BRIGHT}You're getting a snapshot 📸. Prowler Cloud gives you the full picture:{Style.RESET_ALL} +{bar} +{bar} {check} {Style.BRIGHT}Send your findings{Style.RESET_ALL} - directly from the Prowler CLI to Prowler Cloud. +{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - custom scheduling and scan configuration with history, trends and alerts. +{bar} {check} {Style.BRIGHT}Triage{Style.RESET_ALL} - review findings, flag false positives and track accepted risk with your team. +{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, custom dashboards, prioritization with prevention and remediation. +{bar} {check} {Style.BRIGHT}Alerts{Style.RESET_ALL} - get notified when anything you want is happening. +{bar} {check} {Style.BRIGHT}Live Compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date. +{bar} {check} {Style.BRIGHT}Remediation{Style.RESET_ALL} - complete guided remediation including Autonomous remediation with Lighthouse AI. +{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels. +{bar} {check} {Style.BRIGHT}Bulk Provisioning{Style.RESET_ALL} - add your entire AWS Organization in seconds. +{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Anything with our MCP + Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC. +{bar} +{bar} {banner_color}Start free at 👉 {_hyperlink(CLOUD_BANNER_URL, CLOUD_DISPLAY_TEXT)}{Style.RESET_ALL} +""") + def print_banner(args): banner = rf"""{banner_color} _ diff --git a/pyproject.toml b/pyproject.toml index a35503fe41..e0722cc851 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ packages = [ {include = "prowler"} ] readme = "README.md" -version = "3.16.17" +version = "3.16.18" [tool.poetry.dependencies] alive-progress = "3.1.5" diff --git a/tests/config/config_test.py b/tests/config/config_test.py index d980053e9d..da37c508e3 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -8,6 +8,7 @@ from prowler.config.config import ( change_config_var, check_current_version, get_available_compliance_frameworks, + get_available_update, load_and_validate_config_file, ) from prowler.providers.aws.aws_provider import get_aws_available_regions @@ -134,6 +135,44 @@ class Test_Config: == f"Prowler {MOCK_OLD_PROWLER_VERSION} (latest is {MOCK_PROWLER_VERSION}, upgrade for the latest features)" ) + @mock.patch( + "prowler.config.config.requests.get", new=mock_prowler_get_latest_release + ) + @mock.patch("prowler.config.config.prowler_version", new=MOCK_OLD_PROWLER_VERSION) + def test_get_available_update_with_old_version(self): + assert get_available_update() == MOCK_PROWLER_VERSION + + @mock.patch( + "prowler.config.config.requests.get", new=mock_prowler_get_latest_release + ) + @mock.patch("prowler.config.config.prowler_version", new=MOCK_PROWLER_VERSION) + def test_get_available_update_with_latest_version(self): + assert get_available_update() is None + + @mock.patch( + "prowler.config.config.requests.get", new=mock_prowler_get_latest_release + ) + @mock.patch("prowler.config.config.prowler_version", new=MOCK_OLD_PROWLER_VERSION) + @mock.patch.dict(os.environ, {"PROWLER_NO_VERSION_CHECK": "1"}) + def test_get_available_update_opt_out_env_var(self): + assert get_available_update() is None + + @mock.patch( + "prowler.config.config.requests.get", new=mock_prowler_get_latest_release + ) + @mock.patch("prowler.config.config.prowler_version", new=MOCK_OLD_PROWLER_VERSION) + @mock.patch.dict(os.environ, {"DO_NOT_TRACK": "1"}) + def test_get_available_update_do_not_track(self): + assert get_available_update() is None + + @mock.patch( + "prowler.config.config.requests.get", + new=mock.MagicMock(side_effect=Exception("network error")), + ) + @mock.patch("prowler.config.config.prowler_version", new=MOCK_OLD_PROWLER_VERSION) + def test_get_available_update_network_failure(self): + assert get_available_update() is None + def test_change_config_var_aws(self): audit_info = AWS_Audit_Info( session_config=None,