From a212916a49b596208d08a9da27c57020dc1d0ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=90=B4=E7=84=9A=E9=9F=B3=E8=90=BD?= <99181324+qinfenyinluo@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:29:13 +0800 Subject: [PATCH 01/14] fix(github): skip archived repos in CODEOWNERS check (#11735) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../repository_has_codeowners_file.py | 3 ++ .../repository_has_codeowners_file_test.py | 52 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 420b7e7654..2188797585 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -32,6 +32,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### ๐Ÿž Fixed +- GitHub `repository_has_codeowners_file` check no longer flags archived repositories, since they are read-only and cannot be updated without first being unarchived, making the finding not actionable [(#11735)](https://github.com/prowler-cloud/prowler/pull/11735) - Report secret-scanning checks as `MANUAL` instead of `PASS` when the scanner fails (non-zero exit, timeout, unparseable output or missing binary), so a scanner failure is no longer indistinguishable from "no secrets found" [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) - Avoid a false `FAIL` in `cloudwatch_log_group_no_secrets_in_logs` when a multiline event's secrets are all removed by `secrets_ignore_patterns` during the rescan [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) - Key the `cloudwatch_log_group_no_secrets_in_logs` secret scan by log group ARN instead of name, so same-named log groups and streams in different regions no longer collide and reuse each other's findings [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) diff --git a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py index 7120e0411a..2ece6abf42 100644 --- a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py +++ b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py @@ -22,6 +22,9 @@ class repository_has_codeowners_file(Check): """ findings = [] for repo in repository_client.repositories.values(): + if repo.archived: + continue + if repo.codeowners_exists is not None: report = CheckReportGithub(metadata=self.metadata(), resource=repo) if repo.codeowners_exists: diff --git a/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py b/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py index a181ec8404..d56aed307e 100644 --- a/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py +++ b/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py @@ -145,3 +145,55 @@ class Test_repository_has_codeowners_file: result[0].status_extended == f"Repository {repo_name} does have a CODEOWNERS file." ) + + def test_archived_repository_no_codeowners_is_skipped(self): + repository_client = mock.MagicMock + repo_name = "archived-repo" + repository_client.repositories = { + 3: Repo( + id=3, + name=repo_name, + owner="account-name", + full_name="account-name/archived-repo", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + archived=True, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file import ( + repository_has_codeowners_file, + ) + + check = repository_has_codeowners_file() + result = check.execute() + assert len(result) == 0 From 1e1c1c018b6466b416e8cc98a452b11d4b83ae07 Mon Sep 17 00:00:00 2001 From: Sergio Garcia Date: Wed, 1 Jul 2026 06:49:34 -0400 Subject: [PATCH 02/14] feat(iam): add AWS Bedrock AgentCore privilege escalation paths (#11726) --- prowler/CHANGELOG.md | 1 + .../services/iam/lib/privilege_escalation.py | 36 ++ ...policy_allows_privilege_escalation_test.py | 5 + ...policy_allows_privilege_escalation_test.py | 382 ++++++++++++++++++ 4 files changed, 424 insertions(+) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 2188797585..cc0df18d0c 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - CIS Google Cloud Platform Foundation Benchmark v5.0.0 compliance framework for the GCP provider [(#11714)](https://github.com/prowler-cloud/prowler/pull/11714) - CIS Kubernetes Benchmark v2.0.1 compliance framework for the Kubernetes provider [(#11722)](https://github.com/prowler-cloud/prowler/pull/11722) - CIS GitHub Benchmark v1.2.0 compliance framework for the GitHub provider [(#11719)](https://github.com/prowler-cloud/prowler/pull/11719) +- AWS Bedrock AgentCore privilege escalation paths in the IAM privilege escalation checks, covering Runtime, Harness, Code Interpreter and Custom Browser [(#11726)](https://github.com/prowler-cloud/prowler/pull/11726) - `--scan-secrets-validate` flag and `aws.secrets_validate` configuration option to optionally validate the secrets discovered by the secret-scanning checks against the provider APIs; secrets confirmed to be live are reported as critical [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) ### ๐Ÿ”„ Changed diff --git a/prowler/providers/aws/services/iam/lib/privilege_escalation.py b/prowler/providers/aws/services/iam/lib/privilege_escalation.py index 9fded2d5c9..d7f16895d5 100644 --- a/prowler/providers/aws/services/iam/lib/privilege_escalation.py +++ b/prowler/providers/aws/services/iam/lib/privilege_escalation.py @@ -19,6 +19,7 @@ from prowler.providers.aws.services.iam.lib.policy import get_effective_actions # - https://github.com/RhinoSecurityLabs/Security-Research/blob/master/tools/aws-pentest-tools/aws_escalate.py # - https://rhinosecuritylabs.com/aws/aws-privilege-escalation-methods-mitigation/ # - https://github.com/DataDog/pathfinding.cloud (AWS IAM Privilege Escalation Path Library) +# - https://www.beyondtrust.com/blog/entry/aws-agentcore-privilege-escalation (AWS Bedrock AgentCore) privilege_escalation_policies_combination = { # IAM self-escalation and policy manipulation @@ -299,6 +300,7 @@ privilege_escalation_policies_combination = { "PassRole+AgentCoreCreateInterpreter+InvokeInterpreter": { "iam:PassRole", "bedrock-agentcore:CreateCodeInterpreter", + "bedrock-agentcore:StartCodeInterpreterSession", "bedrock-agentcore:InvokeCodeInterpreter", }, # Prerequisite: Existing Bedrock code interpreter with admin role @@ -306,6 +308,40 @@ privilege_escalation_policies_combination = { "bedrock-agentcore:StartCodeInterpreterSession", "bedrock-agentcore:InvokeCodeInterpreter", }, + # Prerequisite: Existing AgentCore Runtime or Harness with admin execution role. + # InvokeAgentRuntimeCommand runs shell commands as root inside the microVM and + # reads the execution role credentials from MMDS, bypassing the agent and guardrails. + "AgentCoreInvokeRuntimeCommand": { + "bedrock-agentcore:InvokeAgentRuntimeCommand", + }, + "PassRole+AgentCoreCreateRuntime+InvokeRuntimeCommand": { + "iam:PassRole", + "bedrock-agentcore:CreateAgentRuntime", + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:InvokeAgentRuntimeCommand", + }, + "PassRole+AgentCoreCreateHarness+InvokeRuntimeCommand": { + "iam:PassRole", + "bedrock-agentcore:CreateHarness", + "bedrock-agentcore:CreateAgentRuntime", + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:GetAgentRuntime", + "bedrock-agentcore:InvokeAgentRuntimeCommand", + }, + # Prerequisite: Existing AgentCore Custom Browser with admin execution role. + # A remote CDP driver on the browser session reads the role credentials from MMDS. + "AgentCoreBrowserSessionConnect": { + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:ConnectBrowserAutomationStream", + }, + "PassRole+AgentCoreCreateBrowser+ConnectBrowser": { + "iam:PassRole", + "bedrock-agentcore:CreateBrowser", + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:ConnectBrowserAutomationStream", + }, # TO-DO: We have to handle AssumeRole just if the resource is * and without conditions # "sts:AssumeRole": {"sts:AssumeRole"}, } diff --git a/tests/providers/aws/services/iam/iam_inline_policy_allows_privilege_escalation/iam_inline_policy_allows_privilege_escalation_test.py b/tests/providers/aws/services/iam/iam_inline_policy_allows_privilege_escalation/iam_inline_policy_allows_privilege_escalation_test.py index 176942ea0b..eda9588aef 100644 --- a/tests/providers/aws/services/iam/iam_inline_policy_allows_privilege_escalation/iam_inline_policy_allows_privilege_escalation_test.py +++ b/tests/providers/aws/services/iam/iam_inline_policy_allows_privilege_escalation/iam_inline_policy_allows_privilege_escalation_test.py @@ -1239,6 +1239,7 @@ class Test_iam_inline_policy_allows_privilege_escalation: "Action": [ "iam:PassRole", "bedrock-agentcore:CreateCodeInterpreter", + "bedrock-agentcore:StartCodeInterpreterSession", "bedrock-agentcore:InvokeCodeInterpreter", ], "Resource": "*", @@ -1286,6 +1287,10 @@ class Test_iam_inline_policy_allows_privilege_escalation: assert search( "bedrock-agentcore:CreateCodeInterpreter", result[0].status_extended ) + assert search( + "bedrock-agentcore:StartCodeInterpreterSession", + result[0].status_extended, + ) assert search( "bedrock-agentcore:InvokeCodeInterpreter", result[0].status_extended ) diff --git a/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py b/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py index 850a5ba2cb..35f0fd1f0e 100644 --- a/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py +++ b/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py @@ -928,6 +928,7 @@ class Test_iam_policy_allows_privilege_escalation: "Action": [ "iam:PassRole", "bedrock-agentcore:CreateCodeInterpreter", + "bedrock-agentcore:StartCodeInterpreterSession", "bedrock-agentcore:InvokeCodeInterpreter", ], "Resource": "*", @@ -973,10 +974,391 @@ class Test_iam_policy_allows_privilege_escalation: assert search( "bedrock-agentcore:CreateCodeInterpreter", result[0].status_extended ) + assert search( + "bedrock-agentcore:StartCodeInterpreterSession", + result[0].status_extended, + ) assert search( "bedrock-agentcore:InvokeCodeInterpreter", result[0].status_extended ) + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_invoke_runtime_command( + self, + ): + """Test detection of AgentCore Runtime/Harness privilege escalation via InvokeAgentRuntimeCommand on an existing resource.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_invoke_runtime_command_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:InvokeAgentRuntimeCommand", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_tags == [] + assert search( + f"Custom Policy {policy_arn} allows privilege escalation using the following actions: ", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:InvokeAgentRuntimeCommand", + result[0].status_extended, + ) + + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_passrole_create_runtime( + self, + ): + """Test detection of AgentCore Runtime privilege escalation by creating a new runtime with a passed role.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_create_runtime_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:PassRole", + "bedrock-agentcore:CreateAgentRuntime", + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:InvokeAgentRuntimeCommand", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert search("iam:PassRole", result[0].status_extended) + assert search( + "bedrock-agentcore:CreateAgentRuntime", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:CreateWorkloadIdentity", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:InvokeAgentRuntimeCommand", + result[0].status_extended, + ) + + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_passrole_create_harness( + self, + ): + """Test detection of AgentCore Harness privilege escalation by creating a new harness with a passed role.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_create_harness_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:PassRole", + "bedrock-agentcore:CreateHarness", + "bedrock-agentcore:CreateAgentRuntime", + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:GetAgentRuntime", + "bedrock-agentcore:InvokeAgentRuntimeCommand", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert search("iam:PassRole", result[0].status_extended) + assert search("bedrock-agentcore:CreateHarness", result[0].status_extended) + assert search( + "bedrock-agentcore:CreateAgentRuntime", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:CreateWorkloadIdentity", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:GetAgentRuntime", result[0].status_extended + ) + assert search( + "bedrock-agentcore:InvokeAgentRuntimeCommand", + result[0].status_extended, + ) + + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_browser_session_connect( + self, + ): + """Test detection of AgentCore Custom Browser privilege escalation via an existing browser session.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_browser_session_connect_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:ConnectBrowserAutomationStream", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert search( + "bedrock-agentcore:StartBrowserSession", result[0].status_extended + ) + assert search( + "bedrock-agentcore:ConnectBrowserAutomationStream", + result[0].status_extended, + ) + + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_passrole_create_browser( + self, + ): + """Test detection of AgentCore Custom Browser privilege escalation by creating a new browser with a passed role.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_create_browser_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:PassRole", + "bedrock-agentcore:CreateBrowser", + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:ConnectBrowserAutomationStream", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert search("iam:PassRole", result[0].status_extended) + assert search("bedrock-agentcore:CreateBrowser", result[0].status_extended) + assert search( + "bedrock-agentcore:StartBrowserSession", result[0].status_extended + ) + assert search( + "bedrock-agentcore:ConnectBrowserAutomationStream", + result[0].status_extended, + ) + + @mock_aws + def test_iam_policy_allows_privilege_escalation_agentcore_wildcard( + self, + ): + """Test detection of AgentCore privilege escalation when the policy grants the bedrock-agentcore:* namespace wildcard.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "agentcore_wildcard_policy" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:*", + ], + "Resource": "*", + } + ], + } + + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert search( + "bedrock-agentcore:InvokeAgentRuntimeCommand", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:StartCodeInterpreterSession", + result[0].status_extended, + ) + assert search( + "bedrock-agentcore:StartBrowserSession", result[0].status_extended + ) + @mock_aws def test_iam_policy_allows_privilege_escalation_iam_put( self, From 3f8c1e822f79c88b5621761e2b7f51ca652cc4b1 Mon Sep 17 00:00:00 2001 From: Chirag Trivedi <66163521+chirag1206@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:54:18 +0530 Subject: [PATCH 03/14] feat(apigateway): add check for secrets in REST API stage variables (#11188) Co-authored-by: Daniel Barranquero --- .../cli/tutorials/configuration_file.mdx | 1 + docs/user-guide/cli/tutorials/pentesting.mdx | 1 + prowler/CHANGELOG.md | 1 + .../__init__.py | 0 ...o_secrets_in_stage_variables.metadata.json | 39 +++ ...y_restapi_no_secrets_in_stage_variables.py | 89 +++++ .../services/apigateway/apigateway_service.py | 2 + ...tapi_no_secrets_in_stage_variables_test.py | 325 ++++++++++++++++++ 8 files changed, 458 insertions(+) create mode 100644 prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/__init__.py create mode 100644 prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.metadata.json create mode 100644 prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.py create mode 100644 tests/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables_test.py diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index a0c920a59b..07508ddc12 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -26,6 +26,7 @@ The following list includes all the AWS checks with configurable variables that |---------------------------------------------------------------|--------------------------------------------------|-----------------| | `acm_certificates_expiration_check` | `days_to_expire_threshold` | Integer | | `acmpca_certificate_authority_pqc_key_algorithm` | `acmpca_pqc_key_algorithms` | List of Strings | +| `apigateway_restapi_no_secrets_in_stage_variables` | `secrets_ignore_patterns` | List of Strings | | `appstream_fleet_maximum_session_duration` | `max_session_duration_seconds` | Integer | | `appstream_fleet_session_disconnect_timeout` | `max_disconnect_timeout_in_seconds` | Integer | | `appstream_fleet_session_idle_disconnect_timeout` | `max_idle_disconnect_timeout_in_seconds` | Integer | diff --git a/docs/user-guide/cli/tutorials/pentesting.mdx b/docs/user-guide/cli/tutorials/pentesting.mdx index 35d5b72be7..a9a8c728df 100644 --- a/docs/user-guide/cli/tutorials/pentesting.mdx +++ b/docs/user-guide/cli/tutorials/pentesting.mdx @@ -12,6 +12,7 @@ The checks with this functionality are the following. AWS: +- apigateway\_restapi\_no\_secrets\_in\_stage\_variables - autoscaling\_find\_secrets\_ec2\_launch\_configuration - awslambda\_function\_no\_secrets\_in\_code - awslambda\_function\_no\_secrets\_in\_variables diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index cc0df18d0c..4abc4f1384 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - CIS GitHub Benchmark v1.2.0 compliance framework for the GitHub provider [(#11719)](https://github.com/prowler-cloud/prowler/pull/11719) - AWS Bedrock AgentCore privilege escalation paths in the IAM privilege escalation checks, covering Runtime, Harness, Code Interpreter and Custom Browser [(#11726)](https://github.com/prowler-cloud/prowler/pull/11726) - `--scan-secrets-validate` flag and `aws.secrets_validate` configuration option to optionally validate the secrets discovered by the secret-scanning checks against the provider APIs; secrets confirmed to be live are reported as critical [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- `apigateway_restapi_no_secrets_in_stage_variables` check for AWS provider, scanning API Gateway REST API stage variables for hardcoded secrets such as passwords, API keys, and tokens [(#11188)](https://github.com/prowler-cloud/prowler/pull/11188) ### ๐Ÿ”„ Changed diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/__init__.py b/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.metadata.json new file mode 100644 index 0000000000..d0866175b9 --- /dev/null +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "aws", + "CheckID": "apigateway_restapi_no_secrets_in_stage_variables", + "CheckTitle": "API Gateway REST API stage variables should not contain secrets", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "apigateway", + "SubServiceName": "", + "ResourceIdTemplate": "arn:aws:apigateway:region::/restapis/api-id/stages/stage-name", + "Severity": "high", + "ResourceType": "AwsApiGatewayStage", + "ResourceGroup": "security", + "Description": "Checks API Gateway REST API stage variables for hardcoded secrets such as passwords, API keys, and tokens. Stage variables should reference AWS Secrets Manager or Parameter Store rather than containing plaintext credentials.", + "Risk": "Hardcoded secrets in stage variables are stored in plaintext in the AWS control plane and are visible to anyone with read access to the API Gateway configuration. This can lead to unauthorized access, credential theft, and lateral movement across systems.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_how-services-use-secrets_api-gateway.html" + ], + "Remediation": { + "Code": { + "CLI": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=remove,path=/variables/", + "NativeIaC": "", + "Other": "1. Open AWS Console > API Gateway\n2. Select the REST API and stage\n3. Go to Stage Variables tab\n4. Remove any variables containing plaintext secrets\n5. Reference secrets using AWS Secrets Manager integration instead", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove hardcoded secrets from API Gateway stage variables. Use AWS Secrets Manager or Parameter Store to manage credentials and retrieve them at runtime using Lambda authorizers or integration request mapping templates.", + "Url": "https://hub.prowler.com/check/apigateway_restapi_no_secrets_in_stage_variables" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Infrastructure Protection" +} diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.py b/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.py new file mode 100644 index 0000000000..490057a5ed --- /dev/null +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables.py @@ -0,0 +1,89 @@ +import json + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.apigateway.apigateway_client import ( + apigateway_client, +) + + +class apigateway_restapi_no_secrets_in_stage_variables(Check): + """Check that API Gateway REST API stage variables contain no hardcoded secrets.""" + + def execute(self) -> list[Check_Report_AWS]: + findings = [] + secrets_ignore_patterns = apigateway_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = apigateway_client.audit_config.get("secrets_validate", False) + + # Collect one payload per stage (its variables) and scan them all in + # batched Kingfisher invocations instead of one subprocess per stage. + # Findings are keyed by (rest_api index, stage index). + def payloads(): + for api_index, rest_api in enumerate(apigateway_client.rest_apis): + for stage_index, stage in enumerate(rest_api.stages): + if stage.variables: + yield (api_index, stage_index), json.dumps( + stage.variables, indent=2 + ) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for api_index, rest_api in enumerate(apigateway_client.rest_apis): + for stage_index, stage in enumerate(rest_api.stages): + report = Check_Report_AWS(metadata=self.metadata(), resource=rest_api) + report.resource_arn = stage.arn + report.resource_id = f"{rest_api.name}/{stage.name}" + report.status = "PASS" + report.status_extended = ( + f"No secrets found in stage variables of API Gateway " + f"REST API {rest_api.name} stage {stage.name}." + ) + + if stage.variables: + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan stage variables of API Gateway REST API " + f"{rest_api.name} stage {stage.name} for secrets: " + f"{scan_error}; manual review is required." + ) + findings.append(report) + continue + + detect_secrets_output = batch_results.get((api_index, stage_index)) + if detect_secrets_output: + variable_names = list(stage.variables.keys()) + secrets_string = ", ".join( + [ + f"{secret['type']} in variable " + f"{variable_names[secret['line_number'] - 2]}" + for secret in detect_secrets_output + ] + ) + report.status = "FAIL" + report.status_extended = ( + f"Potential " + f"{'secrets' if len(detect_secrets_output) > 1 else 'secret'} " + f"found in stage variables of API Gateway REST API " + f"{rest_api.name} stage {stage.name} -> {secrets_string}." + ) + annotate_verified_secrets(report, detect_secrets_output) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/apigateway/apigateway_service.py b/prowler/providers/aws/services/apigateway/apigateway_service.py index 099551f440..94f7d86b8d 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_service.py +++ b/prowler/providers/aws/services/apigateway/apigateway_service.py @@ -179,6 +179,7 @@ class APIGateway(AWSService): tracing_enabled=tracing_enabled, cache_enabled=cache_enabled, cache_data_encrypted=cache_data_encrypted, + variables=stage.get("variables", {}), ) ) except ClientError as error: @@ -265,6 +266,7 @@ class Stage(BaseModel): tracing_enabled: Optional[bool] = None cache_enabled: Optional[bool] = None cache_data_encrypted: Optional[bool] = None + variables: Optional[dict] = {} class PathResourceMethods(BaseModel): diff --git a/tests/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables_test.py b/tests/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables_test.py new file mode 100644 index 0000000000..8afb7b4fc8 --- /dev/null +++ b/tests/providers/aws/services/apigateway/apigateway_restapi_no_secrets_in_stage_variables/apigateway_restapi_no_secrets_in_stage_variables_test.py @@ -0,0 +1,325 @@ +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + + +class Test_apigateway_restapi_no_secrets_in_stage_variables: + @mock_aws + def test_no_rest_apis(self): + from prowler.providers.aws.services.apigateway.apigateway_service import ( + APIGateway, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client", + new=APIGateway(aws_provider), + ), + ): + from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import ( + apigateway_restapi_no_secrets_in_stage_variables, + ) + + check = apigateway_restapi_no_secrets_in_stage_variables() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_stage_with_no_variables(self): + apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1) + rest_api = apigw.create_rest_api(name="test-api") + api_id = rest_api["id"] + + root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"] + resource = apigw.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + apigw.put_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + authorizationType="NONE", + ) + apigw.put_integration( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + type="HTTP", + integrationHttpMethod="POST", + uri="http://test.com", + ) + apigw.create_deployment(restApiId=api_id, stageName="prod") + + from prowler.providers.aws.services.apigateway.apigateway_service import ( + APIGateway, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client", + new=APIGateway(aws_provider), + ), + ): + from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import ( + apigateway_restapi_no_secrets_in_stage_variables, + ) + + check = apigateway_restapi_no_secrets_in_stage_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "No secrets found in stage variables of API Gateway " + "REST API test-api stage prod." + ) + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "test-api/prod" + + @mock_aws + def test_stage_with_safe_variables(self): + apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1) + rest_api = apigw.create_rest_api(name="test-api") + api_id = rest_api["id"] + + root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"] + resource = apigw.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + apigw.put_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + authorizationType="NONE", + ) + apigw.put_integration( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + type="HTTP", + integrationHttpMethod="POST", + uri="http://test.com", + ) + apigw.create_deployment(restApiId=api_id, stageName="prod") + apigw.update_stage( + restApiId=api_id, + stageName="prod", + patchOperations=[ + { + "op": "replace", + "path": "/variables/environment", + "value": "production", + }, + {"op": "replace", "path": "/variables/region", "value": "us-east-1"}, + ], + ) + + from prowler.providers.aws.services.apigateway.apigateway_service import ( + APIGateway, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client", + new=APIGateway(aws_provider), + ), + ): + from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import ( + apigateway_restapi_no_secrets_in_stage_variables, + ) + + check = apigateway_restapi_no_secrets_in_stage_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "No secrets found in stage variables of API Gateway " + "REST API test-api stage prod." + ) + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "test-api/prod" + + @mock_aws + def test_stage_with_secrets_in_variables(self): + apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1) + rest_api = apigw.create_rest_api(name="test-api") + api_id = rest_api["id"] + + root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"] + resource = apigw.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + apigw.put_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + authorizationType="NONE", + ) + apigw.put_integration( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + type="HTTP", + integrationHttpMethod="POST", + uri="http://test.com", + ) + apigw.create_deployment(restApiId=api_id, stageName="prod") + # A safe variable is added alongside the secret so the secret is not the + # only variable present. This guards the line-number -> variable-name + # mapping against an off-by-one that would otherwise still point at the + # single variable and pass unnoticed. + # A syntactically valid JSON Web Token that Kingfisher flags as a secret. + apigw.update_stage( + restApiId=api_id, + stageName="prod", + patchOperations=[ + { + "op": "replace", + "path": "/variables/environment", + "value": "production", + }, + { + "op": "replace", + "path": "/variables/api_token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + }, + ], + ) + + from prowler.providers.aws.services.apigateway.apigateway_service import ( + APIGateway, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client", + new=APIGateway(aws_provider), + ), + ): + from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import ( + apigateway_restapi_no_secrets_in_stage_variables, + ) + + check = apigateway_restapi_no_secrets_in_stage_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "test-api" in result[0].status_extended + assert "prod" in result[0].status_extended + assert "in variable api_token" in result[0].status_extended + # The secret must be attributed to the correct variable, not the + # safe one that precedes it. + assert "in variable environment" not in result[0].status_extended + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "test-api/prod" + + @mock_aws + def test_stage_with_variables_scan_error(self): + apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1) + rest_api = apigw.create_rest_api(name="test-api") + api_id = rest_api["id"] + + root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"] + resource = apigw.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + apigw.put_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + authorizationType="NONE", + ) + apigw.put_integration( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="GET", + type="HTTP", + integrationHttpMethod="POST", + uri="http://test.com", + ) + apigw.create_deployment(restApiId=api_id, stageName="prod") + apigw.update_stage( + restApiId=api_id, + stageName="prod", + patchOperations=[ + {"op": "replace", "path": "/variables/api_token", "value": "value"}, + ], + ) + + from prowler.lib.utils.utils import SecretsScanError + from prowler.providers.aws.services.apigateway.apigateway_service import ( + APIGateway, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client", + new=APIGateway(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher failed"), + ), + ): + from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import ( + apigateway_restapi_no_secrets_in_stage_variables, + ) + + check = apigateway_restapi_no_secrets_in_stage_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "manual review is required" in result[0].status_extended + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "test-api/prod" From 301d13a4b92593c30d99418b5e2d5dabd197d1b1 Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Wed, 1 Jul 2026 15:12:45 +0200 Subject: [PATCH 04/14] docs: unify subscription banner (#11756) --- docs/snippets/subscription-banner.mdx | 8 +++++ docs/user-guide/tutorials/prowler-alerts.mdx | 5 ++- docs/user-guide/tutorials/prowler-app.mdx | 36 +++++++------------ .../prowler-cloud-aws-organizations.mdx | 7 ++-- .../tutorials/prowler-import-findings.mdx | 29 ++++++++------- .../tutorials/prowler-scan-scheduling.mdx | 5 ++- 6 files changed, 42 insertions(+), 48 deletions(-) create mode 100644 docs/snippets/subscription-banner.mdx diff --git a/docs/snippets/subscription-banner.mdx b/docs/snippets/subscription-banner.mdx new file mode 100644 index 0000000000..8313997c84 --- /dev/null +++ b/docs/snippets/subscription-banner.mdx @@ -0,0 +1,8 @@ +export const SubscriptionBanner = ({ children }) => { + return ( + + This feature is available exclusively in Prowler Cloud and Prowler Enterprise with a subscription. + {children} + + ); +}; diff --git a/docs/user-guide/tutorials/prowler-alerts.mdx b/docs/user-guide/tutorials/prowler-alerts.mdx index e4475849ae..9f01a77910 100644 --- a/docs/user-guide/tutorials/prowler-alerts.mdx +++ b/docs/user-guide/tutorials/prowler-alerts.mdx @@ -4,14 +4,13 @@ description: 'Create email alerts from Prowler Cloud findings to monitor relevan --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria. - -This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing). - + ## Prerequisites diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index 5e99a41ae7..19bd48ee60 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -18,17 +18,14 @@ After [installing](/getting-started/installation/prowler-app) **Prowler App**, a To view the auto-generated **Prowler API** documentation, navigate to [http://localhost:8080/api/v1/docs](http://localhost:8080/api/v1/docs). This documentation provides details on available endpoints, parameters, and responses. -## **Step 1: Sign Up** - -### **Sign Up with Email** - +## Step 1: Sign Up +### Sign Up with Email To get started, sign up using your email and password: Sign Up Button Sign Up -### **Sign Up with Social Login** - +### Sign Up with Social Login If Social Login is enabled, you can sign up using your preferred provider (e.g., Google, GitHub). @@ -44,16 +41,14 @@ If your email is not registered, a new account will be created using your social See [how to configure Social Login for Prowler](/user-guide/tutorials/prowler-app-social-login) to enable this feature in your own deployments. -## **Step 2: Log In** - +## Step 2: Log In Once registered, log in with your email and password to access Prowler App. Log In Upon logging in, the Overview page will display. At this stage, no data is present: add a provider to begin scanning your cloud environment. -## **Step 3: Add a Provider** - +## Step 3: Add a Provider To perform security scans, link a cloud provider account. Prowler supports the following providers and more: - **AWS** @@ -77,8 +72,7 @@ Steps to add a provider: Add Provider -## **Step 4: Configure the Provider** - +## Step 4: Configure the Provider Select the cloud provider to scan and configure authentication credentials. Each provider has specific requirements and authentication methods. Select a Provider @@ -111,14 +105,12 @@ For detailed instructions on configuring credentials for each provider, refer to Scan IaC public or private repositories for security issues. -## **Step 5: Test Connection** - +## Step 5: Test Connection After adding your credentials of your cloud account, click the `Launch` button to verify that Prowler App can successfully connect to your provider: Test Connection -## **Step 6: Scan started** - +## Step 6: Scan Started After successfully adding and testing your credentials, Prowler will start scanning your cloud environment, click the `Go to Scans` button to see the progress: Start Now @@ -127,8 +119,7 @@ After successfully adding and testing your credentials, Prowler will start scann Prowler will automatically scan all configured providers every **24 hours**, ensuring your cloud environment stays continuously monitored. -## **Step 7: Monitor Scan Progress** - +## Step 7: Monitor Scan Progress Track the progress of your scan in the `Scans` section: Scan Progress @@ -146,8 +137,7 @@ Each dashboard handles scan data differently: When a new scan completes or a new data ingestion is processed, the dashboards automatically reflect the updated results. -## **Step 8: Analyze the Findings** - +## Step 8: Analyze the Findings While the scan is running, start exploring the findings in these sections: - **Overview**: High-level summary of the scans. @@ -168,8 +158,7 @@ While the scan is running, start exploring the findings in these sections: To view all `new` findings that have not been seen prior to this scan, click the `Delta` filter and select `new`. To view all `changed` findings that have had a status change (from `PASS` to `FAIL` for example), click the `Delta` filter and select `changed`. -## **Step 9: Download the Outputs** - +## Step 9: Download the Outputs Once a scan is complete, navigate to the Scan Jobs section to download the output files generated by Prowler: Scan Jobs section @@ -190,8 +179,7 @@ The `zip` file unpacks into a folder named like `prowler-output-- -## **Step 10: Download specified compliance report** - +## Step 10: Download Specified Compliance Report Once your scan has finished, you donโ€™t need to grab the entire ZIPโ€”just pull down the specific compliance report you want: - Navigate to the **Compliance** section of the UI. diff --git a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx index d0c71c7e40..2a6128e92e 100644 --- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx @@ -4,14 +4,15 @@ description: 'Onboard all AWS accounts in your Organization through a single gui --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" Prowler Cloud enables you to onboard all AWS accounts in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans โ€” all from the Prowler Cloud UI. - -This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing). For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations). - + +For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations). + ## Overview diff --git a/docs/user-guide/tutorials/prowler-import-findings.mdx b/docs/user-guide/tutorials/prowler-import-findings.mdx index 6a3276e403..e02f3ee7a5 100644 --- a/docs/user-guide/tutorials/prowler-import-findings.mdx +++ b/docs/user-guide/tutorials/prowler-import-findings.mdx @@ -4,16 +4,15 @@ description: 'Upload OCSF scan results to Prowler Cloud from external sources or --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class. - -This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing). - + -## OCSF Detection Finding format +## OCSF Detection Finding Format The ingestion API accepts `.ocsf.json` files containing a JSON array of OCSF Detection Finding records. Each finding represents a security check result from Prowler. @@ -130,7 +129,7 @@ The ingestion API accepts `.ocsf.json` files containing a JSON array of OCSF Det Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF classes are not supported for ingestion. -## Required permissions +## Required Permissions The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--push-to-cloud`. @@ -145,7 +144,7 @@ The `--push-to-cloud` flag uploads scan results directly to Prowler Cloud after - A valid Prowler Cloud API key (see [API Keys](/user-guide/tutorials/prowler-app-api-keys)) - The `PROWLER_CLOUD_API_KEY` environment variable configured -### Basic usage +### Basic Usage ```bash export PROWLER_CLOUD_API_KEY="pk_your_api_key_here" @@ -153,7 +152,7 @@ export PROWLER_CLOUD_API_KEY="pk_your_api_key_here" prowler aws --push-to-cloud ``` -### Combining with output formats +### Combining with Output Formats When using `--push-to-cloud` with custom output formats that exclude OCSF, Prowler generates a temporary OCSF file for upload: @@ -169,7 +168,7 @@ When default output formats include OCSF, Prowler reuses the existing file. Defa prowler aws --services accessanalyzer --push-to-cloud -o /tmp/scan-output ``` -### CLI output examples +### CLI Output Examples **Successful upload:** ``` @@ -228,7 +227,7 @@ curl -X POST \ https://api.prowler.com/api/v1/ingestions ``` -### Submit an ingestion batch +### Submit an Ingestion Batch Upload a `.ocsf.json` file containing a JSON array of OCSF Detection Finding records. See [OCSF Detection Finding format](#ocsf-detection-finding-format) for the expected structure. @@ -266,7 +265,7 @@ curl -X POST \ } ``` -### Get ingestion status +### Get Ingestion Status Monitor the progress of an ingestion job. @@ -304,7 +303,7 @@ curl -X GET \ } ``` -### List ingestion jobs +### List Ingestion Jobs Retrieve a list of ingestion jobs for the tenant. @@ -332,7 +331,7 @@ curl -X GET \ "https://api.prowler.com/api/v1/ingestions?filter[status]=completed&page[size]=10" ``` -### Get ingestion errors +### Get Ingestion Errors Retrieve error details for a specific ingestion job. @@ -346,7 +345,7 @@ curl -X GET \ https://api.prowler.com/api/v1/ingestions/3650fef9-8e5f-4808-a95f-74f0afae8499/errors ``` -## Ingestion status values +## Ingestion Status Values | Status | Description | |--------|-------------| @@ -355,7 +354,7 @@ curl -X GET \ | `completed` | All records processed successfully | | `failed` | Job encountered errors during processing | -## CI/CD integration +## CI/CD Integration Automate findings ingestion in CI/CD pipelines by setting the API key as a secret. @@ -391,7 +390,7 @@ prowler_scan: PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY ``` -## Billing impact +## Billing Impact Each unique cloud account discovered in ingested OCSF findings counts as one **provider** in the Prowler Cloud subscription. diff --git a/docs/user-guide/tutorials/prowler-scan-scheduling.mdx b/docs/user-guide/tutorials/prowler-scan-scheduling.mdx index 7b9173de05..961d32ad14 100644 --- a/docs/user-guide/tutorials/prowler-scan-scheduling.mdx +++ b/docs/user-guide/tutorials/prowler-scan-scheduling.mdx @@ -4,14 +4,13 @@ description: 'Create, edit, and monitor recurring scans in Prowler Cloud and Ent --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" Scan Scheduling lets Prowler run recurring scans for connected providers. Use it to keep findings, compliance results, and resource inventory up to date without launching every scan manually. - -This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing). - + ## Prerequisites From 69321418a32227507f263483a4203fa1221174cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 1 Jul 2026 15:45:38 +0200 Subject: [PATCH 05/14] feat(ui): improve scan config ux (#11731) Co-authored-by: alejandrobailo --- ui/CHANGELOG.md | 3 - .../scan-configurations.ts | 150 +++++++--- ui/app/(prowler)/providers/page.test.ts | 9 + ui/app/(prowler)/providers/page.tsx | 36 ++- .../_components/scan-configuration-editor.tsx | 132 ++++----- .../scan-configurations-columns.tsx | 42 +-- .../scan-configurations-manager.tsx | 37 +-- ui/app/(prowler)/scans/config/page.test.ts | 18 ++ .../config}/page.tsx | 21 +- ui/app/(prowler)/scans/page.tsx | 2 +- .../providers-accounts-table.test.tsx | 57 ++++ .../providers/providers-accounts-table.tsx | 31 +++ .../providers/providers-accounts-view.tsx | 12 + .../manage-scan-config-modal.test.tsx | 257 ++++++++++++++++++ .../scan-config/manage-scan-config-modal.tsx | 222 +++++++++++++++ .../providers/table/column-providers.tsx | 14 + .../table/data-table-row-actions.test.tsx | 87 ++++++ .../table/data-table-row-actions.tsx | 49 ++++ ui/components/shadcn/card/card.tsx | 1 + ui/components/shadcn/field/field.tsx | 15 +- ui/components/shadcn/modal/modal.tsx | 9 + .../shadcn/textarea/textarea.test.ts | 17 ++ ui/components/shadcn/textarea/textarea.tsx | 45 ++- .../breadcrumb-navigation.test.tsx | 33 ++- .../ui/breadcrumbs/breadcrumb-navigation.tsx | 32 ++- .../ui/sidebar/submenu-item.test.tsx | 30 ++ ui/lib/menu-list.test.ts | 22 +- ui/lib/menu-list.ts | 22 +- ui/lib/yaml.test.ts | 58 ++++ ui/lib/yaml.ts | 140 +++------- ui/types/formSchemas.ts | 20 +- ui/types/scan-configurations.ts | 13 + 32 files changed, 1319 insertions(+), 317 deletions(-) rename ui/app/(prowler)/{scan-configurations => scans/config}/_components/scan-configuration-editor.tsx (73%) rename ui/app/(prowler)/{scan-configurations => scans/config}/_components/scan-configurations-columns.tsx (72%) rename ui/app/(prowler)/{scan-configurations => scans/config}/_components/scan-configurations-manager.tsx (89%) create mode 100644 ui/app/(prowler)/scans/config/page.test.ts rename ui/app/(prowler)/{scan-configurations => scans/config}/page.tsx (65%) create mode 100644 ui/components/providers/scan-config/manage-scan-config-modal.test.tsx create mode 100644 ui/components/providers/scan-config/manage-scan-config-modal.tsx create mode 100644 ui/components/shadcn/textarea/textarea.test.ts create mode 100644 ui/lib/yaml.test.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e77057779d..8ab6f12eee 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,9 +6,6 @@ All notable changes to the **Prowler UI** are documented in this file. ### ๐Ÿš€ Added -- Add `Scan Configuration` menu item under the Configuration menu (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) -- Scan configuration management page (`/scan-configurations`) to create, edit, and manage scan configurations with live YAML validation against the server JSON Schema (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) -- Surface an "invalid scan configuration" note on compliance requirements that fail solely because the applied scan config does not meet them [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) - Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659) - CIS Controls v8.1 compliance support, including its detail view and report mapping [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700) diff --git a/ui/actions/scan-configurations/scan-configurations.ts b/ui/actions/scan-configurations/scan-configurations.ts index 1ecdb3256b..946fdeca82 100644 --- a/ui/actions/scan-configurations/scan-configurations.ts +++ b/ui/actions/scan-configurations/scan-configurations.ts @@ -14,13 +14,17 @@ import { ScanConfigurationRequestBody, } from "@/types/scan-configurations"; -const SCAN_CONFIGURATION_PATH = "/scan-configurations"; +const SCAN_CONFIGURATION_PATH = "/scans/config"; // Scan Configuration IDs are UUIDs. Validate before interpolating into request // URLs so a malformed/crafted value can't inject path segments (SSRF / path // injection). const scanConfigurationIdSchema = z.uuid(); +// Provider IDs are UUIDs too. Validate the whole array at the action boundary so +// a malformed/crafted id fails here instead of relying on API-side validation. +const providerIdsSchema = z.array(z.uuid()); + const parseConfiguration = (value: string): Record => { // Backend (YamlOrJsonField) accepts either a YAML string or a JSON object. // We parse client-side so failures surface as form errors, not 500s. @@ -38,6 +42,48 @@ const collectProviderIds = (formData: FormData): string[] => { .filter(Boolean); }; +interface ApiErrorSource { + pointer?: string; +} + +interface ApiError { + detail?: string; + title?: string; + source?: ApiErrorSource; +} + +// Route each JSON:API error to the matching form field via its `source.pointer` +// so it renders inline next to the offending input. Only errors we can't anchor +// to a field fall back to `general` (surfaced as a toast). Shared by create and +// update so both flows present validation errors identically โ€” otherwise a +// config error shows inline on create but as a toast on update. +const mapApiErrorsToFields = ( + errorData: { errors?: ApiError[]; message?: string } | null | undefined, + fallbackMessage: string, +): ScanConfigurationErrors => { + const apiErrors = Array.isArray(errorData?.errors) ? errorData!.errors! : []; + + if (apiErrors.length === 0) { + return { general: errorData?.message || fallbackMessage }; + } + + const errors: ScanConfigurationErrors = {}; + const append = (key: keyof ScanConfigurationErrors, detail: string) => { + errors[key] = errors[key] ? `${errors[key]}\n${detail}` : detail; + }; + + for (const err of apiErrors) { + const detail = err?.detail || err?.title || fallbackMessage; + const pointer = err?.source?.pointer; + if (pointer?.includes("name")) append("name", detail); + else if (pointer?.includes("configuration")) + append("configuration", detail); + else if (pointer?.includes("provider_ids")) append("provider_ids", detail); + else append("general", detail); + } + return errors; +}; + export const createScanConfiguration = async ( _prevState: ScanConfigurationActionState, formData: FormData, @@ -95,20 +141,12 @@ export const createScanConfiguration = async ( if (!response.ok) { const errorData = await response.json().catch(() => ({})); - const detail = - errorData?.errors?.[0]?.detail || - errorData?.message || - `Failed to create Scan Configuration: ${response.statusText}`; - const pointer = errorData?.errors?.[0]?.source?.pointer as - | string - | undefined; - const errors: ScanConfigurationErrors = {}; - if (pointer?.includes("name")) errors.name = detail; - else if (pointer?.includes("configuration")) - errors.configuration = detail; - else if (pointer?.includes("provider_ids")) errors.provider_ids = detail; - else errors.general = detail; - return { errors }; + return { + errors: mapApiErrorsToFields( + errorData, + `Failed to create Scan Configuration: ${response.statusText}`, + ), + }; } const data = await response.json(); @@ -199,11 +237,12 @@ export const updateScanConfiguration = async ( if (!response.ok) { const errorData = await response.json().catch(() => ({})); - const detail = - errorData?.errors?.[0]?.detail || - errorData?.message || - `Failed to update Scan Configuration: ${response.statusText}`; - return { errors: { general: detail } }; + return { + errors: mapApiErrorsToFields( + errorData, + `Failed to update Scan Configuration: ${response.statusText}`, + ), + }; } const data = await response.json(); @@ -225,30 +264,67 @@ export const updateScanConfiguration = async ( } }; -export const getScanConfigurationSchema = async (): Promise | null> => { - const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}/scan-configurations/schema`); +// Attach/detach providers on a scan configuration without touching its name or +// YAML โ€” a partial PATCH of `provider_ids` only. Used by the provider row to +// associate/disassociate a config (editing the config itself lives in the Scan +// Config view). The backend's `(tenant, provider)` uniqueness means attaching a +// provider here moves it off any other config automatically. +export const setScanConfigurationProviders = async ( + configId: string, + providerIds: string[], +): Promise => { + const idResult = scanConfigurationIdSchema.safeParse(configId); + if (!idResult.success) { + return { errors: { general: "Invalid Scan Configuration ID" } }; + } + const validId = idResult.data; + const providerIdsResult = providerIdsSchema.safeParse(providerIds); + if (!providerIdsResult.success) { + return { errors: { provider_ids: "Invalid provider ID" } }; + } + const validProviderIds = providerIdsResult.data; + const headers = await getAuthHeaders({ contentType: true }); + try { + const url = new URL(`${apiBaseUrl}/scan-configurations/${validId}`); + // Partial update: only provider_ids (name/configuration are optional on the + // backend update serializer), so we don't type this as the full request body. + const bodyData = { + data: { + type: "scan-configurations" as const, + id: validId, + attributes: { provider_ids: validProviderIds }, + }, + }; const response = await fetch(url.toString(), { - method: "GET", + method: "PATCH", headers, + body: JSON.stringify(bodyData), }); + if (!response.ok) { - throw new Error( - `Failed to fetch Scan Configuration schema: ${response.statusText}`, - ); + const errorData = await response.json().catch(() => ({})); + return { + errors: mapApiErrorsToFields( + errorData, + `Failed to update Scan Configuration: ${response.statusText}`, + ), + }; } - const json = await response.json(); - const schema = json?.data?.attributes?.schema as - | Record - | undefined; - return schema ?? null; + + revalidatePath(SCAN_CONFIGURATION_PATH); + revalidatePath("/providers"); + return { success: "Scan Configuration updated successfully!" }; } catch (error) { - console.error("Error fetching Scan Configuration schema:", error); - return null; + console.error("Error updating Scan Configuration providers:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error updating Scan Configuration. Please try again.", + }, + }; } }; diff --git a/ui/app/(prowler)/providers/page.test.ts b/ui/app/(prowler)/providers/page.test.ts index 96612380df..293ce578ce 100644 --- a/ui/app/(prowler)/providers/page.test.ts +++ b/ui/app/(prowler)/providers/page.test.ts @@ -43,4 +43,13 @@ describe("providers page", () => { expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV"); expect(source).toContain("{isCloudEnvironment && { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const pagePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + + expect(source).toContain("SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE"); + expect(source).not.toContain("catch {\n return [];"); + }); }); diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 72b0af9d7f..ec9c0a7798 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; +import { listScanConfigurations } from "@/actions/scan-configurations"; import { ProvidersAccountsView } from "@/components/providers"; import { SkeletonTableProviders } from "@/components/providers/table"; import { CliImportBanner } from "@/components/scans"; @@ -7,6 +8,10 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { ContentLayout } from "@/components/ui"; import { FilterTransitionWrapper } from "@/contexts"; import { SearchParamsProps } from "@/types"; +import { + SCAN_CONFIGURATION_LIST_STATUS, + type ScanConfigurationListState, +} from "@/types/scan-configurations"; import { ProviderGroupsContent } from "./provider-groups-content"; import { ProviderPageTabs } from "./provider-page-tabs"; @@ -103,24 +108,45 @@ const ProviderGroupsFallback = () => { ); }; +const loadScanConfigs = async ( + isCloud: boolean, +): Promise => { + if (!isCloud) { + return { status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, data: [] }; + } + + try { + return { + status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, + data: await listScanConfigurations(), + }; + } catch (error) { + console.error("Error loading provider scan configurations:", error); + return { status: SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE, data: [] }; + } +}; + const ProvidersTabContent = async ({ searchParams, }: { searchParams: SearchParamsProps; }) => { - const providersView = await loadProvidersAccountsViewData({ - searchParams, - isCloud: process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true", - }); + const isCloud = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const [providersView, scanConfigsState] = await Promise.all([ + loadProvidersAccountsViewData({ searchParams, isCloud }), + loadScanConfigs(isCloud), + ]); return ( ); }; diff --git a/ui/app/(prowler)/scan-configurations/_components/scan-configuration-editor.tsx b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx similarity index 73% rename from ui/app/(prowler)/scan-configurations/_components/scan-configuration-editor.tsx rename to ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx index e74115a58b..f6dcbacb3e 100644 --- a/ui/app/(prowler)/scan-configurations/_components/scan-configuration-editor.tsx +++ b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx @@ -21,11 +21,10 @@ import { import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; import { CustomLink } from "@/components/ui/custom/custom-link"; -import { fontMono } from "@/config/fonts"; import { convertToYaml, defaultScanConfigurationYaml, - validateScanConfigurationPayload, + validateYaml, } from "@/lib/yaml"; import { scanConfigurationFormSchema } from "@/types/formSchemas"; import { ProviderProps } from "@/types/providers"; @@ -37,7 +36,6 @@ interface ScanConfigurationEditorProps { richProviders: ProviderProps[]; existingConfigs: ScanConfigurationData[]; config: ScanConfigurationData | null; - schema: Record | null; } interface ScanConfigurationFormProps { @@ -45,7 +43,6 @@ interface ScanConfigurationFormProps { richProviders: ProviderProps[]; existingConfigs: ScanConfigurationData[]; config: ScanConfigurationData | null; - schema: Record | null; } // `provider_ids` has a zod `.default([])`, so the resolver's input and output @@ -53,14 +50,11 @@ interface ScanConfigurationFormProps { type ScanConfigurationFormInput = z.input; type ScanConfigurationFormValues = z.output; -const MAX_ERRORS_SHOWN = 10; - function ScanConfigurationForm({ onClose, richProviders, existingConfigs, config, - schema, }: ScanConfigurationFormProps) { const isEdit = !!config; const { toast } = useToast(); @@ -87,12 +81,13 @@ function ScanConfigurationForm({ const configText = form.watch("configuration") || ""; const selectedProviders = form.watch("provider_ids") || []; - // Real-time validation against the server schema (ranges/enums). Kept out of - // form state because it's derived purely from the current YAML text โ€” skip it - // while the field is empty so we don't flag an error before the user types. - const yamlValidation = configText.trim() - ? validateScanConfigurationPayload(configText, schema) - : { isValid: true, errors: [] }; + // Mirror the Mutelist editor: the client validates YAML *syntax* live (that it + // parses to a mapping); the API validates the configuration values + // (ranges/enums) on save and returns them inline. Skip while empty so we don't + // flag an error before the user types. + const yamlSyntax = configText.trim() + ? validateYaml(configText) + : { isValid: true as const }; // A provider can only be attached to one config at a time. We exclude // providers that are owned by *other* configs from the selector so the user @@ -111,9 +106,9 @@ function ScanConfigurationForm({ const lockedCount = richProviders.length - selectableProviders.length; const onSubmit = form.handleSubmit(async (values) => { - // The inline panel already lists every schema/syntax error in real time, so - // we don't duplicate them in a toast โ€” just bring the panel into view. - if (yamlValidation.errors.length > 0) { + // Block on a YAML syntax error (the inline message already explains it); the + // API validates the values on save and returns any errors inline. + if (!yamlSyntax.isValid) { errorPanelRef.current?.scrollIntoView({ behavior: "smooth", block: "center", @@ -194,64 +189,64 @@ function ScanConfigurationForm({ Configuration (YAML) -

- Follows the structure of{" "} - - prowler/config/config.yaml - - . Allowed ranges and enums come from the server schema; invalid values - are listed below in real time. -

+
    +
  • + Follows the structure of{" "} + + prowler/config/config.yaml + + ; include only the keys you want to override. +
  • +
  • The configuration is validated on save.
  • +
  • + Learn more about configuring scans{" "} + + here + + . +
  • +